Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TRdaLs8CxuRwMTU7V6jiLa |
||
|---|---|---|
| demo | ||
| src | ||
| test | ||
| vorlagen | ||
| .gitignore | ||
| index.js | ||
| logik.js | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| spiel.json | ||
| vite-pwa.js | ||
| vite.config.js | ||
| vitest.config.js | ||
funke ⚡
Kleine 2.5D-Spiel-Engine für den Familien-Spielplatz. Dünne Schicht über Phaser 3: isometrische Welt, Einheiten mit Wegfindung, Auto-Kampf, Touch-Bedienung, Konten + Spielstände über den Spielserver, und seit 0.6.0 ein kompletter Multiplayer-Unterbau (Freunde, Einladungen, Runden, Spielstand-Austausch) mit einem passenden UI-Baukasten.
Gedacht für Spiele, die Kinder zusammen mit Claude entwickeln —
siehe vorlagen/ für die Kind-Dokumente, die jedes Spiel-Repo bekommt.
Benutzung in einem Spiel
"dependencies": {
"funke": "git+http://<SERVER-IP>:3000/spielplatz/funke.git#v0.6.0"
}
import { startSpiel, IsoWorld, Building, Unit } from 'funke'
Öffentliche API: siehe index.js. Ein vollständiges Beispiel ist demo/main.js.
Glossar (so heißen die Dinge überall — im Code, im Spiel, in der Doku)
| Wort | Bedeutet |
|---|---|
| Zauberwort | Der Code, den man beim Konto-Erstellen eintippt. Den legt Papa am Spielserver an (cli.js code-add). Nicht verwechseln mit einer Einladung! |
| Freund | Jemand aus deiner Freundesliste. Freundschaft ist gegenseitig und liegt am Server. |
| Einladung | „Komm in meine Runde!“ — eine Spiel-Einladung. (Technisch heißen auch Freundschafts-Anfragen und Geschenke einladung, im Spiel aber „Freund werden?“ und „Geschenk“.) |
| Runde | Der Raum vor und während des Spiels (früher „Lobby“). Wer sie aufmacht, ist der Chef (im Code host), die anderen sind Mitspieler (im Code gaeste). |
| Platz | Eine Position in der Runde: frei, Computer oder Mensch (im Code slot). |
Knöpfe: Einladen (ich lade einen Freund ein) · Mitspielen (ich nehme an) · Nicht jetzt (ich lehne ab) · Los geht's! (der Chef startet) · Rausgehen (Runde verlassen) · Entfernen (Freund aus der Liste, mit Rückfrage).
Online-Anbindung: OnlineClient
startSpiel() legt einen OnlineClient an (registry.get('online'), auch
saveClient.online) und verbindet ihn nach dem Anmelden. Er ist der
einzige Besitzer des Online-Zustands. Szenen lesen ihn und hören auf
Ereignisse — sie holen nichts selbst und starten keine Netz-Timer.
Zustand (lesbar):
| Feld | Inhalt |
|---|---|
post |
alle Einladungen von mir oder an mich für dieses Spiel, aktuell |
runde |
meine Runde ({ id, nr, spiel, host, gaeste, status, slots, da, … }) oder null |
presence |
Map<name, { status, seit }> — status ∈ 'da' | 'wartet' | 'spielt' | 'weg' |
freunde |
[{ name, status, seit }] |
verbindung |
'aus' | 'verbunden' | 'getrennt' |
spielerName |
= saveClient.spielerName |
Ereignisse (online.on(typ, cb) gibt einen Abmelder zurück):
| typ | Payload | Wann |
|---|---|---|
post |
{ post, neu } |
Post hat sich geändert; neu = Einladungen mit bisher unbekanntem id:nr |
einladung |
{ einladung } |
pro Einladung in neu (Altlast) |
runde |
{ runde } |
Runde geändert; bei Ende { …alt, status:'beendet' }, danach online.runde === null |
verbindung |
{ status } |
plus verbunden {} / getrennt {} (Altlast) |
presence |
{ spieler } |
wer ist da |
freunde |
{ freunde } |
Liste neu geholt |
stand |
{ rundeId, stand, nr } |
neuer Spielstand (nur steigende nr) |
zug |
{ rundeId, zug } |
Befehl eines Mitspielers (nur Chef; Duplikate nach zug.von:zug.nr gefiltert) |
chat |
{ rundeId, nr, von, text, zeit } |
Chat-Nachricht |
fehler |
{ grund, rundeId?, text } |
Server hat eine WebSocket-Nachricht abgelehnt |
post-still |
{ anzahl } |
vom Banner: Szene hat stillePost, es warten anzahl Einladungen |
Methoden (alle async, außer den ersten sechs):
connect() / disconnect()
offenePost() // post an mich mit status 'offen'
presenceVon(name) // { status:'weg', seit:null } wenn unbekannt
watchRound(rundeId, { alsGast }) / stopWatching() // Nachfragen, falls kein WebSocket
listInvitations() // holt post neu → post
invite(an, { art='runde', menge=0, rundeId=null }) → { ok, einladung } | { ok:false, fehler, grund }
annehmen(einladung) → { ok, einladung, runde } | { ok:false, grund, fehler, einladung }
ablehnen(einladung) → { ok, einladung } | { ok:false, … }
zurueckziehen(einladung) → { ok, einladung } | { ok:false, … }
holeFreunde() / freundEntfernen(name) / freundeUebernehmen(namen) → freunde
onlinePlayers() → [{ name, status, seit }] (setzt presence)
serverCanOnline(), leaderboard(), reportTrophies(t)
createRound(slots) / myRound() / setSlots(id, slots) / startRound(id, slots) → runde | null
roundInfo(id) → runde | { id, status:'beendet' } (404/403) | null (Netz)
joinRound(id) → { ok, runde } | { ok:false, grund, fehler }
endRound(id) / rundeVerlassen()
postState(rundeId, stand) / getState(rundeId) → { stand, nr } | null
sendMove(rundeId, zug) / sendChat(rundeId, text)
grund-Werte vom Server (kebab-case): weg, laeuft, kein-platz,
keine-runde, nicht-offen, schon-freunde; vom Client: netz, fehler.
Technik: WebSocket /api/ws, Anmeldung als erste Nachricht { typ:'hallo', token } (kein Token in der URL); danach kommt ein snapshot. Nachfragen per
setInterval: Post alle 15 s, Presence alle 20 s, Runde alle 5 s solange
runde gesetzt ist. Herzschlag 25 s, Neuverbinden mit Backoff.
Szenen: KontoScene ('login'), LobbyScene ('lobby')
LobbyScene (die „Runde“):
// Chef
this.scene.start('lobby', {
nextScene: 'spiel', nextSceneSolo: '3d'?, backScene: 'start',
slots: [{ team, seite: 'mit' | 'gegen', wahl: ['frei','computer','mensch'] }],
computer: [{ name, sterne, schwierigkeit }],
sofortEinladen: 'Papa'?, sofortWahl: true?, runde?, // runde = eigene offene Runde wieder betreten
})
// Mitspieler (nach dem Annehmen)
this.scene.start('lobby', { nextScene, backScene, runde, alsGast: true })
// Ergebnis: registry 'lobbyErgebnis' = { runde, slots } (runde null = alleine)
Die Lobby scrollt selbst (ScrollListe), Fußzeile fest, Überschriften aus
Sicht des Betrachters („🤝 Dein Team“ / „⚔️ Gegner“), Chef-Zeile mit 👑.
Platz antippen öffnet ein Menü (Freund einladen / Computer / leer lassen /
Einladung zurückziehen / Rauswerfen). Einladungen zeigen eine laufende Uhr,
nach 30 s „🔔 Nochmal klingeln“. Sie hat verlasseRunde() und
beiRundeBeitreten(runde).
Einladungs-Banner
baueEinladungsBanner(game, saveClient, online) (macht startSpiel selbst).
Reine Ansicht von online.post: Karten für offene Runden-Einladungen an mich
(max. 3, jüngste oben, mit Uhr), ▶ Mitspielen / Nicht jetzt / ×.
„▶ Mitspielen“ ruft online.annehmen(e) und dann
aktiveSzene.beiRundeBeitreten(runde); hat die Szene den Haken nicht, geht es
direkt in die Lobby. Hat die aktive Szene stillePost = true (z. B. im
Kampf), gibt es keine Karten — nur (1) im Fenstertitel und das Ereignis
post-still { anzahl }; das Spiel zeigt dann sein eigenes 📬-Icon.
welcheKarten(post, ich, spielName, jetzt) ist die reine Kernfunktion (getestet).
UI-Baukasten (ui.js, tokens.js)
import { tokens, sichererRand, tippe, kuerze, knopf, zeigeDialog, Badge,
presenceEmoji, presenceText, zeile, ScrollListe, VerbindungsPille } from 'funke'
const { FARBE, TEXT, SCHRIFT, ABSTAND, RUNDUNG, HOEHE, TIEFE, SCHMAL, hex } = tokens
| Baustein | Was |
|---|---|
tippe(obj, cb) |
Tipp-Wächter: feuert nur, wenn < 10 px bewegt und < 500 ms gehalten (Wischen ist kein Tipp) |
kuerze(textObj, maxBreite) |
Text mit „…“ kürzen |
knopf(scene, { x, y, w, h, text, art, fontSize, onTap, fest }) |
Knopf im Clash-Look; art: 'primaer' | 'sekundaer' | 'gefahr' | 'geist'; .setBusy(bool[, text]), .setEnabled(bool), .setText(s); fest = klebt an der Kamera |
zeigeDialog(scene, { text, knoepfe: [{ text, art, onTap }], fest }) |
Dialog mit Deckel; jeder Knopf schließt; → { schliessen() } |
new Badge(scene, x, y, anzahl, { fest }) |
roter Zähler; .setAnzahl(n) (0 = unsichtbar) |
presenceEmoji(status) / presenceText(name, { status, seit }) |
🟢 ⏳ 🎮 💤 / „Papa ist da“, „Papa spielt gerade“, … |
zeile(scene, { x, y, breite, links, rechts, farbe, rand, onTap, knoepfe }) |
Listenzeile, zweizeilig auf schmalen Handys; .hoehe, .setLinks(), .setRechts() |
new ScrollListe(scene, { x, y, breite, hoehe }) |
scrollt selbst (Maske, Balken, Rad, Ziehen); .add(), .leeren(), .setInhaltHoehe(), .scrollZu(), .destroy() |
new VerbindungsPille(scene, online, { x, y, name }) |
grün/gelb/rot oben rechts; .setStandZeit(), .destroy() |
sichererRand() |
{ oben, unten, links, rechts } für Handy-Kerbe und Home-Leiste |
zeitVor(ms) / minutenText(ms) |
„vor 3 min“ / „0:42“ |
Szenen und Netz — die fünf Regeln
Phaser benutzt Szenen-Objekte wieder. Damit nichts vom letzten Besuch hängen bleibt, gilt in jeder Szene, die online etwas tut:
- Jedes Feld, das pro Besuch gilt, in
init()zurücksetzen (runde,slots,rundeAnfrage, Dialoge, Listen …). Nie auf Konstruktor-Zustand bauen. - Listener in
create()anmelden, Abmelder sammeln (this.aufraeumen = []); einthis.events.once('shutdown', …)ganz oben increate(), vor dem erstenawait, das alles abmeldet. - Nach jedem
await:if (!this.scene.isActive()) return. - Netz-Timing nur über den
OnlineClient. Szenen starten keinesetIntervalfür Netzdinge; sichtbare Uhren dürfen die Phaser-Uhr nehmen. - Eine Szene, die eine Runde besitzt, hat
verlasseRunde()undbeiRundeBeitreten(runde); im Shutdown räumt sie ihre Listener ab.
Als App installierbar machen (PWA)
Ein Spiel wird "installierbar" (am Handy „Zum Startbildschirm", startet dann im
Vollbild wie eine App und lädt dank Cache blitzschnell) mit funke/vite-pwa:
// vite.config.js des Spiels
import { funkePWA } from 'funke/vite-pwa'
export default defineConfig({
base: './',
plugins: [ funkePWA({ name: 'Clash of Karls', themeColor: '#1a1c2c' }) ],
})
Das Spiel legt drei Symbole in seinem publicDir (z.B. bilder/) ab:
icon-192.png (192×192), icon-512.png (512×512), favicon.png.
Manifest + Service-Worker erzeugt der Build dann automatisch.
Entwickeln an der Engine
npm install
npm run dev # Demo mit Hot-Reload (localhost:5173)
npm test # Vitest: Iso-Mathe, Wegfindung, Kampf, OnlineClient, Banner-Logik
npm run build # baut die Demo nach dist/ (macht der Deployer beim Push)
Regeln:
- Reine Logik (Mathe, Wegfindung, Kampf, Online-Zustand, Banner-Regeln) bleibt Phaser-frei und getestet.
- Nichts Spiel-Spezifisches in die Engine — keine Ressourcennamen, Einheitentypen oder Siegregeln. Das gehört in die Spiel-Repos.
- Bilder werden nur über Manifest-Schlüssel angesprochen; fehlende Bilder
bekommen automatisch Platzhalter (
src/textures.js). - Farben, Schriften, Abstände nur aus
src/tokens.js. - Release = Git-Tag (
v0.1.0, …,v0.6.0); Spiele pinnen einen Tag.