Initial commit: Kreuzjass Punkte-App mit Ziel 1000.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Stefan 2026-07-25 18:26:25 +02:00
commit 2cd921d609
22 changed files with 3769 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View 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
View 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
View 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
View 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
View 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="0156"
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

106
src/game.ts Normal file
View 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 (0156). 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

23
tsconfig.node.json Normal file
View 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
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})