DEV Community

Cover image for 👾 J'ai demandé à un agent d'IA de porter Diablo sur PHP.
Mathieu Ledru
Mathieu Ledru

Posted on

👾 J'ai demandé à un agent d'IA de porter Diablo sur PHP.

En fait, cela ouvre une fenêtre.

Vous pouvez vous promener dans les rues de la ville.

Cliquez sur une porte et elle s'ouvre.

Frappez un squelette dans la cathédrale.

Chargez les données de jeu originales à partir d'un fichier que vous possédez déjà.

Mais ce n'est pas la partie intéressante.

Le plus intéressant, c'est que l'ensemble constitue un véritable moteur de jeu écrit en PHP : un lecteur d'archives MPQ, des décodeurs CEL et CL2, un pipeline de tuiles isométriques, un algorithme de recherche de chemin A*, des affixes d'objets, une IA pour les monstres, un inventaire, des marchands, des missiles, des tables d'éclairage et une interface SDL2, le tout assemblé avec FFI. Ce n'est pas une simple démo. Ce n'est pas un diaporama de sprites. C'est un environnement d'exécution qui démarre, simule et affiche des graphismes.

Cet article est une visite technique de ce dépôt de code source github — https://github.com/matyo91/diablo-php — écrit pour les développeurs PHP, les programmeurs de moteurs et tous ceux qui pensent que « moteur de jeu » et « langage proche de Symfony » ne peuvent pas partager une phrase.

Qu'est-ce que Diablo ?

Diablo 1 est le jeu de rôle d'action isométrique classique que ce projet vise. Le dépôt ne contient ni les illustrations ni les niveaux de Blizzard. Il inclut un environnement d'exécution PHP qui nécessite l'utilisation d'un fichier DIABDAT.MPQ obtenu légalement — l'archive de données originale de Diablo 1.

Ce que le code source implémente réellement révèle le type de jeu. On y trouve une ville centrale avec des PNJ avec lesquels interagir et auprès desquels acheter des objets. Différents types de donjons (cathédrale, catacombes, grottes, enfer) sont générés à partir de graines via des classes nommées DrlgL1, DrlgL2, DrlgL3 et DrlgL4. Le jeu propose également des monstres dotés de modes d'action (immobilité, marche, attaque, coup, mort), des portes dont les cases changent de place à l'ouverture, des coffres et des tonneaux contenant des objets, des projectiles tels que des carreaux de feu et des flèches, une grille d'inventaire, des potions à la ceinture et un panneau de contrôle en bas de l'écran occupant 128 pixels d'une image tampon de 640×480 pixels.

L'atmosphère de ce jeu n'est pas un mood board. Elle est le résultat du remappage des index de palette par l'éclairage via des tables lumineuses, de la révélation des cellules de la carte automatique par la vision, et des signaux audio qui extraient des fichiers WAV du même MPQ que celui utilisé pour les graphismes. Lorsque le joueur meurt, GameState::tick bascule le mode en « mort », lance une animation de mort et joue le signal de mort. Voilà l'ambiance du jeu, exprimée par le code.

Le projet est transparent quant à son niveau de maturité. L'aide en ligne de commande indique toujours que l'environnement d'exécution n'est pas entièrement jouable pour une évaluation humaine. Des simulations automatisées de fumée et de scénarios existent ; la fidélité visuelle pour la vente au détail reste un objectif en constante évolution. Cette transparence est essentielle. Ce qui suit décrit le contenu actuel du projet, et non prétend que chaque pixel de la cathédrale correspond à un fichier binaire des années 1990.

Pourquoi PHP ?

Si vous ne connaissez PHP que derrière nginx, ce projet ressemble à un défi.

Regardez de plus près.

PHP 8.5+ est la version minimale requise dans composer.json. L'environnement d'exécution s'appuie sur des propriétés typées, match, des énumérations comme constantes et une structure PSR-4 sous l'espace de noms Diablo. Il s'agit de la même évolution du langage que celle utilisée par les développeurs Symfony, appliquée ici à une boucle de jeu plutôt qu'à un noyau HTTP.

L'interface de ligne de commande est le vecteur de distribution. bin/diablo.php est un processus de longue durée avec max_execution_time désactivé et une limite de mémoire de 512 Mo. Il n'y a pas de cycle de requête. Il y a une fenêtre, un cycle d'exécution, un rendu.

