Darkwood Blog Blog
  • Articles
  • Veille
  • Releases
  • CrĂ©ateurs
fr
  • de
  • en
Connexion
  • Blog
  • Articles
  • Veille
  • Releases
  • CrĂ©ateurs

đŸ‘Ÿ J'ai demandĂ© Ă  un agent d'IA de porter Diablo sur PHP.

le 12 août 2026

Connectez-vous pour réagir à cet article

🚀 1

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

Le chargement automatique associe Diablo\ Ă  src/.

Lancement

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

Ou via l'environnement :

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

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

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, DrlgL1–DrlgL4
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));

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();

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.

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
 */

Du monde à l'écran :

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

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;

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++]);
        }
    }
}

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);
}

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];
}

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)); }
}

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
    }
}

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;
}

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
}

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 dans GameState` 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 :

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 :

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

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

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*

Connectez-vous pour réagir à cet article

🚀 1

Site

  • Plan du Site
  • Contact
  • Mentions lĂ©gales

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, tous droits réservés