No description
Find a file
2026-09-10 08:46:38 +02:00
demo funke v0.1: Iso-Welt, Einheiten, Auto-Kampf, UI, Login/Spielstände, Demo 2026-08-09 20:55:27 +02:00
src Lobby: fest leere Plätze (nur „frei“ erlaubt) ohne Antippen, z.B. beim 1 gegen 1; v0.6.1 2026-09-10 08:46:38 +02:00
test Multiplayer v3: OnlineClient besitzt Post, Runde und Presence; Lobby mit Chef-Sicht; gestapeltes Einladungs-Banner; UI-Baukasten; v0.6.0 2026-09-09 23:17:30 +02:00
vorlagen funke v0.1: Iso-Welt, Einheiten, Auto-Kampf, UI, Login/Spielstände, Demo 2026-08-09 20:55:27 +02:00
.gitignore funke v0.1: Iso-Welt, Einheiten, Auto-Kampf, UI, Login/Spielstände, Demo 2026-08-09 20:55:27 +02:00
index.js Multiplayer v3: OnlineClient besitzt Post, Runde und Presence; Lobby mit Chef-Sicht; gestapeltes Einladungs-Banner; UI-Baukasten; v0.6.0 2026-09-09 23:17:30 +02:00
logik.js Phaser-freier Einstieg funke/logik für Spiel-Logik und Tests; v0.2.1 2026-08-09 21:35:29 +02:00
package-lock.json PWA-Support: funke/vite-pwa macht Spiele installierbar; v0.3.0 2026-08-14 16:50:28 +02:00
package.json Lobby: fest leere Plätze (nur „frei“ erlaubt) ohne Antippen, z.B. beim 1 gegen 1; v0.6.1 2026-09-10 08:46:38 +02:00
README.md Multiplayer v3: OnlineClient besitzt Post, Runde und Presence; Lobby mit Chef-Sicht; gestapeltes Einladungs-Banner; UI-Baukasten; v0.6.0 2026-09-09 23:17:30 +02:00
spiel.json funke v0.1: Iso-Welt, Einheiten, Auto-Kampf, UI, Login/Spielstände, Demo 2026-08-09 20:55:27 +02:00
vite-pwa.js PWA-Support: funke/vite-pwa macht Spiele installierbar; v0.3.0 2026-08-14 16:50:28 +02:00
vite.config.js funke v0.1: Iso-Welt, Einheiten, Auto-Kampf, UI, Login/Spielstände, Demo 2026-08-09 20:55:27 +02:00
vitest.config.js funke v0.1: Iso-Welt, Einheiten, Auto-Kampf, UI, Login/Spielstände, Demo 2026-08-09 20:55:27 +02:00

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:

  1. Jedes Feld, das pro Besuch gilt, in init() zurücksetzen (runde, slots, rundeAnfrage, Dialoge, Listen …). Nie auf Konstruktor-Zustand bauen.
  2. Listener in create() anmelden, Abmelder sammeln (this.aufraeumen = []); ein this.events.once('shutdown', …) ganz oben in create(), vor dem ersten await, das alles abmeldet.
  3. Nach jedem await: if (!this.scene.isActive()) return.
  4. Netz-Timing nur über den OnlineClient. Szenen starten keine setInterval für Netzdinge; sichtbare Uhren dürfen die Phaser-Uhr nehmen.
  5. Eine Szene, die eine Runde besitzt, hat verlasseRunde() und beiRundeBeitreten(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.