NLDelphi logo

Apada
Start Forum Nieuws Artikelen Links E mail Statistieken

Introductie Object-Georiënteerd Programmeren

Geplaatst door CompuChip op 10-06-04

CompuChip

Elke programmeur heeft wel een koffiezetapparaat op kantoor staan om wakker te blijven tijdens lange Delphi-nachten. Bij iedereen ziet het koffiezetapparaat er anders uit. Sommigen hebben zo'n duootje voor één of twee kopjes, anderen hebben een machine waar koffie, espresso of cappuccino uitkomt, weer anderen hebben een Philips Senseo, maar het basisidee van het koffiezetapparaat blijft hetzelfde. Aan de bovenkant doe je er water en koffiepoeder (of –zakjes, of wat de fabrikant dan ook heeft vastgelegd), je zet een kopje eronder en aan de onderkant komt er koffie uit. Niets minder, maar ook niets meer.

Je kan je afvragen waarom geïnvesteerd wordt in koffiezetapparaten. Dat is vrij logisch: je kan toch ook zélf een filtertje te nemen, koffie erin doen, water koken, een kopje eronder zetten, het water door het filter gieten, en dat twintig keer per dag, maar waarom zou je niet de moeite nemen om even naar de winkel te gaan en een machientje te kopen die het tien keer sneller kan (niet getest)?

Tot hier toe lijkt het me allemaal vrij logisch. In werkelijkheid wordt het koffiezetapparaat (en andere dingen) gebruikt zonder erbij na te denken. Maar diezelfde programmeurs zijn vaak in hun programma's lang bezig om het wiel opnieuw uit te vinden. Waarom? Waarschijnlijk omdat ze nog nooit van OO gehoord hebben, of niet weten wat het is, of denken dat het te ingewikkeld is.

Wat is OO dan?

OO(P) staat voor Object-Oriented (Programming) oftewel Object Georiënteerd Programmeren. Bij deze vorm van programmeren wordt gebruik gemaakt van klassen en objecten. Door OO te programmeren wordt een programma vaak overzichtelijker, omdat het meer structuur heeft en ook beter gespiegeld kan worden aan de buitenwereld. Bijvoorbeeld: een simulatie van een alarmsysteem is beter te begrijpen in termen van huis, ramen, deuren, sensoren dan variabelen, arrays en functies. Om een systeem uit te breiden met bijvoorbeeld een nieuwe sensor hoeft in een OO-programma maar weinig te gebeuren aangezien alle sensoren in feite hetzelfde zijn (overerving) maar toch zijn de mogelijkheden oneindig door de polymorfie. Ook voor andere programmeurs biedt het voordelen om object-geörienteerd te programmeren, namelijk inkapseling. Hiermee zijn meteen de drie basisbegrippen van OOP gevallen, die ik hierna zal uitleggen. Ze zijn eigenlijk al aan bod gekomen in het voorbeeld van het koffiezetapparaat...

De drie pijlers van OO

Inkapseling

Bij OOP wordt gebruik gemaakt van klassen (classes) en objecten (de terminologie is op dit punt wat verwarrend; officieel is een klasse een 'abstracte' definitie en een object is een instantie van een klasse). Een klasse is een verzameling van variabelen, procedures en functies die bij elkaar hoort. De procedures en functies die bij een klasse horen noemen we methoden (methods). Dat is ook precies wat inkapseling (encapsulation) inhoudt, namelijk: dat elk object (elke klasse dus) precies zijn eigen taak heeft maar die wel uitstekend uitvoert. Waar de input vandaan komt en waar de output naar toe gaan, zal het object een worst wezen. Zo is een koffiezetapparaat alleen maar bedoeld om koffie te zetten en niet om de vloer te vegen. Een automotor is alleen voor het leveren van het vermogen. Waar de benzine vandaan komt en waar het vermogen naar toe gaat – daar heeft hij geen weet van. In dit opzicht zijn objecten de bouwstenen van een programma: los heb je er niets aan, maar samen vormen ze een hecht geheel.


Overerving

Overerving (inheritance) komt in de natuur veel voor (nageslacht erft van de ouders, honden en mensen erven bepaalde eigenschappen van zoogdieren, die weer bepaalde eigenschappen van dieren erven, enzovoorts). Ook in programma's is het een krachtig hulpmiddel. Wanneer twee objecten bijna dezelfde taken moeten uitvoeren, zijn er twee mogelijkheden:

  1. Je werkt één object uit, daarna knip en plak je het andere in elkaar. Resultaat: veel duplicate code en slapeloze nachten wanneer er iets veranderd moet worden in de basisfunctionaliteit. Bij een uitbreiding herhaalt dit zich.
  2. De gemeenschappelijke functionaliteit breng je onder in een basisklasse. De twee klassen erven alles van de basisklasse en alleen dat wat per klasse anders is breng je in de afgeleiden onder. Wanneer er iets veranderd moet worden pas je de basisklasse aan en de wijziging wordt in alle subklassen doorgevoerd. Heb je een nieuwe klasse nodig, leid je die gewoon van de basisklasse af en voegt alleen de extra en/of afwijkende functionaliteit toe.


Voorbeeld van overerving in de natuur.
Merk op dat elke klasse alles van zijn voorganger erft.

Polymorfie

Wanneer alleen overerving mogelijk zou zijn, had je aan OOP nog niet veel. Alle zoogdieren bewegen zich immers voort, maar allemaal op een andere manier. Gelukkig is er zoiets als polymorfie. Dit wil zeggen dat afgeleiden bepaalde functionaliteit erven, maar die op hun eigen wijze invullen (implementeren). Zo erven Hond en Mens beide spraakvermogen van Zoogdier, maar Hond blaft en Mens doet iets anders. Toch is het mogelijk om de spraakfunctionaliteit al op Zoogdierniveau te declareren. Van een willekeurig Zoogdier kan dan de .Spreek-methode worden opgeroepen — zelfs zonder dat de programmeur weet wat voor een dier het is - en ieder zoogdier zal het juiste geluid maken. Daarvoor moet Hond wel .Spreek op zijn eigen manier invullen. Of, anders gezegd: Hond moet de methode Spreek herdefiniëren (override).




