Text Adventure
Colossal Cave Adventure Tekst-adventures er en form for spil, der nok må siges at høre fortiden til. Et spil, hvor al kommunikation mellem bruger og spil sker gennem tekst, vil næppe appellere til vor tids computer-spillere. Det første af slagsen blev lavet af William Crowther i 1976, og hed ganske enkelt: Colossal Cave Adventure (aka ADVENT, Cave Adventure eller Adventure), og har givet navn til hele adventure-spil genren. Året efter blev Don Woods, der var studerende ved Stanford University, så fascineret af spillet at han i forståelse med Crowther viderudviklede spillet i de følgende år.
World of Zuul At lave tekst-adventures som en større opgave i forbindelse med undervisning i programmering er ikke en ny idé. Jeg blev oprindelig bekendt med idéen gennem Michael Kölling's BlueJ-bog; hvor han bruger det som et større eksempel: World of Zuul.
Adventure I det projekt vi her vil lave, vil vi blot kalde spillet dets generiske navn: Adventure, og arbejde med dets generelle opbygning.
Spillets forløb
Figur 1:
Console-interface
Interagere med omgivelserne Når man starter spillet udskriver det en velkomst. Dernæst får man en beskrivelse af den lokation hvor man befinder sig. Det kan være et rum i et hus, et sted i en skov, eller et andet sted hvor man som udgangspunkt befinder sig. Man har dernæst mulighed for at interagere med de omgiver man befinder sig i. Man kan f.eks. tage et brød, og spise det eller gemme det til senere; hvis der er et brød det sted hvor man er. Man har også mulighed for at gå til andre lokationer, ved at angive en retning man ønsker at bevæge sig i, f.eks. at man vil gå mod vest. Det er meget typisk for denne slags spil, at man angiver retninger vha. kompas-retninger. Man kan nu bevæge sig rundt i spillets verden, gå på opdagelse, gennemføre quests, løse opgaver etc.
Dialog med programmet Når man interagerer med spillet, foregår det ved at man skriver tekst-kommandoer, der beskriver hvad man ønsker at gøre, og programmet vil dernæst skriver hvad der sker. Man fører på den måde en dialog med spillet; hvor det reagerer på ens handlinger. Dette minder om client-server-paradigmet, men det er ikke altid så enkelt. Man kan også komme ud for at entiteter i spillet udfører handlinger, eller der på anden måde sker ting, der ikke er en reaktion på spillerens handlinger. Det kunne være NPC'ere (Non-Playing Characters), der optræder som personer i spillet. Deres handlinger kan afstedkomme tekstbeskeder fra spillet, som brugeren skal være opmærksom på.
Implementation
  "Bruger-grænsefladen"
CoR Pattern Håndteringen af kommandoer fra brugeren spiller en væsentlig rolle i implementationen af spillet. Da antallet af kommandoer, og deres beskaffenhed, vil kunne variere under udviklingen af spillet (evt. også under spillet), kan man med fordel anvende Chain of Responsability (CoR) Pattern. I forbindelse med CoR Pattern kan det være nyttigt at have en klasse, der fungerer som facade for kæden af handlere. Vi vil derfor lave følgende opbygning, hvor CommandChain er en sådan facade:
Figur 2:
Worker med CommandChain
Worker Her er det Worker, der modtager tekst-kommandoer fra brugeren og sender dem videre til kæden af ICommandHandlers, der vil håndtere dem. Da Worker står for kommunikationen med brugeren, kan den passende tage sig af velkomst-beskeden, henholdsvis afskeds-beskeden, når spilleren starter og slutter med at spille (i.e. starter og slutter en session).
ASCII-art I figur 1, er der anvendt såkaldt ASCII-art, der er brug af diverse tegn på en måde der skal give et grafisk indtryk. Den anvendte ASCII-art er lavet med: http://www.network-science.de/ascii/.
  Spilleren
Player I figur 2, ser vi at handlerne får en reference til Worker. Det skyldes først og fremmest at Worker skal give handlerne adgang til Player, der repræsenterer oplysninger om spilleren.
Figur 3:
Player med Location og Items
Item En Player har en collection af Items, der er ting han bærer rundt på (i.e. ting han har i rygsækken). Alt efter hvor avanceret man laver spillet, kan disse items have forskellige egenskaber, f.eks. vægt, der kan begrænse hvor mange (og hvilke) ting en spiller kan bære. Til at opbevare disse items kan anvendes en almindelig List-collection.
  Verden
