Darkwood Blog Blog
  • Artikel
  • Beobachtung
  • Releases
  • Creators
de
  • en
  • fr
Anmeldung
  • Blog
  • Artikel
  • Beobachtung
  • Releases
  • Creators

đŸ‘Ÿ Ich habe einen KI-Agenten beauftragt, Diablo nach PHP zu portieren.

vom 12. August 2026

Anmelden um auf diesen Beitrag zu reagieren

🚀 1

Es öffnet tatsÀchlich ein Fenster.

Sie können durch die Straßen der Stadt spazieren.

Klicken Sie auf eine TĂŒr, und sie öffnet sich.

Schwing dich an einem Skelett in der Kathedrale.

Laden Sie die Originalspieldaten aus einer Datei, die Sie bereits besitzen.

Das ist aber nicht der interessante Teil.

Das Interessante daran ist, dass es sich um eine vollwertige, in PHP geschriebene Game-Engine handelt: ein MPQ-Archivleser, CEL- und CL2-Decoder, eine isometrische Tile-Pipeline, A*-Pfadfindung, Item-Affixe, Monster-KI, Inventar, HĂ€ndler, Raketen, Beleuchtungstabellen und ein mit FFI verbundenes SDL2-Frontend. Keine Spielzeugdemo. Keine Sprite-Diashow. Eine Laufzeitumgebung, die startet, simuliert und zeichnet.

Dieser Artikel ist eine technische Tour durch das GitHub-Quellcode-Repository — https://github.com/matyo91/diablo-php — geschrieben fĂŒr PHP-Entwickler, Engine-Programmierer und alle, die denken, dass „Game-Engine“ und „Symfony-Ă€hnliche Sprache“ nicht miteinander zu tun haben.

Was ist Diablo?

Diablo 1 ist das klassische isometrische Action-Rollenspiel, auf das sich dieses Projekt konzentriert. Das Repository enthĂ€lt weder Grafiken noch Level von Blizzard. Es enthĂ€lt eine PHP-Laufzeitumgebung, die voraussetzt, dass Sie auf eine legal erworbene DIABDAT.MPQ-Datei verweisen – das originale Datenarchiv von Diablo 1.

Was der Quellcode tatsĂ€chlich implementiert, verrĂ€t, um welche Art von Spiel es sich handelt. Es gibt eine Stadt mit NPCs, mit denen man sprechen und bei denen man einkaufen kann. Es gibt verschiedene Dungeon-Level – Kathedrale, Katakomben, Höhlen, Hölle –, die mithilfe von Klassen namens DrlgL1, DrlgL2, DrlgL3 und DrlgL4 aus Seeds generiert werden. Es gibt Monster mit verschiedenen Modi (Stehen, Gehen, Angreifen, Treffen, Tod), TĂŒren, die beim Öffnen Kachelteile austauschen, Truhen und FĂ€sser, die GegenstĂ€nde enthalten, Feuerbolzen und Pfeile als Geschosse, ein Inventarraster, GĂŒrteltrĂ€nke und ein Bedienfeld am unteren Bildschirmrand, das 128 Pixel eines 640×480-Framebuffers belegt.

Die AtmosphĂ€re in dieser Laufzeitumgebung ist kein Moodboard. Sie entsteht durch die Neuzuordnung von Palettenindizes mithilfe von Lichttabellen durch Lighting, die Anzeige von Automap-Zellen durch Vision und durch Audio-Signale, die WAV-Dateien aus demselben MPQ-Speicher abrufen, der auch fĂŒr die Grafik verwendet wurde. Stirbt der Spieler, wechselt GameState::tick in den Modus „tot“, startet eine Todesanimation und spielt das entsprechende Signal ab. Das ist die Stimmung des Spiels, ausgedrĂŒckt in Code.

Das Projekt ist hinsichtlich seines Reifegrads ehrlich. Die CLI-Hilfe weist weiterhin darauf hin, dass die Laufzeitumgebung fĂŒr eine menschliche Bewertung noch nicht vollstĂ€ndig spielbar ist. Automatisierte Rauch- und Szenario-Tests sind vorhanden; die visuelle QualitĂ€t im Einzelhandel ist jedoch noch nicht endgĂŒltig festgelegt. Diese Ehrlichkeit ist wichtig. Im Folgenden geht es um den tatsĂ€chlichen Funktionsumfang des Projekts, nicht um die Behauptung, dass jedes einzelne Pixel einer BinĂ€rdatei aus den 1990er-Jahren entspricht.

Warum PHP?

Wenn Sie PHP bisher nur hinter nginx kennengelernt haben, erscheint Ihnen dieses Projekt wie eine Herausforderung.

Schau genauer hin.

PHP 8.5+ ist die Mindestanforderung in composer.json. Die Laufzeitumgebung basiert auf typisierten Eigenschaften, match, Enumerationen als Konstanten und einem PSR-4-Layout im Namespace Diablo\. Das ist die gleiche Sprachentwicklung, mit der Symfony-Entwickler bereits arbeiten – nur dass sie hier auf eine Spielschleife anstatt auf einen HTTP-Kernel abzielt.

Die Kommandozeile (CLI) dient als Auslieferungsmedium. bin/diablo.php ist ein langlaufender Prozess mit deaktivierter max_execution_time und einem Speicherlimit von 512 MB. Es gibt keinen Anfragezyklus. Es gibt ein Fenster, einen Tick und ein Rendering.

FFI fungiert als BrĂŒcke. Diablo\Platform\Sdl lĂ€dt SDL2 ĂŒber FFI::cdef, erstellt ein Fenster und einen Renderer, verarbeitet Ereignisse, lĂ€dt RGBA-Texturen hoch und stellt Frames dar. Der native Code verbleibt in einer einzigen Klasse. Der Rest der Engine bleibt in PHP: Dekodieren, Simulieren, Komponieren.