FFI sert d'interface. Diablo\Platform\Sdl charge SDL2 via FFI::cdef, crée une fenêtre et un moteur de rendu, gère les événements, charge les textures RGBA et affiche les images. Le code natif reste confiné à une seule classe. Le reste du moteur demeure en PHP : décodage, simulation et composition.

La portabilité découle de cette séparation. Le pipeline des ressources est entièrement en PHP (MpqArchive, Cel, Cl2, DunMicro). La couche d'affichage utilise la version de SDL2 compatible avec votre machine. Sous macOS, la gestion audio optionnelle fait même appel à afplay pour lire les fichiers WAV extraits du MPQ — une solution de contournement pratique en attendant le traitement de SDL_mixer.

L'expérimentation est ce qui rend cette forme intéressante pour l'ingénierie assistée par IA. On peut vider une palette, figer la première image, comparer la simulation, le rendu et le décodage, masquer des personnages ou composer le monde sur un framebuffer CPU avec --software-compose. Le langage qui héberge WordPress peut également gérer une boucle de blit isométrique. Une fois ce fait admis, nombre d'idées reçues sur les limitations du PHP s'effondrent.

Le moteur de rendu n'utilise pas WebGL. Il s'agit principalement d'un décodage logiciel d'images 8 bits en RGBA, puis d'un chargement sur le GPU via des textures SDL — avec une option de composition logicielle complète pour les diagnostics. Ce choix technique est délibéré : permettre l'inspection des pixels en PHP avant de faire confiance au GPU.

Exécuter Diablo en PHP

Exigences

Extrait du fichier README du dépôt :

  • PHP 8.5+ avec ext-ffi
  • Bibliothèque SDL2 installée sur l'hôte
  • Un fichier DIABDAT.MPQ obtenu légalement (non distribué avec le projet)

Les ressources du jeu Blizzard ne sont pas incluses. Vous devez posséder Diablo et récupérer le fichier DIABDAT.MPQ depuis votre installation. Les portions de code du lecteur MPQ sont adaptées de mpqfs sous licence MIT. Le fichier LICENSE du projet est sous licence MIT.

Installer

composer install
Enter fullscreen mode Exit fullscreen mode

Le chargement automatique associe Diablo\ à src/.

Lancement

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

Ou via l'environnement :

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

Points d'entrée utiles :

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
Enter fullscreen mode Exit fullscreen mode

L'option --smoke permet de jouer en ville, à la cathédrale et de sauvegarder sans avoir à lancer une session complète. L'option --town lance une nouvelle partie en ville sans passer par le menu. Les options --level et --seed vous placent dans une cathédrale pré-initialisée. L'option --help affiche une longue liste d'options de débogage (calques de rendu, arrêt sur image, couverture des tuiles, capture des combats, etc.).

L'interface FFI doit être activée. Le lanceur vérifie ext-ffi et ini_get('ffi.enable') avant de construire Diablo.

Commandes

Entrée Action
↑/↓ Entrée Menu
Cliquer Marcher / attaquer / opérer / parler
Clic droit Éclair de feu
1–4 Potion de ceinture
I / C Inventaire / personnage
E Équipement
H / F Soin / Éclair de feu
S Enregistrer
Échap Enregistrer + menu

La fonction GameState::handleInput gère également les touches de quête, de grimoire et de carte automatique en mode jeu. Le ciblage par clic constitue l'interface principale : le curseur détermine une action (marcher, attaquer un monstre, interagir avec un objet, parler, ramasser un objet, lancer un sort ou attaquer à distance), puis la simulation l'exécute au cours des ticks suivants.

Architecture du référentiel

En bref :

Chemin Rôle
bin/diablo.php Point d'entrée CLI, analyse des options, sonde MPQ / fumée
src/Diablo.php Application : MPQ → ressources → SDL → GameState → boucle
src/GameState.php ~14k lignes : simulation + orchestration du rendu
src/Engine/ Ressources, chemin, éclairage, vision, animation, actions de destination
src/Engine/Render/ Scrollrt, DunMicro, IsoCoords, masques, FB logiciel
src/Levels/ DungeonMap, DrlgL1DrlgL4
src/Items/ ItemDat, outils d'aide à la grille d'inventaire
src/Monsters/ Monstdat, AiProc
src/DiabloUI/ Boîte de dialogue titre, liste de menus, sélecteur
src/Platform/Sdl.php Limite de l'interface FFI SDL2
assets/txtdata/ Tables TSV des objets et des monstres
data/ Petits objets JSON (objets, sorts, monstres)
maps/ Carte de la ville (JSON)
saves/ slot1.json emplacement de sauvegarde