PlantVoort van Hond en Mens zijn hetzelfde, maar Beweeg en Spreek worden overridden.
Mens voegt bovendien een methode Programmeer toe die niet alle Zoogdieren bezitten.

Toegang

Wanneer we een OO-systeem ontwerpen, moeten we niet alleen goed nadenken over de verschillende taken die de objecten gaan uitvoeren. Wanneer iemand met onze klassen gaat werken, of dat nu de maker zelf is of een andere programmeur, willen we zoveel mogelijk fouten voorkomen (dat is immers beter dan debuggen, nietwaar?). Daarvoor hebben we een heel krachtig hulpmiddel, namelijk de compiler. Die kan ons op de vingers tikken als we iets doen wat niet mag, al dan niet per ongeluk, zoals rechtstreeks een variabele van een instantie veranderen of een interne methode aanroepen (ik wil dat SpanStembanden(frequentie: Integer) alleen door Spreek() aangeroepen kan worden en niet rechtstreeks, bijvoorbeeld). Gelukkig geeft de compiler ons een aantal steekwoorden waarmee we kunnen aangeven wat wel en niet mag, namelijk:

public
iedereen binnen en buiten de klasse mag bij de methods en/of variabelen die hieronder staan en mag ze vrijelijk aanpassen.
private
alleen methods van deze klasse mogen bij deze methods en/of variabelen. Afgeleiden en 'buitenstaanders' mogen er niet aan prutsen.
protected
voor alles binnen deze klasse en afgeleiden ervan zijn de methods en/of variabelen hieronder vrij toegankelijk, maar voor alle anderen zijn ze afgeschermd.

In Delphi is er nog een vierde, namelijk published, maar die komt pas bij componentenbouw echt aan de orde. Dit geeft ons meer controle over wat er met ons object gebeurt. Als ik een private variabele probeer te wijzigen vanuit een niet-method, wordt dat door de compiler met een foutmelding afgestraft. Dit is meteen een mooie gelegenheid om wat checks uit te voeren. Ik kan nu mijn variabele private maken, en vervolgens public methods ZetWaarde(waarde: Integer) en HaalWaarde(): Integer maken waarin ik controleer of de ingevoerde waarde geldig is.

En dan nu... de praktijk

Als je tot hier bent gekomen: gefeliciteerd. Het goede nieuws is dat je nu de basis onder de knie hebt. Het goede nieuws is dat we nu een praktijkvoorbeeldje gaan maken. Ik heb gekozen voor een soort PacMan-achtig spel, andere dingen die goed OO te doen zijn, zijn bijvoorbeeld een winkelsimulatie, een klantenbestand of een frisdrankautomaat.

De bedoeling van het programma

We zullen een PacMan-achtig programma gaan maken, dat sterk Object-Oriënted is. Het ideale ontwerp is volledig onafhankelijk van de gebruikersinterface en zou dus in principe in een console programma moeten kunnen compileren en draaien. Aan het eind, wanneer alle klassen af zijn, zal ik dit verwerken in een één-forms-applicatie. De bedoeling is dan de monsters te ontlopen en in twee minuten zo veel mogelijk punten te scoren. Dit is een voorbeeld van wat met de klasse gedaan kan worden.

Meteen in het diepe... de code

Wat tot nu toe besproken is, was een algemene introductie tot OOP. Nu gaan we een voorbeeld maken. De taal waarin we dat doen is – uiteraard – Delphi. Ik kan nu een lange verhandeling houden over hoe klassen gedeclareerd en geïmplementeerd worden, maar ik zelf vind het altijd prettiger om de code voorgezet te krijgen en aan de hand daarvan de kijken hoe het programma is opgebouwd.

We voegen direct een nieuwe unit (dus zonder Form) toe aan het project. In een echt project zal je waarschijnlijk tegelijk met het spel ook de UI willen schrijven, zodat je tussentijds kan testen. De aanpak van dit artikel is dus niet helemaal realistisch, maar toont wel weer een belangrijk principe: de logica is onafhankelijk van de weergave.

De interface

Ik zal eerst alle declaraties doornemen, zodat je een globaal idee hebt van de verschillende klassen en hoe die met elkaar samenhangen.

unit PacMan;

interface

uses
  Windows,
  Classes,
  ExtCtrls {TTimer},
  Graphics { TGraphic, TCanvas },
  Contnrs { TObjectList };


const rectsize: Integer = 20;
const loopInterval: Integer = 10;  // 1 / ... seconds
type TDirection = (dNone, dUp, dDown, dLeft, dRight);
Eerst worden er wat units geïnclude die we nodig hebben, het commentaar erachter geeft de belangrijkste klasse of functie waarvoor de unit benodigd is. Verder maken we twee constanten. rectsize is de breedte en hoogte van één vakje op het veld (in pixels). loopInterval is de interval waarop het veld bijgewerkt wordt. Als deze constante bijvoorbeeld 10 is, wordt het veld elke 1 / 10 seconde bijgewerkt. Hoe kleiner deze waarde, des te soepeler het spel loopt maar des te zwaarder de applicatie. Tenslotte maken we een enumeration TDirection om makkelijk aan te geven in welke richting een object beweegt: links, rechts, boven, beneden of geen (dNone).

type
  TFieldObject = class
    private
      FPosition: TCoord;
      FGraphic: TGraphic;
    public
      constructor Create; virtual;
      destructor Destroy; override;
      function Intersects(AIntersectsWith: TFieldObject): Boolean;
      procedure Draw(ACanvas: TCanvas);
      procedure PacmanIntersect(var Pacman: TFieldObject); virtual; abstract;
      property Position: TCoord read FPosition;
      property Graphic: TGraphic read FGraphic;
  end;