PortabilitĂ€t ergibt sich aus dieser Aufteilung. Die Asset-Pipeline ist reines PHP (MpqArchive, Cel, Cl2, DunMicro). Die Anzeigeschicht ist das SDL2, das Ihr System laden kann. Unter macOS greift die optionale Audioausgabe sogar auf afplay fĂŒr aus dem MPQ extrahierte WAV-Dateien zu – ein pragmatischer Workaround, solange SDL_mixer noch nicht geladen ist.

Experimente machen diese Form fĂŒr KI-gestĂŒtztes Engineering interessant. Man kann eine Farbpalette exportieren, das erste Bild einfrieren, Simulation, Rendering und Dekodierung vergleichen, Akteure ausblenden oder die Welt mit --software-compose auf einem CPU-Framebuffer erstellen. Die Sprache, die WordPress hostet, kann sogar eine isometrische Blit-Schleife ausfĂŒhren. Sobald man das akzeptiert hat, brechen viele Vorurteile darĂŒber, was PHP angeblich nicht kann, in sich zusammen.

Der Renderer ist kein WebGL. Er dekodiert 8-Bit-Grafiken grĂ¶ĂŸtenteils softwareseitig in RGBA und lĂ€dt sie anschließend ĂŒber SDL-Texturen auf die GPU hoch – optional mit einem vollstĂ€ndigen softwareseitigen Kompositionspfad fĂŒr Diagnosezwecke. Dies ist eine bewusste Entscheidung: Die Pixel sollen in PHP ĂŒberprĂŒfbar sein, bevor man der GPU vertraut.

Diablo in PHP ausfĂŒhren

Anforderungen

Aus der README-Datei des Repositorys:

  • PHP 8.5+ mit ext-ffi
  • Die SDL2-Bibliothek ist auf dem Host installiert.
  • Eine legal erworbene DIABDAT.MPQ-Datei (nicht im Projekt enthalten)

Blizzard-Spielressourcen sind nicht enthalten. Sie mĂŒssen Diablo besitzen und die Datei DIABDAT.MPQ aus Ihrer Installation extrahieren. Die MPQ-Reader-Komponenten basieren auf mpqfs und stehen unter der MIT-Lizenz. Die LICENSE-Datei des Projekts ist ebenfalls unter der MIT-Lizenz veröffentlicht.

Installieren

composer install

Autoloading ordnet Diablo\ dem Verzeichnis src/ zu.

Start

php -d ffi.enable=true bin/diablo.php --data="/path/to/DIABDAT.MPQ"

Oder ĂŒber die Umgebung:

DIABLO_DATA=/path/to/DIABDAT.MPQ php -d ffi.enable=true bin/diablo.php --smoke

NĂŒtzliche Einstiegspunkte:

php -d ffi.enable=true bin/diablo.php --data="/path/to/DIABDAT.MPQ" --town
php -d ffi.enable=true bin/diablo.php --data="/path/to/DIABDAT.MPQ" --level=1 --seed=12345

--smoke testet Stadt, Kathedrale und Speicherstand, ohne dass Sie eine komplette Sitzung starten mĂŒssen. --town ĂŒberspringt das MenĂŒ und startet ein neues Spiel in der Stadt. --level und --seed lassen Sie in eine vordefinierte Kathedrale einsteigen. --help zeigt eine lange Liste von Debug-Optionen an (Renderebenen, Standbild, Kachelabdeckung, Kampferfassung und mehr).

FFI muss aktiviert sein. Der Launcher prĂŒft ext-ffi und ini_get('ffi.enable'), bevor er Diablo erstellt.

Steuerelemente

Eingabe Aktion
↑/↓ Eingabe MenĂŒ
Klicken Gehen / angreifen / bedienen / sprechen
Rechtsklick Feuerblitz
1–4 GĂŒrteltrank
I / C Inventar / Charakter
E AusrĂŒstung
H / F Heilen / Feuerblitz
S Speichern
Esc Speichern + MenĂŒ

GameState::handleInput verknĂŒpft im Spielmodus auch Quest-, Zauberbuch- und Automap-Tasten. Die eigentliche BenutzeroberflĂ€che ist das Klicken auf das Ziel: Der Cursor bestimmt eine Zielaktion – Gehen, Monster angreifen, Objekt bedienen, Sprechen, Aufheben, Zauber wirken oder Fernkampfangriff –, die dann vom Simulator in den folgenden Ticks ausgefĂŒhrt wird.

Repository-Architektur

Auf einen Blick:

Pfad Rolle
bin/diablo.php CLI-Einstieg, Optionsanalyse, MPQ-Abfrage / Rauchmelder
src/Diablo.php Anwendung: MPQ → assets → SDL → GameState → Schleife
src/GameState.php ~14.000 Zeilen: Simulations- und Rendering-Orchestrierung
src/Engine/ Assets, Pfad, Beleuchtung, Vision, Animation, Zielaktionen
src/Engine/Render/ Scrollrt, DunMicro, IsoCoords, Masken, Software-FB
src/Levels/ DungeonMap, DrlgL1–DrlgL4
src/Items/ ItemDat, Inventarraster-Hilfsfunktionen
src/Monsters/ Monstdat, AiProc
src/DiabloUI/ Titeldialog, MenĂŒliste, Auswahl
src/Platform/Sdl.php SDL2 FFI-Grenze
assets/txtdata/ TSV-Tabellen fĂŒr GegenstĂ€nde und Monster
data/ Kleine JSON-Objekte (GegenstĂ€nde, ZaubersprĂŒche, Monster)
maps/ Stadtplan im JSON-Format
saves/ slot1.json Speicherplatz speichern

Bootsequenz

Diablo erstellt den Stack an einer Stelle:

$this->mpq = new MpqArchive($dataPath);
$this->assets = new AssetStore($this->mpq);
$this->sdl = new Sdl();
$this->state = new GameState($this->assets, new Audio($this->assets));

Die logische FenstergrĂ¶ĂŸe ergibt sich aus IsoCoords::SCREEN_W / SCREEN_H – 640×480. Die Spielschleife befindet sich in Diablo::run: SDL-Ereignisse in GameState::handleInput abfragen, GameState::tick fortsetzen, GameState::render aufrufen und prĂ€sentieren.

