← Alle Beiträge

Ein Minispiel ohne grafische Assets für seine Spielobjekte entwickeln? Das geht!

Bubble Penetration zeigt, wie ein komplettes Arcade-Spiel mit OpenGL, mathematisch erzeugten Meshes und einer sauberen Trennung von Game-Core Modul und UI entsteht.

  • Android
  • OpenGL
  • Kotlin
  • Game Development
  • Architektur

Ein Bubble-Spiel braucht Blasen. Ein Stern braucht Spitzen. Bei Bubble Penetration entsteht beides direkt im Code: Die Spielobjekte verwenden keine importierten Texturen, 3D-Modelle oder Sprite-Atlanten. Stattdessen entsteht die sichtbare Spielwelt aus Geometrie, Farben, Licht und ein wenig Mathematik. Der Rest ist OpenGL – serviert mit einer kleinen Prise Chili.

Das ist nicht nur ein optischer Trick. Weil die Spielfiguren aus Daten erzeugt werden, kann das eigentliche Game-Core Modul unabhängig vom Android-UI und vom Renderer arbeiten. Genau diese Grenze ist inzwischen ein zentraler Teil der Architektur.

Die kurze Architektur

Touch / UI
    |
    v
normalisierte Eingabe
    |
    v
Game-Core Modul: Regeln, State, Zeit, Replay
    |
    v
GameObject + Mesh-Daten
    |
    v
Android/OpenGL: Transformation, Licht, Blending, Draw Calls

Das game-core Modul kennt weder Android noch GL10. Es entscheidet, ob ein Tap eine Blase trifft, wie viel Zeit gutgeschrieben wird und wann eine Combo endet. Die Android-Seite übersetzt den Zustand anschließend in eine Darstellung.

OpenGL beginnt mit Geometrie, nicht mit Bildern

OpenGL zeichnet keine „Blase“. OpenGL zeichnet Vertices. Erst aus Positionen, Normalen, Farben und dem aktuellen Render-State entsteht ein sichtbares Objekt.

Für Bubble Penetration ist der kleinste gemeinsame Nenner deshalb ein renderer-neutrales Datenobjekt:

data class MeshData(
    val vertices: FloatArray,
    val normals: FloatArray? = null,
    val colors: FloatArray? = null,
) {
    val vertexCount: Int get() = vertices.size / 3
}

Jeder Vertex besteht aus drei Gleitkommazahlen für x, y und z. Normalen beschreiben, in welche Richtung eine Oberfläche zeigt. Sie sind entscheidend für Beleuchtung: Bei einer Kugel ist der normalisierte Positionsvektor gleichzeitig die nach außen gerichtete Normale.

Die Geometrie wird im gemeinsamen Kotlin-Code erzeugt. Der Android-Renderer erhält keine Bilddatei, sondern Daten, die er an OpenGL übergibt.

Die Bubble: eine Kugel aus Streifen

Eine Kugel lässt sich als Rotationsfläche beschreiben. Wir teilen den Höhenwinkel von Südpol bis Nordpol und den Umlaufwinkel rund um die Achse in kleine Schritte. Für jeden Höhenabschnitt entstehen zwei Ringe, die mit einem GL_TRIANGLE_STRIP verbunden werden.

val radius = cos(latitude)
val height = sin(latitude)
val x = radius * cos(longitude)
val z = radius * -sin(longitude)

vertices += listOf(x, height, z)
normals += listOf(x, height, z)

Der echte Mesh-Generator arbeitet mit einem frei wählbaren Winkelabstand. Gekürzt auf die wesentlichen Schleifen sieht er so aus:

object BubbleMeshFactory {
    fun create(smoothnessDegrees: Float = 12f): List<MeshData> {
        require(smoothnessDegrees > 0f)
        val strips = mutableListOf<MeshData>()
        var latitude = -90f
        while (latitude < 90f) {
            val vertices = mutableListOf<Float>()
            val r1 = cos(latitude * PI / 180.0).toFloat()
            val r2 = cos((latitude + smoothnessDegrees) * PI / 180.0).toFloat()
            val h1 = sin(latitude * PI / 180.0).toFloat()
            val h2 = sin((latitude + smoothnessDegrees) * PI / 180.0).toFloat()
            var longitude = 0f
            while (longitude <= 360f) {
                val cosine = cos(longitude * PI / 180.0).toFloat()
                val sine = (-sin(longitude * PI / 180.0)).toFloat()
                vertices += listOf(
                    r2 * cosine, h2, r2 * sine,
                    r1 * cosine, h1, r1 * sine,
                )
                longitude += smoothnessDegrees
            }
            strips += MeshData(vertices.toFloatArray(), vertices.toFloatArray())
            latitude += smoothnessDegrees
        }
        return strips
    }
}

