Creating Life Conway's Game of Life

Soms kan zelfs een eenvoudige set basisregels u zeer interessante resultaten opleveren. In deze tutorial bouwen we de kernmotor van Conway's Game of Life vanaf de basis op.

Notitie: Hoewel deze zelfstudie is geschreven met C # en XNA, zou je in bijna elke 2D-ontwikkelingsomgeving voor games dezelfde technieken en concepten moeten kunnen gebruiken.


Invoering

Conway's Game of Life is een cellulaire automaat die werd bedacht in de jaren 1970 door een Britse wiskundige genaamd, nou ja, John Conway.

Gegeven een tweedimensionaal raster van cellen, met enkele "aan" of "levend" en anderen "uit" of "dood", en een aantal regels die bepalen hoe ze tot leven komen of sterven, kunnen we een interessante "levensvorm hebben" "ontvouwt zich recht voor ons. Dus door simpelweg een paar patronen op ons raster te tekenen en vervolgens de simulatie te starten, kunnen we basale levensvormen zien evolueren, verspreiden, afsterven en uiteindelijk stabiliseren. Download de uiteindelijke bronbestanden of bekijk de onderstaande demo:

Nu, dit "Game of Life" is niet strikt een "spel" - het is meer een machine, vooral omdat er geen speler en geen doel is, het evolueert eenvoudig op basis van zijn initiële condities. Desalniettemin is het heel leuk om mee te spelen en er zijn veel principes van gameontwerp die kunnen worden toegepast op de creatie ervan. Dus, zonder verder oponthoud, laten we aan de slag gaan!

Voor deze zelfstudie ging ik door en bouwde alles in XNA, want daar ben ik het meest comfortabel mee. (Er is een handleiding om met XNA hier te beginnen, als je geïnteresseerd bent.) Je zou echter wel moeten kunnen meegaan met elke 2D-game-ontwikkelomgeving die je kent.


De cellen maken

Het meest elementaire element in Conway's Game of Life zijn de cellen, welke de "levensvormen" zijn die de basis vormen van de hele simulatie. Elke cel kan zich in een van twee toestanden bevinden: "levend" of "dood". Voor de consistentie houden we vast aan die twee namen voor de celstatussen voor de rest van deze zelfstudie.

Cellen bewegen niet, ze beïnvloeden eenvoudigweg hun buren op basis van hun huidige toestand.

Wat betreft het programmeren van hun functionaliteit, zijn er de drie gedragingen die we ze moeten geven:

  1. Ze moeten hun positie, grenzen en status bijhouden, zodat ze correct kunnen worden aangeklikt en getekend.
  2. Ze moeten schakelen tussen levend en dood wanneer erop wordt geklikt, waardoor de gebruiker daadwerkelijk interessante dingen kan laten gebeuren.
  3. Ze moeten als wit of zwart worden getekend als ze respectievelijk dood of levend zijn.

Al het bovenstaande kan worden bereikt door een Cel klasse, die de onderstaande code bevat:

class Cell public Point Position get; privé set;  public Rectangle Bounds get; privé set;  public bool IsAlive krijg; vast te stellen;  openbare cel (puntpositie) positie = positie; Bounds = nieuwe rechthoek (Position.X * Game1.CellSize, Position.Y * Game1.CellSize, Game1.CellSize, Game1.CellSize); IsAlive = false;  public void Update (MouseState mouseState) if (Bounds.Contains (new Point (mouseState.X, mouseState.Y))) // Laat cellen tot leven komen door met de linkermuisknop te klikken of kill ze met de rechtermuisknop. if (mouseState.LeftButton == ButtonState.Pressed) IsAlive = true; else if (mouseState.RightButton == ButtonState.Pressed) IsAlive = false;  public void Draw (SpriteBatch spriteBatch) if (IsAlive) spriteBatch.Draw (Game1.Pixel, Bounds, Color.Black); // Teken niets als het dood is, omdat de standaardachtergrondkleur wit is. 

Het raster en zijn regels

Nu dat elke cel zich correct gaat gedragen, moeten we een rooster maken dat ze allemaal vasthoudt en de logica implementeren die iedereen vertelt of het in leven moet blijven, in leven zal blijven, zal sterven of dood zal blijven (geen zombies!).

De regels zijn redelijk eenvoudig:

  1. Elke levende cel met minder dan twee live buren sterft, alsof veroorzaakt door onderpopulatie.
  2. Elke levende cel met twee of drie levende buren leeft voort op de volgende generatie.
  3. Elke levende cel met meer dan drie live buren sterft als door overbevolking.
  4. Elke dode cel met precies drie levende buren wordt een levende cel, als door reproductie.