SimulationswirbelsÀule

GameState::tick ist der Herzschlag, wenn mode === 'play':

$this->processPlayer();
$this->processMonsters();
$this->processMissiles();
$this->processObjects();
$this->advanceTownerAnims();
$this->checkTriggers();
$this->updateLighting();

Der Tod wird im selben Tick behandelt: Pfad löschen, pmode auf DEATH setzen, UI-Modus auf 'dead' umschalten, dem Spieler eine Nachricht senden, Audio abspielen.

Im Codebaum gibt es schlankere Hilfsklassen (Combat, Player, Monster, Missile, Spell). Der eigentliche Code konzentriert das Verhalten in den GameState-Arrays und -Methoden. Beginnen Sie beim Lesen des Quellcodes mit GameState und arbeiten Sie sich dann zu Engine und Levels vor.

UI-Shell

DiabloUI\TitleDialog lĂ€dt den Titelhintergrund und ein mehrteiliges Logo (PCX) aus der MPQ. Menu verwaltet ZustĂ€nde wie HauptmenĂŒ, Erstellen, Spielen, HĂ€ndler und Beendet. UiList und DrawSelector zeichnen den Fokus. Das In-Game-HUD verwendet UiFont und Grafiken des Bedienfelds (ctrlpan\panel8.cel ist eine der Smoke-Test-Probes).

Architektur rendern

Der Produktions-Zeichenpfad ist nicht der Ă€ltere Diamond Helper in Renderer / Iso. Die Live-Pipeline ist GameState::render → Dungeon-Zeichnen → Scrollrt::drawGame.

Scrollrt dokumentiert seinen eigenen Vertrag:

DrawGame pipeline:
DrawFloor → DrawTileContent(DrawDungeon) → DrawOOB.
TILE 64×32, East +{1,-1}/+64px, zigzag rows, micro L/R, stack y-=32.

KoordinatenrÀume

IsoCoords benennt die RĂ€ume, mit denen die Engine jongliert:

/**
 * Spaces:
 * - mega: dungeon[x][y] 40×40 mega tiles (DRLG)
 * - dPiece: dPiece[x][y] 112×112 piece tiles (ViewPosition lives here)
 * - micro: 32×32 (or triangle) CEL frames stacked on a piece
 * - screen: logical 640×(480−panel) framebuffer pixels
 * - ui: 640×480 DiabloUI rectangle
 */

Die Welt auf der Leinwand:

public static function worldToScreen(int $dx, int $dy): array
{
    return [
        ($dy - $dx) * 32,
        ($dy + $dx) * -16,
    ];
}

Die Höhe des Viewports betrĂ€gt 480 - 128 = 352 — das Panel besitzt den unteren Streifen.

Mikrofliesen

Ein Dungeon-Teil ist keine einzelne Bitmap. Es ist ein Stapel von Mikros: 32×32 (oder dreieckige) CEL-Chunks mit folgenden Typen:

public const TYPE_SQUARE = 0;
public const TYPE_TRANSPARENT_SQUARE = 1;
public const TYPE_LEFT_TRIANGLE = 2;
public const TYPE_RIGHT_TRIANGLE = 3;
public const TYPE_LEFT_TRAPEZOID = 4;
public const TYPE_RIGHT_TRAPEZOID = 5;

DunMicro::decode wandelt rohe Mikrobytes plus eine Palette (und optional eine Leuchttabelle) in RGBA um. Linke Dreiecke werden mit Padding und verbreiterten Zeilen entpackt:

private static function decodeLeftTriangle(string $src, array &$buf, int &$pos, int $len): void
{
    // Bottom-up 31 rows; widths 2,4,...32,...2 with 2 pad bytes before even rows
    for ($i = 0; $i < 31; $i++) {
        if (($i & 1) === 0) {
            $pos += 2; // padding
        }
        $width = $i < 16 ? ($i + 1) * 2 : (31 - $i) * 2;
        $x0 = 32 - $width;
        $y = 30 - $i;
        for ($x = 0; $x < $width && $pos < $len; $x++) {
            $buf[$y * 32 + $x0 + $x] = ord($src[$pos++]);
        }
    }
}

Masken und Transparenz

Nach der Dekodierung verarbeitet MaskType RGBA nachtrÀglich:

  • SOLID — undurchsichtige Texel unverĂ€ndert lassen
  • TRANSPARENT — Alpha-Wert fĂŒr Überblendung auf 128 setzen
  • LINKS / RECHTS — einen dreieckigen PrĂ€fixbereich so verschmelzen, dass WĂ€nde sauber aufeinandertreffen

Die linke PrÀfixmathematik wÀchst von unten nach oben:

private static function leftTransparent(int $x, int $fromBottom, int $w): bool
{
    $prefix = -32 + 2 * $fromBottom;
    if ($prefix <= 0) {
        return false;
    }

    return $x < min($w, $prefix);
}

Zeichnen einer Zelle

Scrollrt::drawCell entscheidet zwischen Laubwerk und Wandbasen, wĂ€hlt linke/rechte Masken anhand von SOL-Flags und der Raumtransparenz (dTransVal / Transparenzliste) aus und zeichnet dann Mikros. Böden können Laubwerk-Mikros mit einem Versatz von −16 Y zeichnen. WĂ€nde zeichnen Mikro 0 und Mikro 1 nebeneinander (MICRO_WIDTH = 32) und stapeln die darĂŒber liegenden Mikros jeweils um 32 Pixel nach oben.

EntitĂ€ten – Spieler, Stadtbewohner, Monster, Objekte, BodengegenstĂ€nde, Geschosse – werden in Dungeon-PĂ€ssen mithilfe eigener Blit-Hilfsfunktionen dargestellt. Flags wie hideActors / hideObjects / hideItems / hideHud ermöglichen es, die Geometrie beim Debuggen zu isolieren.