Location, exits En Player befinder sig på en lokation. Location repræsenterer en lokation i spillet, og spillets verden er opbygning af en (lang) række af lokationer. En lokation er forbundet med andre lokationer, og hver lokation har derfor en collection af lokationer som man kan gå til via forskellige exits. Da vi kan ønske at serialisere spillets verden (i.e. at liste lokationer og andet i en file), er det ikke hensigtsmæssigt at lokationerne har egentlige referencer til hinanden. Hver lokation skal derfor have et id (en integer), der indirekte refererer til det.
Singleton Vi vil lave en collection, der består af alle lokationer i verden, og implementere den som en Singleton, så den også er nemt tilgængelig fra de forskellige dele af vores spil.
Figur 4:
World med Locations
Det vil være World, der kan give os en lokation ud fra dens id, når vi vil bevæge os mellem lokationer.
Ting
Navn og beskrivelse Som nævnt vil Item repræsentere de ting som en spiller kan tage, smide, bruge etc. Når en spiller kommer til en ny lokation vil det blive listet, i forbindelse med beskrivelsen af stedet, hvilke ting der befinder sig på lokationen (e.g. det kan være der er et brød og en kniv). I den forbindelse bør alle items have et kort navn, der egner sig til at optræde i en opremsning af flere ting (e.g. "bread", "axe"). Ud over dette korte navn, kan det give spillet lidt ekstra liv, hvis der også knytter sig en kort beskrivelse til hver ting (e.g. "A not so freshly baked slice of bread", "An old rusty axe"), som kan anvendes i en mere detaljeret beskrivelse af tingene.
Vægt Ud over disse beskrivende egenskaber ved ting, som næppe har nogen funktionel betydning, vil vi også tilføje egenskaben vægt, med henblik på at begrænse, hvor meget en spiller kan slæbe rundt på.
Kommandoer
Vi vil starte med en liste over kommandoer, som skal laves i spillet; hvorefter vi vil gøre os nogle betragtninger om hvordan man parser kommandoer:
echo <tekst>
Ikke en kommando der egentlig har noget med spillet at gøre, men det kan være godt at starte med et ren test-kommando, der ikke afhænger af spillet iøvrigt (i.e. ikke afhænger af spillets tilstand). Formålet med kommandoen er at udskrive den tekst man placerer efter kommandoen. F.eks. vil: "echo Hello World", udskrive "Hello World".
quit/exit
Måske den mest grundlæggende kommando i spillet, som man bør understøtte med begge navne, da det er min erfaring, at man ofte glemmer om det er er quit eller exit der stopper spillet (i.e. afslutter sessionen). Som sagt stopper kommandoen spillet, og gemmer hvad der måtte være af tilstand der skal gemmes — f.eks. hvor spilleren befinder sig, og hvad han har af items.
go <direction>
Alle lokationer har nogen retninger man kan bevæge sig i, for at komme til andre lokationer. Denne kommando bruges til at komme fra den nuværende lokation til den næste.
look
Når man bevæger sig til en ny lokation får man en beskrivelse af det sted man er kommet til, men efter at have udført en række handlinger det pågældende sted, kan denne beskrivelse være rykket langt op. Man kan derfor ønske at få den udskrivet igen, så man kan se hvor man er (og hvad der måtte være af items etc.). Med denne kommando får man bekrivelsen udskrevet igen.
take <item>
Spilleren tager det anførte item fra lokationen (i.e. flytter det fra lokationen til rygsækken).
drop <item>
Spilleren smider det anførte item på lokationen (i.e. flytter det fra rygsækken til lokationen).
inventory
Viser hvad spilleren bærer rundt på (i.e. hvilke items han har i rygsækken).
Ud over at man kan udføre disse kommandoer ved angivelse af deres fulde navn, kan det også være nyttigt, at gøre dem tilgængelige vha. diverse forkortelser (e.g. at man kan skrive "inv" i stedet for "inventory").
Kommando med argumenter Vi har nu set på en lang række af kommandoer, men hvordan skal vi rent praktisk håndtere den tekst de består af. Generelt har vi brug for at udlede selve kommandoen: "go", "take" etc., så den enkelt ICommandHandler kan tage stilling til om den kan håndtere den pågældende kommando. Ud over dette har vi to forskellige syntaktiske tilgangsvinkler til kommandoens argumenter: Enten ønsker vi at betragte dem som et hele (i.e. resten af teksten), som vi f.eks. gør det med echo-kommandoen, eller vi ønsker at skelne mellem de enkelte ord, da de skal opfattes som en række argumenter adskilt med mellemrum.
Command­Parser Til løsningen af disse opgaver er det nyttigt at lave en hjælpe-klasse: CommandParser, som vi vil overlade selve tekst-kommandoen; hvorefter vi kan spørge den om kommandoens navn, og tilgå argumenterne på den måde vi måtte ønske.
Figur 5:
Command­Parser
Count, index, ArgStr og IsCommand Count fortæller hvor mange argumenter der er, og index'eren giver adgang til dem. Har man brug for hele argument-strengen som en samlet tekststreng (e.g. echo-kommandoen), kan man bruge propertien: ArgStr. I forbindelse med at ICommandHandlers checker om den aktuelle kommando er den de kan klare, kan de anvende IsCommand-metoden. Bemærk, at selve kommandoen ikke behøver at være tilgængelig udadtil.
Netop spørgsmålet om hvordan handlerne kan bruge denne hjælpe-klasse, får os til at revurdere den anden parameter til Handle-metoden i ICommandHandler-interfacet. I figur 2 har vi valgt at sende hele kommandoen med, som en tekststreng. Dette har givet god mening fordi den handler, der skal udføre kommandoen normalt vil få brug for alle de oplysninger, der optræder i strengen.
Abstraktions-niveau Problemet er blot, at handlerne skal bruge kommandoen på et højere abstraktions-niveau. I første række er de interesseret i at se om det er en kommando de kan håndtere. Til dette formål er vores hjælpe-klasse: CommandParser, velegnet, men det vil betyde at alle handlere, der bliver spurgt om de kan udføre kommandoen, må lave en instans af CommandParser, selvom kun én af dem kommer til at bruge den til andet end at kalde IsCommand-metoden. Vi vælger derfor i stedet at lave en instans af CommandParser og sende den med i Handle-kaldene.
Hvem laver instansen? Betyder det at Worker skal lave instansen af CommandParser, eller skal man lade CommandChain lave den? For en Worker er en kommando noget som brugeren har indtastet, og vi vil derfor bibeholde, at Worker ikke kender noget til hvordan en kommando håndteres (i.e. parses). Vi vil derfor lade CommandChain foretage instantieringen af CommmandParser, og bibeholde at dens Handle-metode tager en teksstreng som parameter. Dermed har CommandChain og AbstractCommandHandler ikke længere det samme interface.
Figur 6:
Revideret klasse-diagram
Item vægt og player begrænsning
Begrænset hvor meget man kan bære Et item skal have en vægt, der har indvirkning på hvor meget en player kan bære. Det betyder at kommandoen take kan fejle, hvis den ting spilleren forsøger at tage med sig, er for tung - dvs. i forhold til differencen mellem hvor meget han allerede bærer rundt på, og hvor meget han kan bære. Hvis en spiller f.eks. allerede bærer rundt på 25 kg. og højest kan bære 30 kg., kan han ikke løfte en ting der vejer mere end 5 kg, da han derved vil overstige sit maximum på 30 kg.
For Item-klassen er det begrænset hvad der skal laves, den skal kun have tilføjet en vægt-egenskab. Der skal laves mere ved Player-klassen, da den ikke alene skal have en maximal-vægt egenskab, men også kunne beregne hvor meget spilleren allerede bærer rundt på, henholdsvis kan bære mere. Endelig skal take-kommandoen ændres, så den afviser at tage ting spilleren ikke kan bære.
Spinat, eat, IsEdible Det er almindelig kendt, at spiser man spinat bliver man stærk! Hvis en spiller kan finde noget spinat, kan han derfor forøge sin maximale bæreevne ved at spise det. For at gøre dette muligt skal vi have en ny kommando: eat, der kan bruges på items som spilleren bærer rundt på. Samtidig skal vi differentiere mellem items, der er spiselige eller ej. Item-klassen skal derfor have en property: IsEdible, der default er sat til false, men for f.eks. spinat vil være sat til true.
Fejlhåndtering
Ved indtastning af kommandoer kan der forekomme fejl, som vores applikation skal kunne håndtere.
Argumenter Hvis der mangler argumenter til en kommando, eller disse ikke giver mening (e.g. man forsøger at droppe et item man ikke har), er det nærliggende at lade den pågældende CommandHandler håndtere dette, da den har det fornødne kendskab.
Ukendt kommando Er der derimod tale om at selve kommadoen er forkert, at ingen CommandHandler vil vedkende sig håndteringen af den, bør man i stedet lave en særlig CommandHandler der accepterer en hvilken som helst kommando, og håndterer den som værende en ukendt kommando. En sådan CommandHandler skal naturligvis placeres sidst i kæden så enhver anden CommandHandler at håndtere kommandoen inden den opgives.
Det er altså relativ enkelt at håndtere fejlen: "ukendt kommando", men hvordan kan man håndtere fejl i argumenter?
Semantiske fejl Hvis alle de ønskede argumenter er til stede, men de ikke (alle) giver mening (i.e. at der er fejl i semantikken), vil dette normalt koble så tæt til den pågældende kommando, så det bør håndteres konret i den pågældende CommandHandler.
Manglende argumenter Er der derimod tale om for få, eller for mange, argumenter, kan dette håndteres mere generelt. En fejlmeddelelse i forbindelse med en sådan fejl vil normalt indeholde en angivelse af den rigtige syntax for kommandoen. Da hver CommandHandler er den nærmeste til at kende denne syntax, kan man udvide interfacet med en property, der for enhver CommandHandler giver den ønskede syntax. Dernæst kan man lave generel service-metode i AbstractCommandHandler, der checker antallet af argumenter og udskriver en fejlmeddelelse hvis dette ikke passer.