DirectX - Spilleregler tilføjes

Programmet bliver til et spil

I sidste eksempel blev en Game Engine udviklet og arbejdet med en model blev påbegyndt. Samspillet mellem vores Game engine og modellen blev fremhævet, idet Game Engine skal indeholde de generelle aspekter ved arbejdet med grafikken og inputmulighederne. Modellen skal derimod indeholde alle de detaljer, som gør, at programmet ender med at blive et spil:

  1. Spillets grafik (spillebrættet) sættes op på skærmen
  2. Brikkerne sættes op på brættet
  3. Brættet og brikkerne tegnes
  4. Behandling af input fra spilleren
  5. Modellen opdateres
  6. Det afgøres, om spilleren har vundet eller tabt

Af de nævnte punkter er de tre sidstnævnte de sværeste at have med at gøre. De tre punkter indgår i kodningen af UpdateModel metoden. I denne metode skal alle spillets regler omsættes til algoritmer i det aktuelle programmeringssprog. Spillerens input og data fra modellen indgår i disse algoritmer, som ender med en opdateret model. Med udgangspunkt i den opdaterede model skal det undersøges, om spilleren har vundet eller tabt. Spilleren har som regel tabt, hvis der ikke er flere lovlige træk eller hvis tiden, der var afsat, er gået. En vundet situation opstår, når alle brikker er ryddet af brættet, en bestemt situation er indtruffet før tiden er udløbet, eller hvis alle modstandere er elimineret.

Spillet virker i praksis på den måde, at Game engine sørger for flowet: Henter input fra tastatur og mus, opdaterer skærmbilledet, tildeler point og opdater modellen. Ofte indgår der også et element af kunstig intelligens (AI) i spillet, hvis computeren skal agere modspiller. Heri indgår forskellige strategier og overvejelser om, hvordan man kan vinde et spil efter de gældende regler.

  1. Opsætning af spillebræt og brikker
  2. Behandling af spillerens input
  3. Modellen opdateres
  4. Afgør om spilleren har vundet

Det spil vi vil fremstille her er et spil, som vi vil kalde Drain Puzzle - et spil af Tetris familien. Skærmlayout for spillet ses herunder:

1. Opsætning af spillebræt og brikker

Opsætning af View matricen i InitializeGraphics metoden har betydning for placeringen af spillebrættet på skærmblledet. Efter en del eksperimenter med størrelsen på spillebrættet, er jeg nået frem til, at en størrelse på 16 X 16 felter vil være passende. Jeg vil placere dem lidt til højre for midten for at få plads til en brugergrænseflade til venstre på skærmbilledet. Derfor placerer jeg kameraet i punktet (6, 8, -30) og lader det fokusere på punktet (6, 8, 0).

	// Initial values for the view transformation
	this.View = Matrix.LookAtLH(new Vector3(6, 8, -30), 
		new Vector3(6, 8, 0), new Vector3(0, 1, 0));

Som projektion kan jeg anvende en perspektivprojektion i lighed med dem, vi har anvendt i tidligere eksempler:

	this.Projection = Matrix.PerspectiveFovLH((float)(Math.PI / 4.0f),	
		1.0f, 1.0f, 100.0f);

I dette tilfælde er der dog en anden oplagt mulighed - nemlig ortogonal projektion. Med ortogonal projektion bevarer objektet sine dimensioner efter projektionen - dvs. uanset hvor langt væk et objekt er anbragt fra projektionsplanet, så bevarer det sin størrelse. Normalt giver en perspektivprojektion et langt mere realistisk billede; men i dette tilfælde er det jo ligegyldigt, da alle brikker er anbragt i samme Z-plan. En definition af en brugbar ortogonal projektion kunne se således ud:

	this.Projection = Matrix.OrthoLH(28, 28, 1.0f, 100.0f);

Mere om projektioner kan findes her.

I slutningen af InitializeGraphics oprettes et User Interface og et Model onjekt. Model objektets constructor forsynes med parametre, der definerer brættets størrelse.

	theUI = new UI(this);
	theModel = new Model(16, 16, this, theUI);

I Model klassens constructor indsættes følgende kode, som opretter det nødvendige antal Brick objekter. Alle objekterne initialiseres med deres rette position og farven sort.

	Color color = Color.Black;
	// Tabel initialiseres med tomme Bricks
	bricks = new Brick[x, y];
	for (int i = 0; i < x; i++)
		for (int j = 0; j < y; j++)
		{
			bricks[i, j] = new Brick(theEngine.GetDevice(),
			new Vector3(i + 1, j + 1, 0), color);
		}
	Udfyld();