Zwei gegenwÀrtige Wege

Normalerweise werden dekodierte Frames zu SDL-Texturen (createTextureRGBA / updateTextureRGBA) und vom Renderer dargestellt.

Mit --software-compose landen die Blits zunĂ€chst im SoftwareFramebuffer. Dieser Pfad ermöglicht es, jedes Pixel in PHP zu analysieren – einschließlich der RenderTrace-JSON-Daten, die angeben, wer welches Pixel bearbeitet hat – bevor der endgĂŒltige Upload erfolgt.

Weltweite ReprÀsentation

Vom Generator zum Netz

Die Dungeon-Generierung beginnt im Mega-Raum (etwa 40×40 DRLG-Zellen). Generatoren wie DrlgL1::createL5Dungeon platzieren RĂ€ume, Korridore, Treppen und Minisets (Lampen, Schmutz, Schatten) und fĂŒhren anschließend die Teileerweiterung zu dPiece durch – einem 112×112-Raster aus Teile-IDs. DungeonMap enthĂ€lt:

  • dPiece — welches Teil auf welchem ​​Weltfeld liegt
  • dPieceMicros — Mikrodefinitionen pro StĂŒck
  • SOL-Flaggen – solide, transparent, Block-Rakete
  • dTransVal — Transparenzgruppen fĂŒr RĂ€ume/Sektoren
  • dSpecial / Beleuchtungsindizes
  • Helfer wie „isWalkable“, „isSolid“, „blocksMissile“, „isFloorTile“.

Die Stadt wird ĂŒber DungeonMap::loadTown geladen. Kathedralen, Katakomben, Höhlen und die Hölle verfĂŒgen ĂŒber eigene Ladefunktionen, die den entsprechenden DrlgL*-Generator mit einem Seed aufrufen.

Belegung und Sicht

Occupancy erfasst, wer wo steht – Spieler, Monster, GegenstĂ€nde, Objektraster mit signierten IDs fĂŒr sich bewegende Objekte. Vision optimiert die Sichtbarkeit der automatischen Karte. Lighting verwaltet eine Liste von Lichtquellen und erstellt LightTables: Die Schattierung erfolgt durch eine Neuzuordnung des Farbpalettenindex, nicht durch eine RGB-Multiplikation. Dies entspricht der ursprĂŒnglichen Funktionsweise der 8-Bit-Grafik: Farbindizes werden vertauscht und die RGB-Werte anschließend einmalig abgerufen.

Kamera- und Bewegungsscrollfunktion

Die Kamera folgt der dPiece-Position des Spielers. WĂ€hrend der Bewegung interpoliert WalkOffset einen Pixelversatz aus dem Animationsfortschritt, sodass der Sprite (und optional die Kamera) ĂŒber acht Bewegungsframes zwischen den Kacheln gleitet:

private const MOVING_OFFSET = [
    Direction::S => [0, 32],
    Direction::SW => [-32, 16],
    Direction::W => [-64, 0],
    // ...
    Direction::E => [64, 0],
    Direction::SE => [32, 16],
];

public static function fromAnimInfo(AnimationInfo $anim, int $dir, bool $cameraMode = false): array
{
    $progress = $anim->getAnimationProgress();
    [$ox, $oy] = self::MOVING_OFFSET[$dir] ?? [0, 0];
    $x = (int) intdiv($ox * $progress, self::BASE_VALUE_FRACTION);
    $y = (int) intdiv($oy * $progress, self::BASE_VALUE_FRACTION);
    if ($cameraMode) {
        return [-$x, -$y];
    }

    return [$x, $y];
}

Scrollrt erweitert das gezeichnete Kachelfenster mit Overscan, sodass beim Gehen keine leeren RĂ€nder sichtbar werden. --camera-fixed friert die Ansicht ein, wenn die Bewegung des Akteurs vom Scrollen getrennt werden soll.

Asset-Laden

Alles Visuelle und der grĂ¶ĂŸte Teil des Audios beginnen als Pfad innerhalb von DIABDAT.MPQ.

MPQ

MpqArchive ist ein MPQ v1-Reader: Header finden, Hash- und Blocktabellen laden, Sektoren mit MpqCrypto entschlĂŒsseln, mit MpqExplode (PKWARE) oder zlib dekomprimieren (falls angegeben). Öffentliche API: hasFile, readFile, info. Smoke/Inspect prĂŒft bekannte Pfade wie:

  • levels\towndata\town.pal
  • ctrlpan\panel8.cel
  • towners\butch\deadguy.cel
  • plrgfx\warrior\wld\wldas.cl2

AssetStore

final class AssetStore
{
    /** @var array<string,string> */
    private array $cache = [];

    public function read(string $path): string
    {
        $key = strtolower(str_replace('/', '\\', $path));
        if (!isset($this->cache[$key])) {
            $this->cache[$key] = $this->mpq->readFile($path);
        }
        return $this->cache[$key];
    }

    public function loadPalette(string $path): Palette { return Palette::fromBytes($this->read($path)); }
    public function loadCel(string $path): Cel { return Cel::parse($this->read($path)); }
}

Pfadtrennzeichen werden durch Backslashes ersetzt; das Caching erfolgt ĂŒber Kleinbuchstaben. Paletten bestehen aus 256 RGB-EintrĂ€gen. CELs und CL2s werden in RGBA-Zeichenketten dekodiert, die der Renderer hochlĂ€dt.

CEL

CEL ist das Arbeitspferd fĂŒr UI-Panels, Objekte und Many-World-Grafiken. Cel::parse liest eine Frametabelle (und verarbeitet gruppierte CELs, indem es einen Gruppenheader entfernt, wenn die Offsets nicht mit der DateigrĂ¶ĂŸe ĂŒbereinstimmen). decodeFrame durchlĂ€uft RLE:

  • Bytes ≄ 0x80 — transparenter Lauf (vorzeichenbehaftete LĂ€nge)
  • andernfalls — wörtliche Abfolge der Palettenindizes