Séquence de démarrage

Diablo construit la pile à un seul endroit :

$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));
Enter fullscreen mode Exit fullscreen mode

La taille logique de la fenêtre provient de IsoCoords::SCREEN_W / SCREEN_H — 640×480. La boucle de jeu se trouve dans Diablo::run : interroger les événements SDL dans GameState::handleInput, avancer GameState::tick, appeler GameState::render, présenter.

Colonne vertébrale de simulation

GameState::tick est le signal de présence lorsque mode === 'play' :

$this->processPlayer();
$this->processMonsters();
$this->processMissiles();
$this->processObjects();
$this->advanceTownerAnims();
$this->checkTriggers();
$this->updateLighting();
Enter fullscreen mode Exit fullscreen mode

La mort est gérée dans le même cycle : effacer le chemin, définir pmode sur DEATH, passer en mode d'interface utilisateur 'dead', envoyer un message au joueur, lire l'audio.

L'arborescence contient des classes utilitaires plus légères (Combat, Player, Monster, Missile, Spell). Le chemin actif concentre le comportement dans les tableaux et les méthodes de GameState. Lors de la lecture de ce code, commencez par GameState, puis explorez Engine et Levels.

Interface utilisateur

DiabloUI\TitleDialog charge l'arrière-plan du titre et un logo multi-images au format PCX depuis le MPQ. Menu gère les états tels que principal, créer, en cours de lecture, vendeur et mort. UiList et DrawSelector permettent de sélectionner l'élément. L'interface en jeu utilise UiFont et des illustrations CEL du panneau de contrôle (ctrlpan\panel8.cel est un des fichiers de test).

Architecture de rendu

Le chemin de rendu en production n'utilise pas l'ancien assistant diamant dans Renderer / Iso. Le pipeline en direct est GameState::render → rendu du donjon → Scrollrt::drawGame.

Scrollrt documente son propre contrat :

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

Espaces de coordonnées

IsoCoords désigne les espaces que le moteur gère :

/**
 * 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
 */
Enter fullscreen mode Exit fullscreen mode

Du monde à l'écran :

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

La hauteur de la fenêtre d'affichage est de 480 - 128 = 352 — le panneau possède la bande inférieure.

Microtiles

Un élément de donjon n'est pas une simple image bitmap. Il s'agit d'une pile de micro-éléments : des blocs CEL de 32×32 (ou triangulaires) de types différents.

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;
Enter fullscreen mode Exit fullscreen mode

DunMicro::decode convertit les microoctets bruts et une Palette (et une table lumineuse optionnelle) en RGBA. Les triangles de gauche sont décompressés avec un remplissage et des lignes élargies :

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++]);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Masques et transparence

Après le décodage, MaskType effectue un post-traitement RGBA :

  • SOLID — ne pas modifier les texels opaques
  • TRANSPARENT — définir l'alpha à 128 pour le mélange
  • GAUCHE / DROITE — fusionner une région triangulaire préfixée pour que les murs se rejoignent proprement

Le préfixe mathématique de gauche se développe à partir du bas de la tuile :

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);
}
Enter fullscreen mode Exit fullscreen mode

Dessiner une cellule

La fonction Scrollrt::drawCell détermine les bases du feuillage et des murs, sélectionne les masques gauche/droite à partir des indicateurs SOL et de la transparence de la pièce (dTransVal / liste de transparence), puis trace les micro-images. Les sols peuvent dessiner des micro-images de feuillage décalées de -16 en Y. Les murs tracent les micro-images 0 et 1 côte à côte (MICRO_WIDTH = 32), puis empilent les micro-images supérieures par groupes de 32 pixels.

Les entités (joueurs, villageois, monstres, objets, éléments au sol, projectiles) sont affichées dans les passes de donjon grâce à leurs propres fonctions d'affichage. Des options comme hideActors, hideObjects, hideItems et hideHud permettent d'isoler la géométrie lors du débogage.

Deux chemins actuels