Hier is een korte visuele gids voor deze regels in de onderstaande afbeelding. Elke cel gemarkeerd door een blauwe pijl wordt beïnvloed door de bijbehorende genummerde regel hierboven. Met andere woorden, cel 1 zal sterven, cel 2 zal in leven blijven, cel 3 zal sterven en cel 4 zal tot leven komen.

Omdat de spelsimulatie dus een update uitvoert met constante tijdsintervallen, controleert het raster elk van deze regels voor alle cellen in het raster. Dat kan worden bereikt door de volgende code in een nieuwe klasse te plaatsen die ik zal bellen rooster:

class Grid public Point Size get; privé set;  private cel [,] cellen; public Grid () Size = new Point (Game1.CellsX, Game1.CellsY); cellen = nieuwe cel [Size.X, Size.Y]; voor (int i = 0; i < Size.X; i++) for (int j = 0; j < Size.Y; j++) cells[i, j] = new Cell(new Point(i, j));  public void Update(GameTime gameTime)  (… ) // Loop through every cell on the grid. for (int i = 0; i < Size.X; i++)  for (int j = 0; j < Size.Y; j++)  // Check the cell's current state, and count its living neighbors. bool living = cells[i, j].IsAlive; int count = GetLivingNeighbors(i, j); bool result = false; // Apply the rules and set the next state. if (living && count < 2) result = false; if (living && (count == 2 || count == 3)) result = true; if (living && count > 3) result = false; if (! living && count == 3) result = true; cellen [i, j] .IsAlive = resultaat;  (...)

Het enige wat we hier missen is de magie GetLivingNeighbors methode, die eenvoudig telt hoeveel van de buren van de huidige cel momenteel in leven zijn. Dus laten we deze methode toevoegen aan onze rooster klasse:

openbaar int GetLivingNeighbors (int x, int y) int count = 0; // Controleer cel aan de rechterkant. if (x! = Size.X - 1) if (cellen [x + 1, y] .IsAlive) count ++; // Controleer de cel rechtsonder. if (x! = Size.X - 1 && y! = Size.Y - 1) if (cellen [x + 1, y + 1] .IsAlive) count ++; // Controleer cel aan de onderkant. if (y! = Size.Y - 1) if (cellen [x, y + 1] .IsAlive) count ++; // Controleer cel linksonder. if (x! = 0 && y! = Size.Y - 1) if (cellen [x - 1, y + 1] .IsAlive) count ++; // Controleer cel aan de linkerkant. if (x! = 0) als (cellen [x - 1, y] .IsAlive) ++ tellen; // Controleer de cel links bovenaan. if (x! = 0 &&!! 0) als (cellen [x - 1, y - 1] .IsAlive) ++ tellen; // Controleer de cel bovenaan. if (y! = 0) als (cellen [x, y - 1] .IsAlive) ++ tellen; // Controleer cel rechtsboven. if (x! = Size.X - 1 &&!! 0) if (cellen [x + 1, y - 1] .IsAlive) count ++; terugkeer tellen; 

Merk op dat in de bovenstaande code, de eerste als verklaring van elk paar is gewoon controleren dat we niet aan de rand van het rooster staan. Als we deze controle niet hadden, zouden we verschillende uitzonderingen tegenkomen die de grenzen van de array overschrijden. Ook, omdat dit zal leiden tot tellen wordt nooit opgehoogd als we langs de randen kijken, dat betekent dat het spel "veronderstelt" dat randen dood zijn, dus het komt overeen met een permanente rand van witte, dode cellen rond onze spelvensters.


Het raster bijwerken in discrete tijd-stappen

Tot dusverre is alle logica die we hebben geïmplementeerd logisch, maar deze zal zich niet correct gedragen als we niet voorzichtig zijn om ervoor te zorgen dat onze simulatie in discrete tijdstappen wordt uitgevoerd. Dit is gewoon een mooie manier om te zeggen dat al onze cellen op exact hetzelfde moment worden bijgewerkt, omwille van de consistentie. Als we dit niet zouden implementeren, zouden we vreemd gedrag krijgen, omdat de volgorde waarin de cellen werden gecontroleerd er toe zou doen, dus de strikte regels die we zojuist hadden ingesteld zouden uiteenvallen en er zou een mini-chaos volgen.

Onze lus hierboven controleert bijvoorbeeld alle cellen van links naar rechts, dus als de cel aan de linkerkant die we net hebben aangevinkt tot leven kwam, zou dit de telling voor de cel in het midden wijzigen die we nu controleren en mogelijk tot leven laten komen . Maar als we in plaats daarvan van rechts naar links zouden controleren, is de cel aan de rechterkant misschien dood en de cel aan de linkerkant is nog niet tot leven gekomen, dus onze middelste cel zou dood blijven. Dit is slecht omdat het inconsistent is! We moeten in staat zijn om de cellen in willekeurige volgorde te controleren (zoals een spiraal!) En de volgende stap moet altijd identiek zijn.