Frames werden von unten nach oben gespeichert und beim Erstellen von RGBA gespiegelt. Index 0 und Nullwerte werden vollstÀndig transparent. Die optionale lightTable ordnet Indizes vor Palette::rgb neu zu.

CL2

CL2 ist das Animationsblattformat fĂŒr Spieler und Monster. Dateien können mehrere Gruppen enthalten (ĂŒblicherweise acht Blickrichtungen). Cl2::parse erkennt die Gruppierung; selectGroup schaltet die aktive Richtung um; decodeFrame verwendet ein von CELs abweichendes Steuerbyte-RLE. Lichttabellen funktionieren analog.

PCX und Schriftarten

Pcx dekodiert Titel und UI-Grafiken, einschließlich Sprite-Listen. UiFont lĂ€dt Artfont-Streifen in verschiedenen GrĂ¶ĂŸen (load42, load24, load16), misst Zeichenketten und rendert Glyphen fĂŒr MenĂŒs und HUD-Texte.

Von Bytes zu Texturen

Pipeline in einem Satz: MPQ lesen → (entschlĂŒsseln/dekomprimieren) → zwischenspeichern → analysieren → mit Palette/Licht dekodieren → optionale Maske → SDL-Textur oder Software-Blit.

Datentabellen außerhalb der MPQ befinden sich unter assets/txtdata/ als TSV-Dateien: itemdat.tsv, PrĂ€fix-/Suffixtabellen, unique_itemdat.tsv, monstdat.tsv. Dies sind Spieldaten, die der PHP-Laufzeitumgebung gehören; Grafiken verbleiben in der Benutzer-MPQ.

Animationssystem

Timing

AnimationInfo ist die gemeinsame Uhr:

public const BASE_VALUE_FRACTION = 128;

public function setNewAnimation(int $numberOfFrames, int $ticksPerFrame = 1, int $numSkippedFrames = 0): void
{
    $this->numberOfFrames = max(1, $numberOfFrames);
    $this->ticksPerFrame = $ticksPerFrame;
    $this->currentFrame = max(0, min($this->numberOfFrames - 1, $numSkippedFrames));
    $this->tickCounterOfCurrentFrame = 0;
}

public function processAnimation(bool $reverse = false): void
{
    $this->tickCounterOfCurrentFrame++;
    if ($this->tickCounterOfCurrentFrame >= $this->ticksPerFrame) {
        $this->tickCounterOfCurrentFrame = 0;
        ++$this->currentFrame; // or wrap / reverse
    }
}

getAnimationProgress gibt den Wert 0..128 zurĂŒck, der fĂŒr Lauf-Offsets und flĂŒssiges Scrollen verwendet wird. AusrĂŒstung mit schnellen Angriffen/schneller Erholung kann beim Starten von Treffer- oder Angriffsanimationen Frames ĂŒberspringen – das Affix-System leitet diese Information an GameState weiter.

Spielerbögen

Die Spielergrafiken werden unter plrgfx\{class}\
 aufgelöst, wobei RĂŒstungs- und Waffensymbole im PfadprĂ€fix kodiert sind, gefolgt von Modus-Suffixen wie „Stehen/Gehen“ in der Stadt bzw. im Dungeon, „Angreifen“, „Treffer“, „Tod“, „Zaubern“ und „Blocken“. GameState verwaltet separate CL2-Handles fĂŒr diese Modi und speichert die dekodierten Frames pro Richtung im Cache. warmRenderCaches dekodiert Stehen, Gehen, Angreifen, Treffer, Blocken, Zaubern und Tod nach dem Laden fĂŒr alle acht Richtungen vor, sodass der erste Kampfschlag beim Dekodieren nicht ruckelt.

Monster

Monstdat liest monstdat.tsv ein und erstellt CL2-Pfade wie monsters\{suffix}{n|w|a|h|d}.cl2 fĂŒr Stand/Gehen/Angriff/Treffer/Tod. GameState::tryLoadMonsterCl2 tauscht den Modusbuchstaben und speichert TabellenblĂ€tter im Monster-Array. Frame-ZĂ€hler und Treffer-Frames stammen aus den Frame/Rate-Spalten der TSV-Datei.

Stadtbewohner und Objekte

Stadt-NPCs und viele interaktive Objekte nutzen CEL-Animationen, die in advanceTownerAnims bzw. der Objektverarbeitung erweitert werden. TĂŒren sind eine Besonderheit: Beim Öffnen wird oft das darunterliegende Element ausgetauscht, anstatt nur eine dekorative CEL-Animation abzuspielen – setDoorStateOpen / setDoorStateClosed sorgen fĂŒr korrekte Kollisionserkennung in der Spielwelt.

Zustandsautomat

Spielermodi umfassen Stehen, verschiedene Gehvarianten, Angreifen, Fernkampfangriff, Treffer, Blocken, Zaubern und Tod. Monster bieten ein Ă€hnliches Set. Die Zielaktion (DestAction) ist der ĂŒbergeordneten Bewegung zugeordnet: Die ĂŒbergeordnete Absicht (dieses Monster angreifen, jene TĂŒr öffnen) bleibt ĂŒber mehrere Ticks hinweg erhalten, wĂ€hrend sich Laufwege und AnimationszustĂ€nde im Hintergrund verĂ€ndern.

final class DestAction
{
    public const NONE = 0;
    public const WALK = 1;
    public const ATTACK_MON = 2;
    public const OPERATE = 3;
    public const TALK = 4;
    public const PICKUP = 5;
    public const SPELL = 6;
    public const RATTACK_MON = 7;
}

Gameplay-Systeme

Bewegung und Wegfindung

Click-to-move erstellt einen Pfad mit Diablo\Engine\Path — A* mit Achsenkosten 100, Diagonalen 101, maximaler LĂ€nge 25, optionalem Corner-Cut-Callback:

public function findPath(
    int $sx, int $sy, int $gx, int $gy,
    callable $isWalkable,
    ?callable $canStep = null,
    int $maxPath = self::MAX_PATH,
): array {
    // open set sorted by g+h, eight neighbors, reconstruct when goal reached
}