Normalement, les images décodées deviennent des textures SDL (createTextureRGBA / updateTextureRGBA) et le moteur de rendu les présente.

Avec l'option --software-compose, les images sont d'abord déposées dans SoftwareFramebuffer. Ce chemin permet d'analyser chaque pixel en PHP — y compris le JSON RenderTrace indiquant qui a modifié quel pixel — avant le chargement final.

Représentation mondiale

Du générateur au réseau

La génération du donjon commence dans un méga-espace (environ 40×40 cellules DRLG). Des générateurs comme DrlgL1::createL5Dungeon placent les salles, les couloirs, les escaliers, les mini-décors (lampes, saletés, ombres), puis les déplacent par expansion vers dPiece — une grille 112×112 d'identifiants de pièces. DungeonMap contient :

  • dPiece — quelle pièce se trouve sur chaque tuile du monde
  • dPieceMicros — définitions micro par pièce
  • Drapeaux SOL — opaques, transparents, antimissile bloqué
  • dTransVal — groupes de transparence des pièces/secteurs
  • dSpecial / indices d'éclairage
  • des assistants comme isWalkable, isSolid, blocksMissile, isFloorTile

La ville se charge via DungeonMap::loadTown. La cathédrale, les catacombes, les grottes et l'enfer possèdent des chargeurs dédiés qui appellent le générateur DrlgL* correspondant avec une graine.

Occupation et vision

Occupancy suit la position des personnages (joueurs, monstres, objets) grâce à des grilles avec des identifiants spécifiques pour les éléments en mouvement. Vision optimise la visibilité pour la carte automatique. Lighting gère une liste de sources lumineuses et crée des LightTables : l'ombrage est obtenu par réaffectation des index de palette, et non par multiplication RVB. Cela correspond au fonctionnement de l'assombrissement dans les graphismes 8 bits d'origine : inversion des index de couleur, puis recherche RVB.

Défilement de la caméra et de la marche

La caméra suit la position du joueur (dPiece). Lors des déplacements, WalkOffset interpole un décalage d'un pixel par rapport à la progression de l'animation, de sorte que le sprite (et éventuellement la caméra) glisse entre les cases sur huit images de déplacement :

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];
}
Enter fullscreen mode Exit fullscreen mode

Scrollrt agrandit la fenêtre de tuiles dessinées avec un surbalayage afin que les bords vides ne soient pas visibles lors du déplacement. --camera-fixed fige la vue lorsque vous souhaitez isoler le mouvement du personnage du défilement.

Chargement des ressources

Tout ce qui est visuel et la plupart des éléments audio commencent par un chemin à l'intérieur de DIABDAT.MPQ.

MPQ

MpqArchive est un lecteur MPQ v1 : il permet de trouver l’en-tête, de charger les tables de hachage et de blocs, de déchiffrer les secteurs avec MpqCrypto et de décompresser avec MpqExplode (PKWARE) ou zlib (selon l’option choisie). API publique : hasFile, readFile, info. Les sondes Smoke/Inspect analysent les chemins connus, tels que :

  • 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)); }
}
Enter fullscreen mode Exit fullscreen mode

Les séparateurs de chemin sont normalisés en barres obliques inverses ; la mise en cache s’effectue par clé en minuscules. Les palettes comportent 256 entrées RVB. Les fichiers CEL et CL2 sont décodés en chaînes RGBA chargées par le moteur de rendu.

CEL