We beginnen met het creeëren van een abstracte klasse TFieldObject. Alle dingen (levend of niet) die op het spelveld zullen komen te staan worden hiervan afgeleid. Een aantal opmerkingen over de code:
  • Elk object op het veld heeft een positie en een plaatje dat getekend wordt op het veld. Hiervoor maak ik velden (Fields, vandaar de prefix F-) aan. Deze zijn private: externe objecten mogen bijvoorbeeld de positie alleen aanpassen met toestemming van het object zelf, door bijvoorbeeld Move aan te roepen.
  • Objecten willen misschien wel de positie weten van een ander object. Ze moeten dus de positie wel kunnen uitlezen. Vandaar de read-only parameters onderaan de public sectie. We hadden hier ook getters (en, voor read/write properties setters) kunnen schrijven, zoals
    function GetPosition: TCoord;
    procedure SetPosition(AValue: TCoord);
    maar ik vind deze oplossing persoonlijk netter. Voor meer informatie over properties, zie ook het artikel Componentenbouw.
  • De standaard TObject-destructor wordt door onze klasse opnieuw gedefiniëerd. Hiervoor moet het sleutelwoord override worden toegevoegd.
  • De constructor, Intersects en Draw krijgen een zeer summiere implementatie, die alleen de algemeenste code bevat welke voor alle TFieldObjects geldt. We verwachten dan ook dat deze methods overriden gaan worden. Om dit bij voorbaat aan de compiler duidelijk te maken, voegen we het sleutelwoord virtual toe.
  • LET OP: Je ziet bij PacmanIntersect een extra sleutelwoord, abstract. Dit kleine woordje heeft een belangrijke invloed. Het geeft aan dat deze method in deze klasse niet geïmplementeerd hoeft te worden, je zult deze in de implementation sectie dan ook niet terug vinden. Bovendien betekent de aanwezigheid van één of meerdere abstract methods dat de hele klasse abstract wordt. Van een abstracte klasse mag je alleen andere klassen afleiden, je mag hem niet instantiëren! Als je zou proberen
    TFieldObject.Create
    aan te roepen, krijg je een compiler waarschuwing Constructing instance of 'TFieldObject' containing abstract method 'TFieldObject.PacmanIntersect'. Bovendien is de method in alle afgeleide klassen automatisch abstract (en mag je deze dus ook niet instantiëren), tot aan de klasse waarin je hem override. Gebruik abstract zoveel mogelijk, het is een krachtig hulpmiddel om te voorkomen dat je per ongeluk abstracte objecten instantieert (zoals TZoogdier of TFieldObject) in plaats van specifieke objecten (zoals TPaard of TPacMan).
Merk op dat een TFieldObject niet per definitie ook kan bewegen, maar dat een bewegend object in het veld wel automatisch een TFieldObject is. Dat klinkt als een eerste afgeleide klasse:
type TMovingObject = class(TFieldObject)
  private
    FDirection: TDirection;
  public
    procedure Move(); overload; virtual;
    procedure Move(ADirection: TDirection); overload; virtual;
    property Direction: TDirection read FDirection;
  end;
