Introductie Object-Georiënteerd ProgrammerenGeplaatst door CompuChip op 10-06-04CompuChip
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:
-
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.
-
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:
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:
- Het monster kan niet verder in de richting waarin het ging
- Het monster heeft gewoon zin om een andere kant op te gaan (oftewel: uit
een Random getal tussen 0 en 10 komt toevallig 5).
- 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:
- 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).
- De score variabele van TSpel wordt opgehoogd met de score van het geraakte
object.
- 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!
|