CEL est le langage principal pour les panneaux d'interface utilisateur, les objets et de nombreux éléments graphiques du monde. Cel::parse lit une table d'images (et gère les CEL groupés en supprimant l'en-tête de groupe lorsque les décalages ne correspondent pas à la taille du fichier). decodeFrame parcourt le RLE :

  • octets ≥ 0x80 — séquence transparente (longueur signée)
  • sinon — suite littérale des indices de palette

Les images sont stockées de bas en haut et inversées lors de la construction du RGBA. Les valeurs d'index 0 et nulles deviennent totalement transparentes. La fonction lightTable (optionnelle) permet de réaffecter les index avant Palette::rgb.

CL2

CL2 est le format des feuilles d'animation pour les personnages et les monstres. Les fichiers peuvent être multi-groupes (généralement huit directions). Cl2::parse détecte les groupes ; selectGroup permet de changer la direction active ; decodeFrame utilise un RLE (Relative Lineage Encode) à octets de contrôle différent de celui de CEL. Les tables lumineuses fonctionnent de la même manière.

PCX et polices

Pcx décode les titres et les éléments graphiques de l'interface utilisateur, y compris les listes de sprites. UiFont charge des bandes de polices de caractères à différentes tailles (load42, load24, load16), mesure les chaînes de caractères et affiche les glyphes pour les menus et le texte de l'interface.

Des octets aux textures

Pipeline en une phrase : Lecture MPQ → (décryptage/décompression) → cache → analyse → décodage avec palette/lumière → masque optionnel → texture SDL ou transfert logiciel.

Les tables de données situées en dehors du MPQ se trouvent dans le répertoire assets/txtdata/ au format TSV : itemdat.tsv, les tables de préfixes/suffixes, unique_itemdat.tsv et monstdat.tsv. Ce sont des données de jeu appartenant à l’environnement d’exécution PHP ; les éléments graphiques restent dans le MPQ de l’utilisateur.

Système d'animation

Timing

AnimationInfo est l'horloge partagée :

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
    }
}
Enter fullscreen mode Exit fullscreen mode

La fonction getAnimationProgress renvoie une valeur comprise entre 0 et 128, utilisée pour le décalage des déplacements et le défilement fluide. L'équipement d'attaque rapide/récupération rapide peut sauter des images lors du lancement des animations de coup ou d'attaque ; le système d'affixes gère cela via GameState.

Fiches de joueur

L'affichage du joueur est géré par plrgfx\{class}\…, les caractères d'armure et d'arme étant encodés dans le préfixe du chemin, suivis des suffixes de mode tels que se tenir debout/marcher en ville ou dans un donjon, attaquer, toucher, mourir, lancer un sort, bloquer. GameState conserve des descripteurs CL2 distincts pour ces modes et met en cache les images décodées par direction. warmRenderCaches pré-décode les actions se tenir debout, marcher, attaquer, toucher, bloquer, lancer un sort et mourir pour les huit directions après le chargement, afin d'éviter tout problème d'affichage lors du premier coup porté en combat.

Monstres

Monstdat ingère monstdat.tsv et construit des chemins CL2 comme monsters\{suffix}{n|w|a|h|d}.cl2 pour les actions suivantes : se tenir debout, marcher, attaquer, toucher et mourir. GameState::tryLoadMonsterCl2 inverse la lettre du mode et met en cache les feuilles de calcul du tableau des monstres. Le nombre d'images et les images de réussite des attaques proviennent des colonnes d'images/fréquence du fichier TSV.

Citadins et objets

Les PNJ de la ville et de nombreux objets interactifs utilisent des animations CEL avancées grâce à advanceTownerAnims / traitement des objets. Les portes sont particulières : leur ouverture modifie souvent l’élément sous-jacent au lieu de simplement jouer une animation CEL décorative ; setDoorStateOpen / setDoorStateClosed permettent de gérer correctement les collisions avec le monde.

Machine à états

Les modes de jeu incluent la position debout, différentes variantes de marche, l'attaque, l'attaque à distance, toucher, bloquer, lancer des sorts et la mort. Les monstres proposent un ensemble similaire. L'action de déplacement (DestAction) se situe au-dessus du mouvement : l'intention principale (attaquer ce monstre, actionner cette porte) est conservée d'une itération à l'autre, tandis que les trajectoires et les animations de déplacement s'exécutent en arrière-plan.

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;
}
Enter fullscreen mode Exit fullscreen mode

Systèmes de jeu

Mouvement et recherche de chemin

Click-to-move construit un chemin avec Diablo\Engine\Path — A* avec un coût d'axe de 100, une diagonale de 101, une longueur maximale de 25, un rappel de coupe de coin optionnel :

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
}
Enter fullscreen mode Exit fullscreen mode

La fonction GameState convertit la liste des tuiles en un chemin, lance les animations de déplacement, positionne le joueur sur la tuile suivante à la dernière image de doWalk, puis tente de ramasser un objet et d'exécuter l'action suivante dans la file d'attente. La logique de poursuite actualise les chemins lorsqu'un monstre ciblé se déplace.

Combat