Metoden Udfyld ses herunder. Brikkerne på spillepladen initialiseres med forskellige farver, som vælges tilfældigt med et Random objekt. Brugergrænsefladens felt Remains opdateres med antallet af resterende brikker på spillebrættet.

	public void Udfyld()
	{
		Color color = new Color();
		Rest = x * y;
		theUI.remains.Text = " " + Rest; // Opdatér UI
		// Tabel med Bricks i tilfældige farver
		Random rnd = new Random();
		for (int i = 0; i < x; i++)
			for (int j = 0; j < y; j++)
			{
				int c = rnd.Next(5);
				switch (c)
				{
					case 0:
						color = Color.Red; break;
					case 1:
						color = Color.Blue; break;
					case 2:
						color = Color.Yellow; break;
					case 3: 
						color = Color.Green; break;
					case 4:
						color = Color.DarkOrange; break;
				}
				bricks[i, j].Colour = color;
			}
	}

2. Behandling af spillerens input

Metoden UpdateModel i Model objektet bliver først kaldt, når der er input via musen - dvs. når spilleren har udpeget et eller andet; men vi ved endnu ikke, om det er en brugbar brik i spillet, som brugeren har udpeget. Hertil bruges koden fra den tilsvarende metode i slutningen af kapitel 5.

Når vi har fundet ud af, at spilleren har udpeget en brik på brættet, skal vi finde ud af, om brikken også er brugbar. Det er den, hvis den har mindst én brik af samme farve som nabo. Alle forbundne naboer skal findes, fordi de efterfølgende skal slettes fra brættet. Først nulstilles et hjælpefelt, marker, som er blevet føjet til Brick klassen. Dette sker ved at kalde hjælpefunktionen NulstilMarker:

	private void NulstilMarker()
{
for (int i = 0; i < x; i++)
for (int j = 0; j < y; j++)
{ // Nulstilling af markør i aktive Bricks
bricks[i, j].Marker = 0;
}
}

Efter at man har afgjort, at musen peger på en brik, udføres følgende kode:

	if (Geometry.BoxBoundProbe(lower, upper, nearVector, farVector))
	{
		// En Brick er udpeget - kan den bruges?
		fundet = 0;
		Marking(bricks, i, j, bricks[i, j].Colour);
		if (fundet > 1 && bricks[i, j].Colour != Color.Black)
			Rest -= fundet;
		theUI.remains.Text = " " + Rest; // Opdatér UI
   }

I variablen fundet tælles antallet af fundne naboer. Denne variabel nulstilles indledningsvis. Herefter kaldes metoden Marking med 4 parametre: bricks tabellen, den udpegede X koordinat (i), den udpegede Y koordinat (j) og farven på den udpegede brik. Metoden Marking kalder sig selv rekursivt, så når der returneres, kender man antallet af fundne naboer. Hver gang en brugbar nabo findes, adderes én til variablen fundet. Variablen Rest opdateres og brugergrænsefladen opdateres.

Herunder ses metoden Marking, som vha. rekursion undersøger, hvor mange naboer af samme farve den udpegede brik har.

	public void Marking(Brick[,] bricks, int i, int j, Color c)
	{
		if (bricks[i,j].Marker == 1 | 
			bricks[i,j].Marker == 9) return; // Allerede besøgt
		if (bricks[i, j].Colour == c)
		{
			bricks[i, j].Marker = 9; // Skal slettes
			fundet++;
			// Nu testes naboer
			if (i > 0)
				Marking(bricks, i - 1, j, c); // Venstre nabo
			if (i < x - 1)
				Marking(bricks, i + 1, j, c); // Højre nabo
			if (j > 0)
				Marking(bricks, i, j - 1, c); // Underbo
			if (j < y - 1)
				Marking(bricks, i, j + 1, c); // Overbo
		}
		else
			bricks[i, j].Marker = 1; // Er testet
	}

3. Modellen opdateres

Når resultatet af Marking metoden er kendt, skal spillebrættet justeres, idet alle nabobrikker af samme farve fjernes. I denne fase udnytter jeg, at Marking metoden har forsynet alle brikker, der skal fjernes med en bestemt marker (9). Først gennemløbes tabellen med brikkerne rækkevis oppefra og ned - hvis man finder en brik, der skal slettes, sker det ved at flytte farven for alle overliggende brikker i samme søjle en række ned. Den øverste brik i søjlen får farven sort.

	if (fundet > 1)
	{ // Slet alle markerede
		for (int i = x - 1; i > -1; i--)
			for (int j = y - 1; j > -1; j--)
			{
				if (bricks[i, j].Marker == 9)
				{ // Ryk resten af søjlen ned
					for (int n = j; n < y - 1; n++)
						bricks[i, n].Colour = bricks[i, n + 1].Colour;
					bricks[i, y - 1].Colour = Color.Black;
				}
			}
	}

