La gĂ©nĂ©ration procĂ©durale est particuliĂšrement utile lorsqu'elle remplace un contenu rĂ©pĂ©titif par des rĂšgles que les joueurs peuvent comprendre. La variĂ©tĂ© Ă elle seule n'est pas suffisante. Un Ă©tat gĂ©nĂ©rĂ© doit encore prĂ©senter une dĂ©cision significative, chaque action doit avoir un champ d'application prĂ©visible, et le systĂšme doit ĂȘtre capable de distinguer un puzzle valide d'un agencement alĂ©atoire.
Nous avons construit Strata Relay autour de cette contrainte. C'est un puzzle de terrain au tour par tour pour un écran en mode portrait. Le monde logique s'inscrit dans 125 positions de voxels, mais le joueur ne déplace jamais un personnage à travers celui-ci. Au lieu de cela, chaque tour fait muter une croix locale de colonnes de terrain. Le contour résultant détermine si un signal peut voyager entre deux cristaux fixes.
La mĂȘme prĂ©fĂ©rence pour des rĂšgles explicites aide Ă©galement lorsque des applications indĂ©pendantes Ă©changent des Ă©vĂ©nements. Un petit contrat de message versionnĂ© est plus facile Ă comprendre que des objets d'implĂ©mentation partagĂ©s ou un flux de chaque changement de terrain. Les fonctionnalitĂ©s d'interopĂ©rabilitĂ© du Messenger de Symfony 8.2 fournissent un moyen utile d'Ă©tudier cette frontiĂšre. Les exemples de messagerie dans cet article sont hypothĂ©tiques et restent sĂ©parĂ©s du jeu fonctionnel.
Un logiciel compact est plus qu'un petit arbre source
Strata Relay génÚre ses niveaux à partir d'une graine, d'un stade de difficulté et d'un ensemble de rÚgles fixes. Il dispose d'une scÚne Godot créée et pas d'art ou de son de gameplay importés. Cela nous donne une empreinte d'asset spécifique au jeu petite, mais cela ne rend pas toutes les autres métriques petites.
Nous suivons la compacité comme plusieurs propriétés indépendantes :
- Taille du code source : c'est le code que nous maintenons.
- Taille des assets créés : cela couvre les niveaux stockés, les images, les modÚles et l'audio.
- Taille d'exportation : cela inclut le runtime moteur et le packaging.
- Mémoire à l'exécution : cela inclut les allocations moteur, navigateur, CPU et GPU.
- Coût de génération : cela mesure le travail requis pour créer un niveau.
- Profondeur du gameplay : cela décrit les décisions produites par les rÚgles.
La génération procédurale a directement réduit les données de niveau stockées dans ce projet : il n'y a pas de catalogue de tableaux fabriqués à la main. Elle nous a également permis de représenter le tableau à partir de rÚgles de dessin partagées plutÎt qu'en important des arts de carreaux. Cela n'a pas supprimé le runtime WebAssembly de Godot, prouvé une faible utilisation de la mémoire, ou automatiquement rendu le puzzle stratégiquement intéressant. Ces questions nécessitent des preuves séparées.
Le dĂ©terminisme est la fondation. Un gĂ©nĂ©rateur pseudo-alĂ©atoire local transforme la mĂȘme graine et le mĂȘme stade en le mĂȘme chemin, masque de socle, leurres et sĂ©quence de mĂ©lange. Le redĂ©marrage peut reconstruire le tableau initial sans stocker un niveau sĂ©rialisĂ©. New Seed change l'entrĂ©e, pas l'algorithme de gĂ©nĂ©ration.
class SeedRng:
var value: int
func _init(seed: int) -> void:
value = seed & 0x7fffffff
if value == 0:
value = 1
func next_int() -> int:
value = int((value * 1103515245 + 12345) & 0x7fffffff)
return value
Le gĂ©nĂ©rateur est constructif. Il crĂ©e d'abord un chemin rĂ©sonant valide, remplit les cellules restantes avec des leurres bas et haut dĂ©terministes, ajoute du socle dĂ©pendant du stade, puis applique des impulsions locales connues prĂšs du chemin. Comme un Ă©tat de terrain avance Ă travers trois valeurs, appliquer la mĂȘme impulsion deux fois inverse une impulsion antĂ©rieure. Inverser l'ordre de mĂ©lange et doubler chaque impulsion produit une solution tĂ©moin.
Cela donne Ă chaque casse-tĂȘte gĂ©nĂ©rĂ© au moins un chemin vĂ©rifiĂ© vers la victoire sans faire tourner un solveur gĂ©nĂ©ralisĂ©. Le tĂ©moin n'est pas prĂ©tendu ĂȘtre le plus court ou unique. Cette distinction est importante : la gĂ©nĂ©ration constructive prouve la rĂ©solvabilitĂ© pour nos rĂšgles et notre budget, alors que l'optimalitĂ© nĂ©cessiterait une recherche bornĂ©e et une cible de validation diffĂ©rente.
Un monde 5 à 5 à 5 représenté comme un champ de hauteur
La limite logique est de cinq cellules de large, cinq cellules de profondeur et cinq voxels de haut. Nous le représentons délibérément comme 25 colonnes plutÎt qu'un occupation arbitraire de toutes les 125 positions.
Chaque colonne a l'un des trois niveaux de gameplay, mappés à des hauteurs physiques de 1, 3 et 5. Le niveau du milieu est résonant. Une impulsion avance du bas au résonant, du résonant au haut, et du haut au bas. Le wrap est présenté comme un pic se fracturant.
Ce champ de hauteur exclut les grottes, surplombs et blocs flottants. En échange, chaque cellule interactive a une surface supérieure visible. Nous n'avons pas besoin de rotation de caméra, de contrÎles de couche ou de rÚgles pour sélectionner des voxels cachés. Le tableau complet reste lisible dans une vue fixe de 648 à 1152 en portrait.
La reprĂ©sentation maintient Ă©galement l'Ă©tat compact. Le tableau autorisĂ© contient 25 niveaux ternaires, 25 indicateurs immuables, deux coordonnĂ©es d'ancrage, une graine, un stade et des mĂ©tadonnĂ©es de tour. La rĂ©sonance et la charge sont dĂ©rivĂ©es au lieu d'ĂȘtre stockĂ©es comme des matĂ©riaux supplĂ©mentaires.
Il n'y a que deux classes de terrain stockées :
- sol mutable, qui peut ĂȘtre sĂ©lectionnĂ© et changĂ© ;
- pierre immuable, utilisée pour les ancres et le socle dépendant du stade.
La résonance cyan est dérivée de la hauteur médiane d'une colonne. Le chemin chargé plus lumineux est dérivé de la connectivité du graphe. Cela évite un état dupliqué obsolÚte : lorsque les hauteurs changent, le composant connecté est recalculé à partir du tableau qui existe réellement.
La vue isomĂ©trique fixe est une gĂ©omĂ©trie de toile procĂ©durale. Chaque colonne est dessinĂ©e Ă partir d'un diamant supĂ©rieur et de deux polygones latĂ©raux, avec des coutures rĂ©pĂ©tĂ©es montrant la hauteur. C'est un monde logique de voxels prĂ©sentĂ© en 2.5D, pas une scĂšne pleine de nĆuds de maillage 3D indĂ©pendants. Ă cette taille de tableau, le dessin direct conserve la projection, l'ordre et les polygones de contact en un seul endroit.
Le pipeline de tour : mutation locale, propagation bornée
Un tour change la colonne mutable sélectionnée et ses voisins cardinaux mutables. Au maximum, cinq colonnes peuvent changer. Le socle est ignoré. Le budget d'impulsion diminue une fois, puis le jeu recompute la connectivité et l'état terminal.
La rĂšgle autorisĂ©e est assez courte pour ĂȘtre inspectĂ©e directement :
static func apply_pulse(state: Dictionary, cell: Vector2i) -> Dictionary:
if state.get("won", false) or state.get("failed", false):
return {"ok": false, "error": "finished", "state": state, "events": []}
if not is_inside(cell) or bool(state["immutable"][index_of(cell)]):
return {"ok": false, "error": "immutable", "state": state, "events": []}
var next := state.duplicate(true)
var affected := affected_cells(next, cell)
var events: Array = []
for changed in affected:
var idx := index_of(changed)
var before := int(next["levels"][idx])
var after := (before + 1) % 3
next["levels"][idx] = after
events.append({"cell": changed, "before": physical_height(before), "after": physical_height(after)})
next["moves_left"] = int(next["moves_left"]) - 1
next["turn"] = int(next["turn"]) + 1
next["last_cell"] = cell
next["won"] = is_won(next)
next["failed"] = int(next["moves_left"]) <= 0 and not bool(next["won"])
return {"ok": true, "error": "", "state": next, "events": events}
Les Ă©vĂ©nements retournĂ©s ne dĂ©clenchent pas une autre simulation. Ils indiquent Ă la vue quelles colonnes animer et fournissent leurs hauteurs physiques prĂ©cĂ©dentes et suivantes. La transition d'Ă©tat reste complĂšte avant que le rendu ne commence. Cette sĂ©paration empĂȘche le timing de l'animation de changer les rĂšgles.
La propagation des signaux est un remplissage par inondation sur des cellules rĂ©sonantes orthogonalement adjacentes. Elle commence Ă la source dorĂ©e et rĂ©ussit lorsque la cible cyan appartient au mĂȘme composant connectĂ©.
static func charged_cells(state: Dictionary) -> Array[Vector2i]:
var source: Vector2i = state["source"]
if int(state["levels"][index_of(source)]) != RESONANT_LEVEL:
return []
var found: Array[Vector2i] = [source]
var queue: Array[Vector2i] = [source]
while not queue.is_empty():
var current: Vector2i = queue.pop_front()
for next in neighbors(current):
if found.has(next):
continue
if int(state["levels"][index_of(next)]) == RESONANT_LEVEL:
found.append(next)
queue.append(next)
return found
C'est la seule étape de propagation. Elle ne peut pas créer une chaßne de mutation illimitée parce qu'elle ne change jamais le terrain. Le parcours examine un maximum de 25 cellules et atteint un résultat stable immédiatement.
La vue met à jour uniquement les colonnes affectées. present() garde les hauteurs d'affichage actuelles, calcule les hauteurs cibles à partir du nouvel état et enregistre les cellules d'événements. _process() interpole ces valeurs et redessine le chemin dérivé. Le monde logique n'est pas régénéré aprÚs un tour ; la continuité provient de l'application d'une seule transition déterministe à l'état précédent.
Construction de niveaux solvables sans niveaux stockés
Le générateur construit un chemin de source à cible depuis le bord gauche jusqu'au bord droit. Les stades ultérieurs introduisent des courbes, plus d'impulsions de mélange et des cellules immuables hors du chemin valide. Les cellules hors chemin deviennent des leurres bas ou haut, de sorte qu'un tableau puisse contenir des fragments résonants convaincants mais déconnectés.
Les cinq stades ajoutent chacun une conséquence :
- Premier signal : introduit le cycle à trois états avec une impulsion de mélange.
- Relais plié : fait tourner le chemin écrit.
- Socle : introduit des colonnes immuables que croise une impulsion.
- Ăchos faux : ajoutent plus de leurres et d'effets qui se chevauchent.
- Strates profondes : combine cinq opérations de mélange avec six colonnes de socle.
La limite de mouvements est Ă©gale Ă la longueur du tĂ©moin : deux impulsions inverses pour chaque impulsion de mĂ©lange. Nous vĂ©rifions le tĂ©moin via apply_pulse(), la mĂȘme rĂšgle publique utilisĂ©e par l'entrĂ©e du joueur. Il n'y a pas de chemin de mutation rĂ©servĂ© aux solveurs alternatifs qui pourrait s'Ă©loigner du jeu.
Le flux de génération résultant est petit et explicite :
seed + stage
â
ââ construire un chemin rĂ©sonant de source Ă cible
ââ ajouter des leurres et du socle dĂ©terministes
ââ appliquer des impulsions locales rĂ©versibles
ââ rejeter un candidat qui est encore rĂ©solu
ââ rejouer le tĂ©moin inverse via la rĂšgle publique
â
ââ Ă©tat initial jouable
Cette approche Ă©tait mieux adaptĂ©e que la recherche en largeur ou A* pour l'objectif actuel. Vingt-cinq cellules ternaires impliquent dĂ©jĂ jusqu'Ă 3ÂČâ” affectations de niveaux bruts avant masques immuables et mĂ©tadonnĂ©es terminales. Une recherche serait raisonnable si nous avions besoin d'une preuve de solutions minimales. Pour cinq courts niveaux d'enseignement, la construction rĂ©versible fournit la garantie dont nous avons rĂ©ellement besoin avec moins de machinerie.
Performances et validation
L'implémentation mesurée à l'exécution se compose de rules.gd, board_view.gd et main.gd : 686 lignes de GDScript et 22,202 octets. L'inclusion de deux programmes de vérification et de la benchmark fournit six scripts, 816 lignes et 26,354 octets. Il y a une scÚne autorisée et aucun asset de gameplay importé.
Sur un hÎte de développement Apple M4 fonctionnant sous Godot 4.7.2 :
- 5,000 générations déterministes ont en moyenne 80.564 microsecondes chacune ;
- 50,000 mises Ă jour de terrain locales ont en moyenne 12.346 microsecondes chacune.
Le benchmark de mise à jour inclut la copie complÚte de l'état des 25 cellules avant d'appliquer une impulsion. Il exclut le rendu et le travail GPU. Ces chiffres décrivent une machine et une construction ; ils ne sont pas des garanties de performance mobile.
La validation a couvert plus que le benchmark. La suite de rĂšgles sans tĂȘte a complĂ©tĂ© 557 assertions avec zĂ©ro Ă©chec, vĂ©rifiant les limites, le dĂ©terminisme, les limites de hauteur, la localitĂ©, les Ă©tats terminaux et la relecture de tĂ©moins. Un test sĂ©parĂ© de stress a gĂ©nĂ©rĂ© 5,000 casse-tĂȘtes Ă travers les cinq stades. Chaque tableau Ă©chantillonnĂ© a commencĂ© non rĂ©solu et son tĂ©moin a atteint la victoire. La boucle d'exĂ©cution rĂ©elle a Ă©galement vĂ©rifiĂ© l'identitĂ© de redĂ©marrage et la relecture complĂšte de la solution.
Le test de validation dans le navigateur a chargé l'exportation finale, observé une toile peinte de 648 à 1152, injecté une entrée et détecté un cadre rendu modifié. Les tests de production ont ensuite repéré un problÚme d'intégration de pointeur que les vérifications de niveau de code avaient raté : l'entrée de la souris de bureau était déjà convertie en contact par le projet, donc le traitement des deux chemins appliquait une impulsion deux fois. La vue publiée accepte le chemin de contact une fois, et les chemins de souris et de contact utilisent maintenant ce chemin.
L'export Web illustre pourquoi la compacité de la source et la taille de la livraison doivent rester des revendications séparées :
-
index.wasm: 39,514,754 octets bruts, 10,084,297 octets avec gzip niveau 9 ; -
index.pck: 130,544 octets bruts, 113,539 octets avec gzip niveau 9 ; - répertoire de construction complet : 39,100 KiB sur disque.
Le moteur WebAssembly domine l'export brut. Le PCK n'est pas non plus un chiffre pur de Strata Relay : il contient du code et des ressources de projet Darkwaar partagés. Nous n'avons pas isolé la mémoire d'exécution spécifique au jeu des allocations de Godot et de navigateur, donc cet article ne fait aucune revendication sur la mémoire.
Les messages inter-applications appartiennent en dehors de la boucle de terrain
Strata Relay fonctionne hors ligne. Sa simulation de terrain, générateur, validation et condition de victoire vivent dans Godot. Envoyer chaque impulsion à travers une frontiÚre d'application introduirait de la latence, des modes de défaillance et un ordre distribué dans une rÚgle qui est déjà déterministe localement.
Une session terminée est un fait d'un genre différent. Dans un systÚme hypothétique multi-application, une application pourrait publier un événement de completion concis et une autre pourrait le consommer pour des statistiques ou de l'automatisation. Cette frontiÚre devrait transporter un contrat explicite, pas une copie sérialisée des internals de chaque application.
Le flux proposé est délibérément grossier :
simulation de jeu local
â
â rĂ©sumĂ© de session optionnel
v
frontiĂšre d'application ââ Ă©vĂ©nement versionnĂ© ââ> consommateur indĂ©pendant
Par exemple, un type de message hypothĂ©tique sur le rĂ©seau pourrait ĂȘtre game.session.completed.v1. Sa charge utile pourrait contenir un identifiant de jeu, graine, niveau, rĂ©sultat, nombre de tours et durĂ©e. Il ne contiendrait pas l'Ă©tat des 25 cellules aprĂšs chaque tour. Le suffixe .v1 rend la compatibilitĂ© une dĂ©cision de contrat explicite : un changement de schĂ©ma rompant obtient un nouveau type au lieu de changer silencieusement le sens d'un Ă©vĂ©nement existant.
Le Messenger de Symfony enveloppe normalement un message dans une Envelope et utilise des timbres pour des préoccupations de cadre telles que le routage, les réessais et la sélection de bus. Cela est utile dans un déploiement Symfony unique, mais les classes de timbres sérialisés et les noms de bus sont de mauvais contrats entre des applications indépendantes.
Le sĂ©rialiseur d'interopĂ©rabilitĂ© de Symfony 8.2 aborde cette frontiĂšre. messenger.transport.interop_serializer envoie le message JSON sans timbres spĂ©cifiques Ă l'application et ignore les timbres sĂ©rialisĂ©s entrants. Un nom de type sĂ©rialisĂ© stable peut remplacer le nom de classe PHP du producteur. Les applications qui n'utilisent pas PHP peuvent lier la mĂȘme chaĂźne de type Ă leur propre schĂ©ma.
# Symfony 8.2+ â configuration illustrative gĂ©nĂ©rique
framework:
messenger:
identity_stamps: true
transports:
shared_events:
dsn: '%env(EVENT_TRANSPORT_DSN)%'
serializer: messenger.transport.interop_serializer
Le contrat PHP correspondant serait également versionné explicitement :
use Symfony\Component\Messenger\Attribute\AsMessage;
#[AsMessage(serializedTypeName: 'game.session.completed.v1')]
final readonly class GameSessionCompleted
{
public function __construct(
public string $gameId,
public int $seed,
public int $stage,
public string $outcome,
public int $turns,
public int $durationMs,
) {}
}
Avec identity_stamps activĂ©, le Messenger mappe trois identitĂ©s utiles Ă des en-tĂȘtes simples :
-
message_ididentifie un événement logique à travers des tentatives de livraison ; -
correlation_idregroupe les messages appartenant Ă une session ou un flux de publication ; -
causation_ididentifie la commande ou l'événement antérieur qui a produit cet événement.
Ces identifiants résolvent différents problÚmes d'observabilité. Réutiliser une valeur pour les trois cacherait la relation entre un événement, son flux de travail et sa cause. Ils ne remplacent également pas les consommateurs idempotents : un récepteur doit toujours décider comment gérer la livraison dupliquée.
L'indĂ©pendance du transport ne signifie pas indiffĂ©rence au transport. JSON et des en-tĂȘtes simples empĂȘchent les noms de classes PHP de devenir le contrat public, mais la production a toujours besoin de rĂ©tention, d'authentification, d'autorisation, de politique de reprise et d'Ă©volution de schĂ©ma. Si la signature des messages est activĂ©e, des en-tĂȘtes personnalisĂ©s doivent ĂȘtre Ă©crits par le sĂ©rialiseur pour que la signature les couvre. Les producteurs et consommateurs ont Ă©galement besoin de niveaux de correctifs Symfony compatibles avant l'introduction de nouvelles formes d'en-tĂȘtes signĂ©s.
Cet exemple ne décrit pas une intégration de Strata Relay déployée. Le jeu n'émet pas game.session.completed.v1 et ne dépend pas d'un transport de messages. Le contrat existe ici uniquement pour démontrer comment une simulation hors ligne et un événement interopérable peuvent rester des responsabilités distinctes.
Jouez Ă Strata Relay sur Darkwaar
Découvrez comment chaque mutation de terrain change le puzzle. Jouez directement dans votre navigateur.
Conclusion
Strata Relay est compact car ses rÚgles portent un travail utile. Une graine déterministe reconstruit un plateau. Un brouillage constructif remplace les niveaux stockés tout en préservant une solution témoin. Une rÚgle de mutation locale pilote le jeu, la validation et les démonstrations automatisées. Un parcours en largeur borné dérive le signal visible sans créer une seconde source de vérité.
Les mesures gardent ce succÚs en proportion. Le GDScript maintenu est petit, la génération et les mises à jour locales sont peu coûteuses sur la machine mesurée, et l'empreinte des ressources créées est minimale. Le build Web est toujours dominée par le moteur, la mémoire d'exécution reste non mesurée, et la résolvabilité ne prouve ni l'optimalité ni la difficulté.
La frontiĂšre hypothĂ©tique de messagerie suit la mĂȘme discipline. La simulation locale possĂšde l'Ă©volution du terrain. Une application distincte pourrait Ă©changer un fait de session complĂ©tĂ©e via un contrat versionnĂ© sans prendre en charge la boucle de jeu. De petites rĂšgles, un comportement dĂ©terministe et des frontiĂšres explicites rendent Ă la fois le jeu et une future intĂ©gration plus faciles Ă tester, expliquer et changer.


Top comments (0)