Gelukkig is dit echt vrij eenvoudig te implementeren in code. We hebben alleen een tweede raster met cellen nodig voor de volgende status van ons systeem. Telkens wanneer we de volgende staat van een cel bepalen, slaan we deze op in ons tweede raster voor de volgende status van het hele systeem. Wanneer we vervolgens de volgende status van elke cel hebben gevonden, passen we ze allemaal tegelijkertijd toe. We kunnen dus een 2D-array van booleans toevoegen nextCellStates als een privévariabele en voeg deze methode vervolgens toe aan de rooster klasse:

public void SetNextState () for (int i = 0; i < Size.X; i++) for (int j = 0; j < Size.Y; j++) cells[i, j].IsAlive = nextCellStates[i, j]; 

Tot slot, vergeet niet om uw te repareren Bijwerken methode hierboven, zodat het resultaat wordt toegewezen aan de volgende staat in plaats van de huidige en vervolgens aanroepen SetNextState helemaal aan het einde van de Bijwerken methode, direct nadat de lussen zijn voltooid.


Het raster tekenen

Nu we de lastiger delen van de logica van het raster hebben voltooid, moeten we het op het scherm kunnen tekenen. Het raster zal elke cel tekenen door hun tekenmethoden één voor één te noemen, zodat alle levende cellen zwart worden en de doden wit worden.

Het werkelijke raster niet nodig hebben om iets te tekenen, maar het is veel duidelijker vanuit het perspectief van de gebruiker als we wat rasterlijnen toevoegen. Dit stelt de gebruiker in staat om gemakkelijker celgrenzen te zien en communiceert ook een gevoel voor schaal, dus laten we een maken Trek methode als volgt:

public void Draw (SpriteBatch spriteBatch) foreach (cel in cellen) cel. Draw (spriteBatch); // Teken verticale rasterlijnen. voor (int i = 0; i < Size.X; i++) spriteBatch.Draw(Game1.Pixel, new Rectangle(i * Game1.CellSize - 1, 0, 1, Size.Y * Game1.CellSize), Color.DarkGray); // Draw horizontal gridlines. for (int j = 0; j < Size.Y; j++) spriteBatch.Draw(Game1.Pixel, new Rectangle(0, j * Game1.CellSize - 1, Size.X * Game1.CellSize, 1), Color.DarkGray); 

Merk op dat we in de bovenstaande code een enkele pixel nemen en deze uitrekken om een ​​zeer lange en dunne lijn te creëren. Uw specifieke game-engine biedt misschien een simpel Teken lijn methode waarbij u twee punten kunt specificeren en een lijn tussen deze punten kunt laten weergeven, waardoor het nog eenvoudiger zou worden dan het bovenstaande.


Het toevoegen van High-level Game Logic

Op dit punt hebben we alle basisstukken die we nodig hebben om het spel te laten draaien, we moeten het allemaal bij elkaar brengen. Dus om te beginnen, in de hoofdklasse van je spel (degene die alles start), moeten we een paar constanten toevoegen, zoals de afmetingen van het raster en de framerate (hoe snel het zal worden bijgewerkt), en alle andere dingen die we nodig hebben de afbeelding met één pixel, de schermgrootte, enzovoort.

We moeten ook veel van deze dingen initialiseren, zoals het maken van het raster, het instellen van de venstergrootte voor het spel en ervoor zorgen dat de muis zichtbaar is, zodat we op cellen kunnen klikken. Maar al deze dingen zijn motorspecifiek en niet erg interessant, dus we gaan er meteen overheen en komen bij de goede dingen. (Natuurlijk, als je in XNA meegaat, kun je de broncode downloaden om alle details te krijgen.)

Nu we alles hebben ingesteld en klaar voor gebruik, moeten we het spel gewoon kunnen uitvoeren! Maar niet zo snel, want er is een probleem: we kunnen niet echt iets doen omdat het spel altijd actief is. Het is eigenlijk onmogelijk om specifieke vormen te tekenen, omdat ze uit elkaar vallen terwijl je ze tekent, dus we moeten echt in staat zijn om het spel te pauzeren. Het zou ook leuk zijn als we het raster zouden kunnen opruimen als het een puinhoop wordt, omdat onze creaties vaak uit de hand lopen en een puinhoop achterlaten.