Het enige dat deze klasse aan een TFieldObject toevoegt, is de mogelijkheid om te kunnen bewegen. Daarvoor maken we een veld dat de richting van beweging bijhoudt, dat door andere objecten uit te lezen is via een property.
  • Merk op dat de Move methode overloaded is. Je kan hem zonder parameters aanroepen (in welk geval het object in de richting aangegeven door FDirection zal bewegen, of met een expliciete richting van beweging.
  • We hebben de abstracte methode uit TFieldObject nog niet overriden, deze klasse is dus automatisch abstract en kan niet geïnstantiëerd worden!
type TPacMan = class(TMovingObject)
  private
    FScore: Integer;
    FHitpoints: Integer;
    FIsDead: Boolean;
    procedure SetHitpoints(AValue: Integer);
  public
    constructor Create; override;
    procedure PacmanIntersect(var Pacman: TFieldObject); override;
    property IsDead: Boolean read FIsDead;
    property HitPoints: Integer read FHitpoints write SetHitpoints;
    property Score: Integer read FScore write  FScore;
  end;

Daar hebben we onze TPacman. Merk op dat deze van TMovingObject alles erft wat daar is gedeclareerd in de public en protected sectie, dus ook de properties. In het private gedeelte voegen we eerst een aantal nieuwe velden toe. Zo geven we onze Pacman een aantal Hitpoints zodat hij door monsters geraakt kan worden. Ook maken we een veld om aan te geven dat PacMan dood is en het spel dus beëindigd moet worden. Dit hadden we ook kunnen doen door PacMan toegang te geven tot het TSpel waar hij bij hoort, en TSpel weer tot het TForm. Dit is echter geen goed OO-ontwerp. Het is voldoende voor PacMan om te weten dat hij dood is, wat dat voor gevolgen heeft voor het spel als geheel is niet zijn zorg maar die van de overkoepelende TSpel.

We maken voor de twee velden in de private sectie weer twee public properties aan. HitPoints krijgt een setter die als private is gedeclareerd uit de goede gewoonte alles zo'n laag mogelijke zichtbaarheid te geven. SetHitpoints zal wat controles uitvoeren, zoals we later zullen zien. De IsDead property is read-only. Alle functies en procedures die eventueel deze property zouden willen wijzigen, zitten in de TPacMan klasse en kunnen dus rechtstreeks FIsDead aanspreken. Door geen write op te geven voorkomen we dat per ongeluk andere klassen ook FIsDead kunnen instellen.
Tenslotte overriden we PacmanIntersect. Niet dat deze methode iets gaat doen (wat zou je willen doen als Pacman zichzelf snijdt?), maar we willen een instantie van TPacMan gaan aanmaken, en daarvoor moeten we de abstracte method overriden.

Op dezelfde manier maken we een TMonster-klasse:

type TMonster = class(TFieldObject)
  private
    FDamage: Integer;
  public
    constructor Create; override;
    procedure PacmanIntersect(var Pacman: TFieldObject); override;
  end;

In deze klasse wordt opnieuw PacManIntersect overridden, en krijgt — in tegenstelling tot de method in TPacMan zelf — wel degelijk een (niet-triviale) implementatie. Verder is een private veld FDamage dat de schade toegebracht aan PacMan betekent. We maken geen property aangezien dit veld alleen binnen de TMonster klasse gebruikt hoeft te worden. We zullen in dit voorbeeld bij één TMonster-klasse blijven. Uiteraard kan je zoveel monsters bedenken als je maar wilt, waarbij je voor elk monster een aparte klasse aanmaakt die van TMonster erft.
type TGadget = class(TFieldObject)
  private
    FScore: Integer;
  public
    procedure PacmanIntersect(var Pacman: TFieldObject); override;
    property Score: Integer read FScore;
  end;


type TBurger = class(TGadget)
  public
    constructor Create; override;
  end;

type TWater = class(TGadget)
  public
    constructor Create; override;
  end;
We maken als afgeleide van TFieldObject ook een TGadget klasse. Dit zijn alle "statische" objecten, ze bewegen niet... vandaar dat ze direct afgeleid zijn van TFieldObject en niet van TMovingObject. De constructors van de verschillende gadgets willen we opnieuw implementeren, zodat ze het juiste icoontje voor op het veld laden. We overriden vast PacmanIntersect, aangezien die voor alle TGadgets hetzelfde doet (namelijk: FScore bij de score van het spel optellen, die in de Pacman ligt opgeslagen). Tenslotte is er een private veld met bijbehorende public property die bijhoudt welke score aan PacMan wordt toegekend voor het oppakken van dit objectje.

We maken vervolgens van TGadget twee afgeleiden; een flesje water en een hamburger. Alleen de Create wordt hier overridden, waardoor de klassen meteen niet meer abstract zijn. De Move's worden geërfd (georven?) van TGadget en doen dus niets. Score is een public property in TGadget en wordt dus ook doorgegeven (net als alle properties van TFieldObject overigens).

We zijn bijna door de definities heen, we moeten nu nog ëën belangrijke klasse maken...
type TSpel = class
  private
    FTimer: TTimer;
    FCanvas: TCanvas;
    FPacMan: TPacMan;
    FCharacters: TObjectList;
    FGadgets: TObjectList;
    FFieldCorner: TCoord;
    FWholeSecond: Integer;
    function CanMove(ACharacter: TFieldObject; ADirection: TDirection): Boolean;
    procedure GameLoop(Sender: TObject);
    function GetScore: Integer;
    constructor Create; overload;
  public
    constructor Create(const AWidth, AHeight: Integer; const ACanvas: TCanvas); overload;
    destructor Destroy; override;
    procedure Draw();
    procedure Move(ADirection: TDirection);
    property PacMan: TPacMan read FPacMan;
    property Score: Integer read GetScore;
  end;
... de klasse die het allemaal bij elkaar gaat houden: TSpel.

  • FTimer is een TTimer die zorgt dat de spellus continu doorlopen wordt zolang het spel loopt.
  • FCanvas is een TCanvas waarop Spel de huidige spelsituatie zal tekenen. Zoals je ziet weet TSpel dus helemaal niets van een TForm of wat dan ook af, het krijgt alleen een TCanvas aangereikt en gaat er van uit dat het daarop moet tekenen.
  • FPacMan is de 'hoofdpersoon': een TPacMan object.
  • FCharacters en FGadgets zijn twee objectlijsten die respectievelijk de TMonster-(afgeleiden)-objecten en de TGadget-(afgeleiden)-objecten beheren.
  • FFieldCorner geeft de rechteronderhoek van het scherm, oftewel: X is de breedte van FCanvas en Y is de hoogte ervan. De linkerbovenhoek is natuurlijk (0,0).
  • FWholeSecond is een veld dat bijhoudt hoeveel timer-intervallen er zijn verstreken. De timer wordt namelijk enkele malen (standaard 100) per seconde uitgevoerd. De monsters moeten echter maar 1 vakje per seconde bewegen. Het gebruik van dit datalid is te zien in de GameLoop-procedure.
  • CanMove is een functie die, gegeven een FieldObject en de richting waarin het wil bewegen, controleert of dit kan en True of False terug geeft.
  • GameLoop is de procedure die elke 1 / loopInterval seconden wordt uitgevoerd. Dit is de main game loop, die zorgt voor het aanmaken van objecten, vergelijken en tekenen, zeg maar de Beheerder (© Marcel).
  • GetScore is de getter van de Score property, die niets anders doet dan de score van het PacMan object opvragen.
  • Verder maken we nog aan: een constructor en een destructor voor het aanmaken en opruimen (!) van de rotzooi, een Draw functie, een Move procedure voor Pacman en twee public properties zodat ook ánderen bij FPacMan en FScore kunnen, zij het alleen-lezen! (No object messes with TSpel's members!)

De TSpel klasse staat niet in het diagram. Deze maakt gebruik van alle klassen op de onderste rij, dus de gespecialiseerde klassen.

De implementatie

In de implementation-sectie beginnen we met het includen van Math, aangezien deze een aantal functies (voor random getallen) bevat die we willen gebruiken later. Hierna volgt de definitie van TObject.

constructor TFieldObject.Create;
begin
 inherited Create;
 FPosition.X := RandomRange(0, 19);
 FPosition.Y := RandomRange(0, 19);
 FGraphic := TBitmap.Create;
 FGraphic.Transparent := True;
end;

destructor TFieldObject.Destroy;
begin
  FGraphic.Free;
  inherited;
end;
De constructor van het abstracte TFieldObject stelt de positie van het object in op een willekeurige plaats in het veld. De veldbreedte van 20 vakjes is hier hardcoded, wat op zich netter kan natuurlijk, maar hier voldoet omdat we toch niet met het resizen van ons form het speelveld vergroten. Ook maakt de constructor een TBitmap aan in FGraphic. Let op dat hier
FGraphic := TGraphic.Create;
niet is toegestaan omdat TGraphic een abstracte klasse is. Probeer dit dus niet (evenmin als TFieldObject.Create). Onder het motto "Wie de rotzooi maakt ruimt hem op" vernietigt de destructor het TGraphic-object zodra het TFieldObject opgeheven wordt.

procedure TFieldObject.Draw(ACanvas: TCanvas);
begin
 if not Assigned(FGraphic) then
  EInvalidGraphic.Create('Een TFieldObject heeft een ongeldig plaatje. Is er een LoadFromFile uitgevoerd?')
 else
  ACanvas.Draw(FPosition.X * rectsize, FPosition.Y * rectsize, FGraphic);

end;
Een vrij duidelijke functie. Zodra een TFieldObject zichzelf moet tekenen krijgt hij (van een TSpel) een canvas aangereikt. Als FGraphic bestaat (zo niet, is er dus iets erg mis gegaan in de constructor!) wordt de positie berekend: het aantal vakjes * de grootte per vakje is de X en Y positie! Als FGraphic niet bestaat wordt een error geraised, maar dit zou NOOIT voor moeten komen (waarom staat het erin dan? Je weet maar nooit. En ik heb liever een Exception dan een Access Violation)!

function TFieldObject.Intersects(AIntersectsWith: TFieldObject): Boolean;
begin
 Result := (AIntersectsWith.FPosition.X = FPosition.X) and
        (AIntersectsWith.FPosition.Y = FPosition.Y);
end;
Ook een simpele functie. Vergelijkt zichzelf met AIntersectsWith, een ander TFieldObject. In dit geval is het resultaat waar als van beide de X- en de Y-coördinaat overeen komen. Deze functie is natuurlijk uit te breiden: objecten mogen niet meer dan 1 vakje bij elkaar in de buurt komen, bepaalde monsters vernietigen PacMan binnen een straal van 3 centimeter, enz.)

De implementatie van TMovingObject is ook niet moeilijk:
{ TMovingObject }

procedure TMovingObject.Move;
begin
 if FDirection <> dNone then
  Move(FDirection);
end;

procedure TMovingObject.Move(ADirection: TDirection);
begin
 case ADirection of
  dUp:    Dec(FPosition.Y);
  dDown:  Inc(FPosition.Y);
  dLeft:  Dec(FPosition.X);
  dRight: Inc(FPosition.X);
 end;
end;
Move met parameter beweegt het object in de gegeven richting. De FDirection parameter wordt met rust gelaten! Verder hoeven we niets te doen: alle andere methods zijn óf geërfd van TFieldObject, óf zijn nog niet geïmplementeerd omdat TMovingObject nog niet specifiek genoeg is.

{ TPacMan }

constructor TPacMan.Create;
begin
  inherited;
  FGraphic.LoadFromFile('pacman.bmp');
  Hitpoints := 100;
end;

procedure TPacMan.PacmanIntersect(var Pacman: TFieldObject);
begin
  // Pacman snijdt zichzelf per definitie (aangenomen dat Pacman == Self)
  // Dus: doe niets

end;

procedure TPacMan.SetHitpoints(AValue: Integer);
begin
 FHitPoints := AValue;
 FIsDead := (FHitpoints < 0);
end;
TPacMan is een vrij simpele klasse. De constructor doet hetzelfde als die van TFieldObject, doordat inherited aangeroepen wordt. Hierin wordt onder andere het FGraphic datalid geïnitialiseerd met een lege TBitmap. Zodra de "bovenliggende" constructor klaar is, moet TPacMan zelf nog een en ander doen, namelijk de bitmap van zichzelf laden in het daarvoor bestemde object, en zijn aantal hitpoints initialiseren op 100 hitpoints.
We hadden hier expliciet TPacMan.Move en de overloaded versie ervan kunnen implementeren en niets anders laten doen dan het aanroepen van inherited, maar dat doen we dus niet :)