Kleinere Schritte machen die Kugel glatter, erzeugen aber mehr Vertices. Größere Schritte sparen Aufwand und lassen die Blase kantiger erscheinen. Diese Stellschraube ist angenehmer als ein neues Asset zu exportieren: Die Form bleibt reproduzierbar und direkt im Code kontrollierbar.

Beim Zeichnen übernimmt OpenGL den eigentlichen Streifen:

gl.glEnableClientState(GL10.GL_VERTEX_ARRAY)
gl.glEnableClientState(GL10.GL_NORMAL_ARRAY)

for (strip in mesh.strips) {
    strip.vertices.position(0)
    gl.glVertexPointer(3, GL10.GL_FLOAT, 0, strip.vertices)
    strip.normals.position(0)
    gl.glNormalPointer(GL10.GL_FLOAT, 0, strip.normals)
    gl.glDrawArrays(GL10.GL_TRIANGLE_STRIP, 0, strip.vertexCount)
}

gl.glDisableClientState(GL10.GL_NORMAL_ARRAY)
gl.glDisableClientState(GL10.GL_VERTEX_ARRAY)

Der visuelle Charakter entsteht aus dem Render-State: transparente Halo-Schicht, additive Überblendung, Materialfarbe, Spekularlicht und eine zweite, normale Darstellung der Oberfläche.

gl.glDisable(GL10.GL_LIGHTING)
gl.glDepthMask(false)
gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE)
gl.glColor4f(red, green, blue, haloAlpha)
gl.glPushMatrix()
gl.glScalef(haloScale, haloScale, haloScale)
drawMesh(gl)
gl.glPopMatrix()

gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA)
gl.glDepthMask(true)
gl.glEnable(GL10.GL_LIGHTING)
drawMesh(gl)

Der Halo ist also kein Bild am Rand der Blase. Er ist dieselbe Geometrie, ein wenig größer, mit anderem Blending. Das spart Assets und macht die Animation skalierbar.

Der Star: ein 2D-Entwurf mit 3D-Kante

Der Stern folgt einer anderen Idee. Seine Vorderseite wird aus einer Punktliste und einer Dreiecksdefinition aufgebaut:

private val points = arrayOf(
    floatArrayOf(0f, 1f),
    floatArrayOf(-0.5f, 0f),
    floatArrayOf(0.5f, 0f),
    floatArrayOf(-1.5f, 0f),
    floatArrayOf(-0.75f, -0.75f),
    floatArrayOf(-1f, -1.75f),
    floatArrayOf(0f, -1.25f),
    floatArrayOf(1.5f, 0f),
    floatArrayOf(0.75f, -0.75f),
    floatArrayOf(1f, -1.75f),
)

private val triangles = intArrayOf(
    0, 1, 2, 1, 3, 4, 4, 5, 6,
    8, 6, 9, 2, 8, 7, 2, 1, 4,
    4, 8, 2, 8, 4, 6,
)

Aus denselben 2D-Punkten werden mehrere Schichten extrudiert: Vorderseite, Glow, abgeschrägter Rand, Seitenfläche und ein Farbverlauf für das Frontlicht.

data class Parts(
    val face: MeshData,
    val glow: MeshData,
    val bevel: MeshData,
    val side: MeshData,
    val faceLight: MeshData,
)

StarMeshFactory liefert Geometrie, Normalen und – für das Frontlicht – Vertex-Farben. StarGl entscheidet über Materialien, Blend-Modi und damit darüber, wie daraus Glow, Goldfläche und Kante werden. So entsteht aus einer überschaubaren Punktliste ein Objekt, das räumlich wirkt, obwohl seine Grundidee zweidimensional ist.

OpenGL ist eine Zustandsmaschine

OpenGL merkt sich Blend-Modus, Tiefenschreibzugriff, Beleuchtung, aktive Vertex-Arrays und Transformationsmatrix, bis sie explizit geändert werden. Das macht Reihenfolgen wichtig:

Matrix speichern
  -> Objekt transformieren
  -> Glow mit additivem Blending zeichnen
  -> Blend-State und Depth-Mask zurücksetzen
  -> Oberfläche mit Licht zeichnen
Matrix wiederherstellen

glPushMatrix() und glPopMatrix() verhindern, dass die Position einer Blase den nächsten Stern verschiebt. Wer einen Zustand setzt, muss wissen, wann er ihn wieder zurückgibt. OpenGL ist weniger Zeichenstift als globaler Zeichenautomat.

State und Logik leben im Game-Core Modul

Ein GameObject beschreibt im Game-Core Modul Position, Geschwindigkeit, Größe, Farbe, Typ und Lebenszyklus. Das Game-Core Modul führt die Zeit fort, bewegt aktive Objekte und verarbeitet Eingaben:

suspend fun advance(seconds: Float, emit: (MatchEvent) -> Unit) {
    if (isGameOver) return

    elapsedMs += (seconds * 1_000).toLong()
    combo.expireAt(elapsedMs)
    if (timerAlarm.shouldTrigger(state.timerValueMs, elapsedMs)) {
        emit(MatchEvent.TimerAlarm)
    }
    state.timerValueMs =
        (state.timerValueMs - seconds).coerceAtLeast(0f)

    updateCollectColor(state.score, elapsedMs)
    updateGameObjects(seconds, elapsedMs)
    queuedTargets.toList().forEach { resolveTarget(it, emit) }
    queuedTargets.clear()

    if (state.timerValueMs <= 0f) {
        isGameOver = true
        emit(MatchEvent.GameOver)
    }

    emit(MatchEvent.StateChanged(snapshot()))
}

Wichtig ist, was hier nicht vorkommt: kein GLSurfaceView, keine Matrix, kein Android-Context, kein Pixelmaß. Die Methode kann in einem Unit-Test laufen.

Auch Eingaben bleiben neutral. Die Android-Szene normalisiert einen Touch; das Game-Core Modul akzeptiert dann nur erreichbare, noch gültige Ziele innerhalb ihrer Trefferfläche:

fun submitTap(normalizedX: Double, normalizedY: Double): Boolean {
    if (isGameOver) return false
    val tappedAtMs = elapsedMs

    val target = state.activeGameObjects
        .filterNot { it.isDisappearFinished || it.isDisappearing }
        .filter(::isTapAllowed)
        .minByOrNull { hitDistance(it, normalizedX, normalizedY) }
        ?.takeIf { hitDistance(it, normalizedX, normalizedY) <= 1.0 }
        ?: return false
    queuedTargets += QueuedTarget(target, normalizedX, normalizedY, tappedAtMs)
    return true
}

Das Game-Core Modul entscheidet anschließend, ob die Blase die richtige Farbe hatte, ob der Stern Bonuszeit bringt und ob die Combo steigt oder zurückgesetzt wird. Der Renderer reagiert auf Zustand und Events wie TargetCollected oder GameOver.

Warum diese Trennung mehr ist als „sauberer Code“

Die Architektur liefert konkrete Vorteile:

  • Tests werden einfach. Zeitregeln, Treffererkennung, Combos und Spielende brauchen kein Android-Gerät.
  • Replays werden möglich. Eingaben können mit Zeitpunkten und Objekt-IDs aufgezeichnet und deterministisch abgespielt werden.
  • Rendering bleibt austauschbar. OpenGL ES ist die aktuelle Darstellung, aber das Game-Core Modul hängt nicht daran.
  • Fehler lassen sich lokalisieren. Falsche Punktzahl gehört ins Game-Core Modul; falsches Licht in den Renderer.
  • Asset-Freiheit bleibt erhalten. Mesh-Fabriken und Materiallogik sind überprüfbare Bausteine statt Exportdateien.

Die zentrale Regel lautet: Das Game-Core Modul liefert Fakten, der Renderer liefert Wirkung. Das Game-Core Modul sagt „ein goldener Stern liegt bei Position X, hat diese Größe und dieses Alter“. OpenGL leitet daraus unter anderem seine Rotation ab und entscheidet, wie golden, leuchtend und dreidimensional er aussieht.

Was ohne grafische Assets für Spielobjekte möglich ist

Aus mathematisch erzeugter Geometrie lassen sich unter anderem ableiten:

  • skalierbare Partikeleffekte und Halos,
  • Farbverläufe über Vertex-Farben,
  • Beleuchtung über Normalen und Materialien,
  • parametrische Animationen wie Rotation, Pop-in und Fade-out,
  • unterschiedliche Formen durch Punktlisten oder Winkelteilungen,
  • deterministische Varianten für Tests und Replays.

Assets sind deshalb nicht grundsätzlich schlecht. Für komplexe Illustrationen, Charaktere oder handgemalte Oberflächen bleiben sie die richtige Wahl. Für ein Arcade-Spiel mit klaren Formen kann Code aber gleichzeitig Werkzeug, Bauplan und Asset-Pipeline sein.

Fazit: weniger Dateien, mehr Kontrolle

Bubble Penetration beweist nicht, dass Spiele keine Grafiken brauchen. Es zeigt etwas Interessanteres: Grafische Assets sind nur eine mögliche Repräsentation von visueller Information.

Eine Kugel kann ein Bild sein – oder eine Funktion. Ein Stern kann aus einer Datei kommen – oder aus Punkten, Dreiecken und ein paar Materialzuständen. Sobald State und Logik vom UI und Rendering getrennt sind, wird daraus eine Architektur, die sich testen, erweitern und mit ungewöhnlichen Darstellungswegen füttern lässt.

Und wenn am Ende eine leuchtende Blase über den Bildschirm fliegt, merkt niemand, dass im Hintergrund kein einziges PNG dafür zuständig ist. Genau so soll gute Technik sein: sichtbar im Ergebnis, angenehm unsichtbar im Detail. 🌶️