Genereer Random Cave Levels met behulp van Cellular Automata

Procedurele content-generators zijn stukjes code die in je spel zijn geschreven en die op elk moment nieuwe stukken game-inhoud kunnen creëren - zelfs als het spel actief is! Game-ontwikkelaars hebben geprobeerd om procedureel alles te genereren van 3D-werelden tot muzikale soundtracks. Het toevoegen van een generatie aan je spel is een geweldige manier om extra waarde in te pluggen: spelers vinden het geweldig omdat ze telkens wanneer ze spelen nieuwe, onvoorspelbare en opwindende content krijgen.

In deze zelfstudie bekijken we een geweldige methode voor het genereren van willekeurige niveaus en proberen we de grenzen te verleggen van wat u denkt dat kan worden gegenereerd.

gerelateerde berichten

Als je meer wilt lezen over de onderwerpen procedurele inhoud, niveauontwerp, AI of mobiele automaten, moet je deze andere berichten controleren:

  • Creating Life: Conway's Game of Life
  • Portal 2 Level Design: Puzzels maken om je spelers uit te dagen
  • StarCraft II Level Design: Esthetisch Ontwerp en Editor Tips
  • Codering van een Custom Sequence Generator om een ​​Starscape te renderen
  • Gamedev Glossary: ​​Sequence Generators en Pseudorandom Number Generators

Welkom in de grotten!

In deze tutorial gaan we een grotgenerator bouwen. Grotten zijn geweldig voor allerlei gamegenres en -instellingen, maar ze doen me vooral denken aan oude kerkers in rollenspellen.

Bekijk de demo hieronder om te zien welke uitvoer u kunt krijgen. Klik op 'Nieuwe wereld' om een ​​nieuwe grot te maken om naar te kijken. We zullen praten over wat de verschillende instellingen te zijner tijd doen.


Deze generator geeft ons eigenlijk een groot tweedimensionaal array van blokken terug, die elk vast of leeg zijn. In feite zou je deze generator kunnen gebruiken voor alle soorten spellen naast kerker-crawlers: willekeurige niveaus voor strategiespellen, tilemaps voor platformspellen, misschien zelfs als arena's voor een multiplayer-shooter! Als je goed kijkt, maakt het omdraaien van de vaste en lege blokken ook een eilandgenerator. Het gebruikt allemaal dezelfde code en output, wat dit een heel flexibel hulpmiddel maakt.

Laten we beginnen door een eenvoudige vraag te stellen: wat is in hemelsnaam een ​​cellulaire automaat??


Aan de slag met cellen

In de jaren '70 publiceerde een wiskundige John Conway een beschrijving van The Game Of Life, soms zelfs Life genoemd. Het leven was niet echt een spel; het leek meer op een simulatie die een raster van cellen nam (die ofwel levend of dood waren) en enkele eenvoudige regels toepaste.

Vier regels werden op elke cel toegepast in elke stap van de simulatie:

  1. Als een levende cel minder dan twee levende buren heeft, sterft hij.
  2. Als een levende cel twee of drie levende buren heeft, blijft hij levend.
  3. Als een levende cel meer dan drie levende buren heeft, sterft hij.
  4. Als een dode cel precies drie levende buren heeft, wordt hij levend.

Leuk en eenvoudig! Maar als je verschillende combinaties van startgrids uitprobeert, kun je heel vreemde uitkomsten krijgen. Oneindige loops, machines die vormen uitspugen, en meer. The Game of Life is een voorbeeld van een cellulaire automaat - een raster van cellen die worden bepaald door bepaalde regels.

We gaan een systeem implementeren dat erg op Life lijkt, maar in plaats van grappige patronen en vormen te produceren, gaat het geweldige grotsystemen voor onze spellen maken.


Een cellulaire automaat implementeren

We gaan ons cellulair raster representeren als een tweedimensionale array van Booleaanse (waar of vals) waarden. Dit past bij ons omdat we alleen geïnteresseerd zijn in de vraag of een tegel solide is of niet.

Dit is het begin van ons cellenraster:

boolean [] [] cellmap = new boolean [width] [height];

Tip: Merk op dat de eerste index de x-coördinaat voor de array is en de tweede index de y-coördinaat. Dit maakt toegang tot de array natuurlijker in code.