Attaque au corps à corps : startAttack → animation d'attaque → frame d'impact → hitMonster avec calcul des chances de toucher l'armure (y compris la perforation par TARGAC / plEnAc), jet de dégâts, vol de vie, repoussement et durabilité de l'arme. Attaque à distance : startRangeAttack / RATTACK_MON invoque des projectiles de flèches. Les monstres ripostent via applyMeleeHitToPlayer, avec animations de blocage et absorption du bouclier de mana lorsqu'il est actif.

Les résistances sont plafonnées à 75 dans recalc. Les résistances au feu, à la foudre et à la magie réduisent les dégâts élémentaires subis. Les dégâts des pièges réduits de moitié dépendent de l'équipement.

Objets et portes

Dans les thèmes Cathédrale (et autres), les portes occupent des cases et bloquent le passage lorsqu'elles sont fermées. Ouvrir ou fermer une porte modifie l'état de l'élément et déclenche un signal sonore. Les coffres, tonneaux, sanctuaires, pièges et éléments de décoration disposent d'aides au placement et de la fonction operateObject. Les déclencheurs sur les escaliers permettent de passer d'un niveau à l'autre entre la ville et le donjon (checkTriggers).

Articles et inventaire

ItemDat charge les tables TSV et implémente la génération de drop :

  • Filtrer par taux d'obtention et niveau des monstres
  • Tirages uniques rares contre unique_itemdat.tsv
  • Sinon, objets de base ; possibilité de préfixe ou de suffixe magique
  • applyAffixPower associe les noms de pouvoirs aux champs pl* et effects[] (résistance au feu, précision, dégâts %, attributs, vol de vie, repoussement, attaque rapide, sorts de bâton, etc.)

La fonction GameState::recalc additionne les bonus identifiés (ou de qualité normale) et les intègre aux statistiques du joueur, plafonne les résistances, ajuste le rayon de lumière et remet le mana à zéro lorsque NOMANA est équipé. L'inventaire se compose d'un sac de 40 emplacements, plus la ceinture et les emplacements d'équipement ; InventoryGrid prend en charge l'encombrement des objets. Le curseur contient l'image de l'objet provenant de objcurs.cel via Cursor.

Sorts et projectiles

Un clic droit et F lancent un trait de feu ; H soigne. Les charges de bâton peuvent remplacer le sort principal avec castPrimarySpell / tryCastNamedSpell. Les projectiles (flèche, trait de feu, boule de feu, foudre, …) se déplacent à chaque itération en vérifiant qu'ils ne traversent pas les cases pleines ou les blocs de projectiles. Les sorts Apocalypse, Enfer, Malédiction de pierre, Éclair, Passage à travers les murs et autres sorts associés sont disponibles dansGameState` pour les livres et les parchemins.

Commerçants et ville

Le mode vendeur permet de gérer les interactions entre guérisseurs, marchands, forgerons, sorcières, Caïn et tavernes : réparation, recharge, achat/vente. Les personnages restent immobiles pendant que vous parcourez les rues isométriques.

IA des monstres

AiProc::tick ne réagit que lorsqu'un monstre n'est pas déjà en train de marcher :

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

Vérifications sensorielles : même transparence de la pièce ou ligne de mire dégagée via LineClear::notSolid. Les IA à distance nécessitent une ligne de mire dégagée pour les missiles. La planification des déplacements utilise Path avec une longueur maximale courte ; le déplacement dure WALK_FRAMES (8) avant que la case ne soit validée.

Sauvegarde et audio

La commande saveGame enregistre la version 2 du JSON dans saves/slot1.json : nom, classe, position, caractéristiques, or, XP, statistiques, inventaire, ceinture, équipement, graine du donjon, type de niveau, indicateurs de quête, bouclier de mana, temps d'infra/de recherche, niveaux de sorts. La commande load restaure la partie et permet de revenir à la carte correspondante.

Audio associe les repères aux chemins MPQ WAV (sfx\misc\walk1.wav, swing.wav, bfire.wav, …), met en cache les fichiers temporaires et, sous Darwin, peut les afplay avec une limitation de 50 ms afin que le combat ne divise pas une centaine de joueurs.

Défis de rendu

Ces concepts n'ont rien d'exotique une fois qu'on a travaillé avec un moteur isométrique. Il est néanmoins facile de se tromper en PHP.

Palette et lumière. L'art est en 8 bits. Beauté et obscurité coexistent dans l'espace des index. Lighting::makeLightTables génère des remappages ; les chemins de blit doivent s'aligner sur la bonne ligne, sinon tout paraît plat (--fullbright sert précisément à déboguer la géométrie sans ombrage).