GameState wandelt die Kachelliste in einen Pfad um, startet die Gehanimationen, positioniert den Spieler im letzten Frame von doWalk auf der nĂ€chsten Kachel und versucht dann, die Kachel aufzuheben und die nĂ€chste Aktion in der Warteschlange auszufĂŒhren. Die Verfolgungslogik aktualisiert die Pfade, sobald sich ein anvisiertes Monster bewegt.

Kampf

Nahkampf: startAttack → Angriffsanimation → Trefferframe → hitMonster mit Trefferchance gegen RĂŒstung (einschließlich Durchdringung durch TARGAC / plEnAc), Schadenswurf, Lebensraub, RĂŒckstoß und Waffenhaltbarkeit. Fernkampf: startRangeAttack / RATTACK_MON erzeugt Pfeilgeschosse. Monster greifen durch applyMeleeHitToPlayer zurĂŒck, mit Blockanimationen und Mana-Schildabsorption, wenn aktiv.

Die Resistenzen sind in recalc auf 75 begrenzt. Feuer-, Blitz- und Magieresistenzen reduzieren den erlittenen Elementarschaden. Der Schaden durch halbe Fallen hĂ€ngt von der AusrĂŒstung ab.

Objekte und TĂŒren

Kathedralen (und andere Themen) platzieren TĂŒrobjekte, die Felder belegen und Wege blockieren, wenn sie geschlossen sind. Das Öffnen und Schließen einer TĂŒr Ă€ndert den Zustand des Objekts und ertönt ein akustisches Signal. Truhen, FĂ€sser, Schreine, Fallen und Dekorationen verfĂŒgen ĂŒber Platzierungshilfen und die Funktion operateObject. Auslöser an Treppen wechseln zwischen Stadt- und Dungeon-Ebenen (checkTriggers).

Artikel und Inventar

ItemDat lÀdt TSV-Tabellen und implementiert die Drop-Generierung:

  • Filtern nach Drop-Rate und Monsterlevel
  • Seltene, einzigartige WĂŒrfe gegen unique_itemdat.tsv Ansonsten BasisgegenstĂ€nde; Chance auf ein magisches PrĂ€fix oder Suffix
  • applyAffixPower ordnet Kraftnamen den Feldern pl* und effects[] zu (Feuerresistenz, Trefferchance, Schaden %, Attribute, Leben stehlen, RĂŒckstoß, Schnellangriff, Stabzauber und mehr)

GameState::recalc addiert identifizierte (oder normale) Boni zu den Spielerwerten, begrenzt Resistenzen, passt den Lichtradius an und setzt das Mana auf Null, wenn NOMANA ausgerĂŒstet ist. Das Inventar besteht aus einer Tasche mit 40 PlĂ€tzen sowie GĂŒrtel- und AusrĂŒstungsplĂ€tzen; InventoryGrid erkennt die Position von GegenstĂ€nden. Der Cursor zeigt eine Gegenstandsgrafik aus objcurs.cel ĂŒber Cursor an.

ZaubersprĂŒche und Geschosse

Rechtsklick und „F“ wirken einen Feuerblitz; „H“ heilt. Stabladungen können den primĂ€ren Zauber mit „castPrimarySpell“ / „tryCastNamedSpell“ ĂŒberschreiben. Geschosse (Pfeil, Feuerblitz, Feuerball, Blitz usw.) bewegen sich in jedem Tick und prĂŒfen dabei, ob sie feste Felder oder Block-Geschoss-Felder berĂŒhren. Apokalypse, Inferno, Steinfluch, Blitz, Phasenverschiebung und Ă€hnliche Hilfszauber sind in „GameState“ fĂŒr BĂŒcher und Schriftrollen verfĂŒgbar.

HĂ€ndler und Stadt

Der HĂ€ndlermodus steuert Aktionen von Heilern, HĂ€ndlern, Schmieden, Hexen, Kain-Identifizierern und Tavernen-NPCs – Reparaturen, Aufladen, Kauf/Verkauf. Die Stadtbewohner bewegen sich an Ort und Stelle, wĂ€hrend man durch die isometrischen Straßen lĂ€uft.

Monster-KI

AiProc::tick denkt nur dann, wenn sich ein Monster nicht bereits bewegt:

return match ($ai) {
    'Skeleton', 'SkeletonBow', 'BoneDemon' => self::skeletonAi(...),
    'GoatMc', 'GoatBow', 'GoatLord' => self::goatAi(...),
    'Fallen' => self::fallenAi(...),
    'Scavenger' => self::scavengerAi(...),
    default => self::zombieAi(...),
};

SinnprĂŒfungen: Gleiche Transparenz im Raum oder freie Sichtlinie ĂŒber LineClear::notSolid. Fernkampf-KIs benötigen freie Sichtlinien fĂŒr Raketen. Die Wegplanung verwendet Path mit einer kurzen maximalen LĂ€nge; der Weg dauert WALK_FRAMES (8), bevor das Feld belegt wird.

Speichern und Audio

saveGame schreibt Version 2 JSON in die Datei saves/slot1.json: Name, Klasse, Position, Vitalwerte, Gold, EP, Statuswerte, Inventar, GĂŒrtel, AusrĂŒstung, Dungeon-Seed, Leveltyp, Quest-Flags, Manaschild, Infra-/Such-Timer, Zauberstufen. Laden stellt den Spielstand wieder her und betritt die entsprechende Karte erneut.

Audio ordnet Cues MPQ WAV-Pfaden zu (sfx\misc\walk1.wav, swing.wav, bfire.wav, 
), speichert temporĂ€re Dateien im Cache und kann diese auf Darwin-Systemen mit einer Verzögerung von 50 ms abspielen, damit der Kampf nicht hundert Spieler gleichzeitig angreift.

Rendering-Herausforderungen