Dus laten we wat code toevoegen om het spel te pauzeren wanneer de spatiebalk wordt ingedrukt en het scherm leegmaken als er op de spatiebalk wordt gedrukt:

protected override void Update (GameTime gameTime) keyboardState = Keyboard.GetState (); if (GamePad.GetState (PlayerIndex.One) .Buttons.Back == ButtonState.Pressed) this.Exit (); // Schakelen tussen pauze wanneer de spatiebalk wordt ingedrukt. if (keyboardState.IsKeyDown (Keys.Space) && lastKeyboardState.IsKeyUp (Keys.Space)) Paused =! Paused; // Wis het scherm als u op backspace drukt. if (keyboardState.IsKeyDown (Keys.Back) && lastKeyboardState.IsKeyUp (Keys.Back)) grid.Clear (); base.Update (gametime); grid.Update (gametime); lastKeyboardState = keyboardState; 

Het zou ook helpen als we heel duidelijk maakten dat de game was onderbroken, dus als we onze video schrijven Trek methode, laten we wat code toevoegen om de achtergrond rood te maken en "Gepauzeerd" op de achtergrond schrijven:

protected override void Draw (GameTime gameTime) if (gepauzeerd) Graphics Device. Clear (Color.Red); anders GraphicsDevice.Clear (Color.White); spriteBatch.Begin (); if (gepauzeerd) string paused = "Paused"; spriteBatch.DrawString (Font, gepauzeerd, ScreenSize / 2, Color.Gray, 0f, Font.MeasureString (gepauzeerd) / 2, 1f, SpriteEffects.None, 0f);  grid.Draw (spriteBatch); spriteBatch.End (); base.Draw (gametime); 

Dat is het! Alles zou nu moeten werken, dus je kunt het een werveling geven, een paar levensvormen tekenen en zien wat er gebeurt! Ga en ontdek interessante patronen die je kunt maken door opnieuw naar de Wikipedia-pagina te gaan. Je kunt ook spelen met de framerate, celgrootte en rasterafmetingen om het naar wens aan te passen.


Verbeteringen toevoegen

Op dit punt is het spel volledig functioneel en het schaamt je er niet voor om het een dag te noemen. Maar een ergernis die je misschien hebt gemerkt is dat je muisklikken niet altijd registreren wanneer je een cel probeert bij te werken, dus wanneer je klikt en je muis over het raster sleept, laat het een stippellijn achter in plaats van een solide een. Dit gebeurt omdat de snelheid waarmee de cellen worden bijgewerkt ook de snelheid is waarmee de muis wordt gecontroleerd en het is veel te langzaam. We moeten dus eenvoudigweg de snelheid waarmee de game wordt bijgewerkt en de snelheid waarmee de invoer wordt gelezen, ontkoppelen.

Begin met het definiëren van de updatefrequentie en de framerate afzonderlijk in de hoofdklasse:

openbare const int UPS = 20; // Updates per seconde openbare const int FPS = 60;

Nu, bij het initialiseren van het spel, gebruik de framerate (FPS) om te definiëren hoe snel het de muisinvoer en -tekening zal lezen, wat op zijn minst een mooie soepele 60 FPS zou moeten zijn:

IsFixedTimeStep = true; TargetElapsedTime = TimeSpan.FromSeconds (1.0 / FPS);

Voeg vervolgens een timer toe aan uw rooster klasse, zodat het alleen wordt bijgewerkt wanneer het nodig is, onafhankelijk van de framerate:

public void Update (GameTime gameTime) (...) updateTimer + = gameTime.Elapsed Game Time; if (updateTimer.TotalMilliseconds> 1000f / Game1.UPS) updateTimer = TimeSpan.Zero; (...) // Werk de cellen bij en pas de regels toe. 

Nu zou je in staat moeten zijn het spel uit te voeren op elke gewenste snelheid, zelfs een zeer trage 5 updates per seconde, zodat je zorgvuldig je simulatie kunt bekijken, terwijl je nog steeds mooie vloeiende lijnen kunt tekenen met een solide framerate.


Conclusie

Je hebt nu een soepel en functioneel Game of Life in handen, maar voor het geval je het verder wilt verkennen, zijn er altijd meer tweaks die je eraan kunt toevoegen. Het raster veronderstelt bijvoorbeeld dat buiten de randen alles dood is. Je zou het zo kunnen aanpassen dat het raster rondwikkelt, zodat een zweefvliegtuig voor altijd zou vliegen! Er is geen gebrek aan variaties op deze populaire game, dus laat je fantasie de vrije loop.

Bedankt voor het lezen en ik hoop dat je vandaag enkele nuttige dingen hebt geleerd!