Herefter undersøges det, om der ved denne proces er fremkommet tomme søjler - i så fald rykkes efterstående søjler til venstre. Det kan f.eks. gøres således:

	// Slet tomme søjler
	for (int a = x - 1; a > - 1; a--)
	{
		if (bricks[a, 0].Colour == Color.Black)
		{
			for (int i = a; i < x - 1; i++)
				for (int j = 0; j < y; j++)
					bricks[i, j].Colour = bricks[i + 1, j].Colour;
			for (int j = 0; j < y; j++)
				bricks[x - 1, j].Colour = Color.Black;
		}
	}

4. Afgør om spilleren har vundet

Til slut tilføjes kode, som afgør, om spilleren har vundet. I det konkrete tilfælde vil det være uhyre vanskeligt - grænsende til det umulige - at løse opgaven, så jeg vil udskrive en meddelelse med en lille anerkendelse, hvis der til slut er mindre end 10 brikker tilbage på brættet. Dette gøres, når der ikke er flere lovlige træk. For at kunne afgøre, om der er flere lovlige træk, er man nødt til at lave en programstump, som kan tælle alle lovlige træk - et lovligt træk defineres i denne sammenhæng som et tryk på en brik med mindst én nabo. Til dette formål har jeg lavet en metode, Lovlige, som returnerer antallet af brikker, man kan trykke på for at eliminere to eller flere brikker. Hvis der er tre brikker i en gruppe, tæller det som tre lovlige træk, da man jo kan trykke på en hvilken som helst af de tre brikker.

	private int Lovlige()
	{
		int antal = 0;
		for (int i = 0; i < x; i++)
			for (int j = 0; j < y; j++)
				if (bricks[i, j].Colour != Color.Black)
				{
					// Check alle ikke sorte brikker med Marking metoden
					NulstilMarker();
					fundet = 0;
					Marking(bricks, i, j, bricks[i, j].Colour);
					if (fundet > 1)
						antal++;
				}
		return antal;
	}

Metoden Lovlige kaldes i slutningen af metoden UpdateModel:

		Legal = Lovlige(); // Antal lovlige træk
		theUI.legal.Text = " " + Legal; // Opdatér UI
		if (Legal == 0) slut = true;

Brugergrænsefladen opdateres med antal lovlige træk, og hvis antallet af lovlige træk er 0, sættes variablen slut til true. Inden vi foretager os yderligere, skal den sidste opdatering af modellen tegnes på skærmbilledet. Vi vil senere kalde en metode, TestSlut, hvor vi vil udskrive nogle oplysninger til spilleren, hvis spillet er slut - og evt. give mulighed for at starte et nyt spil.

Metoden TestSlut ses herunder. Hvis spillet er slut, og der er færre end 10 brikker tilbage, udskrives en opmuntrende meddelelse til spilleren. Derefter spørges spilleren, om han/hun eventuelt vil starte et nyt spil.

	public void TestSlut()
	{
		if (!slut) return;
		if (Legal == 0 & Rest < 10)
			MessageBox.Show("Godt gået");
		if (Legal == 0)
		{
			MessageBoxButtons buttons = MessageBoxButtons.YesNo;
			DialogResult result;
			result = MessageBox.Show("Start forfra?", "Ikke flere lovlige    træk", buttons,
				MessageBoxIcon.Question, MessageBoxDefaultButton.Button1,
				MessageBoxOptions.RightAlign);
   			if (result == DialogResult.Yes)
			{
				Udfyld();
				Legal = Lovlige(); // Antal lovlige træk
				theUI.legal.Text = " " + Legal; // Opdatér UI
			}
			else Application.Exit();
		}
	}

Hvis der ikke er flere lovlige træk spørges spilleren, om han/hun vil starte forfra.

Det var et problem at finde et godt sted, hvor testen kunne kaldes. Hvis den blev anbragt i slutningen af UpdateModel (et naturligt sted), blev de sidste brikker ikke fjernet fra brættet. I stedet placerede jeg kaldet i slutningen af Engine klassens Render metode:

		theModel.TestSlut();

Koden til spillet kan hentes her.


Sidst opdateret: 26. februar 2007

Index