Nichts davon wirkt exotisch, sobald man Erfahrung mit isometrischen Engines gesammelt hat. In PHP sind Fehler aber nach wie vor leicht zu begehen.

Farbpalette und Licht. Kunst ist 8-Bit. Schönheit und Dunkelheit existieren beide im Indexraum. Lighting::makeLightTables erstellt Remaps; Blit-Pfade mĂŒssen die richtige Zeile durchlaufen, sonst wirkt alles flach (--fullbright dient genau dazu, Geometrie ohne Schatten zu debuggen).

Mikrotile-Topologie. Quadrate, transparente Quadrate, linke/rechte Dreiecke, Trapeze, Laubwerkspassagen, obere Stapel – ein falsches FĂŒllbyte und eine Wand erhĂ€lt einen schwarzen Zahn. ReencodeDungeonCels dient der Normalisierung von Dreiecksdaten vor der Dekodierung.

Ebenenreihenfolge: Zuerst Boden, dann WĂ€nde und Objekte, dann Spezialobjekte, dann OOB-FĂŒllung. Zeichnet man einen Spieler vor die Wand, hinter der er „steht“, bricht die Szene zusammen. Die Scrollrt-DurchlaufzĂ€hler (DrawFloor_tiles, DrawDungeon_ents, 
) existieren, weil Reihenfolgefehler subtil sein können.

Transparenz. Die Raumtransparenz und die Links-/Rechts-Masken entsprechen nicht den RLE-Nullstellen von CEL. Die Alpha-128-Überblendung nĂ€hert sich dem Überblendungsprozess des Originals an. Deaktivieren Sie die Masken mit --debug-disable-masks, wenn Sie Streifen isolieren.

Animation versus Kamera. Gehbewegungen verschieben Sprites und können im Kameramodus optional invertiert werden. Desynchronisiert man sie, gleiten die FĂŒĂŸe durch die Kacheln oder die Welt wird durch GummibĂ€nder geschleudert. Der Fortschritt in AnimationInfo (in 128stel) ist die gemeinsame Sprache zwischen Simulation und Rendering.

Einzigartigkeit der Akte. Trace-Flags stellen sicher, dass eine Akte nicht zweimal in einem Frame gezeichnet wird. Überzeichnung sieht aus wie Flimmern; Unterzeichnung sieht aus wie Teleportation.

Panel versus Welt. Die BenutzeroberflĂ€che hat eine Auflösung von 640×480 Pixeln; die Weltkamera belegt nur 352 Pixel Höhe. Die Klickzuordnung muss die Scrollrt::screenToTile-Berechnung verwenden, die Scroll-Offsets und Overscan beim Gehen berĂŒcksichtigt, sonst trifft Ihr Klick auf die falsche Kachel.

Das sind gewöhnliche Motorprobleme. Das Ungewöhnliche daran ist, sie in einer Sprache zu lösen, deren Standardwerkzeugkasten HTTP und SQL ist.

Leistung

PHP ist nicht C. Das Projekt betrachtet dies als eine technische EinschrÀnkung, nicht als einen Persönlichkeitsfehler.

Byte-Cache. AssetStore speichert MPQ-Lesezugriffe zwischen. Das zweimalige Öffnen desselben CEL ist nach dem ersten Zugriff kostenlos.

Cache dekodieren. GameState verwaltet $playerFrameCache und $microCache (mit Metadaten). warmRenderCaches ĂŒbernimmt die Dekodierungskosten fĂŒr Spielermodi im Voraus. Monster-CL2-Sheets werden nach dem Laden im Monster-Array gespeichert.

Leuchttische. Einmal gebaut, wiederverwendet als Index-Remaps – gĂŒnstiger als RGB-Shading pro Pixel.

FFI-Upload. Der ressourcenintensivste Bereich ist oft das Hochladen und PrĂ€sentieren von Texturen, nicht die PHP-Arithmetik. --profile-frame gibt regelmĂ€ĂŸig die FPS aus und zeigt Statistiken fĂŒr Simulation, Rendering, Dekodierung und Upload (einschließlich der höchsten Perzentile) an, sodass Sie erkennen können, welcher Bereich am meisten Ressourcen verbraucht.

Software-Komponierung. Langsamer, aber wandelt die Frage „Was hat dieses Pixel berĂŒhrt?“ mithilfe von RenderTrace in eine PHP-seitige Antwort um. Verwenden Sie es zum Debuggen, nicht um den Pfad der am hĂ€ufigsten verwendeten Programme zu ĂŒbermitteln.

Speicher. bin/diablo.php setzt memory_limit auf 512 MB. Die Dekodierung von RGBA-Daten fĂŒr eine detailreiche Kathedralenszene summiert sich; Caches sind ein Tausch von RAM gegen Framezeit.

Logik-Takt. Ein Tick bewegt Spieler, Monster, Geschosse, Objekte, Auslöser und Lichter weiter. Die Render-Funktion kann je nach Schleifen-Timing in Diablo::run hĂ€ufiger ausgefĂŒhrt werden als die Logik, aber Gehen und KĂ€mpfen funktionieren tickbasiert.

Interessante Implementierungsdetails

Transparente CEL-LĂ€ufe sind signiert

Der CEL-Decoder behandelt ≄ 0x80 nicht als einfaches „N ĂŒberspringen“. Er interpretiert das Byte als vorzeichenbehaftete LĂ€ngenangabe:

if ($val >= 0x80) {
    $n = -$this->toInt8($val);
    for ($i = 0; $i < $n; $i++) {
        $row[] = null;
        // ...
    }
} else {
    for ($i = 0; $i < $val; $i++) {
        $idx = ord($src[$pos++]);
        $row[] = $idx;
        // ...
    }
}

Wenn man das Vorzeichen falsch setzt, bekommen alle Sprites gepunktete Löcher – oder schlimmer noch, sie fressen die Bytes des nĂ€chsten Frames.

Die Wegfindung berechnet Diagonalen etwas höher

public const AXIS_COST = 100;
public const DIAG_COST = 101;