In de meeste programmeertalen initialiseert deze array met alle waarden ingesteld op vals. Dat is goed voor ons! Als een array-index (X, y) is vals, we zullen zeggen dat de cel leeg is; als het is waar, die steen zal massief gesteente zijn.

Elk van deze arrayposities vertegenwoordigt een van de 'cellen' in ons cellulair raster. Nu moeten we ons raster opzetten, zodat we kunnen beginnen met het bouwen van onze grotten.

We gaan beginnen door elke cel willekeurig in te stellen op dood of levend. Elke cel krijgt dezelfde willekeurige kans om levend gemaakt te worden, en je moet ervoor zorgen dat deze kanswaarde ergens in een variabele wordt gezet, want we zullen het zeker later willen aanpassen en het ergens gemakkelijk toegankelijk maken, zal ons helpen met dat. Ik zal gebruiken 45% om mee te beginnen.

float chanceToStartAlive = 0.45f; public boolean [] [] initialiseMap (boolean [] [] map) for (int x = 0; x 
Onze willekeurige grot vóór simulatiestappen voor cellulaire automaten.

Als we deze code uitvoeren, eindigen we met een groot raster van cellen zoals die hierboven die willekeurig levend of dood zijn. Het is rommelig en het ziet er absoluut niet uit als een grotsysteem dat ik ooit heb gezien. Dus wat nu?


Onze grotten laten groeien

Weet je nog de regels die de cellen beheerden in The Game Of Life? Telkens als de simulatie een stap verder ging, zou elke cel de regels van het leven controleren en kijken of het zou veranderen in leven of dood zijn. We gaan precies hetzelfde idee gebruiken om onze grotten te bouwen - we zullen nu een functie schrijven die elke cel in het rooster doorloopt en enkele basisregels toepast om te bepalen of het leeft of sterft.

Zoals je later zult zien, gaan we dit stukje code meer dan eens gebruiken, dus door het in zijn eigen functie te plaatsen, kunnen we het zo vaak of zo vaak noemen als we willen. We geven het een leuke informatieve naam zoals doSimulationStep (), te.

Wat moet de functie doen? In de eerste plaats gaan we een nieuw raster maken waar we onze bijgewerkte celwaarden in kunnen plaatsen. Om te begrijpen waarom we dit moeten doen, onthoud dat om de nieuwe waarde van een cel in het raster te berekenen, we moeten kijken naar zijn acht buren:

Maar als we de nieuwe waarde van sommige cellen al hebben berekend en ze weer in het raster hebben gezet, is onze berekening een combinatie van nieuwe en oude gegevens, zoals deze:

Oops! Dat is helemaal niet wat we willen. Dus elke keer dat we een nieuwe celwaarde berekenen, in plaats van deze terug te zetten in de oude kaart, gaan we deze naar een nieuwe schrijven.

Laten we beginnen dat te schrijven doSimulationStep () functie, dan:

public doSimulationStep (boolean [] [] oldMap) boolean [] [] newMap = new boolean [width] [height]; // ... 

We willen elke cel in het rooster op zijn beurt overwegen en tellen hoeveel van zijn buren levend en dood zijn. Het tellen van je buren in een array is een van die saaie stukjes code die je een miljoen keer moet schrijven. Hier is een snelle implementatie ervan in een functie die ik heb genoemd countAliveNeighbours ():

// Geeft het aantal cellen in een ring rond (x, y) die levend zijn. public countAliveNeighbours (boolean [] [] map, int x, int y) int count = 0; voor (int i = -1; i<2; i++) for(int j=-1; j<2; j++) int neighbour_x = x+i; int neighbour_y = y+j; //If we're looking at the middle point if(i == 0 && j == 0) //Do nothing, we don't want to add ourselves in!  //In case the index we're looking at it off the edge of the map else if(neighbour_x < 0 || neighbour_y < 0 || neighbour_x >= map.length || neighbour_y> = kaart [0] .length) count = count + 1;  // Anders een normale controle van de buren als (map [neighour_x] [neighbour_y]) count = count + 1; 

Een paar dingen over deze functie:

Eerst de voor loops zijn een beetje raar als je nog nooit zoiets hebt gedaan. Het idee is dat we naar alle cellen willen kijken die er omheen zijn (X, y). Als u de onderstaande illustratie bekijkt, kunt u zien hoe de indexen die we willen, één minder, gelijk aan en één meer zijn dan de oorspronkelijke index. Onze twee voor loops geven ons precies dat, beginnend bij -1, en loop er naar toe +1. Vervolgens voegen we dat toe aan de oorspronkelijke index in de voor loop om elke buur te vinden.

Ten tweede, merk op hoe als we een rasterverwijzing controleren die niet echt is (het staat bijvoorbeeld buiten de kaart) we beschouwen deze als een buur. Ik geef de voorkeur aan het genereren van een grot, omdat dit ertoe bijdraagt ​​de randen van de kaart te vullen, maar je kunt experimenteren door dit niet te doen als je dat wilt.

Dus laten we nu teruggaan naar onze doSimulationStep () functie en voeg wat meer code toe:

public boolean [] [] doSimulationStep (boolean [] [] oldMap) boolean [] [] newMap = new boolean [width] [height]; // Loop over elke rij en kolom van de kaart voor (int x = 0; x birthLimit) nieuwKaart [x] [y] = waar;  else newMap [x] [y] = false;  return newMap; 

Dit loopt over de hele kaart en past onze regels toe op elke rastercel om de nieuwe waarde te berekenen en in te plaatsen newMap. De regels zijn eenvoudiger dan de Game of Life - we hebben twee speciale variabelen, een voor het baren van dode cellen (birthLimit), en een voor het doden van levende cellen (deathLimit). Als levende cellen worden omringd door minder dan deathLimit cellen sterven ze en als er al dode cellen in de buurt zijn birthLimit cellen worden ze levend. Leuk en eenvoudig!

Het enige dat overblijft aan het einde is de laatste stap om de bijgewerkte kaart te retourneren. Deze functie vertegenwoordigt een enkele stap van de regels van onze cellulaire automaten - de volgende stap is om te begrijpen wat er gebeurt als we het eenmaal, tweemaal of vaker toepassen op onze initiële startkaart.


Tweaking en Tuning

Laten we eens kijken naar hoe de hoofdgeneratiecode er nu uitziet, met behulp van de code die we tot nu toe hebben geschreven.

public boolean [] [] generateMap () // Maak een nieuwe kaart boolean [] [] cellmap = new boolean [width] [height]; // Stel de kaart in met willekeurige waarden cellmap = initialiseMap (celmap); // En voer nu de simulatie uit voor een bepaald aantal stappen voor (int i = 0; i 

Het enige echt nieuwe stukje code is een voor loop die onze simulatiemethode een bepaald aantal keren uitvoert. Nogmaals, stop het in een variabele, zodat we het kunnen veranderen, omdat we nu met deze waarden gaan spelen!

Tot nu toe hebben we deze variabelen ingesteld:

  • chanceToStartAlive bepaalt hoe dicht het oorspronkelijke raster is met levende cellen.
  • starvationLimit is de lagere buurlimiet waarbij cellen beginnen te sterven.
  • overpopLimit is de bovenste buurlimiet waarbij cellen beginnen te sterven.
  • birthNumber is het aantal buren dat ervoor zorgt dat een dode cel levend wordt.
  • numberOfSteps is het aantal keren dat we de simulatiestap uitvoeren.

Onze willekeurige grot na twee cellulaire automaten simulatiestappen.

Je kunt deze variabelen gebruiken in de demo bovenaan de pagina. Elke waarde zal de demo dramatisch veranderen, dus speel een beetje rond en kijk wat past.

Een van de meest interessante wijzigingen die u kunt aanbrengen, is de numberOfSteps variabel. Terwijl je de simulatie voor meer stappen uitvoert, verdwijnt de ruwheid van de kaart en verdwijnen eilanden in het niets. Ik heb een knop toegevoegd zodat je de functie zelf kunt aanroepen en de effecten kunt zien. Experimenteer een beetje en je zult een combinatie van instellingen vinden die past bij jouw stijl en het soort niveaus dat jouw spel nodig heeft.


Onze willekeurige grot na zes cellulaire automaten simulatiestappen.

Daarmee ben je klaar. Gefeliciteerd, je hebt zojuist een generator voor procedureel niveau gemaakt, goed gedaan! Leun achterover, ren en voer je code opnieuw uit en lach naar de vreemde en prachtige grindsystemen die naar buiten komen. Welkom in de wereld van procedurele genereren.


Verder nemen

Als je naar je mooie grotgenerator staart en je afvraagt ​​wat je er nog meer mee kunt doen, zijn hier een paar 'extra krediet'-opdrachtideeën:

Flood Fill gebruiken om Quality Checking uit te voeren

Vlakvulling is een heel eenvoudig algoritme dat je kunt gebruiken om alle spaties in een array te vinden die verbinding maken met een bepaald punt. Zoals de naam al doet vermoeden, werkt het algoritme een beetje alsof je een emmer water in je level gooit - het spreidt zich vanaf het startpunt uit en vult alle hoeken in.

Vlakvulling is geweldig voor cellulaire automaten, omdat je het kunt gebruiken om te zien hoe groot een bepaalde grot is. Als je de demo een paar keer uitvoert, zie je dat sommige kaarten uit één grote grot bestaan, terwijl andere een paar kleinere grotten hebben die van elkaar zijn gescheiden. Met Vlakvulling kun je ontdekken hoe groot een grot is en vervolgens het niveau opnieuw genereren als het te klein is, of bepalen waar je wilt dat de speler start als je denkt dat hij groot genoeg is. Op Wikipedia ziet u een grote schets van de opvulling met vloed.

Snelle en eenvoudige plaatsing van een schat

Het plaatsen van schatten in koele gebieden vereist soms veel code, maar we kunnen eigenlijk een vrij simpel stukje code schrijven om schatten uit de weg te plaatsen in onze grotsystemen. We hebben al onze code die telt hoeveel buren een vierkant heeft, dus door ons voltooide grotsysteem in een lus te plaatsen, kunnen we zien hoe omringd door muren een bepaald vierkant is.

Als een lege rastercel wordt omringd door veel massieve muren, is deze waarschijnlijk helemaal aan het einde van een gang of weggestopt in de muren van het grottensysteem. Dit is een geweldige plek om een ​​schat te verbergen - dus door een eenvoudige controle van onze buren kunnen we schatten in de hoeken en de steegjes laten glippen.

public void placeTreasure (boolean [] [] world) // Hoe verborgen moet een plek zijn voor een schat? // Ik vind dat 5 of 6 goed is. 6 voor zeer zeldzame schat. int treasureHiddenLimit = 5; voor (int x = 0; x < worldWidth; x++) for (int y=0; y < worldHeight; y++) if(!world[x][y]) int nbs = countAliveNeighbours(world, x, y); if(nbs >= treasureHiddenLimit) placeTreasure (x, y); 

Dit is niet perfect. Het plaatst soms een schat in ontoegankelijke gaten in het grottensysteem, en soms zijn de vlekken ook heel voor de hand liggend. Maar in een mum van tijd is het een geweldige manier om verzamelobjecten over uw niveau te verspreiden. Probeer het uit in de demo door op de placeTreasure () knop!


Conclusies en verdere lectuur

Deze tutorial liet je zien hoe je een eenvoudige maar complete procedurele generator bouwt. Met slechts een paar eenvoudige stappen schreven we code die in een oogwenk nieuwe niveaus kan creëren. Hopelijk heeft dit je een voorproefje gegeven van het potentieel van het bouwen van procedurele contentgeneratoren voor je games!

Als je meer wilt lezen, is Roguebasin een geweldige bron van informatie over procedurele generatiesystemen. Het concentreert zich meestal op roguelike games, maar veel van zijn technieken kunnen worden gebruikt in andere soorten spellen, en er is veel inspiratie voor het procedureel genereren van andere delen van een game!

Als je meer wilt over Procedural Content Generation of Cellular Automata, is hier een geweldige online versie van The Game Of Life (hoewel ik je sterk aanraad om "Conway's Game Of Life" in Google te typen). Je zou ook graag Wolfram Tones willen, een charmant experiment in het gebruik van cellulaire automaten om muziek te genereren!