Topologie de microtuiles. Carrés, carrés transparents, triangles gauche/droite, trapèzes, zones de feuillage, piles supérieures : une erreur de remplissage et un mur se retrouve avec une dent noire. ReencodeDungeonCels sert à normaliser les données des triangles avant le décodage.

Ordre des calques. Sol d'abord, puis murs et entités, puis éléments spéciaux, puis remplissage hors limites. Si vous dessinez un joueur avant le mur derrière lequel il se trouve, la scène s'effondre. Les compteurs de passes de Scrollrt (DrawFloor_tiles, DrawDungeon_ents, …) existent car les bugs d'ordre sont subtils.

Transparence. La transparence de la pièce et les masques gauche/droite ne correspondent pas aux zéros RLE de CEL. Le mélange Alpha 128 reproduit approximativement le résultat du blit original. Désactivez les masques avec --debug-disable-masks lors de l'isolation des bandes.

Animation versus caméra. Les décalages de marche déplacent les sprites et peuvent être inversés en mode caméra. Désynchronisez-les et les pieds glisseront à travers les tuiles ou le monde se comportera comme un élastique. La progression de AnimationInfo en 128èmes est le langage commun entre la simulation et le rendu.

Unicité des acteurs. Les indicateurs de tracé garantissent qu'un acteur n'est pas dessiné deux fois dans une même image. Un surdessin ressemble à un scintillement ; un sous-dessin ressemble à une téléportation.

Panneau versus monde. L'interface utilisateur a une résolution de 640×480 ; la caméra du monde n'occupe que 352 pixels de hauteur. Le mappage des clics doit utiliser la fonction Scrollrt::screenToTile qui prend en compte les décalages de défilement et le surbalayage lors des déplacements, sinon l'action « cliquer sur cette porte » ciblera la mauvaise case.

Ce sont des problèmes de moteur ordinaires. Ce qui est inhabituel, c'est de les résoudre dans un langage dont les outils standard sont HTTP et SQL.

Performance

PHP n'est pas du C. Le projet considère cela comme une contrainte technique, et non comme un défaut de personnalité.

Cache d'octets. AssetStore met en mémoire les lectures MPQ. L'ouverture du même CEL à deux reprises est gratuite après la première occurrence.

Cache de décodage. GameState conserve $playerFrameCache et $microCache (avec les métadonnées). warmRenderCaches prend en charge le coût de décodage en amont pour les modes de jeu. Les feuilles CL2 des monstres sont conservées dans le tableau des monstres une fois chargées.

Tables lumineuses. Conçues une seule fois, réutilisées pour le remappage des index — moins chères que l'ombrage RVB par pixel.

Téléchargement FFI. Le principal facteur de consommation de ressources est souvent le chargement et l'affichage des textures, et non les calculs PHP. L'option --profile-frame affiche les FPS périodiques et détaille les statistiques de simulation, de rendu, de décodage et de chargement (y compris les percentiles élevés) afin d'identifier les processus les plus gourmands en ressources.

Compilation logicielle. Plus lente, mais transforme la question « Quel pixel a été modifié ? » en une réponse côté PHP via RenderTrace. À utiliser pour le débogage, et non pour déployer le chemin critique.

Mémoire. bin/diablo.php définit memory_limit à 512M. Le décodage RGBA pour une scène de cathédrale complexe représente une quantité importante de mémoire ; les caches impliquent un échange de RAM contre du temps d'affichage.

Rythme logique. Chaque « tick » fait avancer le joueur, les monstres, les missiles, les objets, les déclencheurs et les lumières. Le rendu peut s'exécuter plus fréquemment que le rythme logique selon la durée de la boucle dans Diablo::run, mais la précision des déplacements et des combats est basée sur le rythme.

Détails d'implémentation intéressants

Les pistes transparentes CEL sont signées

Le décodeur CEL ne traite pas ≥ 0x80 comme un simple « saut N ». Il réinterprète l'octet comme une longueur signée :

php
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;
// ...
}
}

Si le panneau est incorrect, chaque sprite se retrouve couvert de trous à pois — ou pire, il consomme les octets de l'image suivante.

Les diagonales de recherche de chemin sont légèrement plus chères

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