Diese diagonale Strafe von einem Punkt begĂŒnstigt Achsenschritte, ohne Diagonalen zu verbieten, und wird mit der Eckregel canStep kombiniert, um zu verhindern, dass man durch feste Ecken schneidet. Die Monster-KI verwendet dieselbe Klasse Path mit einer kĂŒrzeren maximalen LĂ€nge, damit Gruppen nicht in jedem Tick Routen quer ĂŒber die Karte planen.

Affixe sind Daten, Kampf ist recalc

PrĂ€fixe und Suffixe sind TSV-Zeilen. ItemDat::applyAffixPower schreibt Felder wie plFireRes, plToHit, plEnAc und plFastAttack. GameState::recalc ist der zentrale Aggregationspunkt – der Punkt, den Symfony-Entwickler als „Neuaufbau des Spielerstatistik-View-Modells“ bezeichnen könnten. Nicht identifizierte magische GegenstĂ€nde halten pl* zurĂŒck, bis sie identifiziert sind; normale Werte gelten immer. Diese eine Regel verhindert eine Reihe von Fehlern, die dazu fĂŒhren, dass der Schaden sprunghaft ansteigt, nachdem man ein mysteriöses Schwert ausgerĂŒstet hat.

Audio ist eine Cue-Tabelle, kein Mixer-Diagramm.

private const CUES = [
    'death' => 'sfx\\misc\\dead.wav',
    'player_hit' => 'sfx\\misc\\swing2.wav',
    'swing' => 'sfx\\misc\\swing.wav',
    'door' => 'sfx\\items\\invgrab.wav',
    'pickup' => 'sfx\\items\\invpot.wav',
    'missile' => 'sfx\\misc\\bfire.wav',
    'cast' => 'sfx\\misc\\cast1.wav',
    'heal' => 'sfx\\misc\\healing.wav',
    'walk' => 'sfx\\misc\\walk1.wav',
];

Das Gameplay ruft play('swing') auf. Plattformen, die GerÀusche erzeugen können, tun dies; andere erhalten weiterhin einen Hinweis. Die Engine blockiert die Simulation nicht aufgrund von Audio.

Die FFI-Grenze ist absichtlich dĂŒnn.

Platform\Sdl verwaltet die Bibliotheksauflösung, cdef, Fenster, Renderer, Ereignisse und Textur-Uploads. GameState ruft SDL_* niemals direkt auf. Das entspricht dem Prinzip, Doctrine hinter einem Repository zu halten – die Schnittstelle zu ersetzen oder zu simulieren, ohne den Cathedral-Generator neu zu schreiben.

SpeicherstÀnde sind absichtlich im langweiligen JSON-Format.

Versioniert, formatiert, eine einzige Speicherdatei. Kein binĂ€res Hero-Format. Sie können SpielstĂ€nde vergleichen, sie absichtlich beschĂ€digen oder Skriptlader verwenden. FĂŒr eine Forschungslaufzeitumgebung ist Langeweile ein erwĂŒnschtes Merkmal.

Szenario-Harnesses als ausfĂŒhrbare Spezifikationen

--scenario=ALL fĂŒhrt deterministische Bewegungs-, Kampf-, TĂŒr-, Aufhebe-, Treppen- und Fernkampf-Proben durch, die PASS / FAIL ausgeben. Sie ersetzen zwar nicht die Pixelanalyse, verhindern aber, dass Refaktorierungen unbemerkt Fehler wie „TĂŒr anklicken, TĂŒr öffnet sich“ verursachen. Smoke-Tests prĂŒfen die MPQ und testen die Route Stadt → Kathedrale → Speichern. Zusammen bilden sie ein CI-förmiges Sicherheitsnetz um ein 14.000 Zeilen langes Zustandsobjekt.

Abschluss

PHP kann weit mehr als typische Webanwendungen ausfĂŒhren.

Dieses Repository demonstriert einen vollstĂ€ndigen vertikalen Ausschnitt einer isometrischen 2D-ARPG-Laufzeitumgebung: Archiv-I/O, Sprite-Codecs, prozedurale Dungeon-Verkabelung, Wegfindung, Kampf, GegenstĂ€nde, KI, BenutzeroberflĂ€che und ein SDL2-Frontend – zusammengesetzt als gewöhnliche PSR-4-Klassen, die Sie mit den gleichen Augen lesen können wie eine Symfony-Codebasis.

Es zeigt auch etwas Unscheinbareres: Alte Spiele ĂŒberleben, wenn jemand noch ihre Daten laden und ihre Systeme in einer modernen Sprache erklĂ€ren kann. Sie bringen Ihre eigene DIABDAT.MPQ-Datei mit. Das Projekt liefert den Decoder, den Simulator und das Fenster. Das rechtliche Eigentum verbleibt bei Ihnen; das technische VerstĂ€ndnis wird zu frei zugĂ€nglichem Quellcode.

Ist es fertig? Der Hilfetext verweigert die Bewertung ohne menschliche BestĂ€tigung. Ist es interessant? Gehen Sie in die Stadt, öffnen Sie eine TĂŒr, steigen Sie in eine Kathedrale hinab und beobachten Sie, wie PHP Mikrokacheln in 640×480 Pixeln rendert.

Das war die Forderung: Diablo nach PHP portieren.

Die Überraschung besteht nicht darin, dass ein KI-Agent beim Schreiben von Tausenden von Zeilen geholfen hat.

Die Überraschung besteht darin, dass die Linien einen Motor bilden – und der Motor lĂ€uft.

Quellcode des Spiels

Der Quellcode befindet sich hier: github.com/matyo91/diablo-php

Eintrag: bin/diablo.php · Namensraum: Diablo\ · Lizenz: MIT (Code) · Assets: Bringen Sie Ihre eigene DIABDAT.MPQ* mit.

Anmelden um auf diesen Beitrag zu reagieren

🚀 1

Site

  • Sitemap
  • Kontakt
  • Impressum

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, alle Rechte vorbehalten