Du programmierst in C eine Welt aus Blöcken, durch die du selbst läufst: Mit der Maus siehst du dich um, du springst über Hügel, reißt Blöcke ab und baust neue. Eine Sonne wandert über den Himmel, nachts leuchten deine Fackeln, berechnet von einem Licht-Shader, den du selbst schreibst, und die Blöcke bekommen Texturen, die du selbst zeichnest.
In der Anaglyph-Aktivität beschreibst du eine Szene, und der Viewer zeigt sie. Bei PixelRAM setzt du jedes Pixel selbst. Hier schreibst du ein Programm, das 60-mal in der Sekunde reagiert: auf deine Tasten, auf deine Maus und auf eine Welt, die sich dabei verändert. Dafür verwenden wir raylib, eine Bibliothek für Spiele in C. Sie öffnet ein Fenster, liest Tastatur und Maus, zeichnet mit der Grafikkarte in 2D und 3D und spielt Töne ab. Übrigens bringt auch PixelRAM seine Pixel mit raylib auf den Bildschirm.
Du brauchst etwas C: Variablen, Schleifen, Funktionen und #define. Wenn du Echtzeitgrafik mit C gemacht hast, kennst du alles, was du brauchst. Ein wenig Mathematik kommt dazu, Sinus und Kosinus für die Blickrichtung und Vektoren für Bewegung und Licht; beides wird an der Stelle erklärt, an der du es brauchst. Alle Befehle von raylib, die vorkommen, stehen im Handbuch.
Für diese Anleitung brauchst du die raylib-Vorlage:
Stelle zuerst sicher, dass du keinen Ordner geöffnet hast. Um sicherzugehen, drücke einfach den Shortcut für »Ordner schließen«: StrgK und dann F. Dein Workspace sollte jetzt ungefähr so aussehen:

Klicke auf »Clone Repository« und füge die Adresse der Vorlage ein:
https://github.com/specht/raylib-starter.git

Bestätige mit Enter. Danach wählst du das Verzeichnis, in dem das Repository gespeichert werden soll: den Ordner /workspace.

Das Repository wird jetzt in einen Unterordner von /workspace geklont, der so heißt wie das Repository. Beantworte die Frage, ob es jetzt geöffnet werden soll, mit »Open«:

Öffne die Datei main.c und gib im Terminal ein:
make
Beim ersten Mal lädt make die Webseite herunter, in der dein Programm läuft; sie kommt von PixelRAM. Dann übersetzt es main.c in die Datei main.html: Dein C-Programm läuft als WebAssembly im Browser.


Klicke unten rechts auf »Go Live«. Im neuen Tab siehst du die Dateien des Projekts; klicke dort auf main.html. Du solltest einen orangefarbenen Würfel auf einem Gitter sehen:

make, dann die Vorschau neu laden, falls sie es nicht von selbst tut. Findet make einen Fehler, nennt es die Zeile in deiner Datei, und main.html bleibt, wie sie war. Lies die erste Fehlermeldung zuerst, die übrigen sind oft nur Folgefehler. Was dein Programm mit printf ausgibt, steht in der Konsole der Seite: Fahre mit der Maus über das Bild und klicke unten rechts auf das Terminal-Symbol.
Übersetze die Vorlage im Terminal mit make, so dass main.html
entsteht.
make muss im Ordner raylib-starter laufen. Steht im Terminal etwas
anderes vor dem $, wechsle mit cd /workspace/raylib-starter dorthin.
Sieh dir main.c an. Das ganze Programm ist eine Schleife, die raylib 60-mal in der Sekunde durchläuft, bis du die Seite schließt:
int main(void) { InitWindow(960, 540, "Klötzchenwelt"); SetTargetFPS(60); // Die Kamera: wo sie steht, wohin sie schaut und wo oben ist Camera3D camera = { 0 }; camera.position = (Vector3){ 6, 5, 8 }; camera.target = (Vector3){ 0, 0, 0 }; camera.up = (Vector3){ 0, 1, 0 }; camera.fovy = 45; camera.projection = CAMERA_PERSPECTIVE; while (!WindowShouldClose()) { BeginDrawing(); ClearBackground(SKYBLUE); BeginMode3D(camera); DrawGrid(10, 1); DrawCube((Vector3){ 0, 0.5f, 0 }, 1, 1, 1, ORANGE); DrawCubeWires((Vector3){ 0, 0.5f, 0 }, 1, 1, 1, MAROON); EndMode3D(); DrawText("Hallo, 3D!", 20, 20, 30, DARKBLUE); EndDrawing(); } CloseWindow(); return 0; }
Ein Punkt im Raum hat drei Koordinaten. In raylib zeigt x nach rechts, y nach oben und z nach vorne, auf dich zu, wenn du von vorne auf die Welt schaust. Das Gitter, das DrawGrid(10, 1) zeichnet, liegt flach auf dem Boden, also bei y = 0, und reicht von −5 bis 5 in x- und z-Richtung. Eine Einheit ist in diesem Projekt ein Block.
DrawCube(position, width, height, length, color) zeichnet einen Quader, dessen Mitte an der Position liegt. Der Würfel bei (0, 0.5f, 0) ist einen Block hoch und steht deshalb genau auf dem Boden. DrawCubeWires zeichnet dieselben Kanten noch einmal als Linien.
(Vector3){ 0, 0.5f, 0 } ist ein Wert vom Typ Vector3: drei Zahlen x, y und z, zusammengefasst wie in einem struct. raylib benutzt Vector3 für Positionen und für Richtungen. Das f hinter 0.5 macht daraus eine float-Zahl.
Was du siehst, bestimmt die Kamera:
position: wo die Kamera steht,target: welchen Punkt sie anschaut,up: welche Richtung oben ist, hier die y-Achse,fovy: wie weit der Blick nach oben und unten reicht, in Grad. Ein kleiner Wert wirkt wie ein Fernglas, ein großer wie eine Weitwinkellinse.Alles, was zwischen BeginMode3D(camera) und EndMode3D() gezeichnet wird, zeigt raylib durch diese Kamera. Was danach kommt, wie DrawText, liegt flach auf dem Bild, wie eine Beschriftung auf einer Glasscheibe.
Was siehst du, wenn du die Kamera nach (Vector3){ 0, 12, 0.1f }
stellst? Überleg erst, dann probier es aus.
Stell die Kamera danach wieder zurück. Probiere auch, weitere Würfel mit anderen Farben und Größen dazuzustellen, zum Beispiel einen langen, flachen Balken mit DrawCube((Vector3){ 2, 0.25f, 0 }, 3, 0.5f, 1, DARKGREEN). Die Namen der Farben, die raylib kennt, stehen im Handbuch.
Ein paar Würfel kann man einzeln hinschreiben. Eine ganze Welt braucht eine andere Idee: Wir teilen den Raum in ein Gitter aus Feldern, jedes Feld ist einen Block groß, und für jedes Feld merken wir uns, was dort ist: Luft, Gras, Erde, Stein … Das ist ein dreidimensionales Array.
Ersetze alles, was in main.c über int main(void) steht, durch diese Zeilen:
#include <math.h> #include <stdlib.h> #include <raylib.h> // ---------------------------------------------------------------- Die Welt #define WIDTH 48 // x #define HEIGHT 24 // y, nach oben #define DEPTH 48 // z enum { AIR, GRASS, DIRT, STONE, WOOD, LEAVES, SAND, BLOCK_TYPES }; Color colors[BLOCK_TYPES] = { { 0, 0, 0, 0 }, { 96, 168, 64, 255 }, // Gras { 130, 92, 62, 255 }, // Erde { 125, 125, 130, 255 }, // Stein { 150, 110, 64, 255 }, // Holz { 52, 128, 56, 255 }, // Laub { 222, 204, 150, 255 }, // Sand }; unsigned char world[WIDTH][HEIGHT][DEPTH]; // Der Block an einer Stelle; außerhalb der Welt ist Luft int block(int x, int y, int z) { if (x < 0 || y < 0 || z < 0 || x >= WIDTH || y >= HEIGHT || z >= DEPTH) return AIR; return world[x][y][z]; }
WIDTH, HEIGHT und DEPTH sind die Größe der Welt in Blöcken.enum gibt jeder Blockart eine Nummer: AIR ist 0, GRASS ist 1 und so weiter. BLOCK_TYPES ist dann automatisch die Anzahl der Arten.colors hat für jede Art eine Farbe, in derselben Reihenfolge. colors[STONE] ist das Grau des Steins. Die vier Zahlen sind Rot, Grün, Blau und Deckkraft, jeweils von 0 bis 255.world[x][y][z] ist der Block an einer Stelle. unsigned char ist eine kleine Zahl von 0 bis 255, mehr brauchen wir für eine Blockart nicht.block(x, y, z) liest einen Block, sagt aber für Stellen außerhalb der Welt einfach AIR, statt über den Rand des Arrays hinaus zu lesen. Das wird gleich wichtig.Wie viele Felder hat das Array world?
Noch ist die Welt leer: Ein globales Array ist in C am Anfang mit Nullen gefüllt, also mit AIR. Für die Landschaft berechnen wir für jede Stelle (x, z) eine Höhe und füllen die Säule darunter mit Blöcken: oben Gras, darunter drei Lagen Erde, ganz unten Stein. Die Höhe entsteht aus drei Sinuswellen mit verschiedenen Längen, die sich überlagern, so wie die Wellen bei PixelRAM. Wo die Welt niedrig ist, liegt Sand.
void tree(int x, int y, int z) { for (int i = 0; i < 4; i++) world[x][y + i][z] = WOOD; for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) for (int dy = 3; dy <= 5; dy++) if (abs(dx) + abs(dz) + (dy - 3) <= 3 && block(x + dx, y + dy, z + dz) == AIR) world[x + dx][y + dy][z + dz] = LEAVES; } void generate_world(void) { for (int x = 0; x < WIDTH; x++) { for (int z = 0; z < DEPTH; z++) { // Hügel aus drei Wellen, die sich überlagern float h = 7.0f + 2.0f * sinf(x * 0.13f) + 1.5f * cosf(z * 0.11f) + 1.5f * sinf((x + z) * 0.07f); int height = (int)h; for (int y = 0; y < height; y++) { if (y == height - 1) world[x][y][z] = height <= 5 ? SAND : GRASS; else if (y >= height - 3) world[x][y][z] = DIRT; else world[x][y][z] = STONE; } } } SetRandomSeed(7); for (int i = 0; i < 14; i++) { int x = GetRandomValue(3, WIDTH - 4); int z = GetRandomValue(3, DEPTH - 4); int y = HEIGHT - 7; while (y > 0 && block(x, y - 1, z) == AIR) y--; if (block(x, y - 1, z) == GRASS) tree(x, y, z); } }
Am Ende stellt generate_world noch 14 Bäume an zufällige Stellen. SetRandomSeed(7) sorgt dafür, dass die »zufälligen« Stellen bei jedem Start dieselben sind. Mit einer anderen Zahl wächst ein anderer Wald. Ein Baum ist ein Stamm aus vier Holzblöcken und eine Krone aus Laub, die nach oben schmaler wird: abs(dx) + abs(dz) ist der Abstand vom Stamm, gemessen in Schritten entlang der Achsen.
Jetzt zeichnen wir die Welt. Für jeden Block einen DrawCube aufzurufen, wäre viel Arbeit für den Computer: Die meisten Blöcke stecken im Boden, und niemand sieht sie. Gezeichnet wird deshalb nur ein Block, der an Luft grenzt:
bool visible(int x, int y, int z) { return block(x + 1, y, z) == AIR || block(x - 1, y, z) == AIR || block(x, y + 1, z) == AIR || block(x, y - 1, z) == AIR || block(x, y, z + 1) == AIR || block(x, y, z - 1) == AIR; }
Hier hilft, dass block außerhalb der Welt AIR sagt: Am Rand gilt ein Block als sichtbar, ohne dass wir über das Array hinaus lesen.
Ersetze main durch diese Fassung. Die Kamera steht jetzt hoch über der Welt, und UpdateCamera(&camera, CAMERA_ORBITAL) lässt sie langsam um ihr Ziel kreisen, die Mitte der Welt:
int main(void) { InitWindow(960, 540, "Klötzchenwelt"); SetTargetFPS(60); generate_world(); Camera3D camera = { 0 }; camera.position = (Vector3){ -20, 30, -20 }; camera.target = (Vector3){ WIDTH / 2, 4, DEPTH / 2 }; camera.up = (Vector3){ 0, 1, 0 }; camera.fovy = 45; camera.projection = CAMERA_PERSPECTIVE; while (!WindowShouldClose()) { // raylib dreht die Kamera langsam um ihr Ziel UpdateCamera(&camera, CAMERA_ORBITAL); BeginDrawing(); ClearBackground(SKYBLUE); BeginMode3D(camera); int blocks = 0; for (int x = 0; x < WIDTH; x++) for (int y = 0; y < HEIGHT; y++) for (int z = 0; z < DEPTH; z++) if (world[x][y][z] != AIR && visible(x, y, z)) { Vector3 center = { x + 0.5f, y + 0.5f, z + 0.5f }; DrawCube(center, 1, 1, 1, colors[world[x][y][z]]); DrawCubeWires(center, 1, 1, 1, Fade(BLACK, 0.25f)); blocks++; } EndMode3D(); DrawText(TextFormat("%d Blöcke %d FPS", blocks, GetFPS()), 10, 10, 20, DARKBLUE); EndDrawing(); } CloseWindow(); return 0; }

Oben links zählt das Programm, wie viele Blöcke es zeichnet, und wie viele Bilder pro Sekunde (FPS) es schafft. Behalte die Zahl im Auge: Später zeichnen wir die Welt viel geschickter.
block außerhalb der Welt STONE statt AIR sagen würde?
Bis jetzt schwebt die Kamera über der Welt. Jetzt gehst du hinein: Die Kamera wird zu deinen Augen.
Eine Figur in der Welt hat eine Position und eine Blickrichtung. Die Position sind ihre Füße. Die Blickrichtung beschreiben wir mit zwei Winkeln: yaw dreht nach links und rechts, pitch nach oben und unten. Daraus berechnet view_direction() einen Vektor, der vom Auge aus nach vorne zeigt:
// --------------------------------------------------------------- Die Figur Vector3 feet; // Mitte der Fußsohlen float yaw = 0; // nach links und rechts, im Bogenmaß float pitch = 0; // nach oben und unten #define EYE_HEIGHT 1.6f Vector3 view_direction(void) { return (Vector3){ sinf(yaw) * cosf(pitch), sinf(pitch), cosf(yaw) * cosf(pitch), }; }
Winkel misst C im Bogenmaß: Eine halbe Umdrehung ist π (etwa 3,14), eine ganze 2π. Ohne Neigung bleiben von view_direction nur Sinus und Kosinus der Drehung:
yaw |
Blickrichtung (x, y, z) | du schaust … |
|---|---|---|
| 0 | (0, 0, 1) | nach vorne, in z-Richtung |
| π/2 ≈ 1,57 | (1, 0, 0) | nach rechts, in x-Richtung |
Ist die Neigung nicht 0, wird der Blick um cosf(pitch) kürzer in der Waagerechten, und sinf(pitch) gibt an, wie weit er nach oben zeigt. So hat der Vektor immer die Länge 1.
Die Figur hat yaw = PI (also 3,14…) und pitch = 0. In welche
Richtung zeigt view_direction()?
move_player liest die Tasten und bewegt die Figur. Schreibe sie unter view_direction:
void move_player(float dt) { // Umsehen: Maus (wenn gefangen) oder Pfeiltasten if (IsCursorHidden()) { Vector2 mouse = GetMouseDelta(); yaw -= mouse.x * 0.003f; pitch -= mouse.y * 0.003f; } if (IsKeyDown(KEY_LEFT)) yaw += 2.0f * dt; if (IsKeyDown(KEY_RIGHT)) yaw -= 2.0f * dt; if (IsKeyDown(KEY_UP)) pitch += 1.5f * dt; if (IsKeyDown(KEY_DOWN)) pitch -= 1.5f * dt; pitch = Clamp(pitch, -1.5f, 1.5f); // Laufen: vorwärts in Blickrichtung, aber ohne nach oben zu fliegen Vector3 forward = { sinf(yaw), 0, cosf(yaw) }; Vector3 right = { -cosf(yaw), 0, sinf(yaw) }; Vector3 move = { 0 }; if (IsKeyDown(KEY_W)) move = Vector3Add(move, forward); if (IsKeyDown(KEY_S)) move = Vector3Subtract(move, forward); if (IsKeyDown(KEY_D)) move = Vector3Add(move, right); if (IsKeyDown(KEY_A)) move = Vector3Subtract(move, right); move = Vector3Scale(Vector3Normalize(move), 4.5f * dt); feet = Vector3Add(feet, move); }
GetMouseDelta() den Blick: Das ist, wie weit sich die Maus seit dem letzten Bild bewegt hat. Clamp hält die Neigung zwischen −1,5 und 1,5, sonst könntest du dich über Kopf drehen.forward ist die Blickrichtung ohne Neigung, right steht im rechten Winkel dazu. Jede gedrückte Taste addiert eine dieser Richtungen zu move. Vector3Normalize macht den Vektor wieder 1 lang, sonst wärst du schräg (W und D zusammen) schneller als geradeaus.dt ist die Zeit seit dem letzten Bild in Sekunden. Wer 4,5 Blöcke pro Sekunde laufen soll, läuft in einem Bild 4.5f * dt Blöcke weit. So ist die Figur auf einem schnellen und einem langsamen Computer gleich schnell.Die Rechenbefehle für Vektoren wie Vector3Add und Vector3Scale kommen aus raymath.h. Schreibe deshalb oben unter die Zeile mit raylib.h:
#include <raymath.h>
Die Figur läuft 4,5 Blöcke pro Sekunde, und das Programm schafft 60 Bilder pro Sekunde. Wie weit bewegt sie sich in einem Bild?
In main steht die Kamera jetzt dort, wo die Augen der Figur sind, und schaut in die Blickrichtung. Ersetze main noch einmal:
int main(void) { InitWindow(960, 540, "Klötzchenwelt"); SetTargetFPS(60); SetExitKey(KEY_NULL); // Esc gibt nur die Maus frei generate_world(); feet = (Vector3){ 8.5f, 9, 10.5f }; yaw = 0.8f; // schräg über die Welt schauen pitch = -0.2f; Camera3D camera = { 0 }; camera.up = (Vector3){ 0, 1, 0 }; camera.fovy = 70; camera.projection = CAMERA_PERSPECTIVE; while (!WindowShouldClose()) { float dt = GetFrameTime(); if (dt > 0.05f) dt = 0.05f; // Ein Klick ins Bild fängt die Maus, Esc gibt sie frei if (!IsCursorHidden() && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) DisableCursor(); if (IsKeyPressed(KEY_ESCAPE)) EnableCursor(); move_player(dt); Vector3 eyes = { feet.x, feet.y + EYE_HEIGHT, feet.z }; camera.position = eyes; camera.target = Vector3Add(eyes, view_direction()); BeginDrawing(); ClearBackground(SKYBLUE); BeginMode3D(camera); int blocks = 0; for (int x = 0; x < WIDTH; x++) for (int y = 0; y < HEIGHT; y++) for (int z = 0; z < DEPTH; z++) if (world[x][y][z] != AIR && visible(x, y, z)) { Vector3 center = { x + 0.5f, y + 0.5f, z + 0.5f }; DrawCube(center, 1, 1, 1, colors[world[x][y][z]]); DrawCubeWires(center, 1, 1, 1, Fade(BLACK, 0.25f)); blocks++; } EndMode3D(); DrawText(TextFormat("%d Blöcke %d FPS", blocks, GetFPS()), 10, 10, 20, DARKBLUE); if (!IsCursorHidden() && GetTime() < 8) DrawText("Klick ins Bild: Maus fangen. WASD laufen, Pfeiltasten umsehen.", 10, 36, 20, WHITE); EndDrawing(); } CloseWindow(); return 0; }
Neu ist auch die Maus: Ein Klick ins Bild ruft DisableCursor() auf. Der Mauszeiger verschwindet, und jede Bewegung der Maus dreht den Blick, wie in einem Spiel. Esc gibt die Maus wieder frei (SetExitKey(KEY_NULL) sorgt dafür, dass Esc nicht gleich das ganze Programm beendet).

Lauf ein wenig herum. Du merkst schnell: Du bist ein Geist. Du gehst durch Hügel und Bäume hindurch, und wenn das Land abfällt, schwebst du in der Luft.
Damit die Welt dich aufhält, braucht die Figur einen Körper. Wir stellen sie uns als schmale Säule vor: 0,6 Blöcke breit und tief und 1,7 Blöcke hoch. Die Figur steckt in einem Block, wenn ihre Säule in ein festes Feld hineinragt. Dafür muss man nur die wenigen Felder prüfen, die die Säule berührt: floorf rundet ab und findet so das Feld, in dem eine Koordinate liegt.
Schreibe solid unter block und collides unter die #defines der Figur. Die neuen Variablen vy und on_ground kommen zu den anderen über view_direction:
bool solid(int x, int y, int z) { if (x < 0 || z < 0 || x >= WIDTH || z >= DEPTH || y < 0) return true; return block(x, y, z) != AIR; } #define RADIUS 0.3f // die Figur ist 0,6 Blöcke breit #define BODY_HEIGHT 1.7f #define EYE_HEIGHT 1.6f bool collides(Vector3 f) { for (int x = (int)floorf(f.x - RADIUS); x <= (int)floorf(f.x + RADIUS); x++) for (int y = (int)floorf(f.y); y <= (int)floorf(f.y + BODY_HEIGHT); y++) for (int z = (int)floorf(f.z - RADIUS); z <= (int)floorf(f.z + RADIUS); z++) if (solid(x, y, z)) return true; return false; } float vy = 0; // Geschwindigkeit nach oben, in Blöcken pro Sekunde bool on_ground = false;
solid hat am Rand und unter der Welt unsichtbare Wände, damit du nicht hinunterfällst.
Am Ende von move_player stand bisher feet = Vector3Add(feet, move);. Ersetze diese Zeile durch:
// Jede Richtung einzeln: So gleitet man an einer Wand entlang, statt an // ihr kleben zu bleiben. Vector3 next = feet; next.x += move.x; if (!collides(next)) feet.x = next.x; next = feet; next.z += move.z; if (!collides(next)) feet.z = next.z;
Das Programm probiert jeden Schritt erst aus: Würde die Figur nach dem Schritt in einem Block stecken, bleibt sie, wo sie ist.
Was passiert, wenn das Programm den Schritt in x und z zusammen ausprobiert und ganz verwirft, sobald die Figur dabei in einem Block stecken würde? Du läufst schräg gegen eine Wand.
Unter den Schritt in x und z kommt die Schwerkraft:
// Schwerkraft und Springen vy -= 22.0f * dt; if (on_ground && IsKeyDown(KEY_SPACE)) vy = 7.5f; next = feet; next.y += vy * dt; if (collides(next)) { if (vy < 0) { on_ground = true; feet.y = floorf(next.y) + 1.0f; // genau auf den Block stellen } vy = 0; } else { feet.y = next.y; on_ground = false; }
vy ist die Geschwindigkeit nach oben. Die Schwerkraft verkleinert sie in jeder Sekunde um 22 Blöcke pro Sekunde, also wird die Figur immer schneller, je länger sie fällt. Ein Sprung setzt vy auf 7,5, und die Schwerkraft bremst ihn wieder ab. Stößt die Figur beim Fallen auf einen Block, steht sie auf dem Boden: vy wird 0, und die Füße kommen genau auf die Oberseite des Blocks.
Zuletzt soll die Figur nicht mehr bei y = 9 in der Luft beginnen, sondern oben über der Welt, und dann auf den Boden fallen. Ändere die Startposition in main:
feet = (Vector3){ 8.5f, HEIGHT - 2, 10.5f };

Ein Sprung beginnt mit vy = 7.5, und die Schwerkraft verkleinert vy
um 22 pro Sekunde. Wie viele Blöcke hoch kommen die Füße? Runde auf zwei
Stellen.
Oben ist vy = 0. Wie lange dauert das, und wie schnell war die Figur im Durchschnitt in dieser Zeit?
Bau collides, die Schritte in x und z und die Schwerkraft für vy in dein
Programm ein und speichere.
Um einen Block abzubauen, musst du wissen, auf welchen Block du schaust. Dafür gehen wir vom Auge aus in kleinen Schritten die Blickrichtung entlang, bis wir in einem Block landen, höchstens sechs Blöcke weit. Das ist ein Strahl, wie ein Laserpointer. Das Feld, durch das der Strahl zuletzt noch durch Luft ging, merken wir uns auch: Dort entsteht ein neuer Block, wenn du baust.
Schreibe das über main:
// ------------------------------------------------------------------ Zielen bool hit = false; int hx, hy, hz; // der Block, auf den du schaust int ax, ay, az; // die Luft davor: Hier entsteht ein neuer Block void aim(Vector3 eyes, Vector3 look) { hit = false; ax = (int)floorf(eyes.x); ay = (int)floorf(eyes.y); az = (int)floorf(eyes.z); // In kleinen Schritten den Blick entlang, bis ein Block im Weg ist for (float t = 0; t < 6.0f; t += 0.02f) { Vector3 p = Vector3Add(eyes, Vector3Scale(look, t)); int x = (int)floorf(p.x), y = (int)floorf(p.y), z = (int)floorf(p.z); if (block(x, y, z) != AIR) { hit = true; hx = x; hy = y; hz = z; return; } ax = x; ay = y; az = z; } }
Warum geht aim den Blick in Schritten von 0,02 Blöcken entlang und
nicht in Schritten von 0,5?
In main braucht das Programm die Blockart, die du gerade baust. Schreibe vor die Schleife:
int selected = GRASS;
und ersetze in der Schleife den Teil, der die Maus fängt:
if (!IsCursorHidden() && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) DisableCursor(); else { if (hit && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) world[hx][hy][hz] = AIR; if (hit && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && block(ax, ay, az) == AIR) { world[ax][ay][az] = selected; if (collides(feet)) // nicht in sich selbst bauen world[ax][ay][az] = AIR; } } if (IsKeyPressed(KEY_ESCAPE)) EnableCursor(); for (int i = 1; i < BLOCK_TYPES; i++) if (IsKeyPressed(KEY_ONE + i - 1)) selected = i;
Mit der linken Maustaste wird der Block, auf den du schaust, zu Luft. Die rechte Maustaste setzt einen Block der gewählten Art in das Luftfeld davor, aber nicht, wenn du selbst dort stehst. Die Tasten 1 bis 6 wählen die Art: KEY_ONE + 1 ist dasselbe wie KEY_TWO, weil die Tasten in raylib Nummern in einer Reihe haben.
Damit aim in jedem Bild aufgerufen wird und der Block, auf den du schaust, einen Rahmen bekommt, ändere zwei Stellen. Die Kamera:
Vector3 eyes = { feet.x, feet.y + EYE_HEIGHT, feet.z }; Vector3 look = view_direction(); camera.position = eyes; camera.target = Vector3Add(eyes, look); aim(eyes, look);
und, direkt vor EndMode3D():
if (hit) DrawCubeWires((Vector3){ hx + 0.5f, hy + 0.5f, hz + 0.5f }, 1.01f, 1.01f, 1.01f, BLACK);
Der Rahmen ist eine Spur größer als der Block, damit er nicht in dessen Kanten verschwindet.
Zuletzt zeichnen wir in 2D über das Bild: ein Fadenkreuz in der Mitte, damit du weißt, wohin du zielst, und unten eine Leiste mit den Blockarten. Für die Namen kommt über colors eine Liste dazu:
const char *names[BLOCK_TYPES] = { "Luft", "Gras", "Erde", "Stein", "Holz", "Laub", "Sand", };
Und nach EndMode3D(), vor dem DrawText mit den FPS:
// 2D über dem Bild: Fadenkreuz, Leiste mit den Blöcken, Hinweise int mx = GetScreenWidth() / 2, my = GetScreenHeight() / 2; DrawRectangle(mx - 10, my - 1, 20, 2, WHITE); DrawRectangle(mx - 1, my - 10, 2, 20, WHITE); int slots = BLOCK_TYPES - 1; int left = mx - slots * 26; for (int i = 1; i < BLOCK_TYPES; i++) { int fx = left + (i - 1) * 52, fy = GetScreenHeight() - 62; DrawRectangle(fx, fy, 48, 48, Fade(BLACK, 0.4f)); DrawRectangle(fx + 8, fy + 8, 32, 32, colors[i]); DrawText(TextFormat("%d", i), fx + 4, fy + 2, 10, WHITE); if (i == selected) DrawRectangleLinesEx((Rectangle){ fx - 2, fy - 2, 52, 52 }, 3, WHITE); } DrawText(names[selected], mx - MeasureText(names[selected], 20) / 2, GetScreenHeight() - 88, 20, WHITE);
MeasureText sagt, wie breit ein Text wird; so steht der Name genau in der Mitte.

Bau aim, das Abbauen mit der linken und das Bauen mit der rechten
Maustaste in dein Programm ein und speichere.
Sieh dir einen gebauten Steinblock genau an: Alle Seiten haben genau dieselbe Farbe. Ohne die dunklen Kanten könntest du nicht erkennen, wo eine Seite aufhört und die nächste anfängt. In der Wirklichkeit ist das anders, weil das Licht von einer Seite kommt: Eine Fläche, die zur Sonne zeigt, ist hell, eine Fläche, die schräg zur Sonne steht, ist dunkler, und eine Fläche, die von der Sonne weg zeigt, bekommt nur das Licht, das von überall her kommt.
Um das zu berechnen, muss das Programm für jede Fläche wissen, wohin sie zeigt. Diese Richtung heißt Normale: ein Vektor der Länge 1, der senkrecht aus der Fläche herausragt. Die Oberseite eines Blocks hat die Normale (0, 1, 0), die rechte Seite (1, 0, 0). DrawCube verrät uns diese Richtungen nicht. Deshalb zeichnen wir die Blöcke ab jetzt selbst, Seite für Seite.
raylib zeichnet alles aus Dreiecken. Mit den Befehlen aus rlgl.h können wir der Grafikkarte die Ecken selbst schicken: rlBegin(RL_QUADS) sagt, dass immer vier Ecken ein Viereck bilden, rlColor4ub und rlNormal3f gelten für die folgenden Ecken, rlVertex3f ist eine Ecke. Schreibe oben dazu:
#include <rlgl.h>
und unter visible diese beiden Funktionen:
// Eine Seite eines Blocks: vier Ecken, eine Richtung (die Normale), eine Farbe void face(Vector3 a, Vector3 b, Vector3 c, Vector3 d, Vector3 n, Color f) { rlColor4ub(f.r, f.g, f.b, 255); rlNormal3f(n.x, n.y, n.z); rlVertex3f(a.x, a.y, a.z); rlVertex3f(b.x, b.y, b.z); rlVertex3f(c.x, c.y, c.z); rlVertex3f(d.x, d.y, d.z); } // Zeichnet nur die Seiten, an die Luft grenzt: Die anderen sieht man nie. int draw_block(int x, int y, int z) { int type = world[x][y][z]; // Jeder Block ist ein wenig heller oder dunkler, dann wirkt die Welt // weniger glatt float shade = 0.92f + 0.08f * (((x * 7 + y * 13 + z * 31) % 5) / 4.0f); Color f = ColorBrightness(colors[type], shade - 1.0f); float x0 = x, x1 = x + 1, y0 = y, y1 = y + 1, z0 = z, z1 = z + 1; int faces = 0; rlBegin(RL_QUADS); if (!solid(x, y + 1, z)) { // oben face((Vector3){x0, y1, z0}, (Vector3){x0, y1, z1}, (Vector3){x1, y1, z1}, (Vector3){x1, y1, z0}, (Vector3){0, 1, 0}, f); faces++; } if (!solid(x, y - 1, z) && y > 0) { // unten face((Vector3){x0, y0, z0}, (Vector3){x1, y0, z0}, (Vector3){x1, y0, z1}, (Vector3){x0, y0, z1}, (Vector3){0, -1, 0}, f); faces++; } if (!solid(x + 1, y, z) && x < WIDTH - 1) { // rechts (+x) face((Vector3){x1, y0, z0}, (Vector3){x1, y1, z0}, (Vector3){x1, y1, z1}, (Vector3){x1, y0, z1}, (Vector3){1, 0, 0}, f); faces++; } if (!solid(x - 1, y, z) && x > 0) { // links (-x) face((Vector3){x0, y0, z0}, (Vector3){x0, y0, z1}, (Vector3){x0, y1, z1}, (Vector3){x0, y1, z0}, (Vector3){-1, 0, 0}, f); faces++; } if (!solid(x, y, z + 1) && z < DEPTH - 1) { // vorne (+z) face((Vector3){x0, y0, z1}, (Vector3){x1, y0, z1}, (Vector3){x1, y1, z1}, (Vector3){x0, y1, z1}, (Vector3){0, 0, 1}, f); faces++; } if (!solid(x, y, z - 1) && z > 0) { // hinten (-z) face((Vector3){x0, y0, z0}, (Vector3){x0, y1, z0}, (Vector3){x1, y1, z0}, (Vector3){x1, y0, z0}, (Vector3){0, 0, -1}, f); faces++; } rlEnd(); return faces; }
solid »Wand«; damit die Schichten dort trotzdem zu sehen sind, prüft draw_block den Rand eigens.shade macht jeden Block ein wenig heller oder dunkler, je nach seiner Position. Dann wirkt eine Wiese nicht wie eine einzige grüne Fläche.Ein Grasblock liegt mitten in einer flachen Wiese: links, rechts, vorne
und hinten Gras, darunter Erde, darüber Luft. Wie viele Seiten zeichnet
draw_block für ihn?
Wie hell eine Fläche wird, berechnet die Grafikkarte, und zwar mit einem kleinen Programm, einem Shader. Shader sind in einer eigenen Sprache geschrieben, GLSL, die ein wenig wie C aussieht. In der Vorlage liegen schon zwei: light.vs und light.fs. Der Vertex-Shader light.vs läuft für jede Ecke eines Dreiecks und rechnet aus, wo die Ecke auf dem Bildschirm landet. Der Fragment-Shader light.fs läuft für jeden Pixel einer Fläche und rechnet seine Farbe aus:
#version 100 precision mediump float; // Vom Vertex-Shader, für jeden Pixel einer Fläche varying vec3 position; varying vec3 normal; varying vec4 color; uniform vec3 sun; // Richtung, aus der die Sonne scheint uniform vec3 sunlight; // Farbe und Stärke des Sonnenlichts uniform vec3 ambient; // Licht, das von überall kommt void main() { vec3 n = normalize(normal); // Wie direkt trifft die Sonne diese Fläche? 1 = senkrecht, 0 = gar nicht float sunAmount = max(dot(n, sun), 0.0); vec3 light = ambient + sunlight * sunAmount; gl_FragColor = vec4(color.rgb * light, 1.0); }
Die wichtigste Zeile ist dot(n, sun), das Skalarprodukt der Normale und der Richtung zur Sonne. Für zwei Vektoren der Länge 1 ist es 1, wenn sie in dieselbe Richtung zeigen, 0, wenn sie im rechten Winkel stehen, und negativ, wenn sie voneinander weg zeigen; max(…, 0.0) macht daraus 0. Dazwischen ist es der Kosinus des Winkels. Genau so viel Sonnenlicht kommt auf einer Fläche an: Je schräger die Sonne auf eine Fläche scheint, desto mehr verteilt sich ihr Licht. Zu diesem Sonnenlicht kommt ambient, das Licht, das vom Himmel kommt und auch die Schattenseiten erreicht.
uniform sind Werte, die das C-Programm dem Shader gibt, für alle Pixel gleich. varying sind Werte, die der Vertex-Shader für jede Ecke ausrechnet und die Grafikkarte für jeden Pixel dazwischen mischt.
Die Sonne steht in Richtung Vector3Normalize((Vector3){ 0.5f, 0.8f, 0.3f }).
Welchen Wert hat sunAmount im Fragment-Shader für die Oberseite eines
Blocks? Runde auf zwei Stellen.
Die Normale der Oberseite ist (0, 1, 0). Was bleibt vom Skalarprodukt übrig? Und was macht Vector3Normalize mit (0,5; 0,8; 0,3)?
Jetzt muss main den Shader laden, ihm die Werte geben und mit ihm zeichnen. make packt die beiden Dateien schon mit in die Webseite, so dass LoadShader sie findet. Vor die Schleife kommt:
Shader light = LoadShader("light.vs", "light.fs"); int loc_sun = GetShaderLocation(light, "sun"); int loc_sunlight = GetShaderLocation(light, "sunlight"); int loc_ambient = GetShaderLocation(light, "ambient");
GetShaderLocation sucht für jeden Namen eines uniform die Stelle, an der der Shader ihn erwartet. In der Schleife, nach move_player(dt);:
// Die Sonne steht schräg über der Welt Vector3 sun = Vector3Normalize((Vector3){ 0.5f, 0.8f, 0.3f }); Vector3 sunlight = { 0.75f, 0.7f, 0.6f }; Vector3 ambient = { 0.43f, 0.46f, 0.54f };
und nach aim(eyes, look);:
SetShaderValue(light, loc_sun, &sun, SHADER_UNIFORM_VEC3); SetShaderValue(light, loc_sunlight, &sunlight, SHADER_UNIFORM_VEC3); SetShaderValue(light, loc_ambient, &ambient, SHADER_UNIFORM_VEC3);
Zuletzt ersetzt du die Schleife, die die Welt zeichnet, durch eine, die draw_block aufruft, eingerahmt von BeginShaderMode und EndShaderMode:
BeginShaderMode(light); int faces = 0; for (int x = 0; x < WIDTH; x++) for (int y = 0; y < HEIGHT; y++) for (int z = 0; z < DEPTH; z++) if (world[x][y][z] != AIR) faces += draw_block(x, y, z); EndShaderMode();
Die Zeile mit den FPS zählt jetzt Seiten statt Blöcke; ändere sie zu:
DrawText(TextFormat("%d Seiten %d FPS", faces, GetFPS()), 10, 10, 10, WHITE);
Die Funktion visible brauchst du nicht mehr; draw_block prüft jetzt jede Seite einzeln. Vor CloseWindow() gibt UnloadShader(light); den Shader wieder frei.

Vergleiche die Zahl der Seiten und die FPS mit vorher. Jetzt siehst du auch, warum die Ecken der Vierecke gegen den Uhrzeigersinn laufen müssen: Probiere bei einer Seite die umgekehrte Reihenfolge aus.
Zeichne die Welt mit draw_block und dem Licht-Shader und speichere.
Bisher steht die Sonne still. Mit einer Variable time_of_day zwischen 0 und 1 (0 und 1 sind Mitternacht, 0,5 ist Mittag) kann sie wandern. Schreibe über main:
float time_of_day = 0.4f; // 0 und 1: Mitternacht, 0,5: Mittag
Und ersetze in der Schleife die Zeilen der festen Sonne durch:
// Die Zeit vergeht: ein Tag dauert zwei Minuten, mit T schneller time_of_day += dt / 120.0f + (IsKeyDown(KEY_T) ? dt * 0.2f : 0); time_of_day -= floorf(time_of_day); if (IsKeyPressed(KEY_N)) // N springt zwischen Tag und Nacht time_of_day = (time_of_day > 0.25f && time_of_day < 0.75f) ? 0.9f : 0.4f; float angle = (time_of_day - 0.25f) * 2 * PI; // Sonnenaufgang bei 0,25 Vector3 sun = Vector3Normalize((Vector3){ cosf(angle), sinf(angle), 0.4f }); float day = Clamp(sinf(angle) * 4.0f + 0.3f, 0.0f, 1.0f); Color sky = ColorLerp((Color){ 12, 16, 36, 255 }, (Color){ 140, 195, 240, 255 }, day); Vector3 sky_v = { sky.r / 255.0f, sky.g / 255.0f, sky.b / 255.0f }; Vector3 sunlight = { 0.75f * day, 0.7f * day, 0.6f * day }; // Nachts bleibt bläuliches Mondlicht Vector3 ambient = { 0.2f + 0.23f * day, 0.22f + 0.24f * day, 0.34f + 0.2f * day };
cosf(angle) und sinf(angle) sind ihre Position auf dem Kreis, so wie bei der Blickrichtung. Um 0,25 geht sie auf, um 0,75 unter.day ist 1, solange die Sonne hoch genug steht, und 0 in der Nacht. Damit mischt ColorLerp die Farbe des Himmels zwischen Nachtblau und Tagblau, und das Sonnenlicht wird nachts zu 0. Ein schwaches, bläuliches Mondlicht bleibt als ambient.Statt ClearBackground(SKYBLUE) steht jetzt ClearBackground(sky).
Nachts ist es dunkel, Zeit für Licht. Eine Fackel ist eine neue Blockart, die du bauen kannst. Sie ist nicht fest, du kannst durch sie hindurchgehen. Ergänze das enum, names und colors um eine siebte Art, TORCH:
enum { AIR, GRASS, DIRT, STONE, WOOD, LEAVES, SAND, TORCH, BLOCK_TYPES };
{ 255, 200, 80, 255 }, // Fackel
In solid ist eine Fackel jetzt wie Luft:
bool solid(int x, int y, int z) { if (x < 0 || z < 0 || x >= WIDTH || z >= DEPTH || y < 0) return true; int type = block(x, y, z); return type != AIR && type != TORCH; }
Eine Fackel leuchtet für ihre Umgebung. Dafür sucht das Programm in jedem Bild alle Fackeln der Welt und gibt ihre Positionen dem Shader, als Liste von höchstens 16 Punkten. Das kommt in der Schleife vor die Zeilen mit SetShaderValue:
// Fackeln einsammeln: Sie leuchten im Shader Vector3 torches[16]; int torch_count = 0; for (int x = 0; x < WIDTH; x++) for (int y = 0; y < HEIGHT; y++) for (int z = 0; z < DEPTH; z++) if (world[x][y][z] == TORCH && torch_count < 16) torches[torch_count++] = (Vector3){ x + 0.5f, y + 0.7f, z + 0.5f };
Die Fackeln selbst zeichnen wir nach EndShaderMode() ohne den Licht-Shader: Was selbst leuchtet, braucht kein Licht von außen. Ein Stiel aus Holz und oben eine Flamme:
// Fackeln leuchten selbst: ohne Licht-Shader gezeichnet for (int i = 0; i < torch_count; i++) { Vector3 p = torches[i]; DrawCube((Vector3){ p.x, p.y - 0.4f, p.z }, 0.15f, 0.6f, 0.15f, (Color){ 120, 80, 40, 255 }); DrawCube((Vector3){ p.x, p.y - 0.05f, p.z }, 0.22f, 0.18f, 0.22f, colors[TORCH]); }
Und in der Schleife, die die Welt zeichnet, werden Fackeln nicht als Block gezeichnet:
if (world[x][y][z] != AIR && world[x][y][z] != TORCH)
Weit entfernte Dinge sehen in der Wirklichkeit blasser aus, weil Luft nie ganz klar ist. Das hilft dem Auge, Entfernungen zu schätzen, und versteckt nebenbei den Rand der Welt. Der Shader mischt dafür die Farbe eines Pixels mit der Farbe des Himmels, je weiter er von den Augen entfernt ist.
Hier ist der ganze neue Fragment-Shader mit Fackeln und Nebel. Ersetze light.fs durch:
#version 100 precision mediump float; varying vec3 position; varying vec3 normal; varying vec4 color; uniform vec3 sun; // Richtung, aus der die Sonne scheint uniform vec3 sunlight; // Farbe und Stärke des Sonnenlichts uniform vec3 ambient; // Licht, das von überall kommt uniform vec3 sky; // Farbe des Himmels, für den Nebel uniform vec3 eyes; // wo die Kamera ist #define MAX_TORCHES 16 uniform vec3 torches[MAX_TORCHES]; uniform int torchCount; void main() { vec3 n = normalize(normal); // Wie direkt trifft die Sonne diese Fläche? 1 = senkrecht, 0 = gar nicht float sunAmount = max(dot(n, sun), 0.0); vec3 light = ambient + sunlight * sunAmount; // Jede Fackel leuchtet warm, je näher, desto heller for (int i = 0; i < MAX_TORCHES; i++) { if (i >= torchCount) break; vec3 toTorch = torches[i] - position; float dist = length(toTorch); float facing = max(dot(n, toTorch / dist), 0.0); light += vec3(1.0, 0.6, 0.3) * (0.3 + 0.7 * facing) / (1.0 + 0.3 * dist * dist); } vec3 result = color.rgb * min(light, vec3(1.4)); // Nebel: weit Entferntes verschwimmt mit dem Himmel float fog = clamp((length(position - eyes) - 16.0) / 20.0, 0.0, 1.0); gl_FragColor = vec4(mix(result, sky, fog), 1.0); }
Jede Fackel gibt warmes, orangefarbenes Licht. Wie bei der Sonne zählt, wie sehr eine Fläche der Fackel zugewandt ist; dazu kommt der Abstand: Das Licht wird mit 1.0 + 0.3 * dist * dist geteilt, also schwächer, je weiter die Fläche entfernt ist. min(light, vec3(1.4)) verhindert, dass eine Fläche zwischen vielen Fackeln weiß ausbrennt.
Damit der Shader die neuen Werte bekommt, braucht main vier weitere Stellen:
int loc_sky = GetShaderLocation(light, "sky"); int loc_eyes = GetShaderLocation(light, "eyes"); int loc_torches = GetShaderLocation(light, "torches"); int loc_torch_count = GetShaderLocation(light, "torchCount");
und in der Schleife nach den anderen SetShaderValue:
SetShaderValue(light, loc_sky, &sky_v, SHADER_UNIFORM_VEC3); SetShaderValue(light, loc_eyes, &eyes, SHADER_UNIFORM_VEC3); SetShaderValueV(light, loc_torches, torches, SHADER_UNIFORM_VEC3, torch_count > 0 ? torch_count : 1); SetShaderValue(light, loc_torch_count, &torch_count, SHADER_UNIFORM_INT);
SetShaderValueV gibt dem Shader eine ganze Liste auf einmal. Steht keine Fackel in der Welt, schicken wir trotzdem einen Eintrag, denn eine Liste mit null Einträgen kennt der Shader nicht; torchCount ist dann 0, und die Schleife im Shader hört sofort auf.

Drück N: Es wird Nacht, und deine Fackeln leuchten. Auf dieser Seite zeigt der dunkle Modus die Bilder ebenfalls bei Nacht.
Eine Fläche zeigt direkt zu einer Fackel. Wie viel Licht von der Fackel kommt bei ihr an, wenn sie zwei Blöcke entfernt ist, verglichen mit einer Fläche direkt an der Fackel? Gib einen Anteil an, etwa 0,5 für die Hälfte, und runde auf zwei Stellen.
Das Licht wird durch 1.0 + 0.3 * dist * dist geteilt. Direkt an der Fackel ist der Abstand 0.
Ersetze light.fs durch den Shader mit Fackeln und Nebel und speichere.
Noch hat jeder Block eine einzige Farbe. In Spielen sehen Flächen nach etwas aus: Gras hat Halme, Stein hat Risse, Holz hat Maserung. Dafür klebt die Grafikkarte ein Bild auf jede Fläche, eine Textur.
In der Vorlage liegt textures.png, ein Bild aus acht Kacheln von 16 × 16 Pixeln nebeneinander:

Gras bekommt oben die erste Kachel, an den Seiten die zweite mit dem grünen Rand und unten Erde. Ein Holzblock zeigt an den Seiten Rinde und oben die Jahresringe. Die Reihenfolge der Kacheln steht als enum im Programm, und tile_for sagt, welche Kachel eine Seite bekommt. Schreibe beides unter world:
// ---------------------------------------------------------------- Texturen // textures.png ist ein Streifen aus Kacheln, alle gleich groß, in dieser // Reihenfolge von links nach rechts enum { T_GRASS_TOP, T_GRASS_SIDE, T_DIRT, T_STONE, T_WOOD_SIDE, T_WOOD_TOP, T_LEAVES, T_SAND, TILE_COUNT }; // Welche Kachel bekommt eine Seite? dir: 0 oben, 1 unten, 2 seitlich int tile_for(int type, int dir) { switch (type) { case GRASS: return dir == 0 ? T_GRASS_TOP : dir == 1 ? T_DIRT : T_GRASS_SIDE; case DIRT: return T_DIRT; case STONE: return T_STONE; case WOOD: return dir == 2 ? T_WOOD_SIDE : T_WOOD_TOP; case LEAVES: return T_LEAVES; default: return T_SAND; } }
Welcher Teil des Bildes auf welche Ecke kommt, sagen Texturkoordinaten: zwei Zahlen u und v von 0 bis 1. u = 0 ist der linke Rand des ganzen Bildes, u = 1 der rechte, v = 0 oben und v = 1 unten. Die Kachel Nummer tile reicht von u = tile / 8 bis u = (tile + 1) / 8. Dazwischen mischt die Grafikkarte, für jeden Pixel der Fläche.
Damit eine Kachel nicht auf dem Kopf steht, müssen die Ecken einer Seite immer in derselben Reihenfolge kommen: von außen gesehen links unten, rechts unten, rechts oben, links oben. Das ist auch gegen den Uhrzeigersinn, wie bisher. Ersetze face und draw_block:
// Eine Seite eines Blocks: vier Ecken, von außen gesehen links unten, rechts // unten, rechts oben, links oben; dazu die Richtung (Normale), eine // Helligkeit und die Kachel aus der Textur void face(Vector3 lb, Vector3 rb, Vector3 rt, Vector3 lt, Vector3 n, Color f, int tile) { float u0 = (float)tile / TILE_COUNT, u1 = (float)(tile + 1) / TILE_COUNT; rlColor4ub(f.r, f.g, f.b, 255); rlNormal3f(n.x, n.y, n.z); rlTexCoord2f(u0, 1); rlVertex3f(lb.x, lb.y, lb.z); rlTexCoord2f(u1, 1); rlVertex3f(rb.x, rb.y, rb.z); rlTexCoord2f(u1, 0); rlVertex3f(rt.x, rt.y, rt.z); rlTexCoord2f(u0, 0); rlVertex3f(lt.x, lt.y, lt.z); } // Zeichnet nur die Seiten, an die Luft grenzt: Die anderen sieht man nie. int draw_block(int x, int y, int z) { int type = world[x][y][z]; // Jeder Block ist ein wenig heller oder dunkler, dann wirkt die Welt // weniger glatt float shade = 0.92f + 0.08f * (((x * 7 + y * 13 + z * 31) % 5) / 4.0f); Color f = ColorBrightness(WHITE, shade - 1.0f); int top = tile_for(type, 0), bottom = tile_for(type, 1), side = tile_for(type, 2); float x0 = x, x1 = x + 1, y0 = y, y1 = y + 1, z0 = z, z1 = z + 1; int faces = 0; rlBegin(RL_QUADS); if (!solid(x, y + 1, z)) { // oben face((Vector3){x0, y1, z1}, (Vector3){x1, y1, z1}, (Vector3){x1, y1, z0}, (Vector3){x0, y1, z0}, (Vector3){0, 1, 0}, f, top); faces++; } if (!solid(x, y - 1, z) && y > 0) { // unten face((Vector3){x0, y0, z0}, (Vector3){x1, y0, z0}, (Vector3){x1, y0, z1}, (Vector3){x0, y0, z1}, (Vector3){0, -1, 0}, f, bottom); faces++; } if (!solid(x + 1, y, z) && x < WIDTH - 1) { // rechts (+x) face((Vector3){x1, y0, z1}, (Vector3){x1, y0, z0}, (Vector3){x1, y1, z0}, (Vector3){x1, y1, z1}, (Vector3){1, 0, 0}, f, side); faces++; } if (!solid(x - 1, y, z) && x > 0) { // links (-x) face((Vector3){x0, y0, z0}, (Vector3){x0, y0, z1}, (Vector3){x0, y1, z1}, (Vector3){x0, y1, z0}, (Vector3){-1, 0, 0}, f, side); faces++; } if (!solid(x, y, z + 1) && z < DEPTH - 1) { // vorne (+z) face((Vector3){x0, y0, z1}, (Vector3){x1, y0, z1}, (Vector3){x1, y1, z1}, (Vector3){x0, y1, z1}, (Vector3){0, 0, 1}, f, side); faces++; } if (!solid(x, y, z - 1) && z > 0) { // hinten (-z) face((Vector3){x1, y0, z0}, (Vector3){x0, y0, z0}, (Vector3){x0, y1, z0}, (Vector3){x1, y1, z0}, (Vector3){0, 0, -1}, f, side); faces++; } rlEnd(); return faces; }
Die Farbe einer Ecke ist jetzt Weiß, ein wenig heller oder dunkler: Die Textur bringt die Farbe mit, und Weiß lässt sie, wie sie ist.
Vor die Schleife kommt, gleich nach LoadShader:
Texture2D texture = LoadTexture("textures.png"); SetTextureFilter(texture, TEXTURE_FILTER_POINT); // scharfe Pixel
TEXTURE_FILTER_POINT sorgt für scharfe Pixel. Ohne diese Zeile würde die Grafikkarte zwischen den Pixeln weich überblenden, und die Blöcke sähen verschwommen aus. Beim Zeichnen sagt rlSetTexture, welche Textur die Flächen bekommen:
BeginShaderMode(light); rlSetTexture(texture.id); int faces = 0; for (int x = 0; x < WIDTH; x++) for (int y = 0; y < HEIGHT; y++) for (int z = 0; z < DEPTH; z++) if (world[x][y][z] != AIR && world[x][y][z] != TORCH) faces += draw_block(x, y, z); rlSetTexture(0); EndShaderMode();
Vor UnloadShader(light); am Ende kommt UnloadTexture(texture);. make packt textures.png mit in die Webseite, so wie die Shader.
Der Vertex-Shader reicht die Texturkoordinaten an jeden Pixel weiter. In light.vs kommen drei Zeilen dazu:
attribute vec2 vertexTexCoord; varying vec2 uv; // wo in der Textur dieser Punkt liegt uv = vertexTexCoord;
Die ersten beiden gehören zu den anderen attribute und varying oben, die dritte in main. Im Fragment-Shader light.fs kommen varying vec2 uv; und die Textur dazu:
uniform sampler2D texture0; // die Textur mit allen Kacheln
texture2D liest an der Stelle uv die Farbe der Textur. Ersetze die Zeile mit result durch:
vec3 pixel = texture2D(texture0, uv).rgb; vec3 result = pixel * color.rgb * min(light, vec3(1.4));
Licht, Fackeln und Nebel rechnen weiter wie vorher; sie hellen jetzt die Pixel der Textur auf oder dunkeln sie ab.

Lade textures.png und gib jeder Seite ihre Kachel, dann speichere.
Die Kacheln in textures.png sind nur ein Anfang. Zeichne deine eigenen im 2D Game Studio:

kacheln-zustand-1.png.
raylib-starter, und nenne sie textures.png. Die alte Datei wird dabei ersetzt; die Vorlage hat sie noch, falls du sie zurück willst.make, und deine Welt sieht aus wie gezeichnet.Die Welt gehört jetzt dir. Ein paar Ideen, von leicht bis schwer:
enum und names, eine Farbe in colors und eine Kachel in textures.png und in tile_for.generate_world, setze Berge, einen See aus blauen Blöcken oder einen Wald mit mehr Bäumen. Eine andere Zahl bei SetRandomSeed verteilt die Bäume anders.generate_world Luft in den Stein, wo eine weitere Welle über einem Wert liegt.GetTime(), gezählt wird, wenn du einen Goldblock abbaust.Gib deine Welt ab: den ganzen Ordner raylib-starter mit main.html.
Deine Lehrkraft kann den Code ansehen und deine Welt im Browser
starten; du kannst jederzeit neu abgeben.
Du hast eine Welt gebaut, die auf dich reagiert: eine Kamera, die deinen Augen folgt, eine Figur mit Körper und Schwerkraft, einen Strahl zum Zielen und einen Shader für Licht, Tag, Nacht und Nebel. Genau diese Bausteine stecken auch in großen Spielen; dort heißen sie Kamera, Physik, Raycasting und Beleuchtung.
Alle Befehle von raylib, die in diesem Projekt vorkommen, und ein paar mehr findest du im Handbuch. Die vollständige Liste aller Funktionen steht im raylib-Cheatsheet, und unter raylib.com/examples gibt es Hunderte Beispiele, die du direkt im Browser ausprobieren kannst.