SetHitpoints is de setter van de Hitpoints property. Deze stelt niet alleen de hitpoints in, maar controleert ook of er nog overblijven. Zo niet, is PacMan dood. De setter wordt opgeroepen in de PacManIntersect van TMonster (vrijwel onderaan).

{ TMonster }
constructor TMonster.Create;
begin
  inherited;
  FDamage := 10;
  FGraphic.LoadFromFile('monster.bmp');
end;

procedure TMonster.PacmanIntersect(var Pacman: TFieldObject);
begin
  inherited;
  if PacMan is TPacMan then
   TPacMan(Pacman).Hitpoints := TPacMan(PacMan).HitPoints - FDamage;
end;
De implementatie van de TMonster-klasse blijft kort. Dat komt doordat elk TMonster de meeste functionaliteit al van TMovingObject (en daardoor ook van TFieldObject) erft. Het enige wat moet gebeuren is in de Create het plaatje (monster.bmp) in de FGraphic te zetten. Ook moet de schade die bij het raken van PacMans Hitpoints wordt afgetrokken ingesteld worden. Als dit een abstracte klasse was (met abstracte constructor) werden het plaatje en FDamage natuurlijk pas in de afgeleiden ingesteld.
De PacManIntersect doet hier wel wat, namelijk de hitpoints verminderen met de toegebrachte schade. Let erop, dat hier de setter wordt opgeroepen van TPacMan's HitPoints property, die niet alleen de hitpoints instelt maar ook controleert of het nieuwe aantal boven 0 blijft. Zo niet, wordt de FIsDead flag gezet, en wordt het spel zo snel mogelijk beëindigd (bij de volgende uitvoering van TSpel.Move).

{ TGadget }
procedure TGadget.PacmanIntersect(var Pacman: TFieldObject);
begin
  if PacMan is TPacMan then
   TPacMan(Pacman).Score := TPacMan(PacMan).Score - FDamage;
end;

{ TBurger }
constructor TBurger.Create;
begin
  inherited;
  FScore := 20;
  FGraphic.LoadFromFile('burger.bmp');
