Initial commit: Kreuzjass Punkte-App mit Ziel 1000.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
2cd921d609
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
8
.oxlintrc.json
Normal file
8
.oxlintrc.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
README.md
Normal file
19
README.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Kreuzjass Punkte
|
||||||
|
|
||||||
|
Mobile-first Web-App zum Punktezählen beim Kreuzjassen (Schlangajass).
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard-Zielpunktzahl: **1000**. Partie wird im Browser gespeichert (`localStorage`).
|
||||||
|
|
||||||
|
## Nutzung
|
||||||
|
|
||||||
|
1. Teamnamen und Ziel festlegen
|
||||||
|
2. Pro Runde: Team mit weniger Punkten wählen und Summe eingeben (Rest = 157 − x)
|
||||||
|
3. Optional Weis und Matsch (257)
|
||||||
|
4. Stand mit Schlangen-Strichen (100 / 50 / 20)
|
||||||
15
index.html
Normal file
15
index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#1a3d2e" />
|
||||||
|
<meta name="description" content="Kreuzjass Punkte zählen — digitaler Schlangajass" />
|
||||||
|
<title>Kreuzjass — Punkte</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
237
jass_regeln.txt
Normal file
237
jass_regeln.txt
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
|
||||||
|
Jassregeln
|
||||||
|
Jassen kann anfangs etwas kompliziert wirken, aber keine Sorge! Hier ist alles in 5 Abschnitten Schritt für Schritt erklärt, damit auch Anfänger schnell ins Spiel finden. Also schnapp dir ein Kartenspiel und los geht's, schon bald wirst du selbstbewusst mitjassen können!
|
||||||
|
|
||||||
|
1. Die Karten
|
||||||
|
|
||||||
|
2. Basis & Stechen
|
||||||
|
|
||||||
|
3. Trumpf
|
||||||
|
|
||||||
|
Übersicht der Rangfolge aller Karten im Jass
|
||||||
|
|
||||||
|
Bock, Gais & Slalom (Zusatzregeln)
|
||||||
|
|
||||||
|
4. Punkte
|
||||||
|
|
||||||
|
Übersicht der Punkte
|
||||||
|
|
||||||
|
Weisen (Zusatzregeln)
|
||||||
|
|
||||||
|
Übersicht aller Weise
|
||||||
|
|
||||||
|
5. Spielablauf am Beispiel Kreuzjass
|
||||||
|
|
||||||
|
Punktezählen (Optional)
|
||||||
|
|
||||||
|
Die Karten
|
||||||
|
|
||||||
|
Das Jassen wird mit einem speziellen Kartensatz (Vorarlberger Büschel) von 36 Karten gespielt, die in vier Farben unterteilt sind:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Eichel, Schelle, Laub und Herz.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Jede dieser Farben besteht aus neun Karten, beginnend mit den Zahlen Sechs bis Zehn, gefolgt von den Bildkarten Unter, Ober, König und dem höchsten Rang, dem Ass (Sau).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Jede Karte hat also zwei Werte: einen Stichwert (wie stark sie ist) und einen Punktwert (wie viele Punkte sie bringt). Die Punkte zeigen, wie viel Gewinn eine Karte für dein Team bringt.
|
||||||
|
|
||||||
|
Büschel_Herz.png
|
||||||
|
Büschel_Schelle.png
|
||||||
|
Büschel_Eichel.png
|
||||||
|
Büschel_Laub.png
|
||||||
|
Stechen
|
||||||
|
|
||||||
|
Die Grundregel des Jassens basiert auf dem sogenannten Stechen. Das Ziel ist es, die höchste Karte der Runde zu spielen und damit alle anderen Karten dieser Runde zu "stechen" und einzusammeln.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Die Karten folgen mit ihrem Stichwert einer festen Rangfolge:
|
||||||
|
|
||||||
|
Sieben ist höher als Sechs, Acht ist höher als Sieben und so weiter. Die stärkste Karte ist das Ass, das alle anderen Karten sticht, während der Sechser die schwächste Karte ist.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Zusammengefasst sticht eine höhere Karte immer eine niedrige.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Der Spieler, der als Erster eine Karte ausspielt, bestimmt den sogenannte Farbzwang. Das bedeutet, dass alle anderen Spieler eine Karte derselben Farbe legen müssen. Spielt der erste Spieler zum Beispiel ein Herz, musst auch du eine Herzkarte ausspielen. Karten anderer Farben können nur gespielt werden, wenn du keine Karte in der ausgespielten Farbe hast. In diesem Fall können diese auch nicht gewinnen und sind automatisch unterlegen (außer Trumpf – siehe weiter unten).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Der Spieler mit der stärksten Karte gewinnt den Stich für sich und legt alle gespielten Karten verdeckt vor sich hin.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Ziel ist es so viele Karten mit hoher Punktzahl (die Punkte werden unter noch erklärt) zu sammeln.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
So weit, so einfach, doch der Trumpf bringt zusätzliche Spannung und Vielfalt ins Spiel!
|
||||||
|
|
||||||
|
Jass Jassen Stechen Rangfolge für Jassregeln
|
||||||
|
Trumpf
|
||||||
|
|
||||||
|
Der Trumpf wird von einem Spieler bestimmt und verändert die übliche Rangfolge der Karten beim Stechen. Mit der Wahl des Trumpfs wird diese Farbe zur Stärksten im Spiel. So kann ein Trumpf-Sechser sogar ein Ass schlagen. In der Trumpffarbe ändert sich die Reihenfolge der Karten:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Das Ass, ist nur noch die drittstärkste Karte.
|
||||||
|
|
||||||
|
Die zweitstärkste Karte ist der Trumpf-Neuner, auch "Nell" genannt, während der Trumpf-Unter, der "Buur", zur mächtigsten Karte des gesamten Spiels wird.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Wenn du an der Reihe bist den Trumpf festzulegen, ist es klug darauf zu achten, ob du diese beiden wichtigen Karten – den Buur und das Nell – sowie möglichst viele weitere Karten dieser Farbe in deiner Hand hast.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Für den Trumpf gilt kein Farbzwang, und er kann immer gespielt werden, außer der Trumpf an sich ist der Farbzwang, dann müssen alle Spieler ebenso Trumpf spielen. Der Buur, als stärkste Karte im Spiel, ist vom Farbzwang ausgenommen. Ihr könnt diese Karte so lange zurückhalten, wie ihr möchtet, selbst wenn Trumpf gespielt wird.
|
||||||
|
|
||||||
|
|
||||||
|
Jass Jassen Trumpf Rangfolge für Jassregeln
|
||||||
|
Rangfolge der Karten im Jass
|
||||||
|
|
||||||
|
Jass Jassen Trumpf Stichwert Rangfolge für Jassregeln
|
||||||
|
Bock, Gais & Slalom (Zusatzregel)
|
||||||
|
|
||||||
|
Anstelle einer Trumpffarbe kann man auch Bock, Gaiß oder Slalom ansagen, was dem Spiel zusätzliche Würze verleiht.
|
||||||
|
|
||||||
|
Es sollte jedoch zu Beginn des Spiels ausgemacht werden, ob Bock, Gaiß und Slalom angesagt werden darf.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Bei Bock bleibt die übliche Rangfolge (ohne Trumpf) bestehen: Das Ass ist die stärkste Karte, gefolgt vom König und so weiter.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Gaiß hingegen dreht die Hierarchie um: Hier wird der Sechser zur stärksten Karte, gefolgt vom Siebener, während das Ass zur schwächsten Karte wird.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Slalom bringt noch mehr Abwechslung, denn dabei wechselt die Rangfolge in jeder Runde: Mal gelten die Regeln von Bock, mal die von Gaiß. Mit was begonnen wird entscheidet der Mitspieler, der den Slalom angesagt hat.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Wenn du viele hohe Karten, insbesondere Asse, in deiner Hand hast, solltest du Bock ansagen. Bei vielen niedrigen Karten, besonders Sechsern, bietet sich Gaiß an. Solltest du eine Mischung aus hohen und niedrigen Karten haben, ist Slalom eine clevere Wahl.
|
||||||
|
|
||||||
|
Punkte
|
||||||
|
|
||||||
|
Beim Jassen zählt nicht nur, wer den Stich gewinnt, sondern auch wie viele Punkte die Karten bringen. Am Ende jeder Runde werden die gestochenen Karten jedes Mitspielers oder Teams gezählt. Der Spieler oder das Team mit der höchsten Punktzahl gewinnt die Runde und schließlich das Spiel!
|
||||||
|
|
||||||
|
Insgesamt gibt es 157 Punkte pro Runde, einschließlich der Punkte für das Nell und den Buur. 152 Punkte durch die Karten und der letzte Stich bringt zusätzliche 5 Bonuspunkte.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Hier findest du eine Übersicht, wie viel Punkte jede Karte zählt:
|
||||||
|
|
||||||
|
Jassen Jasskarten Punkte Übersicht für Jassregeln
|
||||||
|
Beim Spielen mit Bock, Gaiß und Slalom ist die Achterkarte 8 Punkte wert, um die fehlenden Punkte von Buur und Nell auszugleichen, da ja ohne Trumpf gespielt wird.
|
||||||
|
|
||||||
|
Matsch und gegnerische Weis-Punkte: Wenn ein Team oder Spieler alle neun Stiche macht, nennt man das einen Matsch. Dafür gibt es 100 Extra-Punkte, also insgesamt 257 Punkte. Das Team ohne einen Stich bekommt auch keine Weis-Punkte.
|
||||||
|
|
||||||
|
Weisen (Zusatzregel)
|
||||||
|
|
||||||
|
Für erfahrene Jassrunden gibt es die optionale Spielmöglichkeit, schon im ersten Stich Extrapunkte zu sammeln. Wer bestimmte Kartenkombinationen auf der Hand hält, kann diese als Weis ansagen. Eine besondere Form des Weisen ist der Stöck. Der Spieler mit dem höchsten Weis erhält die Zusatzpunkte und zeigt seine gewiesenen Karten.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Mehr Infos zu dieser Spielregeln für Fortgeschrittene findest du hier.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Die wichtigsten Punkte zum Weisen auf einen Blick:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Ansagen: Der Weis muss direkt beim Ausspielen der ersten eigene Karte angesagt werden (z.B. Vier Zehner). Spätere Ausrufe sind ungültig.
|
||||||
|
|
||||||
|
Höchster Weis: Nur der Spieler mit dem höchsten Weis erhält die Punkte.
|
||||||
|
|
||||||
|
Teamspiele: In Teamspielen darf der Mitspieler seinen Weis ebenfalls anrechnen, wenn sein Partner den höchsten Weis hat.
|
||||||
|
|
||||||
|
Keine Punkte ohne Stich: Weispunkte werden nur gezählt, wenn man mindestens einen Stich gemacht hat (außer bei den Stöck).
|
||||||
|
|
||||||
|
Gleich hohe Weise: Bei gleichen Weisen zählt der Weis in der Trumpffarbe mehr. Wenn beide keinen Trumpf haben, zählt der zuerst angesagte Weis.
|
||||||
|
|
||||||
|
Nur ein Weis: Eine Karte kann nicht für mehrere Weise verwendet werden. Nur der höhere Weis zählt.
|
||||||
|
|
||||||
|
Keine umgekehrte Reihenfolge bei Gaiß: Auch bei Gaiß bleibt Ass die höchste Karte für den Weis, unabhängig von den Spielregeln.
|
||||||
|
|
||||||
|
Stöck: Dies ist eine besondere Form des Weisens. Hält man den Trumpf Ober und den Trumpf König in der Hand, kann man beim Ausspielen der zweiten dieser Karte, 20 Zusatzpunkte ansagen. Die Stöck Punkte erhält man immer.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Alle Weise als Übersicht
|
||||||
|
|
||||||
|
Jassen Jasskarten Weis Weisen Übersicht für Jassregeln
|
||||||
|
Spielablauf am Beispiel Kreuzjass
|
||||||
|
|
||||||
|
Der Kreuzjass wird von vier Spielern gespielt, wobei sich jeweils zwei Spieler zu einem Team zusammenschließen. Diese Teammitglieder sitzen sich überkreuzt gegenüber, was dem Spiel seinen Namen gibt. Dadurch wechselt in jeder Runde die Reihenfolge zwischen Teammitgliedern und Gegnern.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Teambestimmung:
|
||||||
|
|
||||||
|
Natürlich können die Spieler frei entscheiden, mit wem sie im Team spielen möchten. Die korrekte Teamverteilung erfolgt jedoch durch das sichtbare Austeilen der Karten: Die beiden Spieler, die als Erste ein Ass erhalten, bilden automatisch ein Team.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Erste Spielrunde:
|
||||||
|
|
||||||
|
Beim Kreuzjassen geht es darum, als Zweierteam die meisten Stiche zu haben wobei auch der Trumpf beachtet werden muss. Damit die Trumpfansage möglichst fair abläuft, darf der Mitspieler aus dem Team die Karten bis zur Entscheidung nicht sehen. Hebt er sie hoch, gibt es 20 Punkte für das gegnerische Team.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Nach dem Austeilen der Karten, aber noch vor dem Start des Spiels (vor dem Auswerfen der ersten eigenen Karte), ist der richtige Moment zum Weisen. Verpasst man dieses kurze Zeitfenster, können wertvolle Punkte verloren gehen.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Der Spieler, der das Weli (den Sechser von Schelle) auf der Hand hat, eröffnet das Spiel und bestimmt die Trumpffarbe für die erste Runde. In der ersten Runde ist es nicht erlaubt, die Trumpfansage an den Mitspieler weiterzugeben (zu schieben), und es dürfen weder Bock, Gaiß noch Slalom angesagt werden.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Alle weiteren Spielrunde und Trumpfschieben:
|
||||||
|
|
||||||
|
Der Kartengeber und der Ansager wechseln nach jeder Runde im Uhrzeigersinn zum nächsten Spieler. Nach der ersten Spielrunde übernimmt der erste Spieler, der das Weli hatte, das Mischeln und ausgeben der Karten, der Spieler zu seiner Rechten hebt die Karten ab oder klopft, und der Spieler zu seiner Linken startet die nächste Runde und bestimmt die Trumpffarbe.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Ab jetzt können neben den Trumpffarben auch Bock, Gaiß oder Slalom gewählt werden. Ist der Ansager unsicher, darf er die Entscheidung über den Trumpf an seinen Mitspieler weitergeben, also "schieben". Dennoch bleibt es dem ursprünglichen Ansager, welche die Entscheidung von sich geschoben hat, vorbehalten, die erste Karte auszuspielen. (Im Vorfeld kann entschieden werden, ob man mit oder ohne Schieben spielt.)
|
||||||
|
|
||||||
|
Punktezählen
|
||||||
|
|
||||||
|
Jeder Spieler zählt seine eigenen Stiche, aber einer ist für das Aufschreiben der Gesamtpunkte zuständig. Dafür braucht man nur Papier und einen Stift. Beim Kreuzjass zählt nur das Team mit den wenigeren Stichen seine Punkte. Die Punkte des anderen Teams werden dann einfach ermittelt, indem man ihre Stiche von der Gesamtsumme (157) abzieht.
|
||||||
|
|
||||||
|
Der Schreiber macht zwei Spalten für die beiden Teams und drei Zeilen durch eine S-Kurve auf das Papier. Wegen diese Form nennt man das Punkteschreiben auch Schlangajass.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Striche in den Zeilen stehen für 20 Punkte (unten),
|
||||||
|
|
||||||
|
50 Punkte (Mitte) und
|
||||||
|
|
||||||
|
100 Punkte (oben).
|
||||||
|
|
||||||
|
Einzelne Punkte werden einzeln dazugeschrieben und bei Bedarf wieder gestrichen.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Ein Weis wird am besten direkt nach der ersten Runde notiert, damit er nicht vergessen wird.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Beim Matsch (Wenn ein Team alle neun Stiche des Spiels für sich gewinnt) bekommen sie zustäzliche100 Extra-Punkte, also insgesamt 257 Punkte. Das Teams ohne Punkte bekommt keine Weis-Punkte und müssen, falls aufgeschrieben, nachträglich gestrichen werden.
|
||||||
2277
package-lock.json
generated
Normal file
2277
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "kreuzjass",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^6.4.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#1a3d2e"/>
|
||||||
|
<path d="M16 6v20M6 16h20" stroke="#d4a017" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 223 B |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
491
src/App.css
Normal file
491
src/App.css
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
:root {
|
||||||
|
--felt-deep: #0f2a1f;
|
||||||
|
--felt: #1a3d2e;
|
||||||
|
--felt-lit: #245240;
|
||||||
|
--ink: #1a1612;
|
||||||
|
--ink-soft: #4a4238;
|
||||||
|
--paper: #efe6d4;
|
||||||
|
--paper-edge: #d9ccb4;
|
||||||
|
--brass: #d4a017;
|
||||||
|
--brass-deep: #a67c0a;
|
||||||
|
--chalk: #f7f1e4;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--font-display: 'Fraunces', 'Georgia', serif;
|
||||||
|
--font-ui: 'Manrope', system-ui, sans-serif;
|
||||||
|
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--chalk);
|
||||||
|
background: var(--felt-deep);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 120% 80% at 50% -10%, #2d5a45 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 80% 50% at 100% 100%, #0a1f16 0%, transparent 45%),
|
||||||
|
linear-gradient(165deg, var(--felt-lit), var(--felt-deep));
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
width: min(440px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.25rem 1.1rem 2rem;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
animation: rise 0.45s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: clamp(2.4rem, 9vw, 3.2rem);
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--brass);
|
||||||
|
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand.sm {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.55rem;
|
||||||
|
margin: 0.55rem 0 0.4rem;
|
||||||
|
color: var(--chalk);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
margin: 0;
|
||||||
|
color: color-mix(in srgb, var(--chalk) 72%, transparent);
|
||||||
|
max-width: 28ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: color-mix(in srgb, var(--chalk) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: 4px 12px 8px 4px;
|
||||||
|
padding: 1.25rem 1.15rem 1.35rem;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--paper-edge),
|
||||||
|
0 18px 40px var(--shadow),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.25);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
animation: sheet-in 0.4s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sheet-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) rotate(-0.4deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel label,
|
||||||
|
.panel fieldset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel legend {
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel input[type='text'],
|
||||||
|
.panel input[type='number'] {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid color-mix(in srgb, var(--ink) 18%, transparent);
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.55rem 0.1rem;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-bottom-color: var(--felt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel input:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
color: var(--ink) !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check input {
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
accent-color: var(--felt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg button {
|
||||||
|
border: 2px solid color-mix(in srgb, var(--ink) 15%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--paper) 70%, white);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.15s, border-color 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg button.on {
|
||||||
|
background: var(--felt);
|
||||||
|
border-color: var(--felt);
|
||||||
|
color: var(--chalk);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weis-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem 0.5rem;
|
||||||
|
border-top: 1px dashed color-mix(in srgb, var(--ink) 20%, transparent);
|
||||||
|
border-bottom: 1px dashed color-mix(in srgb, var(--ink) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview strong {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s, opacity 0.12s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--brass);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 4px 0 var(--brass-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.ghost {
|
||||||
|
background: color-mix(in srgb, var(--chalk) 12%, transparent);
|
||||||
|
color: var(--chalk);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--chalk) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: color-mix(in srgb, var(--chalk) 75%, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: 3px 14px 10px 4px;
|
||||||
|
padding: 1.15rem 0.9rem 1rem;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--paper-edge),
|
||||||
|
0 22px 48px var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 0;
|
||||||
|
animation: sheet-in 0.45s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1px 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-gutter {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
color-mix(in srgb, var(--ink) 25%, transparent) 8%,
|
||||||
|
color-mix(in srgb, var(--ink) 25%, transparent) 92%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
width: 1px;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.25rem 0.35rem;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col:not(.lead) {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(2.4rem, 12vw, 3rem);
|
||||||
|
font-weight: 650;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
animation: tick 0.35s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tick {
|
||||||
|
from {
|
||||||
|
transform: scale(1.06);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-height: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-label {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 600;
|
||||||
|
width: 1.6rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--felt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rest {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.65rem 0.25rem 0;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--ink) 12%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow: auto;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.2rem 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
animation: rise 0.3s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history .rn {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history .empty {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.3fr 1fr;
|
||||||
|
gap: 0.55rem;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.win {
|
||||||
|
background: var(--brass);
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
animation: rise 0.4s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play {
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.app {
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
426
src/App.tsx
Normal file
426
src/App.tsx
Normal file
@ -0,0 +1,426 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { Game, Round, TeamId } from './game'
|
||||||
|
import {
|
||||||
|
computeRound,
|
||||||
|
createGame,
|
||||||
|
DEFAULT_TARGET,
|
||||||
|
MATSCH_TOTAL,
|
||||||
|
newRoundId,
|
||||||
|
ROUND_TOTAL,
|
||||||
|
snakeMarks,
|
||||||
|
totals,
|
||||||
|
winner,
|
||||||
|
} from './game'
|
||||||
|
import { clearGame, loadGame, saveGame } from './storage'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
type Screen = 'setup' | 'play' | 'round'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [game, setGame] = useState<Game | null>(null)
|
||||||
|
const [screen, setScreen] = useState<Screen>('setup')
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const existing = loadGame()
|
||||||
|
if (existing && existing.rounds.length > 0) {
|
||||||
|
setGame(existing)
|
||||||
|
setScreen('play')
|
||||||
|
} else if (existing) {
|
||||||
|
setGame(existing)
|
||||||
|
setScreen('setup')
|
||||||
|
}
|
||||||
|
setReady(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (game) saveGame(game)
|
||||||
|
}, [game])
|
||||||
|
|
||||||
|
if (!ready) return null
|
||||||
|
|
||||||
|
if (screen === 'setup' || !game) {
|
||||||
|
return (
|
||||||
|
<SetupScreen
|
||||||
|
initial={game}
|
||||||
|
onStart={(next) => {
|
||||||
|
setGame(next)
|
||||||
|
setScreen('play')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screen === 'round') {
|
||||||
|
return (
|
||||||
|
<RoundScreen
|
||||||
|
game={game}
|
||||||
|
onCancel={() => setScreen('play')}
|
||||||
|
onSave={(round) => {
|
||||||
|
setGame({ ...game, rounds: [...game.rounds, round] })
|
||||||
|
setScreen('play')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayScreen
|
||||||
|
game={game}
|
||||||
|
onAddRound={() => setScreen('round')}
|
||||||
|
onUndo={() => {
|
||||||
|
if (game.rounds.length === 0) return
|
||||||
|
setGame({ ...game, rounds: game.rounds.slice(0, -1) })
|
||||||
|
}}
|
||||||
|
onNewGame={() => {
|
||||||
|
clearGame()
|
||||||
|
setGame(null)
|
||||||
|
setScreen('setup')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetupScreen({
|
||||||
|
initial,
|
||||||
|
onStart,
|
||||||
|
}: {
|
||||||
|
initial: Game | null
|
||||||
|
onStart: (game: Game) => void
|
||||||
|
}) {
|
||||||
|
const [teamA, setTeamA] = useState(initial?.teamA ?? 'Wir')
|
||||||
|
const [teamB, setTeamB] = useState(initial?.teamB ?? 'Ihr')
|
||||||
|
const [target, setTarget] = useState(initial?.target ?? DEFAULT_TARGET)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="hero">
|
||||||
|
<p className="brand">Kreuzjass</p>
|
||||||
|
<h1>Punkte zählen</h1>
|
||||||
|
<p className="lede">
|
||||||
|
Digitaler Schlangajass — nur das Team mit weniger Punkten zählt, der Rest ergibt
|
||||||
|
sich aus {ROUND_TOTAL}.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="panel setup"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onStart(
|
||||||
|
createGame(
|
||||||
|
teamA.trim() || 'Wir',
|
||||||
|
teamB.trim() || 'Ihr',
|
||||||
|
Number(target) || DEFAULT_TARGET,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Team A
|
||||||
|
<input
|
||||||
|
value={teamA}
|
||||||
|
onChange={(e) => setTeamA(e.target.value)}
|
||||||
|
maxLength={24}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Team B
|
||||||
|
<input
|
||||||
|
value={teamB}
|
||||||
|
onChange={(e) => setTeamB(e.target.value)}
|
||||||
|
maxLength={24}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Zielpunktzahl
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={100}
|
||||||
|
step={50}
|
||||||
|
value={target}
|
||||||
|
onChange={(e) => setTarget(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn primary">
|
||||||
|
Partie starten
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayScreen({
|
||||||
|
game,
|
||||||
|
onAddRound,
|
||||||
|
onUndo,
|
||||||
|
onNewGame,
|
||||||
|
}: {
|
||||||
|
game: Game
|
||||||
|
onAddRound: () => void
|
||||||
|
onUndo: () => void
|
||||||
|
onNewGame: () => void
|
||||||
|
}) {
|
||||||
|
const score = totals(game)
|
||||||
|
const champ = winner(game)
|
||||||
|
const marksA = snakeMarks(score.a)
|
||||||
|
const marksB = snakeMarks(score.b)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app play">
|
||||||
|
<header className="bar">
|
||||||
|
<p className="brand sm">Kreuzjass</p>
|
||||||
|
<p className="target">Ziel {game.target}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{champ && (
|
||||||
|
<div className="banner win" role="status">
|
||||||
|
{champ === 'a' ? game.teamA : game.teamB} hat gewonnen!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="sheet" aria-label="Punktestand">
|
||||||
|
<div className="sheet-cols">
|
||||||
|
<TeamColumn name={game.teamA} total={score.a} marks={marksA} leading={score.a >= score.b} />
|
||||||
|
<div className="sheet-gutter" aria-hidden="true" />
|
||||||
|
<TeamColumn name={game.teamB} total={score.b} marks={marksB} leading={score.b >= score.a} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="history">
|
||||||
|
{game.rounds.length === 0 && (
|
||||||
|
<li className="empty">Noch keine Runde — tippe unten auf «Runde».</li>
|
||||||
|
)}
|
||||||
|
{[...game.rounds].reverse().map((round, i) => {
|
||||||
|
const r = computeRound(round)
|
||||||
|
const n = game.rounds.length - i
|
||||||
|
return (
|
||||||
|
<li key={round.id}>
|
||||||
|
<span className="rn">R{n}</span>
|
||||||
|
<span>
|
||||||
|
{r.pointsA}
|
||||||
|
{round.matsch && round.team === 'a' ? ' M' : ''}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{r.pointsB}
|
||||||
|
{round.matsch && round.team === 'b' ? ' M' : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="actions">
|
||||||
|
<button type="button" className="btn ghost" onClick={onUndo} disabled={game.rounds.length === 0}>
|
||||||
|
Rückgängig
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn primary" onClick={onAddRound} disabled={!!champ}>
|
||||||
|
Runde
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn ghost" onClick={onNewGame}>
|
||||||
|
Neu
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamColumn({
|
||||||
|
name,
|
||||||
|
total,
|
||||||
|
marks,
|
||||||
|
leading,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
total: number
|
||||||
|
marks: ReturnType<typeof snakeMarks>
|
||||||
|
leading: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`col ${leading ? 'lead' : ''}`}>
|
||||||
|
<h2>{name}</h2>
|
||||||
|
<p className="total">{total}</p>
|
||||||
|
<div className="snake" aria-hidden="true">
|
||||||
|
<SnakeRow label="100" count={marks.hundreds} />
|
||||||
|
<SnakeRow label="50" count={marks.fifties} />
|
||||||
|
<SnakeRow label="20" count={marks.twenties} />
|
||||||
|
{marks.rest > 0 && <p className="rest">+{marks.rest}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SnakeRow({ label, count }: { label: string; count: number }) {
|
||||||
|
if (count === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="snake-row">
|
||||||
|
<span className="snake-label">{label}</span>
|
||||||
|
<span className="ticks">{'丨'.repeat(count)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoundScreen({
|
||||||
|
game,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
game: Game
|
||||||
|
onCancel: () => void
|
||||||
|
onSave: (round: Round) => void
|
||||||
|
}) {
|
||||||
|
const [team, setTeam] = useState<TeamId>('a')
|
||||||
|
const [cardPoints, setCardPoints] = useState('')
|
||||||
|
const [matsch, setMatsch] = useState(false)
|
||||||
|
const [weisA, setWeisA] = useState('')
|
||||||
|
const [weisB, setWeisB] = useState('')
|
||||||
|
|
||||||
|
const parsedCards = cardPoints === '' ? 0 : Number(cardPoints)
|
||||||
|
const parsedWeisA = weisA === '' ? 0 : Number(weisA)
|
||||||
|
const parsedWeisB = weisB === '' ? 0 : Number(weisB)
|
||||||
|
|
||||||
|
const preview = computeRound({
|
||||||
|
id: 'preview',
|
||||||
|
team,
|
||||||
|
cardPoints: parsedCards,
|
||||||
|
matsch,
|
||||||
|
weisA: parsedWeisA,
|
||||||
|
weisB: parsedWeisB,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardsValid =
|
||||||
|
matsch ||
|
||||||
|
(Number.isFinite(parsedCards) &&
|
||||||
|
parsedCards >= 0 &&
|
||||||
|
parsedCards < ROUND_TOTAL &&
|
||||||
|
Number.isInteger(parsedCards))
|
||||||
|
|
||||||
|
const teamName = team === 'a' ? game.teamA : game.teamB
|
||||||
|
const loserWeisDisabled = (side: TeamId) => matsch && team !== side
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="bar">
|
||||||
|
<p className="brand sm">Kreuzjass</p>
|
||||||
|
<button type="button" className="link" onClick={onCancel}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="panel round"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!cardsValid) return
|
||||||
|
onSave({
|
||||||
|
id: newRoundId(),
|
||||||
|
team,
|
||||||
|
cardPoints: matsch ? 0 : parsedCards,
|
||||||
|
matsch,
|
||||||
|
weisA: loserWeisDisabled('a')
|
||||||
|
? 0
|
||||||
|
: Number.isFinite(parsedWeisA)
|
||||||
|
? Math.max(0, Math.round(parsedWeisA))
|
||||||
|
: 0,
|
||||||
|
weisB: loserWeisDisabled('b')
|
||||||
|
? 0
|
||||||
|
: Number.isFinite(parsedWeisB)
|
||||||
|
? Math.max(0, Math.round(parsedWeisB))
|
||||||
|
: 0,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1>Neue Runde</h1>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>{matsch ? 'Wer hat den Matsch?' : 'Wer hat weniger Punkte?'}</legend>
|
||||||
|
<div className="seg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={team === 'a' ? 'on' : ''}
|
||||||
|
onClick={() => setTeam('a')}
|
||||||
|
>
|
||||||
|
{game.teamA}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={team === 'b' ? 'on' : ''}
|
||||||
|
onClick={() => setTeam('b')}
|
||||||
|
>
|
||||||
|
{game.teamB}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={matsch}
|
||||||
|
onChange={(e) => setMatsch(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Matsch (alle 9 Stiche → {MATSCH_TOTAL})
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!matsch && (
|
||||||
|
<label>
|
||||||
|
Punkte von {teamName}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
max={ROUND_TOTAL - 1}
|
||||||
|
value={cardPoints}
|
||||||
|
onChange={(e) => setCardPoints(e.target.value)}
|
||||||
|
placeholder="0–156"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="weis-grid">
|
||||||
|
<label>
|
||||||
|
Weis {game.teamA}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
value={weisA}
|
||||||
|
onChange={(e) => setWeisA(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={loserWeisDisabled('a')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Weis {game.teamB}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
value={weisB}
|
||||||
|
onChange={(e) => setWeisB(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
disabled={loserWeisDisabled('b')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="preview" aria-live="polite">
|
||||||
|
<div>
|
||||||
|
<span>{game.teamA}</span>
|
||||||
|
<strong>{preview.pointsA}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{game.teamB}</span>
|
||||||
|
<strong>{preview.pointsB}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn primary" disabled={!cardsValid}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
106
src/game.ts
Normal file
106
src/game.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
export type TeamId = 'a' | 'b'
|
||||||
|
|
||||||
|
export const ROUND_TOTAL = 157
|
||||||
|
export const MATSCH_TOTAL = 257
|
||||||
|
export const DEFAULT_TARGET = 1000
|
||||||
|
|
||||||
|
export interface Round {
|
||||||
|
id: string
|
||||||
|
/**
|
||||||
|
* Ohne Matsch: Team mit den wenigeren Kartenpunkten.
|
||||||
|
* Mit Matsch: Team, das den Matsch gemacht hat.
|
||||||
|
*/
|
||||||
|
team: TeamId
|
||||||
|
/** Kartenpunkte des Teams mit weniger Punkten (0–156). Bei Matsch ignoriert. */
|
||||||
|
cardPoints: number
|
||||||
|
matsch: boolean
|
||||||
|
weisA: number
|
||||||
|
weisB: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Game {
|
||||||
|
teamA: string
|
||||||
|
teamB: string
|
||||||
|
target: number
|
||||||
|
rounds: Round[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundResult {
|
||||||
|
pointsA: number
|
||||||
|
pointsB: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeRound(round: Round): RoundResult {
|
||||||
|
const { team, cardPoints, matsch, weisA, weisB } = round
|
||||||
|
|
||||||
|
if (matsch) {
|
||||||
|
if (team === 'a') {
|
||||||
|
return { pointsA: MATSCH_TOTAL + weisA, pointsB: 0 }
|
||||||
|
}
|
||||||
|
return { pointsA: 0, pointsB: MATSCH_TOTAL + weisB }
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamped = Math.max(0, Math.min(ROUND_TOTAL - 1, Math.round(cardPoints)))
|
||||||
|
const other = ROUND_TOTAL - clamped
|
||||||
|
|
||||||
|
if (team === 'a') {
|
||||||
|
return {
|
||||||
|
pointsA: clamped + weisA,
|
||||||
|
pointsB: other + weisB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pointsA: other + weisA,
|
||||||
|
pointsB: clamped + weisB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totals(game: Game): { a: number; b: number } {
|
||||||
|
return game.rounds.reduce(
|
||||||
|
(acc, round) => {
|
||||||
|
const r = computeRound(round)
|
||||||
|
return { a: acc.a + r.pointsA, b: acc.b + r.pointsB }
|
||||||
|
},
|
||||||
|
{ a: 0, b: 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function winner(game: Game): TeamId | null {
|
||||||
|
const { a, b } = totals(game)
|
||||||
|
if (a >= game.target && a > b) return 'a'
|
||||||
|
if (b >= game.target && b > a) return 'b'
|
||||||
|
if (a >= game.target && b >= game.target) {
|
||||||
|
if (a > b) return 'a'
|
||||||
|
if (b > a) return 'b'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGame(
|
||||||
|
teamA = 'Team A',
|
||||||
|
teamB = 'Team B',
|
||||||
|
target = DEFAULT_TARGET,
|
||||||
|
): Game {
|
||||||
|
return { teamA, teamB, target, rounds: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newRoundId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schlangajass-Darstellung: Striche für 100 / 50 / 20 + Rest */
|
||||||
|
export function snakeMarks(points: number): {
|
||||||
|
hundreds: number
|
||||||
|
fifties: number
|
||||||
|
twenties: number
|
||||||
|
rest: number
|
||||||
|
} {
|
||||||
|
let rest = Math.max(0, points)
|
||||||
|
const hundreds = Math.floor(rest / 100)
|
||||||
|
rest %= 100
|
||||||
|
const fifties = Math.floor(rest / 50)
|
||||||
|
rest %= 50
|
||||||
|
const twenties = Math.floor(rest / 20)
|
||||||
|
rest %= 20
|
||||||
|
return { hundreds, fifties, twenties, rest }
|
||||||
|
}
|
||||||
5
src/index.css
Normal file
5
src/index.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,650&family=Manrope:wght@500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
33
src/storage.ts
Normal file
33
src/storage.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { Game } from './game'
|
||||||
|
import { createGame, DEFAULT_TARGET } from './game'
|
||||||
|
|
||||||
|
const KEY = 'kreuzjass-partie'
|
||||||
|
|
||||||
|
export function loadGame(): Game | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const data = JSON.parse(raw) as Game
|
||||||
|
if (!data || !Array.isArray(data.rounds)) return null
|
||||||
|
return {
|
||||||
|
teamA: data.teamA || 'Team A',
|
||||||
|
teamB: data.teamB || 'Team B',
|
||||||
|
target: data.target || DEFAULT_TARGET,
|
||||||
|
rounds: data.rounds,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveGame(game: Game): void {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(game))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGame(): void {
|
||||||
|
localStorage.removeItem(KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOrResume(): Game {
|
||||||
|
return loadGame() ?? createGame()
|
||||||
|
}
|
||||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
tsconfig.node.json
Normal file
23
tsconfig.node.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user