Cette pénalité d'un point pour les diagonales favorise les déplacements le long des axes sans interdire les diagonales, et se combine avec une règle d'angle canStep pour éviter de traverser les angles fermés. L'IA des monstres utilise la même classe Path avec une longueur maximale plus courte afin que les groupes ne planifient pas des itinéraires traversant toute la carte à chaque tick.

Les affixes sont des données, le combat est recalc

Les préfixes et suffixes sont des lignes TSV. ItemDat::applyAffixPower écrit des champs comme plFireRes, plToHit, plEnAc, plFastAttack. GameState::recalc est le point d'agrégation unique — ce que les développeurs Symfony pourraient considérer comme une « reconstruction du modèle de vue des statistiques du joueur ». Les objets magiques non identifiés ne contiennent pas pl* jusqu'à leur identification ; les objets normaux sont toujours pris en compte. Cette règle permet d'éviter des bugs du type « j'ai équipé une épée mystérieuse et mes dégâts ont soudainement augmenté ».

L'audio est une table de repérage, pas un graphique de mixage

php
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',
];

Le jeu appelle la fonction play('swing'). Les plateformes capables d'émettre du son le font ; les autres reçoivent un journal d'instructions. Le moteur ne bloque pas la simulation au niveau audio.

La limite FFI est intentionnellement mince

Platform\Sdl gère la résolution de la bibliothèque, les définitions de contenu, la fenêtre, le moteur de rendu, les événements et le chargement des textures. GameState n'appelle jamais directement SDL_*. C'est le même principe que celui qui consiste à conserver Doctrine dans un dépôt : remplacer ou simuler les composants externes sans réécrire le générateur de la cathédrale.

Les sauvegardes sont volontairement au format JSON ennuyeux.

Fichier à un seul emplacement, versionné et formaté. Pas de format binaire. Possibilité de comparer une sauvegarde, de la corrompre volontairement ou d'utiliser des chargeurs de scripts. Pour un environnement d'exécution de recherche, la simplicité est un atout.

Scénarios de déploiement sous forme de spécifications exécutables

L'option --scenario=ALL exécute des vérifications déterministes de déplacement, de combat, d'ouverture de porte, de ramassage d'objets, d'accès aux escaliers et d'attaques à distance, affichant PASS ou FAIL. Ces vérifications ne remplacent pas l'analyse visuelle, mais elles empêchent les refactorisations de casser silencieusement le comportement « cliquer sur une porte, la porte s'ouvre ». Des tests de sécurité préliminaires explorent la file d'attente des joueurs et simulent le parcours ville → cathédrale → sauvegarde. Ensemble, ils forment un filet de sécurité autour d'un objet d'état de 14 000 lignes.

Conclusion

PHP peut exécuter bien plus que les applications web classiques.

Ce dépôt présente une vue d'ensemble complète d'un environnement d'exécution ARPG isométrique 2D : entrées/sorties d'archives, codecs de sprites, câblage procédural des donjons, recherche de chemin, combats, objets, IA, interface utilisateur et une interface SDL2 — le tout assemblé sous forme de classes PSR-4 ordinaires que vous pouvez lire avec les mêmes yeux que vous utilisez sur une base de code Symfony.

Cela illustre aussi un aspect plus discret : les jeux anciens survivent grâce à la possibilité de charger leurs données et d'expliquer leur fonctionnement dans un langage moderne. Vous fournissez votre propre fichier DIABDAT.MPQ. Le projet inclut le décodeur, le simulateur et l'interface. Vous conservez la propriété légale ; les connaissances techniques deviennent une source de partage.

Est-ce terminé ? Le texte d'aide refuse de le qualifier de prêt sans confirmation humaine. Est-ce intéressant ? Allez en ville, ouvrez une porte, descendez dans une cathédrale reconstituée et observez PHP charger des microtuiles en 640×480.

Voilà la demande : porter Diablo sur PHP.

La surprise n'est pas qu'un agent d'IA ait contribué à la rédaction de milliers de lignes.

La surprise, c'est que ces lignes forment un moteur — et que le moteur tourne.

Code source du jeu

Le code source se trouve ici : github.com/matyo91/diablo-php

Point d'entrée : bin/diablo.php · Espace de noms : Diablo\ · Licence : MIT (code) · Ressources : apportez votre propre fichier DIABDAT.MPQ*

Top comments (0)