end;

{ TWater }
constructor TWater.Create;
begin
  inherited;
  FScore := 10;
  FGraphic.LoadFromFile('water.bmp');
end;
TGadget ziet er maar kaal uit, omdat het een abstracte klasse is. De constructor is abstract en mag dus niet geïmplementeerd worden. De constructors van de afgeleide klassen worden wel overriden, om de juiste score en weergavebitmap te laden. Verder implementeert de PacmanIntersect functionaliteit die voor alle TGadgets hetzelfde is, namelijk: als PacMan over de gadget heen loopt moet de score ervan bij de score van het spel opgeteld worden. Oefening: maak het zo dat de HitPoints weer worden aangevuld bij het oppakken van een TBurger :).

Nu nog een ding in deze unit en dan zijn we klaar: de implementatie van TSpel!
{ TSpel }

function TSpel.CanMove(ACharacter: TFieldObject;
  ADirection: TDirection): Boolean;
begin
 Result := True;
 case ADirection of
  dUp     : Result := ACharacter.FPosition.Y > 0;
  dDown   : Result := ACharacter.FPosition.Y < FFieldCorner.Y div rectsize - 1;
  dLeft   : Result := ACharacter.FPosition.X > 0;
  dRight  : Result := ACharacter.FPosition.X < FFieldCorner.X div rectsize - 1;
 end;
end;
Deze functie controleert alleen of een object dat in FDirection wil bewegen, in die richting niet op een wand stuit. Een uitbreding zou kunnen zijn in FWallParts naar een object (muur, ...) te zoeken op de positie waar naartoe gegaan wordt, en als dit bestaat, de beweging te weigeren.
constructor TSpel.Create(const AWidth, AHeight: Integer; const ACanvas: TCanvas);
begin
  Create();
  FFieldCorner.X := AWidth;
  FFieldCorner.Y := AHeight;
  FCanvas := ACanvas;
end;

constructor TSpel.Create;
begin
  inherited Create;
  FCharacters := TObjectList.Create;
  FGadgets    := TObjectList.Create;
  FPacMan := TPacMan.Create;
  FTimer := TTimer.Create(nil);
  with FTimer do begin
    Interval := 1000 div loopInterval;
    OnTimer := Self.GameLoop;
    Enabled := True;
  end;
end;
De private constructor van TSpel roept eerst inherited aan, wat in dit geval dus neer komt op TObject.Create. De beide object-lijsten worden aangemaakt. Ook een TPacMan wordt gemaakt en de FTimer. Daarna worden van FTimer wat properties ingesteld: de Interval is 1 / loopInterval seconde. Bij het Timer-event moet de GameLoop functie worden afgevuurd. Alles klaar? Op uw plaatsen... af! De public constructor doet alles wat de private constructor doet en stelt bovendien meteen de eigenschappen van het veld (FCanvas, breedte, hoogte) in.
destructor TSpel.Destroy;
begin
 FCharacters.Free;
 FGadgets.Free;
 FTimer.Free;
 FPacMan.Free;
 inherited;
end;
In Destroy wordt alles weer vrijgegeven. We kunnen even tellen: in de constructor hebben we 1, 2, 3... vier keer Create gebruikt. In de destructor moet dan ook vier keer Free voorkomen... check, geen memory leaks. Ook roepen we inherited aan: TObject.Destroy doet niets, maar het aanroepen kan nooit kwaad en zorgt voor compatibiliteit met een eventuele volgende Delphi-versie waarin de Borland-heren en dames besluiten ineens wél een TObject-destructor te implementeren. Denk er ook aan dat we deze pas ná ons eigen opruimwerk aanroepen, om de zelfde reden als we de constructor van TObject vóór onze eigen constructiewerkzaamheden aanriepen.
procedure TSpel.Draw();
var i: Integer;
begin
 FCanvas.FillRect(Rect(0, 0, FFieldCorner.X, FFieldCorner.Y));
De Draw procedure, waar het allemaal om gaat... Op voorwaarde dat FCanvas toegekend is aan een geldig TCanvas, vullen we eerst het hele veld (van (0,0) tot (FFieldCorner.X, FFieldCorner.Y)) met een witte kleur. Daarmee is het Canvas schoonveegd.
for i := 0 to (FFieldCorner.X div rectsize) do begin
  FCanvas.MoveTo(i * rectsize, 0);
  FCanvas.LineTo(i * rectsize, FFieldCorner.Y);
 end;
 for i := 0 to (FFieldCorner.Y div rectsize) do begin
  FCanvas.MoveTo(0, i * rectsize);
  FCanvas.LineTo(FFieldCorner.X, i * rectsize);
 end;
Deze twee loopjes tekenen het raster. Ze kunnen samengevoegd worden in één loop, maar dat werkt alleen als het Canvas gegarandeerd vierkant is en niet rechthoekig. Ze kunnen ook weggelaten worden. In combinatie met het ekenen van bewegingen in meerdere stappen ziet het spel er dan vloeiender uit. Voorlopig zijn we tevreden met het aster.
 FPacMan.Draw(FCanvas);
 for i := 0 to FCharacters.Count - 1 do
  TFieldObject(FCharacters.Items[i]).Draw(FCanvas);
 for i := 0 to FGadgets.Count - 1 do
  TFieldObject(FGadgets.Items[i]).Draw(FCanvas);
end;
We geven zowel aan onze PacMan als aan al onze "Monsters" en "Gadgets" in de twee lijsten het Canvas mee, met het verzoek zichzelf hierop te tekenen. Een duidelijk voorbeeld van strikte scheiding van taken: TSpel weet niet waar hij de posities en bitmaps vandaan moet halen, de objecten weten niet waar ze het canvas vandaan moeten halen, maar ze voeren strikt hun eigen taak uit en alles komt op zijn Formpjes terecht...
procedure TSpel.GameLoop(Sender: TObject);
var i : Integer;
    monster: TMovingObject;
    gadget : TGadget;
begin
 FTimer.Enabled := False;
De GameLoop procedure die elke zoveel milliseconden aangeroepen wordt... Om veel casts te voorkomen declareren we een tijdelijk TFieldObject en TGadget object. We stellen ook de FTimer.Enabled tijdelijk op False in. De kans dat de procedure er zo lang over doet dat verschillende uitvoeringen elkaar overlappen en op elkaar gaan wachten is klein, maar toch...
 if (FCharacters.Count < 5) and (Random(10) > 8) then
  FCharacters.Add(TMonster.Create);
Als er minder dan 5 monsters zijn moeten er dus nog wat bij. Zolang niet aan deze voorwaarde is voldaan is er elke uitvoering van deze functie een kans van ongeveer 10% dat er een monster gecreëerd wordt. Let erop dat we niets met het nieuwe monster doen: hij maakt zichzelf aan en wordt in de lijst gestopt. Wat er verder mee gebeurt, is niet onze verantwoordelijkheid.
 if (FGadgets.Count < 20) then
  if (Random(100) > 90) then
    FGadgets.Add(TBurger.Create)
  else if (Random(100) < 10) then
    FGadgets.Add(TWater.Create); 
Hetzelfde geldt voor de gadgets. Als er minder dan 20 zijn, is er een kans van ongeveer 10% dat er óf een TBurger, óf een TWater aangemaakt wordt. FGadgets is een lijst van TGadgets en accepteert zonder morren alle gadgets: of het nu een TWater of een TBurger is. Daarom zijn ook geen extra casts nodig. Een mooi voorbeeld van polymorphisme.
 Inc(FWholeSecond, 1000 div loopInterval);
 if (FWholeSecond >= 1000) then
 begin
  for i := 0 to FCharacters.Count - 1 do begin
   monster := TMovingObject(FCharacters.Items[i]);
   while (not CanMove(monster, monster.Direction)) or
      (Random(10) = 5) or (monster.Direction = dNone) do
     monster.Direction := TDirection(RandomRange(0, 16) mod 4);
   monster.Move;
   if monster.Intersects(FPacMan) then
     monster.PacmanIntersect(TFieldObject(FPacMan));
  end;
  FWholeSecond := 0;
 end;
De monsters bewegen slechts elke hele seconde. FWholeSecond wordt elke uitvoering van de functie met loopInterval opgehoogd, totdat deze gelijk is aan 1000 ms. Dan moeten de monsters bewegen en wordt FWholeSecond weer op 0 gezet om opnieuw te beginnen met tellen.
Het bewegen van de monsters gaat als volgt: er wordt door de lijst gelopen. Elk item wordt tijdelijk in de monster-variabele gezet. Op deze manier hebben we maar een keer een cast nodig van TFieldObject naar TMonster. We kunnen ook zonder deze variabele, maar dan moet monster overal vervangen worden door TFieldObject(FCharacters.Items[i]). Er volgt een while-loopje waarin de richting van het monster wordt aangepast wanneer aan één van deze drie voorwaarden is voldaan:
  1. Het monster kan niet verder in de richting waarin het ging
  2. Het monster heeft gewoon zin om een andere kant op te gaan (oftewel: uit een Random getal tussen 0 en 10 komt toevallig 5).
  3. Het monster staat stil.
Door de while-loop wordt de richting — als hij al ingesteld wordt — net zolang opnieuw random genomen totdat aan alle drie de voorwaarden voldaan is. Let er wel op dat RandomRange een integer teruggeeft en dat we deze dus expliciet moeten casten naar een TDirection.
for i := FGadgets.Count - 1 downto 0 do begin
  gadget := TGadget(FGadgets.Items[i]);
  if gadget.Intersects(PacMan) then begin
   gadget.PacmanIntersect(TFieldObject(FPacMan));
   FGadgets.Delete(i);
   Continue;
  end;
 end;
Daarna loopen we door de TGadgets in de FGadgets-lijst. Elk object wordt weer in een tijdelijke variabele gezet van het laagst mogelijke type, zodat we niet omlaag hoeven te casten vanaf TFieldObject. In de 3e regel wordt gecontroleerd of het object op dezelfde plaats is als PacMan (of beter gezegd: andersom). Als dat zo is worden drie stappen gedaan:
  1. De PacManIntersect wordt opgeroepen. Deze doet nu nog niets, maar het is bijvoorbeeld denkbaar dat bepaalde objecten schadelijk zijn en Hitpoints afnemen, of dat de score van een bepaald object random bepaald wordt zodra Pacman het wil pakken (PacManIntersect mag immers FScore ook aanpassen en niet alleen de constructor).
  2. De score variabele van TSpel wordt opgehoogd met de score van het geraakte object.
  3. Het object wordt uit te lijst gewist.
Vooral vanwege deze derde stap is het zo belangrijk dat deze lijst terug telt; alle andere loops liepen van 0 tot ....Count - 1, deze loopt omgekeerd. Anders ontstaat namelijk een Access Violation aan het eind, omdat in het midden een item uit de lijst met 20 objecten is gewist, waardoor index 19 ineens niet meer bestaat.
 Draw;
 FTimer.Enabled := True;
end;
En nadat alle gadgets zijn verwerkt, monsters aangemaakt, bewegingen geset, enzovoorts, roepen we Draw van het TSpel-object op om de weergave bij te werken.
function TSpel.GetScore: Integer;
begin
  Result := FPacMan.Score;
end;

procedure TSpel.Move(ADirection: TDirection);
begin
 if not CanMove(FPacMan, ADirection) then Exit;
 FPacMan.FDirection := ADirection;
 FPacMan.Move;
 if PacMan.IsDead then begin
  // Game over!;
  FTimer.Enabled := False;
 end;

end;
TSpel vormt eigenlijk de interface met de buitenwereld, de Pacman property is read-only. Daarom zal een form (of wat dan ook maar als userinterface gebruikt wordt) deze procedure oproepen als de gebruiker PacMan wil bewegen. Allereerst roept de procedure een method van zijn eigen klasse op om te controleren of PacMan wel de gewenste richting uit mag bewegen. CanMove weet niet dat het om een PacMan gaat, maar doet gewoon zijn werk en geeft True of False terug. Als de beweging is toegestaan wordt de richting ingesteld en wordt .Move opgeroepen. Korter was geweest om .Move(ADirection) te doen, maar daarmee wordt de Direction property van PacMan niet gewijzigd! Tenslotte wordt gecontroleerd of de IsDead van PacMan niet True is. Zo wel, is het spel natuurlijk voorbij en wordt de timer uitgeschakeld.

We zijn er bijna...

Rest ons nog het formulier zelf. Zo onderaan verstopt zou je bijna zeggen dat dit het minst belangrijke van het hele programma is. Eerlijk gezegd, is het dat ook! De klassen zijn zo ontworpen, dat ze zelfs in een console applicatie zouden kunnen draaien mits ze maar op een (in-memory) canvas kunnen tekenen! Een goed OO ontwerp is ook zoveel mogelijk onafhankelijk van de (graphische) user-interface.
Voor de volledigheid zal ik de code toch vermelden en even toelichten:

procedure TPacManForm.GameTimerTimer(Sender: TObject);
begin
 edtHitpoints.Text := IntToStr(Spel.PacMan.Hitpoints);
 edtScore.Text     := IntToStr(Spel.Score);
 edtTijd.Text      := IntToStr(StrToInt(edtTijd.Text) - 1);
 if edtTijd.Text = '0' then begin
  ShowMessage('Uw tijd is op');
  GameTimer.Enabled := False;
  Spel.Free;
 end;


end;



In deze versie van het spel, is de bedoeling in twee minuten tijd zoveel mogelijk punten te verzamelen. We hebben een timer op het form die elke seconde afgaat. Daarbij worden de hitpoints en score van het Spel-object opgevraagd en weergegeven. Van de overgebleven tijd wordt steeds een seconde afgetrokken en als de edit-box '0' bevat wordt een bericht geprint, de timer gedisabled en het Spel-object vernietigd. Het bijhouden van de tijd was mooier met een private variabele, maar ik heb aan de User interface niet veel tijd besteed.

Gezien het geringe belang van de UI voor dit artikel, wil hier verder niet op in gaan. Hieronder kun je de source downloaden, de code achter het Form is uitgebreid becommentariëerd.

Het einde...

Als je dat allemaal doorgelezen hebt: gefeliciteerd! Het kostte even tijd, maar je hebt er ook wat voor :)
Je kunt de broncode van het hele programma hieronder downloaden. Het programma is natuurlijk nog lang niet af. Lees de broncode nog eens door, en probeer wat verbeteringen / uitbreidingen aan te brengen. Er kunnen nog veel meer monsters in bijvoorbeeld. En misschien weet je andere toepassingen dan het spelletje tegen de klok wat ik nu gemaakt heb. Er zitten ook nog wat kleine foutjes in, zo gaan de monsters altijd naar de linkerrand (?).

Nog enkele opmerkingen...

  • In alle klassen die zichzelf op het veld tekenen (dus TFieldObject en alle afgeleiden) is het nu zo dat de constructors ook het juiste plaatje laden. Waarschijnlijk zou het netter zijn om hiervoor een aparte method LoadImageFromFile te maken, die vanuit de constructor van TFieldObject wordt aangeroepen. Vervolgens hoeven de meeste klassen niet meer de constructor te overriden, maar alleen LoadImageFromFile.
  • In een meer geavanceerd ontwerp zou in TSpel in plaats van twee TObjectLists een aparte TManager-klasse meer op zijn plaats zijn. Hiervan zouden dan TGadgetManager en TMonsterManager afgeleid worden die de objecten aanmaken, beheren, tekenen, vrijgeven, zoeken, sorteren, enzovoorts.
  • De Score property van PacMan zou in plaats van direct naar FScore te schrijven een setter kunnen hebben, of Pacman zou een public method AddToScore kunnen krijgen.
  • Nu loopt het spel niet zo soepel. De reden is dat na Pacman.Move niet wordt gecontroleerd op intersecties met monsters en gadgets. De code die dit doet staat al in GameLoop, deze zou naar een private method kunnen verhuizen die vervolgens vanuit zowel TSpel.GameLoop als TSpel.Move aangeroepen wordt.
  • In dit artikel heb ik nog een hele hoop weggelaten. Ik heb het niet over interfaces gehad, en over published. Ik wilde dit artikel echter beperken tot de basisbegrippen (en toepassing daarvan) van OOP, en daarbij meteen de Delphi syntax laten zien.
  • Een m.i. zeer goed boek om OO te leren denken is C++ in 24 uur van Jesse Liberty. Het is dan wel C++, maar in de loop van het boek worden onder andere de concepten die ik hier behandeld heb uitgebreider naar voren gebracht.


De .ZIP bevat het volledige werkende programma.
Het programma is gemaakt met Delphi 7, alleen "native" componenten.

En natuurlijk zijn reacties altijd welkom (zie ook de link hieronder en mijn profiel). Veel programmeerplezier gewenst!


Wil je reageren op dit artikel? Dat kan op het forum. Er zijn op dit moment 22 reacties

Je kunt de schrijver van dit artikel helpen door het artikel een cijfer en een nivo te geven. De schrijver kan deze informatie wellicht weer in één van de volgende artikelen gebruiken en andere lezers kunnen door het stemmen meteen een eerste indruk krijgen van dit artikel. Je kunt je stem uitbrengen door lid te worden van de NLDelphi community. Je kunt je hier aanmelden.
Uitgebrachte stemmen
Aantal stemmen: 14
Gemiddeld cijfer (1-5): 4.6
Gemiddeld nivo (1-3): 2.3

Alle artikelen in de categorie Delphi diversen
Alle artikelen van CompuChip
Alle artikelen

Copyright © 2007 NLDelphi.com
ml>