View
236
Download
1
Category
Preview:
Citation preview
Thisbookisforsaleathttp://leanpub.com/developing-games-with-ruby
Thisversionwaspublishedon2014-12-16
*****
ThisisaLeanpubbook.LeanpubempowersauthorsandpublisherswiththeLeanPublishingprocess.LeanPublishingistheactofpublishinganin-progressebookusinglightweighttoolsandmanyiterationstogetreaderfeedback,pivotuntilyouhavetherightbookandbuildtractiononceyoudo.
GosuBasicsHelloWorldScreenCoordinatesAndDepthMainLoopMovingThingsWithKeyboardImagesAndAnimationMusicAndSound
WarmingUpUsingTilesetsIntegratingWithTexturePackerCombiningTilesIntoAMapUsingTiledToCreateMapsLoadingTiledMapsWith
PrototypingTheGame
SwitchingBetweenGameStatesImplementingMenuStateImplementingPlayStateImplementingWorldMap
ImplementingFloatingCameraImplementingTheTankImplementingBulletsAndExplosionsRunningThePrototype
OptimizingGame
PerformanceProfilingRubyCodeToFindBottlenecksAdvancedProfilingTechniquesOptimizingInefficientCodeProfilingOnDemand
AdjustingGameSpeedForVariablePerformanceFrameSkipping
RefactoringThePrototype
GameProgrammingPatterns
ObjectsAddingBoundingBoxesAndDetectingCollisionsCatchingBulletsImplementingTurnSpeedPenaltiesImplementingTerrainSpeed
CreatingArtificialIntelligence
DesigningAIUsingFiniteStateMachineImplementingAIVisionControllingTankGunImplementingAIInput
ImplementingTankMotionStatesWiringTankMotionStatesIntoFiniteStateMachine
MakingThePrototypePlayable
DrawingWaterBeyond
MapBoundariesGeneratingTreeClustersGeneratingRandomObjectsImplementingARadarDynamicSoundVolumeAndPanning
GivingEnemiesIdentityRespawningTanksAndRemovingDeadOnesDisplayingExplosionDamageTrailsDebuggingBulletPhysics
MakingCameraLookAheadReviewingTheChanges
DealingWithThousandsOfGameObjects
SpatialPartitioningImplementingAQuadtree
IntegratingObjectPoolWithQuadTreeMovingObjectsInQuadTree
ImplementingPowerups
ImplementingBasePowerup
ImplementingPowerupGraphicsImplementingPowerupSoundsImplementingRepairDamagePowerupImplementingHealthBoost
ImplementingFireRateBoostImplementingTankSpeedBoostSpawningPowerupsOnMapRespawningPowerupsAfterPickup
ImplementingGameStatistics
TrackingKills,DeathsandDamageMakingDamagePersonalTrackingDamageFromChainReactions
ABoyWhoWantedToCreateWorlds
Oncetherewasaboywhofellinlovewiththismagicaldevicethatcouldbringthingstolifeinsideaglaringscreen.Hespentendlesshoursexploringimaginaryworlds,
fightingstrangecreatures,shootingpixelatedspaceships,racingboxycars.Theboykeptpondering.“Howisthismade?Iwanttocreatemyownworlds…”.
Thenhediscoveredprogramming.“Icanfinallydoit!”-hethought.Andhetried.Andfailed.Thenhetriedharder.Hefailedagainandagain.Hewastoonaivetorealizethatthoseworldshe
wastryingtocreateweretoosophisticated,andhisknowledgewastoolimited.Hegaveupcreatingthoseworlds.
Whathedidn’tgiveupiswritingcodeforthismagicaldevice.Herealizedheisn’tsmartenoughtocreateworlds,yethefoundouthecouldcreatesimplerthingslikesmallapplications-web,desktop,serversideor
whatnot.Fewyearslaterhefoundhimselfgettingpaidtomakethose.
Applicationsgotincreasinglybigger,theyspannedacrossmultipleservers,integratedwitheachother,becamepatsofhugeinfrastructures.Theboy,nowagrownman,wasallintoit.Itwasfunandchallengingenoughtospendover10000hourslearning
andbuildingwhatotherswantedhimtobuild.
Someofthesethingswereuseful,somewhereboringandpointless.Somewereneverfinished.Therewerethingshewasproudof,therewereothersthathewouldn’twanttotalkabout,nonethelesseverythinghebuiltmadehimabetterbuilder.Yetheneverfoundthetime,courageorreasonto
buildwhathereallywantedtobuildsincehewasalittleboy-hisownworlds.
Untilonedayherealizedthatnoonecanstophimfromfollowinghisdream.Hefeltthatequippedwithhiscurrentknowledgeandexperiencehewillbeabletolearntocreateworldsofhisown.Andhewentforit.
Thisboymustliveinmanysoftwaredevelopers,whodreamaboutcreatinggames,butinsteadselltheirsoftwarecraftsmanshipskillstothosewhoneedsomethingelse.Thisboyisme,andyou.Andit’stimetosethimfree.
Welcometotheworldofgamedevelopmentthatwaswaitingforyoualltheseyears.
WhyRuby?
Whenitcomestogamedevelopment,everyonewilltellyouthatyoushouldgowithC++orsomeotherstaticallytypedlanguagethatcompilesdowntobaremetalinstructions.Orthatyoushouldgowithfullblowngamedevelopmentplatform
likeUnity.Slow,dynamiclanguageslikeRubyseemlikethelastchoiceanysanegamedeveloperwouldgofor.
Afriendofminesaid“There’slittlereasontodevelopadesktopgamewithRuby”,andhewasabsolutelyright.Perhapsthisisthereasonwhytherearenobooksaboutit.Allthecasualgameactionhappensinmobiledevices,anddesktop
gamesareforseasonedgamerswhodemandfastanddetailed3Dgraphics,motion-capturedanimationsandsophisticatedgamemechanics-thingsweknowwearenotgoingtobeabletobuildonourown,withoutmillionsfromVCpocketsandHollywoodgradeequipment.
Now,bearwithme.Yourgamewillnotbea3D
MMORPGsetinhuge,photorealisticrepresentationofMiddle-earth.Let’sleavethosethingstoBethesda,UbisoftandRockstarGames.Afterall,everyonehastostartsomewhere,andyouhavetobesmartenoughtounderstand,thateventhoughthatlittleboyinyouwantstocreateanimprovedversionofGrandTheftAutoV,wewillhavetogoforsomethingthat
resembleslesserknownSuperNintendotitlesinstead.
Whynotgomobilethen?Thosedevicesseemperfectforsimplergames.Ifyouareatruegameratheart,youwillagreethattouchscreengamesyoufindinmodernphonesandtabletsareonlygoodforkilling10minutesofyourtimewhiletakingadump.Youhavetofeeltheresistancewhenyouclicka
button!Screensizealsodoesmatter.Playinganythingonmobilephoneisatortureforthosewhoknowwhatplayingrealgamesshouldfeellike.
So,yourgamewillhavetobesmallenoughforyoutobeabletocompleteit,itwillhavetohavesimple2Dgraphics,andwouldnotrequirethelatestGeForcewithatleast512MBofRAM.Thisfactgivesyouthe
benefitofchoice.Youdon’thavetoworryaboutperformancethatmuch.Youcanchooseafriendlyandproductivelanguagethatisdesignedforprogrammerhappiness.AndthisiswhereRubystartstoshine.It’sbeautiful,simpleandelegant.Itisclosetopoetry.
WhatYouShouldKnowBeforeReadingThisBook
Asyoucanreadonthecover,thisbookis“forthosewhowritecodeforliving”.It’snotarequirement,andyouwill
mostlikelybeabletounderstandeverythingevenifyouareastudentorhobbyist,butthisbookwillnotteachyouhowtobeagoodprogrammer.Ifyouwanttolearnthat,startwithtimelessclassic:ThePragmaticProgrammer:FromJourneymantoMaster.
YoushouldunderstandRubyatleasttosomeextent.Thereareplentyofbooksand
resourcescoveringthatsubject.TryWhy’sPoignantGuideToRubyorEloquentRuby.Youcanalsolearnitwhilereadingthisbook.Itshouldn’tbetoohard,especiallyifyoualreadywritecodeforliving.Afterallprogramminglanguageismerelyatool,andwhenyoulearnone,othersarerelativelyeasytoswitchto.
Youshouldknowhowtousethecommandline.BasicknowledgeofGitcanalsobehandy.
Youdon’thavetoknowhowtodraworcomposemusic.Wewillusemediathatisavailableforfree.However,knowledgeofgraphicsandaudioeditingsoftwarewon’thurt.
WhatAreWeGoingToBuild?
Thisquestionisofparamountimportance.Theanswerwillusuallydetermineifyouwilllikelytosucceed.Ifyouwanttooverstepyourboundaries,youwillfail.Itshouldn’tbetooeasyeither.Ifyouknowsomethingabout
programmingalready,IbetyoucanimplementTicTacToe,butwillyoufeelproudaboutit?Willyoubeabletosay“I’vebuiltaworld!”.Iwouldn’t.
GraphicsTobeginwith,weneedtoknowwhatkindofgraphicsweareaimingfor.Wewillinstantlyruleout3Dforseveralreasons:
Wedon’twanttoincreasethescopeandcomplexityRubymaynotbefastenoughfor3DgamesLearningproper3Dgraphicsprogrammingrequiresreadingaseparatebookthatisseveraltimesthickerthanthisone.
Now,wehavetoswallowourprideandacceptthefactthat
thegamewillhavesimple2Dgraphics.Therearethreechoicestogofor:
ParallelProjectionTopDownSide-Scrolling
ParallelProjection(thinkFallout1&2)isprettycloseto3Dgraphics,itrequiresdetailedartifyouwantittolookdecent,sowewould
havearoughstartifwewentforit.
TopDownview(oldtitlesofLegendofZelda)offersplentyoffreedomtoexploretheenvironmentinalldirectionsandrequireslessgraphicaldetail,sincethingslooksimplerfromabove.
SideScrollinggames(SuperMarioBros.)usuallyinvolvesomephysicsrelatedto
jumpingandrequiremoreefforttolookgood.Feelingofexplorationislimited,sinceyouusuallymovefromlefttorightmostofthetime.
GoingwithTopDownviewwillgiveusachancetocreateourgameworldasopenforexplorationaspossible,whilehavingsimplegraphicsandmovementmechanics.Soundslikethebestchoiceforus.
IfyouareasbadatdrawingthingsasIam,youcouldstillwonderhowwearegoingtogetourgraphics.Thankfully,thereisthisopengameart.org.It’slikeGitHubofgamemedia,wewillsurelyfindsomethingthere.Italsocontainsaudiosamplesandtracks.
GameDevelopmentLibrary
Implementitallyourselforharnessthepowerofsomegamedevelopmentlibrarythatoffersyouboilerplatesandconvenientaccesstocommonfunctions?Ifyou’relikeme,youwoulddefinitelywanttoimplementitallyourself,butthatmaybethereasonwhyIfailedtomakeadecentgamesomanytimes.
Ifyouwilltrytoimplementitallyourself,youwillmost
likelyendupreimplementingsomeexistinggamelibrary,poorly.Itwon’ttakelongwhileyoureachapointwhereyouneedtointerfacewithunderlyingoperatingsystemlibrariestogetgraphics.Andguessifthosebindingswillworkinadifferentoperatingsystem?
So,swallowyourprideagain,becausewearegoingtouseanexistinggame
developmentlibrary.Goodnewsisthatyouwillbeabletoactuallyfinishthegame,anditwillbeportabletoWindows,MacandLinux.Wewillstillhavetobuildourowngameengineforourselvesontopofit,sodon’tthinkitwon’tbefun.
ThereareseveralgamelibrariesavailableforRuby,butit’sasimplechoice,becauseGosuisheadand
shouldersaboveothers.It’sverymature,hasalargeandactivecommunity,anditismainlywritteninC++buthasfirstclassRubysupport,soitwillbebothfastandconvenienttouse.
ManyofotherRubygamelibrariesarebuiltontopofGosu,soit’sasolidchoice.
ThemeAndMechanicsChoosingtherightthemeisundoubtedlyimportant.Itshouldbesomethingthatappealstoyou,somethingyouwillwanttoplay,anditshouldnotimplydifficultgamemechanics.IloveMMORPGs,andIalwaysdreamedofmakinganopenworldgamewhereyoucan
roamaround,meetotherplayers,fightmonstersandlevelup.GuesshowmanytimesIstartedbuildingsuchagame?EvenifIwouldn’thavelostthecount,Iwouldn’tbeproudtosaythenumber.
Thistime,equippedwithlogicandsanity,I’vepickedsomethingchallengingenough,yetstillprettysimpletobuild.Areyouready?
Drumroll…
Wewillbebuildingamultidirectionalshooterarcadegamewhereyoucontrolatank,roamaroundanisland,shootenemytanksandtrynottogetdestroyedbyothers.
IfyouhaveplayedBattleCityorTankForce,youshouldeasilygettheidea.Ibelievethatimplementingsuchagame(withseveraltwists)
wouldexposeustoperfectlevelofdifficultyandprovidesubstantialamountofexperience.
Wewilluseasubsetofthesegorgeousgraphicswhichareavailableonopengameart.org,generouslyprovidedbyCsabaFelvegi.
PreparingTheTools
Whilewritingthisbook,IwillbeusingMacOSX(10.9),butitshouldbepossibletorunalltheexamplesonotheroperatingsystemstoo.
GosuWikihas“GettingStarted”pagesforMac,LinuxandWindows,soIwillnotbegoingintomuchdetailhere.
GettingGosutorunonMacOsXIfyouhaven’tsetupyourMacfordevelopment,firstinstallXcodeusingAppStore.SystemRubyshould
workjustfine,butyoumaywanttouseRbenvorRVMtoavoidpollutingsystemRuby.I’vehadtroubleinstallingGosuwithRVM,butyourexperiencemayvary.
Toinstallthegem,simplyrun:
$geminstallgosu
Youmayneedtoprefixitwithsudoifyouareusing
systemRuby.
Totestifgemwasinstalledcorrectly,youshouldbeabletorunthistoproduceanemptyblackwindow:
$irbirb(main):001:0>require'gosu'=>trueirb(main):002:0>Gosu::Window.new(320,240,false).show=>nil
MostdeveloperswhouseMaceverydaywillalsorecommendinstallingHomebrewpackagemanager,replaceTerminalappwithiTerm2anduseOh-My-ZshtomanageZSHconfiguration.
GettingTheSampleCode
YoucanfindsamplecodeatGitHub:https://github.com/spajus/ruby-gamedev-book-examples.
Cloneittoaconvenientlocation:
$cd~/gamedev$gitclonegit@github.com:spajus/ruby-gamedev-book-examples.git
Thesourcecodeoffinalproductcanbefoundathttps://github.com/spajus/tank_island
OtherTools
Allyouneedforthisadventureisagoodtexteditor,terminalandprobablysomegraphicseditor.TryGIMPifyouwantafreeone.I’musingPixelmator,it’swonderful,butforMaconly.Anoteworthyfactisthat
PixelmatorwasbuiltbyfellowLithuanians.
Whenitcomestoeditors,Idon’tleavehomewithoutVim,butaslongaswhatyouusemakesyouproductive,itdoesn’tmakeanydifference.Vim,EmacsorSublimeareallgoodenoughtowritecode,justhavesomegoodpluginsthatsupportRuby,andyou’reset.IfyoureallyfeelyouneedanIDE,which
maybethecaseifyouarecomingfromastaticlanguage,youcan’tgowrongwithRubyMine.
GosuBasics
BynowGosushouldbeinstalledandreadyforaspin.Butbeforewerushintobuildingourgame,wehavetogetacquaintedwithourlibrary.Wewillgothroughseveralsimpleexamples,familiarizeourselveswithGosuarchitectureandcore
principles,andtakeacoupleofbabystepstowardsunderstandinghowtoputeverythingtogether.
Tomakethischaptereasiertoreadandunderstand,IrecommendwatchingWritingGamesWithRubytalkgivenbyMikeMooreatLARubyConference2014.Infact,thistalkpushedmetowardsrethinkingthiscrazyideaofusingRubyforgame
development,sothisbookwouldn’texistwithoutit.Thankyou,Mike.
HelloWorldTohonorthetraditions,wewillstartbywriting“HelloWorld”togetatasteofwhatGosufeelslike.ItisbasedonRubyTutorialthatyoucanfindinGosuWiki.01-hello/hello_world.rb
1require'gosu'23classGameWindow<Gosu::Window4definitialize(width=320,height=240,fullscreen=false)5super6self.caption='Hello'7@message=Gosu::Image.from_text(8self,'Hello,World!',Gosu.default_font_name,30)9end1011defdraw12@message.draw(10,10,0)13end14end1516window=GameWindow.new17window.show
WehaveextendedGosu::WindowwithourownGameWindowclass,initializingitas320x240window.superpassedwidth,heightandfullscreeninitializationparametersfromGameWindowtoGosu::Window.
Thenwedefinedourwindow’scaption,andcreated@messageinstancevariablewithanimagegeneratedfromtext"Hello,
World!"usingGosu::Image.from_text.
WehaveoverriddenGosu::Window#drawinstancemethodthatgetscalledeverytimeGosuwantstoredrawourgamewindow.Inthatmethodwecalldrawonour@messagevariable,providingxandyscreencoordinatesbothequalto10,andz(depth)valueequalto0.
ScreenCoordinatesAndDepthJustlikemostconventionalcomputergraphicslibraries,Gosutreatsxashorizontalaxis(lefttoright),yasverticalaxis(toptobottom),andzasorder.
Screencoordinatesanddepth
xandyaremeasuredinpixels,andvalueofzisarelativenumberthatdoesn’tmeananythingonit’sown.Thepixelintop-leftcornerof
thescreenhascoordinatesof0:0.
zorderinGosuisjustlikez-indexinCSS.Itdoesnotdefinezoomlevel,butincasetwoshapesoverlap,onewithhigherzvaluewillbedrawnontop.
MainLoopTheheartofGosulibraryisthemainloopthathappensin
Gosu::Window.ItisexplainedfairlywellinGosuwiki,sowewillnotbediscussingithere.
MovingThingsWithKeyboardWewillmodifyour“Hello,World!”exampletolearnhowtomovethingsonscreen.Thefollowingcodewillprintcoordinatesofthe
messagealongwithnumberoftimesscreenwasredrawn.ItalsoallowsexitingtheprogrambyhittingEscbutton.01-hello/hello_movement.rb1require'gosu'23classGameWindow<Gosu::Window4definitialize(width=320,height=240,fullscreen=false)5super6self.caption='HelloMovement'7@x=@y=108@draws=0
9@buttons_down=010end1112defupdate13@x-=1ifbutton_down?(Gosu::KbLeft)14@x+=1ifbutton_down?(Gosu::KbRight)15@y-=1ifbutton_down?(Gosu::KbUp)16@y+=1ifbutton_down?(Gosu::KbDown)17end1819defbutton_down(id)20closeifid==Gosu::KbEscape21@buttons_down+=122end2324defbutton_up(id)
25@buttons_down-=126end2728defneeds_redraw?29@draws==0||@buttons_down>030end3132defdraw33@draws+=134@message=Gosu::Image.from_text(35self,info,Gosu.default_font_name,30)36@message.draw(@x,@y,0)37end3839private4041definfo42"[x:#{@x};y:#
{@y};draws:#{@draws}]"43end44end4546window=GameWindow.new47window.show
Runtheprogramandtrypressingarrowkeys:
$ruby01-hello/hello_movement.rb
Themessagewillmovearoundaslongasyoukeeparrowkeyspressed.
Usearrowkeystomovethemessagearound
Wecouldwriteashorterversion,butthepointhereisthatifwewouldn’toverride
needs_redraw?thisprogramwouldbeslowerbyorderofmagnitude,becauseitwouldcreate@messageobjecteverytimeitwantstoredrawthewindow,eventhoughnothingwouldchange.
Hereisascreenshotoftopdisplayingtwoversionsofthisprogram.Secondscreenhasneeds_redraw?methodremoved.Seethedifference?
RedrawingonlywhennecessaryVSredrawingeverytime
Rubyisslow,soyouhavetouseitwisely.
ImagesAndAnimation
It’stimetomakesomethingmoreexciting.Ourgamewillhavetohaveexplosions,thereforeweneedtolearntoanimatethem.Wewillsetupabackgroundsceneandtriggerexplosionsontopofitwithourmouse.01-hello/hello_animation.rb1require'gosu'23defmedia_path(file)4File.join(File.dirname(File.dirname
5__FILE__)),'media',file)6end78classExplosion9FRAME_DELAY=10#ms10SPRITE=media_path('explosion.png')1112defself.load_animation(window)13Gosu::Image.load_tiles(14window,SPRITE,128,128,false)15end1617definitialize(animation,x,y)18@animation=animation19@x,@y=x,y20@current_frame=0
21end2223defupdate24@current_frame+=1ifframe_expired?25end2627defdraw28returnifdone?29image=current_frame30image.draw(31@x-image.width/2.0,32@y-image.height/2.0,330)34end3536defdone?37@done||=@current_frame==@animation.size
38end3940private4142defcurrent_frame43@animation[@current_frame%@animation.size]44end4546defframe_expired?47now=Gosu.milliseconds48@last_frame||=now49if(now-@last_frame)>FRAME_DELAY50@last_frame=now51end52end53end5455classGameWindow<
Gosu::Window56BACKGROUND=media_path('country_field.png')5758definitialize(width=800,height=600,fullscreen=false)59super60self.caption='HelloAnimation'61@background=Gosu::Image.new(62self,BACKGROUND,false)63@animation=Explosion.load_animation(self)64@explosions=[]65end6667defupdate68@explosions.reject!(&:done?)
69@explosions.map(&:update)70end7172defbutton_down(id)73closeifid==Gosu::KbEscape74ifid==Gosu::MsLeft75@explosions.push(76Explosion.new(77@animation,mouse_x,mouse_y))78end79end8081defneeds_cursor?82true83end8485defneeds_redraw?86!@scene_ready||
@explosions.any?87end8889defdraw90@scene_ready||=true91@background.draw(0,0,0)92@explosions.map(&:draw)93end94end9596window=GameWindow.new97window.show
Runitandclickaroundtoenjoythosebeautifulspecialeffects:
Multipleexplosionsonscreen
Nowlet’sfigureouthowitworks.OurGameWindowinitializeswith@backgroundGosu::Imageand
@animation,thatholdsarrayofGosu::Imageinstances,oneforeachframeofexplosion.Gosu::Image.load_tiles
handlesitforus.
Explosion::SPRITEpointsto“tileset”image,whichisjustaregularimagethatcontainsequallysizedsmallerimageframesarrangedinorderedsequence.Rowsofframesare
big,andithas8rowsof8tilesperrow,itiseasytotellthatthereare64tiles128x128pixelseach.So,@animation[0]holds128x128Gosu::Imagewithtop-lefttile,and@animation[63]-thebottom-rightone.
Gosudoesn’thandleanimation,it’ssomethingyouhavefullcontrolover.Wehavetodraweachtileina
sequenceourselves.YoucanalsousetilestoholdmapgraphicsThelogicbehindthisisprettysimple:
1. Explosionknowsit’s@current_frame
number.Itbeginswith0.2. Explosion#frame_expired?
checksthelasttimewhen@current_framewasrendered,andwhenitisolderthanExplosion::FRAME_DELAY
milliseconds,@current_frameisincreased.
3. WhenGameWindow#updateiscalled,@current_frameisrecalculatedforall@explosions.Also,explosionsthathavefinishedtheiranimation(displayedthelastframe)areremovedfrom@explosionsarray.
4. GameWindow#drawdrawsbackgroundimageandall@explosionsdrawtheircurrent_frame.
5. Again,wearesavingresourcesandnotredrawingwhenthereareno@explosionsinprogress.needs_redraw?handlesit.
Itisimportanttounderstandthatupdateanddraworderis
unpredictable,thesemethodscanbecalledbyyoursystematdifferentrate,youcan’ttellwhichonewillbecalledmoreoftenthantheotherone,soupdateshouldonlybeconcernedwithadvancingobjectstate,anddrawshouldonlydrawcurrentstateonscreenifitisneeded.Theonlyreliablethinghereistime,consultGosu.millisecondstoknowhowmuchtimehavepassed.
Ruleofthethumb:drawshouldbeaslightweightaspossible.Prepareallcalculationsinupdateandyouwillhaveresponsive,smoothgraphics.
MusicAndSoundOurpreviousprogramwasclearlymissingasoundtrack,sowewilladdone.Abackgroundmusicwillbe
looping,andeachexplosionwillbecomeaudible.01-hello/hello_sound.rb1require'gosu'23defmedia_path(file)4File.join(File.dirname(File.dirname
5__FILE__)),'media',file)6end78classExplosion9FRAME_DELAY=10#ms10SPRITE=media_path('explosion.png')11
12defself.load_animation(window)13Gosu::Image.load_tiles(14window,SPRITE,128,128,false)15end1617defself.load_sound(window)18Gosu::Sample.new(19window,media_path('explosion.mp3'))20end2122definitialize(animation,sound,x,y)23@animation=animation24sound.play25@x,@y=x,y26@current_frame=027end
2829defupdate30@current_frame+=1ifframe_expired?31end3233defdraw34returnifdone?35image=current_frame36image.draw(37@x-image.width/2.0,38@y-image.height/2.0,390)40end4142defdone?43@done||=@current_frame==@animation.size
44end4546defsound47@sound.play48end4950private5152defcurrent_frame53@animation[@current_frame%@animation.size]54end5556defframe_expired?57now=Gosu.milliseconds58@last_frame||=now59if(now-@last_frame)>FRAME_DELAY60@last_frame=now61end
62end63end6465classGameWindow<Gosu::Window66BACKGROUND=media_path('country_field.png')6768definitialize(width=800,height=600,fullscreen=false)69super70self.caption='HelloAnimation'71@background=Gosu::Image.new(72self,BACKGROUND,false)73@music=Gosu::Song.new(74self,media_path('menu_music.mp3'))
75@music.volume=0.576@music.play(true)77@animation=Explosion.load_animation(self)78@sound=Explosion.load_sound(self)79@explosions=[]80end8182defupdate83@explosions.reject!(&:done?)84@explosions.map(&:update)85end8687defbutton_down(id)88closeifid==Gosu::KbEscape89ifid==Gosu::MsLeft90@explosions.push(
91Explosion.new(92@animation,@sound,mouse_x,mouse_y))93end94end9596defneeds_cursor?97true98end99100defneeds_redraw?101!@scene_ready||@explosions.any?102end103104defdraw105@scene_ready||=true106@background.draw(0,0,0)107@explosions.map(&:draw)108end
109end110111window=GameWindow.new112window.show
Runitandenjoythecinematicexperience.Addingsoundreallymakesadifference.
$ruby01-hello/hello_sound.rb
Weonlyaddedcoupleofthingsoverpreviousexample.
72@music=Gosu::Song.new(73self,media_path('menu_music.mp3'))74@music.volume=0.575@music.play(true)
GameWindowcreatesGosu::Songwithmenu_music.mp3,adjuststhevolumesoit’salittlemorequietandstartsplayinginaloop.
16defself.load_sound(window)17Gosu::Sample.new(18window,
media_path('explosion.mp3'))19end
Explosionhasnowgotload_soundmethodthatloadsexplosion.mp3soundeffectGosu::Sample.ThissoundeffectisloadedonceinGameWindowconstructor,andpassedintoeverynewExplosion,whereitsimplystartsplaying.
HandlingaudiowithGosuisverystraightforward.UseGosu::Songtoplaybackgroundmusic,andGosu::Sampletoplayeffectsandsoundsthatcanoverlap.
WarmingUp
Beforewestartbuildingourgame,wewanttoflexourskillslittlemore,gettoknowGosubetterandmakesureourtoolswillbeabletomeetourexpectations.
UsingTilesets
AfterplayingaroundwithGosuforawhile,weshouldbecomfortableenoughtoimplementaprototypeoftop-downviewgamemapusingthetilesetofourchoice.Thisgroundtilesetlookslikeagoodplacetostart.
IntegratingWithTexturePacker
Afterdownloadingandextractingthetileset,it’sobviousthatGosu::Image#load_tiles
willnotsuffice,sinceitonlysupportstilesofsamesize,andthereisatilesetinthepackagethatlookslikethis:
Tilesetwithtilesofirregularsize
AndthereisalsoaJSONfilethatcontainssomemetadata:
{"frames":{"aircraft_1d_destroyed.png":{"frame":{"x":451,"y":102,"w":57,"h":42},
"rotated":false,"trimmed":false,"spriteSourceSize":{"x":0,"y":0,"w":57,"h":42},"sourceSize":{"w":57,"h":42}},"aircraft_2d_destroyed.png":{"frame":
{"x":2,"y":680,"w":63,"h":47},"rotated":false,"trimmed":false,"spriteSourceSize":{"x":0,"y":0,"w":63,"h":47},"sourceSize":{"w":63,"h":47}},...}},"meta":{ "app":"http://www.texturepacker.com", "version":"1.0", "image":"decor.png", "format":"RGBA8888", "size":{"w":512,"h":1024}, "scale":"1", "smartupdate":"$TexturePacker:SmartUpdate:2e6b6964f24c7abfaa85a804e2dc1b05$"
}
LookslikethesetileswerepackedwithTexturePacker.AftersomediggingI’vediscoveredthatGosudoesn’thaveanyintegrationwithit,soIhadthesechoices:
1. Cuttheoriginaltilesetimageintosmallerimages.
2. ParseJSONandharnessthebenefitsofTexturePacker.
Firstoptionwastoomuchworkandwouldprovetobelessefficient,becauseloadingmanysmallfilesisalwaysworsethanloadingonebiggerfile.Therefore,secondoptionwasthewinner,andIalsothought“whynotwriteagemwhileI’matit”.Andthat’sexactlywhatIdid,and
youshoulddothesameinsuchasituation.ThegemisavailableonGitHub:
https://github.com/spajus/gosu-texture-packer
Youcaninstallthisgemusinggeminstallgosu_texture_packer.Ifyouwanttoexaminethecode,easiestwayistocloneitonyourcomputer:
$gitclonegit@github.com:spajus/gosu-texture-packer.git
Let’sexaminethemainideabehindthisgem.Hereisaslightlysimplifiedversionthatdoeshandleseverythinginunder20linesofcode:02-warmup/tileset.rb1require'json'2classTileset3definitialize(window,json)4@json=JSON.parse(File.read(json))
5image_file=File.join(6File.dirname(json),@json['meta']['image'])7@main_image=Gosu::Image.new(8@window,image_file,true)9end1011defframe(name)12f=@json['frames'][name]['frame']13@main_image.subimage(14f['x'],f['y'],f['w'],f['h'])15end16end
IfbynowyouarefamiliarwithGosudocumentation,youwillwonderwhatthehellisGosu::Image#subimage.Atthepointofwritingitwasnotdocumented,andIaccidentallydiscovereditwhilediggingthroughGosusourcecode.
I’mluckythisfunctionexisted,becauseIwasreadytobringouttheheavyartilleryanduseRMagickto
extractthosetiles.WewillprobablyneedRMagickatsomepointoftimelater,butit’sbettertoavoiddependenciesaslongaspossible.
CombiningTilesIntoAMapWithtilesetloadingissueoutoftheway,wecanfinallyget
backtodrawingthatcoolmapofours.
Thefollowingprogramwillfillthescreenwithrandomtiles.02-warmup/random_map.rb1require'gosu'2require'gosu_texture_packer'34defmedia_path(file)5File.join(File.dirname(File.dirname
6__FILE__)),'media',
file)7end89classGameWindow<Gosu::Window10WIDTH=80011HEIGHT=60012TILE_SIZE=1281314definitialize15super(WIDTH,HEIGHT,false)16self.caption='RandomMap'17@tileset=Gosu::TexturePacker.load_json(18self,media_path('ground.json'),:precise)19@redraw=true20end
2122defbutton_down(id)23closeifid==Gosu::KbEscape24@redraw=trueifid==Gosu::KbSpace25end2627defneeds_redraw?28@redraw29end3031defdraw32@redraw=false33(0..WIDTH/TILE_SIZE).eachdo|x|34(0..HEIGHT/TILE_SIZE).eachdo|y|35@tileset.frame(36@tileset.frame_list.sample).draw
37x*(TILE_SIZE),38y*(TILE_SIZE),390)40end41end42end43end4445window=GameWindow.new46window.show
Runit,thenpressspacebartorefillthescreenwithrandomtiles.
$ruby02-warmup/random_map.rb
Mapfilledwithrandomtiles
Theresultdoesn’tlookseamless,sowewillhavetofigureoutwhat’swrong.Afterplayingaroundfora
while,I’venoticedthatit’sanissuewithGosu::Image.
Whenyouloadatilelikethis,itworksperfectly:
Gosu::Image.new(self,image_path,true,0,0,128,128)Gosu::Image.load_tiles(self,image_path,128,128,true)
Andthefollowingproducessocalled“texturebleeding”:
Gosu::Image.new(self,image_path,true)Gosu::Image.new(self,image_path,true).subimage(0,0,128,128)
Goodthingwe’renotbuildingourgameyet,right?Welcometotheintricaciesofsoftwaredevelopment!
Now,Ihavereportedmyfindings,butuntilitgetsfixed,weneedaworkaround.Andtheworkaroundwasto
Whilelowlevelapproachtodrawingtilesinscreenmaybeappropriateinsomescenarios,likerandomlygeneratedmaps,wewillexploreanotheralternatives.Oneofthemisthisgreat,opensource,crossplatform,generictilemapeditorcalledTiled.
Ithassomelimitations,forinstance,alltilesintilesethavetobeofsame
proportions.Ontheupside,itwouldbeeasytoloadTiledtilesetswithGosu::Image#load_tiles.
Tiled
Tiledusesit’sowncustom,XMLbasedtmxformatforsavingmaps.ItalsoallowsexportingmapstoJSON,whichiswaymore
convenient,sinceparsingXMLinRubyisusuallydonewithNokogiri,whichisheavierandit’snativeextensionsusuallycausemoretroublethanonesJSONparseruses.So,let’sseehowthatJSONlookslike:02-warmup/tiled_map.json1{"height":10,2"layers":[3{4"data":[65,65,65,65,65,65,65,65,65,65,65,65,65,0,0,65,6\
55,65,65,65,65,65,65,0,0,65,65,65,65,65,65,65,65,0,0,0,65,65\6,65,65,65,65,65,0,0,0,0,65,65,65,65,65,65,0,0,0,0,65,65,65\7,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65\8,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65\9],10"height":10,11"name":"Water",12"opacity":1,13"type":"tilelayer",14"visible":true,15"width":10,16"x":0,17"y":0
18},19{20"data":[0,0,7,5,57,43,0,0,0,0,0,0,28,1,1,42,0,0,0,0,\210,0,44,1,1,42,0,0,0,0,0,0,28,1,1,27,43,0,0,0,0,0,28,1,1\22,1,27,43,0,0,0,0,28,1,1,1,59,16,0,0,0,0,48,62,61,61,16,0,\230,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0\24,0,0,0,0,0],25"height":10,26"name":"Ground",27"opacity":1,28"type":"tilelayer",29"visible":true,30"width":10,
31"x":0,32"y":033}],34"orientation":"orthogonal",35"properties":36{3738},39"tileheight":128,40"tilesets":[41{42"firstgid":1,43"image":"media\/ground.png",44"imageheight":1024,45"imagewidth":1024,46"margin":0,47"name":"ground",48"properties":49{50
51},52"spacing":0,53"tileheight":128,54"tilewidth":12855},56{57"firstgid":65,58"image":"media\/water.png",59"imageheight":128,60"imagewidth":128,61"margin":0,62"name":"water",63"properties":64{6566},67"spacing":0,68"tileheight":128,69"tilewidth":12870}],
71"tilewidth":128,72"version":1,73"width":1074}
Therearefollowingthingslistedhere:
Twodifferenttilesets,“ground”and“water”Mapwidthandheightintilecount(10x10)Layerswithdataarraycontainstilenumbers
CoupleofextrathingsthatTiledmapscanhave:
ObjectlayerscontaininglistsofobjectswiththeircoordinatesPropertieshashontilesandobjects
Thisdoesn’tlooktoodifficulttoparse,sowe’regoingtoimplementaloaderforTiledmaps.Andmakeitopensource,ofcourse.
LoadingTiledMapsWithGosuProbablytheeasiestwaytoloadTiledmapistotakeeachlayerandrenderitonscreen,tilebytile,likeacake.Wewillnotcareaboutcachingatthispoint,andtheonlyoptimizationwouldbenotdrawingthingsthatareoutofscreenboundaries.
Aftercoupleofdaysoftestdrivendevelopment,I’veendedupwritinggosu_tiledgem,thatallowsyoutoloadTiledmapswithjustafewlinesofcode.
Iwillnotgothroughdescribingtheimplementation,butifyouwanttoexaminethethoughtprocess,takealookatgosu_tiledgem’sgitcommithistory.
Tousethegem,dogeminstallgosu_tiledandexaminethecodethatshowsamapoftheislandthatyoucanscrollaroundwitharrowkeys:02-warmup/island.rb1require'gosu'2require'gosu_tiled'34classGameWindow<Gosu::Window5MAP_FILE=File.join(File.dirname(6__FILE__),'island.json')
7SPEED=589definitialize10super(640,480,false)11@map=Gosu::Tiled.load_json(self,MAP_FILE)12@x=@y=013@first_render=true14end1516defbutton_down(id)17closeifid==Gosu::KbEscape18end1920defupdate21@x-=SPEEDifbutton_down?(Gosu::KbLeft)22@x+=SPEEDifbutton_down?(Gosu::KbRight)
23@y-=SPEEDifbutton_down?(Gosu::KbUp)24@y+=SPEEDifbutton_down?(Gosu::KbDown)25self.caption="#{Gosu.fps}FPS.Usearrowkeystopan"26end2728defdraw29@first_render=false30@map.draw(@x,@y)31end3233defneeds_redraw?34[Gosu::KbLeft,35Gosu::KbRight,36Gosu::KbUp,37Gosu::KbDown].eachdo|b|38returntrueif
button_down?(b)39end40@first_render41end42end4344GameWindow.new.show
Runit,usearrowkeystoscrollthemap.
$ruby02-warmup/island.rb
Theresultisquitesatisfying,anditscrollssmoothlywithoutanyoptimizations:
NoiseInsomecasesrandomgeneratedmapsmakeallthedifference.WormsandDiablowouldprobablybejustaveragegamesifitwasn’tforthosealwaysunique,procedurallygeneratedmaps.
Wewilltrytomakeaveryprimitivemapgeneratorourselves.Tobeginwith,we
willbeusingonly3differenttiles-water,sandandgrass.Forimplementingfullytilededges,thegeneratormustbeawareofavailabletilesetsandknowhowtocombinetheminvalidways.Wemaycomebacktoit,butfornowlet’skeepthingssimple.
Now,generatingnaturallylookingrandomnessissomethingworthhavingabookofit’sown,soinstead
oftryingtopoorlyreinventwhatotherpeoplehavealreadydone,wewilluseawellknownalgorithmperfectlysuitedforthistask-Perlinnoise.
IfyouhaveeverusedPhotoshop’sCloudfilter,youalreadyknowhowPerlinnoiselookslike:
isperlin_noisegemalreadyavailable,itlooksprettysolid,sowewilluseit.
Thefollowingprogramgenerates100x100mapwith30%chanceofwater,15%chanceofsandand55%chanceofgrass:02-warmup/perlin_noise_map.rb1require'gosu'2require'gosu_texture_packer'3require'perlin_noise'4
5defmedia_path(file)6File.join(File.dirname(File.dirname
7__FILE__)),'media',file)8end910classGameWindow<Gosu::Window11MAP_WIDTH=10012MAP_HEIGHT=10013WIDTH=80014HEIGHT=60015TILE_SIZE=1281617definitialize18super(WIDTH,HEIGHT,false)19load_tiles20@map=generate_map
21@zoom=0.222end2324defbutton_down(id)25closeifid==Gosu::KbEscape26@map=generate_mapifid==Gosu::KbSpace27end2829defupdate30adjust_zoom(0.005)ifbutton_down?(Gosu::KbDown)31adjust_zoom(-0.005)ifbutton_down?(Gosu::KbUp)32set_caption33end3435defdraw36tiles_x.timesdo|x|37tiles_y.timesdo|y|
38@map[x][y].draw(39x*TILE_SIZE*@zoom,40y*TILE_SIZE*@zoom,410,42@zoom,43@zoom)44end45end46end4748private4950defset_caption51self.caption='PerlinNoise.'<<52"Zoom:#{'%.2f'%@zoom}."<<53'UseUp/Downtozoom.Spacetoregenerate.'
54end5556defadjust_zoom(delta)57new_zoom=@zoom+delta58ifnew_zoom>0.07&&new_zoom<259@zoom=new_zoom60end61end6263defload_tiles64tiles=Gosu::Image.load_tiles(65self,media_path('ground.png'),128,128,true)66@sand=tiles[0]67@grass=tiles[8]68@water=Gosu::Image.new(
69self,media_path('water.png'),true)70end7172deftiles_x73count=(WIDTH/(TILE_SIZE*@zoom)).ceil+174[count,MAP_WIDTH].min75end7677deftiles_y78count=(HEIGHT/(TILE_SIZE*@zoom)).ceil+179[count,MAP_HEIGHT].min80end8182defgenerate_map83noises=Perlin::Noise.new(2)84contrast=Perlin::Curve.contrast(
85Perlin::Curve::CUBIC,2)86map={}87MAP_WIDTH.timesdo|x|88map[x]={}89MAP_HEIGHT.timesdo|y|90n=noises[x*0.1,y*0.1]91n=contrast.call(n)92map[x][y]=choose_tile(n)93end94end95map96end9798defchoose_tile(val)99caseval100when0.0..0.3#30%
chance101@water102when0.3..0.45#15%chance,wateredges103@sand104else#55%chance105@grass106end107end108109end110111window=GameWindow.new112window.show
Runtheprogram,zoomwithup/downarrowsand
MapgeneratedwithPerlinnoise
Thisisalittlelongerthanourpreviousexamples,sowewillanalyzesomepartstomakeitclear.
81defgenerate_map82noises=Perlin::Noise.new(2)83contrast=Perlin::Curve.contrast(84Perlin::Curve::CUBIC,2)85map={}86MAP_WIDTH.timesdo|x|87map[x]={}88MAP_HEIGHT.timesdo|y|89n=noises[x*0.1,y*0.1]90n=contrast.call(n)91map[x][y]=choose_tile(n)92end93end94map95end
generate_mapistheheartofthisprogram.ItcreatestwodimensionalPerlin::Noisegenerator,thenchoosesarandomtileforeachlocationofthemap,accordingtonoisevalue.Tomakethemapalittlesharper,cubiccontrastisappliedtonoisevaluebeforechoosingthetile.Trycommentingoutcontrastapplication-itwilllooklikeaboringgolfcourse,since
noisevalueswillkeepbuzzingaroundthemiddle.
97defchoose_tile(val)98caseval99when0.0..0.3#30%chance100@water101when0.3..0.45#15%chance,wateredges102@sand103else#55%chance104@grass105end106end
Herewecouldgocrazyifwehadmoredifferenttilesto
use.Wecouldadddeepwatersat0.0..0.1,mountainsat0.9..0.95andsnowcapsat0.95..1.0.Andallthiswouldhavebeautifultransitions.
PlayerMovementWithKeyboardAndMouseWehavelearnedtodrawmaps,butweneeda
protagonisttoexplorethem.ItwillbeatankthatyoucanmovearoundtheislandwithWASDkeysanduseyourmousetotargetit’sgunatthings.Thetankwillbedrawnontopofourislandmap,anditwillbeaboveground,butbelowtreelayer,soitcansneakbehindpalmtrees.That’sasclosetorealdealasitgets!02-warmup/player_movement.rb
1require'gosu'2require'gosu_tiled'3require'gosu_texture_packer'45classTank6attr_accessor:x,:y,:body_angle,:gun_angle78definitialize(window,body,shadow,gun)9@x=window.width/210@y=window.height/211@window=window12@body=body13@shadow=shadow14@gun=gun15@body_angle=0.016@gun_angle=0.017end18
19defupdate20atan=Math.atan2(320-@window.mouse_x,21240-@window.mouse_y)22@gun_angle=-atan*180/Math::PI23@body_angle=change_angle(@body_angle,24Gosu::KbW,Gosu::KbS,Gosu::KbA,Gosu::KbD)25end2627defdraw28@shadow.draw_rot(@x-1,@y-1,0,@body_angle)29@body.draw_rot(@x,@y,1,@body_angle)30@gun.draw_rot(@x,@y,2,@gun_angle)31end
3233private3435defchange_angle(previous_angle,up,down,right,left)36if@window.button_down?(up)37angle=0.038angle+=45.0if@window.button_down?(left)39angle-=45.0if@window.button_down?(right)40elsif@window.button_down?(down)41angle=180.042angle-=45.0if@window.button_down?(left)43angle+=45.0if@window.button_down?(right)44elsif
@window.button_down?(left)45angle=90.046angle+=45.0if@window.button_down?(up)47angle-=45.0if@window.button_down?(down)48elsif@window.button_down?(right)49angle=270.050angle-=45.0if@window.button_down?(up)51angle+=45.0if@window.button_down?(down)52end53angle||previous_angle54end55end5657classGameWindow<Gosu::Window58MAP_FILE=
File.join(File.dirname(59__FILE__),'island.json')60UNIT_FILE=File.join(File.dirname(File.dirname
61__FILE__)),'media','ground_units.json')62SPEED=56364definitialize65super(640,480,false)66@map=Gosu::Tiled.load_json(self,MAP_FILE)67@units=Gosu::TexturePacker.load_json(68self,UNIT_FILE,:precise)69@tank=Tank.new(self,70
@units.frame('tank1_body.png'),71@units.frame('tank1_body_shadow.png'
72@units.frame('tank1_dualgun.png'
73@x=@y=074@first_render=true75@buttons_down=076end7778defneeds_cursor?79true80end8182defbutton_down(id)83closeifid==Gosu::KbEscape84@buttons_down+=185end
8687defbutton_up(id)88@buttons_down-=189end9091defupdate92@x-=SPEEDifbutton_down?(Gosu::KbA)93@x+=SPEEDifbutton_down?(Gosu::KbD)94@y-=SPEEDifbutton_down?(Gosu::KbW)95@y+=SPEEDifbutton_down?(Gosu::KbS)96@tank.update97self.caption="#{Gosu.fps}FPS."<<98'UseWASDandmousetocontroltank'99end100
101defdraw102@first_render=false103@map.draw(@x,@y)104@tank.draw()105end106end107108GameWindow.new.show
Tankspriteisrenderedinthemiddleofscreen.Itconsistsofthreelayers,bodyshadow,bodyandgun.Bodyandit’sshadowarealwaysrenderedinsameangle,oneontopofanother.Theangleis
determinedbykeysthatarepressed.Itsupports8directions.
Gunisalittlebitdifferent.Itfollowsmousecursor.Todeterminetheanglewehadtousesomemath.Theformulatogetangleindegreesisarctan(delta_x/delta_y)
*180/PI.Youcanseeitexplainedinmoredetailonstackoverflow.
Runitandstrollaroundtheisland.Youcanstillmoveonwaterandintothedarkness,awayfromthemapitself,butwewillhandleitlater.
$ruby02-warmup/player_movement.rb
Seethattankhidingbetweenthebushes,readytogoin8directionsandblowthingsupwiththatpreciselyaimeddoublecannon?
Bynowwemaystartrealizing,thatthereisonekeycomponentmissinginourdesigns.Wehaveavirtualmap,whichisbiggerthanourscreenspace,andweshouldperformallcalculationsusingthatmap,andonlythencutouttherequiredpieceandrenderitinourgamewindow.
Therearethreedifferentcoordinatesystemsthathave
Thisiswherealllogicwillhappen.Playerlocation,enemylocations,poweruplocations-allthiswillhavegamecoordinates,anditshouldhavenothingtodowithyourscreenposition.
ViewportCoordinatesViewportisthepositionofvirtualcamera,thatis“filming”worldinaction.Don’tconfuseitwithscreen
coordinates,becauseviewportwillnotnecessarilybemappedpixeltopixeltoyourgamewindow.Imaginethis:youhaveahugeworldmap,yourplayerisstandinginthemiddle,andgamewindowdisplaystheplayerwhileslowlyzoomingin.Inthisscenario,viewportisconstantlyshrinking,whilegamemapstaysthesame,andgamewindowalsostaysthesame.
ScreenCoordinatesThisisyourgamedisplay,pixelbypixel.Youwilldrawstaticinformation,likeyourHUDdirectlyonit.
HowToPutItAllTogetherInourgameswewillwanttoseparategamecoordinatesfromviewportandscreenasmuchaspossible.Basically,wewillprogramourselvesa“cameraman”whowillbe
busyfollowingtheaction,zoominginandout,perhapschangingtheviewanglenowandthen.
Let’simplementaprototypethatwillallowustonavigateandzoomaroundabigmap.Wewillonlydrawobjectsthatarevisibleinviewport.Somemathwillbeunavoidable,butinmostcasesit’sprettybasic-that’sthebeautyof2Dgames:
02-warmup/coordinate_system.rb1require'gosu'23classWorldMap4attr_accessor:on_screen,:off_screen56definitialize(width,height)7@images={}8(0..width).step(50)do|x|9@images[x]={}10(0..height).step(50)do|y|11img=Gosu::Image.from_text(12$window,"#{x}:#{y}",13
Gosu.default_font_name,15)14@images[x][y]=img15end16end17end1819defdraw(camera)20@on_screen=@off_screen=021@images.eachdo|x,row|22row.eachdo|y,val|23ifcamera.can_view?(x,y,val)24val.draw(x,y,0)25@on_screen+=126else27@off_screen+=128end29end30end
31end32end3334classCamera35attr_accessor:x,:y,:zoom3637definitialize38@x=@y=039@zoom=140end4142defcan_view?(x,y,obj)43x0,x1,y0,y1=viewport44(x0-obj.width..x1).include?(x)&&45(y0-obj.height..y1).include?(y)46end47
48defviewport49x0=@x-($window.width/2)/@zoom50x1=@x+($window.width/2)/@zoom51y0=@y-($window.height/2)/@zoom52y1=@y+($window.height/2)/@zoom53[x0,x1,y0,y1]54end5556defto_s57"FPS:#{Gosu.fps}."<<58"#{@x}:#{@y}@#{'%.2f'%@zoom}."<<59'WASDtomove,arrowstozoom.'60end6162defdraw_crosshair
63$window.draw_line(64@x-10,@y,Gosu::Color::YELLOW,65@x+10,@y,Gosu::Color::YELLOW,100)66$window.draw_line(67@x,@y-10,Gosu::Color::YELLOW,68@x,@y+10,Gosu::Color::YELLOW,100)69end70end717273classGameWindow<Gosu::Window74SPEED=107576definitialize77super(800,600,false)78$window=self
79@map=WorldMap.new(2048,1024)80@camera=Camera.new81end8283defbutton_down(id)84closeifid==Gosu::KbEscape85ifid==Gosu::KbSpace86@camera.zoom=1.087@camera.x=088@camera.y=089end90end9192defupdate93@camera.x-=SPEEDifbutton_down?(Gosu::KbA)94@camera.x+=SPEEDifbutton_down?(Gosu::KbD)95@camera.y-=SPEEDif
button_down?(Gosu::KbW)96@camera.y+=SPEEDifbutton_down?(Gosu::KbS)9798zoom_delta=@camera.zoom>0?0.01:1.099100ifbutton_down?(Gosu::KbUp)101@camera.zoom-=zoom_delta102end103ifbutton_down?(Gosu::KbDown)104@camera.zoom+=zoom_delta105end106self.caption=@camera.to_s107end108
109defdraw110off_x=-@camera.x+width/2111off_y=-@camera.y+height/2112cam_x=@camera.x113cam_y=@camera.y114translate(off_x,off_y)do115@camera.draw_crosshair116zoom=@camera.zoom117scale(zoom,zoom,cam_x,cam_y)do118@map.draw(@camera)119end120end121info='Objectson/offscreen:'<<122"#{@map.on_screen}/#{@map.off_screen}"
123info_img=Gosu::Image.from_text(124self,info,Gosu.default_font_name,30)125info_img.draw(10,10,1)126end127end128129GameWindow.new.show
Runit,useWASDtonavigate,up/downarrowstozoomandspacebartoresetthecamera.
$ruby02-warmup/coordinate_system.rb
Itdoesn’tlookimpressive,butunderstandingtheconceptofdifferentcoordinatesystemsandbeingabletostitchthemtogetherisparamounttothesuccessofourfinalproduct.
Prototypeofseparatecoordinatesystems
Luckilyforus,GosuhelpsusbyprovidingGosu::Window#translate
thathandlescameraoffset,Gosu::Window#scalethataidszooming,andGosu::Window#rotatethatwasnotusedyet,butwillbegreatforshakingtheviewtoemphasizeexplosions.
PrototypingTheGame
Warmingupwasreallyimportant,butlet’scombineeverythingwelearned,addsomenewchallenges,andbuildasmallprototypewithfollowingfeatures:
1. Cameralooselyfollowstank.
2. Camerazoomsautomaticallydependingontankspeed.
3. Youcantemporarilyoverrideautomaticcamerazoomusingkeyboard.
4. Musicandsoundeffects.5. Randomlygenerated
map.6. Twomodes:menuand
gameplay.
7. TankmovementwithWADSkeys.
8. Tankaimingandshootingwithmouse.
9. Collisiondetection(tanksdon’tswim).
10. Explosions,visiblebullettrajectories.
11. Bulletrangelimiting.
Soundsfun?Hellyes!However,beforewestart,weshouldplanaheadalittleandthinkhowourgame
architecturewilllooklike.Wewillalsostructureourcodealittle,soitwillnotbesmashedintoonerubyclass,aswedidinearlierexamples.Booksshouldshowgoodmanners!
SwitchingBetweenGameStatesFirst,let’sthinkhowtohookintoGosu::Window.Sincewe
willhavetwogamestates,Statepatternnaturallycomestomind.
So,ourGameWindowclasscouldlooklikethis:03-prototype/game_window.rb1classGameWindow<Gosu::Window23attr_accessor:state45definitialize6super(800,600,false)7end8
9defupdate10@state.update11end1213defdraw14@state.draw15end1617defneeds_redraw?18@state.needs_redraw?19end2021defbutton_down(id)22@state.button_down(id)23end2425end
Ithascurrent@state,andallusualmainloopactionsareexecutedonthatstateinstance.Wewilladdbaseclassthatallgamestateswillextend.Let’snameitGameState:03-prototype/states/game_state.rb1classGameState23defself.switch(new_state)4$window.state&&$window.state.leave5$window.state=new_state6new_state.enter
7end89defenter10end1112defleave13end1415defdraw16end1718defupdate19end2021defneeds_redraw?22true23end2425defbutton_down(id)26end27end
ThisclassprovidesGameState.switch,thatwillchangethestateforourGosu::Window,andallenterandleavemethodswhenappropriate.Thesemethodswillbeusefulforthingslikeswitchingmusic.
NoticethatGosu::Windowisaccessedusingglobal$windowvariable,whichwill
beconsideredananti-patternbymostgoodprogrammers,butthereissomelogicbehindthis:
1. TherewillbeonlyoneGosu::Windowinstance.
2. Itlivesaslongasthegameruns.
3. Itisusedinsomewaybynearlyallotherclasses,sowewouldhavetopassitaroundallthetime.
4. AccessingitusingSingletonorstaticutilityclasswouldnotgiveanyclearbenefits,justaddmorecomplexity.
Chingu,anothergameframeworkbuiltontopofGosu,alsousesglobal$window,soit’sprobablynottheworstideaever.
Wewillalsoneedanentrypointthatwouldfireupthe
gameandenterthefirstgamestate-themenu.03-prototype/main.rb1require'gosu'2require_relative'states/game_state'3require_relative'states/menu_state'4require_relative'states/play_state'5require_relative'game_window'67moduleGame8defself.media_path(file)9File.join(File.dirname(File.dirname
10__FILE__)),'media',file)11end12end1314$window=GameWindow.new15GameState.switch(MenuState.instance
16$window.show
InourentrypointwealsohaveasmallhelperwhichwillhelploadingimagesandsoundsusingGame.media_path.
Therestisobvious:wecreateGameWindowinstanceandstoreitin$windowvariable,asdiscussedbefore.ThenweuseGameState.switch)toloadMenuState,andshowthegamewindow.
ImplementingMenuStateThisishowsimpleMenuStateimplementation
lookslike:03-prototype/states/menu_state.rb1require'singleton'2classMenuState<GameState3includeSingleton4attr_accessor:play_state56definitialize7@message=Gosu::Image.from_text(8$window,"TanksPrototype",9Gosu.default_font_name,100)10end1112defenter13music.play(true)14music.volume=1
15end1617defleave18music.volume=019music.stop20end2122defmusic23@@music||=Gosu::Song.new(24$window,Game.media_path('menu_music.mp3'
25end2627defupdate28continue_text=@play_state?"C=Continue,":""29@info=Gosu::Image.from_text(
30$window,"Q=Quit,#{continue_text}N=NewGame",31Gosu.default_font_name,30)32end3334defdraw35@message.draw(36$window.width/2-@message.width/2,37$window.height/2-@message.height/2,3810)39@info.draw(40$window.width/2-@info.width/2,41$window.height/2-@info.height/2+200,4210)43end44
45defbutton_down(id)46$window.closeifid==Gosu::KbQ47ifid==Gosu::KbC&&@play_state48GameState.switch(@play_state)49end50ifid==Gosu::KbN51@play_state=PlayState.new52GameState.switch(@play_state)53end54end55end
It’saSingleton,sowecanalwaysgetitwith
MenuState.instance.
Itstartsplayingmenu_music.mp3whenyouenterthemenu,andstopthemusicwhenyouleaveit.InstanceofGosu::Songiscachedin@@musicclassvariabletosaveresources.
Wehavetoknowifplayisalreadyinprogress,sowecanaddapossibilitytogobacktothegame.That’swhy
MenuStatehas@play_statevariable,andeitherallowscreatingnewPlayStatewhenNkeyispressed,orswitchestoexisting@play_stateifCkeyispressed.
Herecomestheinterestingpart,implementingtheplaystate.
ImplementingPlayStateBeforewestartimplementingactualgameplay,weneedtothinkwhatgameentitieswewillbebuilding.WewillneedaMapthatwillholdourtilesandprovideworldcoordinatesystem.WewillalsoneedaCamerathatwillknowhowtofloataroundandzoom.TherewillbeBullets
flyingaround,andeachbulletwilleventuallycauseanExplosion.
Havingallthattakencareof,PlayStateshouldlookprettysimple:03-prototype/states/play_state.rb1require_relative'../entities/map'2require_relative'../entities/tank'3require_relative'../entities/camera'4require_relative'../entities/bullet'
5require_relative'../entities/explosion'6classPlayState<GameState78definitialize9@map=Map.new10@tank=Tank.new(@map)11@camera=Camera.new(@tank)12@bullets=[]13@explosions=[]14end1516defupdate17bullet=@tank.update(@camera)18@bullets<<bulletifbullet19@bullets.map(&:update)20@bullets.reject!(&:done?)
21@camera.update22$window.caption='TanksPrototype.'<<23"[FPS:#{Gosu.fps}.Tank@#{@tank.x.round}:#{@tank.y.round}]"24end2526defdraw27cam_x=@camera.x28cam_y=@camera.y29off_x=$window.width/2-cam_x30off_y=$window.height/2-cam_y31$window.translate(off_x,off_y)do32zoom=@camera.zoom33$window.scale(zoom,zoom,cam_x,cam_y)do34@map.draw(@camera)
35@tank.draw36@bullets.map(&:draw)37end38end39@camera.draw_crosshair40end4142defbutton_down(id)43ifid==Gosu::MsLeft44bullet=@tank.shoot(*@camera.mouse_coords
45@bullets<<bulletifbullet46end47$window.closeifid==Gosu::KbQ48ifid==Gosu::KbEscape49GameState.switch(MenuState.instance
50end51end5253end
Updateanddrawcallsarepassedtotheunderlyinggameentities,sotheycanhandlethemthewaytheywantitto.Suchencapsulationreducescomplexityofthecodeandallowsdoingeverypieceoflogicwhereit
belongs,whilekeepingitshortandsimple.
Thereareafewinterestingpartsinthiscode.Both@tank.updateand@tank.shootmayproduceanewbullet,ifyourtank’sfirerateisnotexceeded,andifleftmousebuttoniskeptdown,hencetheupdate.Ifbulletisproduced,itisaddedto@bulletsarray,andtheylivetheirownlittlelifecycle,
untiltheyexplodeandarenolongerused.@bullets.reject!(&:done?)
cleansupthegarbage.
PlayState#drawdeservesextraexplanation.@camera.xand@camera.ypointstogamecoordinateswhereCameraiscurrentlylookingat.Gosu::Window#translatecreatesablockwithinwhichallGosu::Imagedrawoperationsaretranslatedby
givenoffset.Gosu::Window#scaledoesthesamewithCamerazoom.
Crosshairisdrawnwithouttranslatingandscalingit,becauseit’srelativetoscreen,nottoworldmap.
Basically,thisdrawmethodistheplacethattakescaredrawingonlywhat@cameracansee.
Ifit’shardtounderstandhowthisworks,getbackto“GameCoordinateSystem”chapterandletitsinkin.
ImplementingWorldMapWewillstartanalyzinggameentitieswithMap.03-prototype/entities/map.rb1require'perlin_noise'2require'gosu_texture_packer'
34classMap5MAP_WIDTH=1006MAP_HEIGHT=1007TILE_SIZE=12889definitialize10load_tiles11@map=generate_map12end1314deffind_spawn_point15whiletrue16x=rand(0..MAP_WIDTH*TILE_SIZE)17y=rand(0..MAP_HEIGHT*TILE_SIZE)18ifcan_move_to?(x,y)19return[x,y]20else21puts"Invalidspawn
point:#{[x,y]}"22end23end24end2526defcan_move_to?(x,y)27tile=tile_at(x,y)28tile&&tile!=@water29end3031defdraw(camera)32@map.eachdo|x,row|33row.eachdo|y,val|34tile=@map[x][y]35map_x=x*TILE_SIZE36map_y=y*TILE_SIZE37ifcamera.can_view?(map_x,map_y,tile)38tile.draw(map_x,
map_y,0)39end40end41end42end4344private4546deftile_at(x,y)47t_x=((x/TILE_SIZE)%TILE_SIZE).floor48t_y=((y/TILE_SIZE)%TILE_SIZE).floor49row=@map[t_x]50row[t_y]ifrow51end5253defload_tiles54tiles=Gosu::Image.load_tiles(55$window,
Game.media_path('ground.png'),56128,128,true)57@sand=tiles[0]58@grass=tiles[8]59@water=Gosu::Image.new(60$window,Game.media_path('water.png'),true)61end6263defgenerate_map64noises=Perlin::Noise.new(2)65contrast=Perlin::Curve.contrast(66Perlin::Curve::CUBIC,2)67map={}68MAP_WIDTH.timesdo|x|69map[x]={}
70MAP_HEIGHT.timesdo|y|71n=noises[x*0.1,y*0.1]72n=contrast.call(n)73map[x][y]=choose_tile(n)74end75end76map77end7879defchoose_tile(val)80caseval81when0.0..0.3#30%chance82@water83when0.3..0.45#15%chance,wateredges84@sand85else#55%chance
86@grass87end88end89end
ThisimplementationisverysimilartotheMapwehadbuiltin“GeneratingRandomMapWithPerlinNoise”,withsomeextraadditions.can_move_to?verifiesiftileundergivencoordinatesisnotwater.Prettysimple,butit’senoughforourprototype.
Also,whenwedrawthemapwehavetomakesureiftileswearedrawingarecurrentlyvisiblebyourcamera,otherwisewewillendupdrawingoffscreen.camera.can_view?handlesit.Currentimplementationwillprobablybecausingabottleneck,sinceitbruteforcesthroughallthemapratherthancherry-pickingthevisibleregion.Wewill
probablyhavetogetbackandchangeitlater.
find_spawn_pointisonemoreaddition.Itkeepspickingarandompointonmapandverifiesifit’snotwaterusingcan_move_to?.Whensolidtileisfound,itreturnsthecoordinates,soourTankwillbeabletospawnthere.
ImplementingFloatingCameraIfyouplayedtheoriginalGrandTheftAutoorGTA2,youshouldrememberhowfascinatingthecamerawas.Itbackedawaywhenyouweredrivingathighspeeds,closedinwhenyouwerewalkingonfoot,andfloatedaroundasifasmartdronewasfollowingyourprotagonistfromabove.
ThefollowingCameraimplementationisfarinferiortotheoneGTAhadnearlytwodecadesago,butit’sastart:03-prototype/entities/camera.rb1classCamera2attr_accessor:x,:y,:zoom34definitialize(target)5@target=target6@x,@y=target.x,target.y7@zoom=18end9
10defcan_view?(x,y,obj)11x0,x1,y0,y1=viewport12(x0-obj.width..x1).include?(x)&&13(y0-obj.height..y1).include?(y)14end1516defmouse_coords17x,y=target_delta_on_screen18mouse_x_on_map=@target.x+19(x+$window.mouse_x-($window.width/2))/@zoom20mouse_y_on_map=@target.y+21(y+$window.mouse_y-($window.height/2))/@zoom22[mouse_x_on_map,
mouse_y_on_map].map(&:round)23end2425defupdate26@x+=@target.speedif@x<@target.x-$window.width/427@x-=@target.speedif@x>@target.x+$window.width/428@y+=@target.speedif@y<@target.y-$window.height/429@y-=@target.speedif@y>@target.y+$window.height/43031zoom_delta=@zoom>0?0.01:1.032if$window.button_down?(Gosu::KbUp)
33@zoom-=zoom_deltaunless@zoom<0.734elsif$window.button_down?(Gosu::KbDown)35@zoom+=zoom_deltaunless@zoom>1036else37target_zoom=@target.speed>1.1?0.85:1.038if@zoom<=(target_zoom-0.01)39@zoom+=zoom_delta/340elsif@zoom>(target_zoom+0.01)41@zoom-=zoom_delta/342end43end
44end4546defto_s47"FPS:#{Gosu.fps}."<<48"#{@x}:#{@y}@#{'%.2f'%@zoom}."<<49'WASDtomove,arrowstozoom.'50end5152deftarget_delta_on_screen53[(@x-@target.x)*@zoom,(@y-@target.y)*@zoom]54end5556defdraw_crosshair57x=$window.mouse_x58y=$window.mouse_y59$window.draw_line(60x-10,y,
Gosu::Color::RED,61x+10,y,Gosu::Color::RED,100)62$window.draw_line(63x,y-10,Gosu::Color::RED,64x,y+10,Gosu::Color::RED,100)65end6667private6869defviewport70x0=@x-($window.width/2)/@zoom71x1=@x+($window.width/2)/@zoom72y0=@y-($window.height/2)/@zoom73y1=@y+($window.height/2)/@zoom
74[x0,x1,y0,y1]75end76end
OurCamerahas@targetthatittriestofollow,@xand@ythatitcurrentlyislookingat,and@zoomlevel.
Allthemagichappensinupdatemethod.Itkeepstrackofthedistancebetween@targetandadjustitselftostaynearby.Andwhen
@target.speedshowssomemovementmomentum,cameraslowlybacksaway.
Cameraalsotelsifyoucan_view?anobjectatsomecoordinates,sowhenotherentitiesdrawthemselves,theycancheckifthereisaneedforthat.
Anothernoteworthymethodismouse_coords.Ittranslatesmousepositiononscreento
mousepositiononmap,sothegamewillknowwhereyouaretargetingyourguns.
ImplementingTheTankMostofourtankcodewillbetakenfrom“PlayerMovementWithKeyboardAndMouse”:03-prototype/entities/tank.rb
1classTank2attr_accessor:x,:y,:body_angle,:gun_angle3SHOOT_DELAY=50045definitialize(map)6@map=map7@units=Gosu::TexturePacker.load_json(8$window,Game.media_path('ground_units.json':precise)9@body=@units.frame('tank1_body.png')10@shadow=@units.frame('tank1_body_shadow.png'
11@gun=@units.frame('tank1_dualgun.png'
12@x,@y=
@map.find_spawn_point13@body_angle=0.014@gun_angle=0.015@last_shot=016sound.volume=0.317end1819defsound20@@sound||=Gosu::Song.new(21$window,Game.media_path('tank_driving.mp3'
22end2324defshoot(target_x,target_y)25ifGosu.milliseconds-@last_shot>SHOOT_DELAY26@last_shot=Gosu.milliseconds
27Bullet.new(@x,@y,target_x,target_y).fire(100)28end29end3031defupdate(camera)32d_x,d_y=camera.target_delta_on_screen33atan=Math.atan2(($window.width/2)-d_x-$window.mouse_x,34($window.height/2)-d_y-$window.mouse_y)35@gun_angle=-atan*180/Math::PI36new_x,new_y=@x,@y37new_x-=speedif$window.button_down?(Gosu::KbA)38new_x+=speedif$window.button_down?(Gosu::KbD)
39new_y-=speedif$window.button_down?(Gosu::KbW)40new_y+=speedif$window.button_down?(Gosu::KbS)41if@map.can_move_to?(new_x,new_y)42@x,@y=new_x,new_y43else44@speed=1.045end46@body_angle=change_angle(@body_angle,47Gosu::KbW,Gosu::KbS,Gosu::KbA,Gosu::KbD)4849ifmoving?50sound.play(true)51else52sound.pause53end54
55if$window.button_down?(Gosu::MsLeft)56shoot(*camera.mouse_coords)57end58end5960defmoving?61any_button_down?(Gosu::KbA,Gosu::KbD,Gosu::KbW,Gosu::KbS)62end6364defdraw65@shadow.draw_rot(@x-1,@y-1,0,@body_angle)66@body.draw_rot(@x,@y,1,@body_angle)67@gun.draw_rot(@x,@y,2,@gun_angle)68end
6970defspeed71@speed||=1.072ifmoving?73@speed+=0.03if@speed<574else75@speed=1.076end77@speed78end7980private8182defany_button_down?(*buttons)83buttons.eachdo|b|84returntrueif$window.button_down?(b)85end86false
87end8889defchange_angle(previous_angle,up,down,right,left)90if$window.button_down?(up)91angle=0.092angle+=45.0if$window.button_down?(left)93angle-=45.0if$window.button_down?(right)94elsif$window.button_down?(down)95angle=180.096angle-=45.0if$window.button_down?(left)97angle+=45.0if$window.button_down?(right)98elsif$window.button_down?(left)
99angle=90.0100angle+=45.0if$window.button_down?(up)101angle-=45.0if$window.button_down?(down)102elsif$window.button_down?(right)103angle=270.0104angle-=45.0if$window.button_down?(up)105angle+=45.0if$window.button_down?(down)106end107angle||previous_angle108end109end
TankhastobeawareoftheMaptocheckwhereit’s
moving,anditusesCameratofindoutwheretoaimtheguns.Whenitshoots,itproducesinstancesofBullet,thataresimplyreturnedtothecaller.Tankwon’tkeeptrackofthem,it’s“fireandforget”.
ImplementingBulletsAndExplosionsBulletswillrequiresomesimplevectormath.Youhave
apointthatmovesalongthevectorwithsomespeed.Italsoneedstolimitthemaximumvectorlength,soifyoutrytoaimtoofar,thebulletwillonlygoasfarasitcanreach.03-prototype/entities/bullet.rb1classBullet2COLOR=Gosu::Color::BLACK3MAX_DIST=3004START_DIST=2056definitialize(source_x,source_y,target_x,target_y)7@x,@y=source_x,
source_y8@target_x,@target_y=target_x,target_y9@x,@y=point_at_distance(START_DIST)10iftrajectory_length>MAX_DIST11@target_x,@target_y=point_at_distance(MAX_DIST)12end13sound.play14end1516defdraw17unlessarrived?18$window.draw_quad(@x-2,@y-2,COLOR,19@x+2,@y-2,COLOR,20@x-2,@y+2,COLOR,
21@x+2,@y+2,COLOR,221)23else24@explosion||=Explosion.new(@x,@y)25@explosion.draw26end27end2829defupdate30fly_distance=(Gosu.milliseconds-@fired_at)*0.001*@speed31@x,@y=point_at_distance(fly_distance)32@explosion&&@explosion.update33end3435defarrived?
36@x==@target_x&&@y==@target_y37end3839defdone?40exploaded?41end4243defexploaded?44@explosion&&@explosion.done?45end4647deffire(speed)48@speed=speed49@fired_at=Gosu.milliseconds50self51end5253private
5455defsound56@@sound||=Gosu::Sample.new(57$window,Game.media_path('fire.mp3'))58end5960deftrajectory_length61d_x=@target_x-@x62d_y=@target_y-@y63Math.sqrt(d_x*d_x+d_y*d_y)64end6566defpoint_at_distance(distance)67return[@target_x,@target_y]ifdistance>trajectory_length68distance_factor=
distance.to_f/trajectory_length69p_x=@x+(@target_x-@x)*distance_factor70p_y=@y+(@target_y-@y)*distance_factor71[p_x,p_y]72end73end
PossiblythemostinterestingpartofBulletimplementationispoint_at_distancemethod.Itreturnscoordinatesofpointthatisbetweenbulletsource,
whichispointthatbulletwasfiredfrom,andit’starget,whichisthedestinationpoint.Thereturnedpointisasfarawayfromsourcepointasdistancetellsitto.
Afterbullethasdoneflying,itexplodeswithfanfare.InourprototypeExplosionisapartofBullet,becauseit’stheonlythingthattriggersit.ThereforeBullethastwostagesofit’slifecycle.Firstit
fliestowardsthetarget,thenit’sexploding.ThatbringsustoExplosion:03-prototype/entities/explosion.rb1classExplosion2FRAME_DELAY=10#ms34defanimation5@@animation||=6Gosu::Image.load_tiles(7$window,Game.media_path('explosion.png'),128,128,false)8end910defsound11@@sound||=Gosu::Sample.new(
12$window,Game.media_path('explosion.mp3'))
13end1415definitialize(x,y)16sound.play17@x,@y=x,y18@current_frame=019end2021defupdate22@current_frame+=1ifframe_expired?23end2425defdraw26returnifdone?27image=current_frame28image.draw(29@x-image.width/2+
3,30@y-image.height/2-35,3120)32end3334defdone?35@done||=@current_frame==animation.size36end3738private3940defcurrent_frame41animation[@current_frame%animation.size]42end4344defframe_expired?45now=Gosu.milliseconds46@last_frame||=now
47if(now-@last_frame)>FRAME_DELAY48@last_frame=now49end50end51end
Thereisnothingfancyaboutthisimplementation.Mostofitistakenfrom“ImagesAndAnimation”chapter.
RunningThePrototype
Wehavewalkedthroughallthecode.YoucangetitatGitHub.
Nowit’stimetogiveitaspin.ThereisavideoofmeplayingitavailableonYouTube,butit’salwaysbesttoexperienceitfirsthand.Runmain.rbtostartthegame:
$ruby03-prototype/main.rb
TanksPrototypegameplay
Onethingshouldbebuggingyouatthispoint.FPSshowsonly30,ratherthan60.Thatmeansourprototypeisslow.
OptimizingGamePerformance
Tomakegamesthatarefastanddon’trequireapowerhousetorun,wemustlearnhowtofindandfixbottlenecks.Goodnewsisthatifyouwasn’tthinking
aboutperformancetobeginwith,yourprogramcanusuallybeoptimizedtoruntwiceasfastjustbyeliminatingoneortwobiggestbottlenecks.
Wewillbeusingacopyoftheprototypecodetokeepbothoptimizedandoriginalversion,thereforeifyouareexploringsamplecode,lookat04-prototype-optimized.
ProfilingRubyCodeToFindBottlenecksWewilltrytofindbottlenecksinourTanksprototypegamebyprofilingitwithruby-prof.
It’sarubygem,justinstallitlikethis:
$geminstallruby-prof
Thereareseveralwaysyoucanuseruby-prof,sowewillbeginwiththeeasiestone.Insteadofrunningthegamewithruby,wewillrunitwithruby-prof:
$ruby-prof03-prototype/main.rb
Thegamewillrun,buteverythingwillbetentimesslowerasusual,becauseeverycalltoeveryfunctionis
beingrecorded,andafteryouexittheprogram,profilingoutputwillbedumpeddirectlytoyourconsole.
Downsideofthisapproachisthatwearegoingtoprofileeverythingthereis,includingthesuper-slowmapgenerationthatusesPerlinNoise.Wedon’twanttooptimizethat,soinordertofindbottlenecksinourplaystateratherthanmap
generation,wehavetokeepplayingatdreadful2FPSforatleast30seconds.
Thiswastheoutputoffirst“naive”profilingsession:
Initialprofilingresults
It’sobvious,thatCamera#viewportandCamera#can_view?aretopCPUburners.Thismeanseitherthatour
implementationiseitherverybad,ortheassumptionthatcheckingifcameracanviewobjectisslowerthandrawingtheobjectoffscreen.
Herearethoseslowmethods:
classCamera#...defcan_view?(x,y,obj)x0,x1,y0,y1=viewport(x0-obj.width..x1).include?(x)&&(y0-obj.height..y1).include?(y)end
#...defviewportx0=@x-($window.width/2)/@zoomx1=@x+($window.width/2)/@zoomy0=@y-($window.height/2)/@zoomy1=@y+($window.height/2)/@zoom[x0,x1,y0,y1]end#...end
Itdoesn’tlookfundamentallybroken,sowewilltryour“checkingisslowerthan
rendering”hypothesisbyshort-circuitingcan_view?toreturntrueeverytime:
classCamera#...defcan_view?(x,y,obj)returntrue#shortcircuitingx0,x1,y0,y1=viewport(x0-obj.width..x1).include?(x)&&(y0-obj.height..y1).include?(y)end#...end
Aftersavingcamera.rbandrunningthegamewithoutprofiling,youwillnoticeasignificantspeedup.Hypothesiswascorrect,checkingvisibilityismoreexpensivethansimplyrenderingit.ThatmeanswecanthrowawayCamera#can_view?andcallstoit.
Butbeforedoingthat,let’sprofileonceagain:
Profilingresultsaftershort-circuitingCamera#can_view?
WecanseeCamera#can_view?isstillintop3,sowewillremoveifcamera.can_view?(map_x,
map_y,tile)fromMap#drawandfornowkeepitlikethis:
classMap#...defdraw(camera)@map.eachdo|x,row|row.eachdo|y,val|tile=@map[x][y]map_x=x*TILE_SIZEmap_y=y*TILE_SIZEtile.draw(map_x,map_y,0)endendend#...end
AftercompletelyremovingCamera#can_view?,profilingsessionlookslikedead-end-nomorelowhangingfruitsontop:
ProfilingresultsafterremovingCamera#can_view?
Thegamestilldoesn’tfeelfastenough,FPSoccasionallykeepsdroppingdownto~45,
sowewillhavetodoprofileourcodeinsmarterway.
AdvancedProfilingTechniquesWewouldgetmoreaccuracywhenprofilingonlywhatwewanttooptimize.InourcaseitiseverythingthathappensinPlayState,exceptforMapgeneration.Thistimewewill
havetouseruby-profAPItohookintoplacesweneed.
MapgenerationhappensinPlayStateinitializer,sowewillleverageGameState#enterandGameState#leavetostartandstopprofiling,sinceithappensafterstateisinitialized.Hereishowwehookin:
require'ruby-prof'classPlayState<GameState
#...defenterRubyProf.startend
defleaveresult=RubyProf.stopprinter=RubyProf::FlatPrinter.new(result
printer.print(STDOUT)end#...end
Thenwerunthegameasusual:
$ruby04-prototype-optimized/main.rb
Now,afterwepressNtostartnewgame,Mapgenerationhappensrelativelyfast,andthenprofilingkicksin,FPSdropsto15.AftermovingaroundandshootingforawhilewehitEsctoreturntothemenu,andatthatpointPlayState#leavespitsprofilingresultsouttotheconsole:
ProfilingresultsforPlayState
WecanseethatGosu::Image#drawtakesupto20%ofallexecutiontime.ThengoesGosu::Window#caption,but
weneedittomeasureFPS,sowewillleaveitalone,andfinallywecanseeHash#each,whichisguaranteedtobetheonefromMap#draw,andittriggersallthoseGosu::Image#drawcalls.
OptimizingInefficientCodeAccordingtoprofilingresults,weneedtooptimize
thismethod:
classMap#...defdraw(camera)@map.eachdo|x,row|row.eachdo|y,val|tile=@map[x][y]map_x=x*TILE_SIZEmap_y=y*TILE_SIZEtile.draw(map_x,map_y,0)endendend#...end
Butwehavetooptimizeitinmorecleverwaythanwedidbefore.Ifinsteadofloopingthroughallmaprowsandcolumnsandblindlyrenderingeverytileorcheckingiftileisvisiblewecouldcalculatetheexactmapcellsthatneedtobedisplayed,wewouldreducemethodcomplexityandgetmajorperformanceboost.Let’sdothat.
WewilluseCamera#viewporttoreturnmapboundariesthatarevisiblebycamera,thendividethoseboundariesbyMap#TILE_SIZEtogettilenumbersinsteadofpixels,andretrievethemfromthemap.
classMap#...defdraw(camera)viewport=camera.viewportviewport.map!{|p|p/TILE_SIZE}
x0,x1,y0,y1=viewport.map(&:to_i)(x0..x1).eachdo|x|(y0..y1).eachdo|y|row=@map[x]ifrowtile=@map[x][y]map_x=x*TILE_SIZEmap_y=y*TILE_SIZEtile.draw(map_x,map_y,0)endendendend
Thisoptimizationyieldedastoundingresults.Wearenowgettingnearlystable60
ProfilingresultsforPlayStateafterMap#drawoptimization
NowwejusthavetodosomethingaboutthatGosu::Window#caption,becauseitisconsuming1/3
ofourCPUcycles!Eventhoughgameisalreadyflyingsofastthatwewillhavetoreducetankandbulletspeedstomakeitlookmorerealistic,wecannotletourselvesleavethislowhangingfruitremainunpicked.
Wewillupdatethecaptiononcepersecond,itshouldremovethebottleneck:
classPlayState<GameState#...
defupdate#...update_captionend#...private
defupdate_captionnow=Gosu.millisecondsifnow-(@caption_updated_at||0)>1000$window.caption='TanksPrototype.'<<"[FPS:#{Gosu.fps}."<<"Tank@#{@tank.x.round}:#{@tank.y.round}]"@caption_updated_at=nowend
endend
Nowit’sgettinghardtogetFPStodropbelow58,andprofilingresultsshowthattherearenomorebottlenecks:
ProfilingOnDemandWhenyoudevelopagame,youmaywanttoturnonprofilingnowandthen.Toavoidcommentingoutoraddingandremovingprofilingeverytimeyouwanttodoso,usethistrick:
#...require'ruby-prof'ifENV['ENABLE_PROFILING']classPlayState<GameState#...defenter
RubyProf.startifENV['ENABLE_PROFILING']end
defleaveifENV['ENABLE_PROFILING']result=RubyProf.stopprinter=RubyProf::FlatPrinter.new(result
printer.print(STDOUT)endend
defbutton_down(id)#...ifid==Gosu::KbQleave$window.closeendend
#...end
Now,toenableprofiling,simplystartyourgamewithENABLE_PROFILING=1
environmentalvariable,likethis:
$ENABLE_PROFILING=1ruby-prof03-prototype/main.rb
AdjustingGameSpeedForVariable
PerformanceYoushouldhavenoticedthatouroptimizedTanksprototyperunswaytoofast.Tanksandbulletsshouldtravelsamedistancenomatterhowfastorslowthecodeis.
OnewouldexpectGosu::Window#update_interval
tobedesignedexactlyforthatpurpose,butitreturns
16.6666inbothoriginalandoptimizedversionoftheprototype,soyoucanguessitisthedesiredinterval,nottheactualone.
Tofindoutactualupdateinterval,wewilluseGosu.millisecondsandcalculateitourselves.Todothat,wewillintroduceGame#track_update_interval
thatwillbecalledinGameWindow#update,and
Game#update_interval
whichwillretrieveactualupdateinterval,sowecanuseittoadjustourrunspeed.
WewillalsoaddGame#adjust_speedmethodthatwilltakearbitraryspeedvalueandshiftitsoisasfastasitwaswhenthegamewasrunningat30FPS.Theformulaissimple,if60FPSexpectstocallGosu::Window#updateevery
16.66ms,ourspeedadjustmentwilldivideactualupdateratefrom33.33,whichroughlyequalsto16.66*2.So,ifbulletwouldfly100pixelsperupdatein30FPS,adjustedspeedwillchangeitto50pixelsat60FPS.
Hereistheimplementation:
#04-prototype-optimized/main.rbmoduleGame
#...defself.track_update_intervalnow=Gosu.milliseconds@update_interval=(now-(@last_update||=0)).to_f@last_update=nowend
defself.update_interval@update_interval||=$window.update_intervalend
defself.adjust_speed(speed)speed*update_interval/33.33endend
#04-prototype-
optimized/game_window.rbclassGameWindow<Gosu::Window#...defupdateGame.track_update_interval@state.updateend#...end
Now,tofixthatspeedproblem,wewillneedtoapplyGame.adjust_speedtotank,bulletandcameramovements.
Hereareallthechangesneededtomakeourgamerunatroughlysamespeedindifferentconditions:
#04-prototype-optimized/entities/tank.rbclassTank#...defupdate(camera)#...shift=Game.adjust_speed(speed)new_x-=shiftif$window.button_down?(Gosu::KbA)new_x+=shiftif$window.button_down?(Gosu::KbD)new_y-=shiftif$window.button_down?(Gosu::KbW)
new_y+=shiftif$window.button_down?(Gosu::KbS)#...end#...end
#04-prototype-optimized/entities/bullet.rbclassBullet#...defupdate#...fly_speed=Game.adjust_speed(@speed)fly_distance=(Gosu.milliseconds-@fired_at)*0.001*fly_speed@x,@y=point_at_distance(fly_distance)#...
end#...end
#04-prototype-optimized/entities/camera.rbclassCamera#...defupdateshift=Game.adjust_speed(@target.speed)
@x+=shiftif@x<@target.x-$window.width/4@x-=shiftif@x>@target.x+$window.width/4@y+=shiftif@y<@target.y-$window.height/4@y-=shiftif@y>@target.y+$window.height/4
zoom_delta=@zoom>0?0.01:1.0zoom_delta=Game.adjust_speed(zoom_delta)#...end#...end
ThereisonemoretricktomakethegameplayableevenatverylowFPS.Youcansimulatesuchconditionsbyaddingsleep0.3toGameWindow#drawmethod.Atthatframerategamecursoris
veryunresponsive,soyoumaywanttostartshowingnativemousecursorwhenthingsgetugly,i.e.whenupdateintervalexceeds200milliseconds:
#04-prototype-optimized/game_window.rbclassGameWindow<Gosu::Window#...defneeds_cursor?Game.update_interval>200end#...end
FrameSkippingYouwillseestrangethingshappeningatverylowframerates.Forexample,bulletexplosionsareshowingupframebyframe,soexplosionspeedseemswaytooslowandunrealistic.Toavoidthat,wewillmodifyourExplosionclasstoemployframeskippingifupdaterateistooslow:
#04-prototype-optimized/explosion.rbclassExplosionFRAME_DELAY=16.66#ms#...defupdateadvance_frameend
defdone?@done||=@current_frame>=animation.sizeend#...private#...defadvance_framenow=Gosu.millisecondsdelta=now-(@last_frame||=now)ifdelta>FRAME_DELAY
@last_frame=nowend@current_frame+=(delta/FRAME_DELAY).floorendend
Nowourprototypeisplayableevenatlowerframerates.
RefactoringThePrototype
Atthispointyoumaybethinkingwheretogonext.Wewanttoimplementenemies,collisiondetectionandAI,butdesignofcurrentprototypeisalreadylimiting.Codeisbecomingtightlycoupled,thereisnoclean
separationbetweendifferentdomains.
Ifweweretocontinuebuildingontopofourprototype,thingswouldgetuglyquickly.Thuswewilluntanglethespaghettiandrewritesomepartsfromscratchtoachieveelegance.
GameProgrammingPatterns
IwouldliketotipmyhattoRobertNystrom,whowrotethisamazingbookcalledGameProgrammingPatterns.Thebookisavailableonlineforfree,itisarelativelyquickread-I’vedevoureditwithpleasureinroughly4hours.Ifyouareguessingthatthischapterisinspiredbythatbook,youareabsolutelyright.
Componentpatternisespeciallynoteworthy.Wewillbeusingittodomajorhousekeeping,anditisgreattimetodoso,becausewehaven’timplementedmuchofthegameyet.
WhatIsWrongWithCurrentDesignUntilthispointwehavebeenbuildingthecodein
monolithicfashion.Tankclassholdsthecodethat:
1. Loadsallgroundunitsprites.Ifsomeotherclasshandledit,wecouldreusethecodetoloadotherunits.
2. Handlessoundeffects.3. UsesGosu::Songfor
movingsounds.Thatlimitsonlyonetankmovementsoundper
wholegame.Basically,weabusedGosuhere.
4. Handleskeyboardandmouse.IfweweretocreateAIthatcontrolsthetank,wewouldnotbeabletoreuseTankclassbecauseofthis.
5. Drawsgraphicsonscreen.
6. Calculatesphysicalproperties,likespeed,acceleration.
7. Detectsmovementcollisions.
Bulletisnotperfecteither:
1. Itrendersit’sgraphics.2. Ithandlesit’smovement
trajectoriesandotherphysics.
3. IttreatsExplosionaspartofit’sownlifecycle.
4. Drawsgraphicsonscreen.
5. Handlessoundeffects.
EventherelativelysmallExplosionclassistoomonolithic:
1. Itloadsit’sgraphics.2. Ithandlesrendering,
animationandframeskipping
3. Itloadsandplaysit’ssoundeffects.
DecouplingUsingComponentPattern
Bestdesignseparatesconcernsincodesothateverythinghasit’sownplace,andeveryclasshandlesonlyonething.Let’strysplittingupTankclassintocomponentsthathandlespecificdomains:
DecoupledTank
WewillintroduceGameObjectclasswillcontainsharedfunctionalityforallgameobjects(Tank,Bullet,Explosion),eachofthemwouldhaveit’sownsetofcomponents.Everycomponentwillhaveit’sparentobject,soitwillbeabletointeractwithit,changeit’sattributes,orpossiblyinvokeothercomponentsifitcomestothat.
Gameobjectsandtheircomponents
AlltheseobjectswillbeheldwithinObjectPool,whichwouldnotcaretoknowifobjectisatankorabullet.PurposeofObjectPoolisa
littledifferentinRuby,sinceGCwilltakecareofmemoryfragmentationforus,butwestillneedasingleplacethatknowsabouteveryobjectinthegame.
invokeupdateanddrawmethods.
Now,let’sbeginbyimplementingbaseclassforGameObject:05-refactor/entities/game_object.rb1classGameObject2definitialize(object_pool)3@components=[]4@object_pool=object_pool5@object_pool.objects<<self6end
78defcomponents9@components10end1112defupdate13@components.map(&:update)14end1516defdraw(viewport)17@components.each{|c|c.draw(viewport)}18end1920defremovable?21@removable22end2324defmark_for_removal25@removable=true
26end2728protected2930defobject_pool31@object_pool32end33end
WhenGameObjectisinitialized,itregistersitselfwithObjectPoolandpreparesempty@componentsarray.ConcreteGameObjectclassesshouldinitialize
Componentssothatarraywouldnotbeempty.
updateanddrawmethodswouldcyclethrough@componentsanddelegatethosecallstoeachoftheminasequence.Itisimportanttoupdateallcomponentsfirst,andonlythendrawthem.Keepinmindthat@componentsarrayorderhassignificance.Firstelements
willalwaysbeupdatedanddrawnbeforelastones.
Wewillalsoprovideremovable?methodthatwouldreturntrueforobjectsthatmark_for_removalwasinvokedon.ThiswaywewillbeabletoweedoutoldbulletsandexplosionsandfeedthemtoGC.
Nextup,baseComponentclass:
05-refactor/entities/components/component.rb1classComponent2definitialize(game_object=nil)3self.object=game_object4end56defupdate7#override8end910defdraw(viewport)11#override12end1314protected1516defobject=(obj)
17ifobj18@object=obj19obj.components<<self20end21end2223defx24@object.x25end2627defy28@object.y29end3031defobject32@object33end34end
ItregistersitselfwithGameObject#components,providessomeprotectedmethodstoaccessparentobjectandit’smostoftencalledproperties-xandy.
RefactoringExplosionExplosionwasprobablythesmallestclass,sowewillextractit’scomponentsfirst.05-refactor/entities/explosion.rb
1classExplosion<GameObject2attr_accessor:x,:y34definitialize(object_pool,x,y)5super(object_pool)6@x,@y=x,y7ExplosionGraphics.new(self)8ExplosionSounds.play9end10end
Itismuchcleanerthanbefore.ExplosionGraphicswillbeaComponentthathandlesanimation,and
ExplosionSoundswillplayasound.05-refactor/entities/components/explosion_graphics.rb1classExplosionGraphics<Component2FRAME_DELAY=16.66#ms34definitialize(game_object)5super6@current_frame=07end89defdraw(viewport)10image=current_frame11image.draw(12x-image.width/2+
3,13y-image.height/2-35,1420)15end1617defupdate18now=Gosu.milliseconds19delta=now-(@last_frame||=now)20ifdelta>FRAME_DELAY21@last_frame=now22end23@current_frame+=(delta/FRAME_DELAY).floor24object.mark_for_removalifdone?25end2627private28
29defcurrent_frame30animation[@current_frame%animation.size]31end3233defdone?34@done||=@current_frame>=animation.size35end3637defanimation38@@animation||=39Gosu::Image.load_tiles(40$window,Utils.media_path('explosion.png'
41128,128,false)42end43end
Everythingthatisrelatedtoanimatingtheexplosionisnowclearlyseparated.mark_for_removaliscalledontheexplosionafterit’sanimationisdone.05-refactor/entities/components/explosion_sounds.rb1classExplosionSounds2class<<self3defplay4sound.play5end67private8
9defsound10@@sound||=Gosu::Sample.new(11$window,Utils.media_path('explosion.mp3'
12end13end14end
Sinceexplosionsoundsaretriggeredonlyonce,whenitstartstoexplode,ExplosionSoundsisastaticclasswithplaymethod.
RefactoringBulletNow,let’sgoupalittleandreimplementourBullet:05-refactor/entities/bullet.rb1classBullet<GameObject2attr_accessor:x,:y,:target_x,:target_y,:speed,:fired_at34definitialize(object_pool,source_x,source_y,target_x,target_y)5super(object_pool)6@x,@y=source_x,source_y7@target_x,@target_y=
target_x,target_y8BulletPhysics.new(self)9BulletGraphics.new(self)10BulletSounds.play11end1213defexplode14Explosion.new(object_pool,@x,@y)15mark_for_removal16end1718deffire(speed)19@speed=speed20@fired_at=Gosu.milliseconds21end22end
Allphysics,graphicsandsoundsareextractedintoindividualcomponents,andinsteadofmanagingExplosion,itjustregistersanewExplosionwithObjectPoolandmarksitselfforremovalinexplodemethod.05-refactor/entities/components/bullet_physics.rb1classBulletPhysics<Component2START_DIST=20
3MAX_DIST=30045definitialize(game_object)6super7object.x,object.y=point_at_distance(START_DIST)8iftrajectory_length>MAX_DIST9object.target_x,object.target_y=point_at_distance(MAX_DIST)10end11end1213defupdate14fly_speed=Utils.adjust_speed(object.speed)
15fly_distance=(Gosu.milliseconds-
object.fired_at)*0.001*fly_speed16object.x,object.y=point_at_distance(fly_distance)17object.explodeifarrived?18end1920deftrajectory_length21d_x=object.target_x-x22d_y=object.target_y-y23Math.sqrt(d_x*d_x+d_y*d_y)24end2526defpoint_at_distance(distance)27ifdistance>trajectory_length
28return[object.target_x,object.target_y]29end30distance_factor=distance.to_f/trajectory_length31p_x=x+(object.target_x-x)*distance_factor32p_y=y+(object.target_y-y)*distance_factor33[p_x,p_y]34end3536private3738defarrived?39x==object.target_x&&y==object.target_y
40end41end
BulletPhysicsiswherethemostofBulletendedupat.ItdoesallthecalculationsandtriggersBullet#explodewhenready.Whenwewillbeimplementingcollisiondetection,theimplementationwillgosomewherehere.05-refactor/entities/components/bullet_graphics.rb
1classBulletGraphics<Component2COLOR=Gosu::Color::BLACK34defdraw(viewport)5$window.draw_quad(x-2,y-2,COLOR,6x+2,y-2,COLOR,7x-2,y+2,COLOR,8x+2,y+2,COLOR,91)10end1112end
AfterpullingawayBulletgraphicscode,itlooksverysmallandelegant.Wewillprobablyneverhavetoeditanythinghereagain.05-refactor/entities/components/bullet_sounds.rb1classBulletSounds2class<<self3defplay4sound.play5end67private89defsound
10@@sound||=Gosu::Sample.new(11$window,Utils.media_path('fire.mp3'))12end13end14end
JustlikeExplosionSounds,BulletSoundsarestatelessandstatic.Wecouldmakeitjustlikearegularcomponent,butconsideritourlittleoptimization.
RefactoringTank
TimetotakealookatfreshlydecoupledTank:05-refactor/entities/tank.rb1classTank<GameObject2SHOOT_DELAY=5003attr_accessor:x,:y,:throttle_down,:direction,:gun_angle,:sounds,:physics45definitialize(object_pool,input)6super(object_pool)7@input=input8@input.control(self)9@physics=TankPhysics.new(self,object_pool)10@graphics=
TankGraphics.new(self)11@sounds=TankSounds.new(self)12@direction=@gun_angle=0.013end1415defshoot(target_x,target_y)16ifGosu.milliseconds-(@last_shot||0)>SHOOT_DELAY17@last_shot=Gosu.milliseconds18Bullet.new(object_pool,@x,@y,target_x,target_y).fire(100)19end20end21end
Tankclasswasreducedover5times.WecouldgofurtherandextractGuncomponent,butfornowit’ssimpleenoughalready.Now,thecomponents.05-refactor/entities/components/tank_physics.rb1classTankPhysics<Component2attr_accessor:speed34definitialize(game_object,object_pool)5super(game_object)
6@object_pool=object_pool7@map=object_pool.map8game_object.x,game_object.y=@map.find_spawn_point9@speed=0.010end1112defcan_move_to?(x,y)13@map.can_move_to?(x,y)14end1516defmoving?17@speed>018end1920defupdate21ifobject.throttle_down22accelerate23else
24decelerate25end26if@speed>027new_x,new_y=x,y28shift=Utils.adjust_speed(@speed)29case@object.direction.to_i30when031new_y-=shift32when4533new_x+=shift34new_y-=shift35when9036new_x+=shift37when13538new_x+=shift39new_y+=shift40when18041new_y+=shift42when225
43new_y+=shift44new_x-=shift45when27046new_x-=shift47when31548new_x-=shift49new_y-=shift50end51ifcan_move_to?(new_x,new_y)52object.x,object.y=new_x,new_y53else54object.sounds.collideif@speed>155@speed=0.056end57end58end59
60private6162defaccelerate63@speed+=0.08if@speed<564end6566defdecelerate67@speed-=0.5if@speed>068@speed=0.0if@speed<0.01#damp69end70end
Whilewehadtoripplayerinputawayfromit’smovement,wegotourselves
abenefit-tanknowbothacceleratesanddecelerates.Whendirectionalbuttonsarenolongerpressed,tankkeepsmovinginlastdirection,butquicklydeceleratesandstops.AnotheradditionthatwouldhavebeenmoredifficulttoimplementonpreviousTankiscollisionsound.WhenTankabruptlystopsbyhittingsomething(fornowit’sonlywater),collisionsoundisplayed.Wewillhavetofix
that,becausemetalbangisnotappropriatewhenyoustopontheedgeofariver,butwenowdiditforthesakeofscience.05-refactor/entities/components/tank_graphics.rb1classTankGraphics<Component2definitialize(game_object)3super(game_object)4@body=units.frame('tank1_body.png')5@shadow=units.frame('tank1_body_shadow.png'
6@gun=units.frame('tank1_dualgun.png')
7end89defdraw(viewport)10@shadow.draw_rot(x-1,y-1,0,object.direction)11@body.draw_rot(x,y,1,object.direction)12@gun.draw_rot(x,y,2,object.gun_angle)13end1415private1617defunits18@@units=Gosu::TexturePacker.load_json(19$window,
Utils.media_path('ground_units.json':precise)20end21end
Again,graphicsareneatlypackedandseparatedfromeverythingelse.Eventuallyweshouldoptimizedrawtotakeviewportintoconsideration,butit’sgoodenoughfornow,especiallywhenwehaveonlyonetankinthegame.
05-refactor/entities/components/tank_sounds.rb1classTankSounds<Component2defupdate3ifobject.physics.moving?4if@driving&&@driving.paused?5@driving.resume6elsif@driving.nil?7@driving=driving_sound.play(1,1,true)8end9else10if@driving&&@driving.playing?11@driving.pause12end13end14end
1516defcollide17crash_sound.play(1,0.25,false)18end1920private2122defdriving_sound23@@driving_sound||=Gosu::Sample.new(24$window,Utils.media_path('tank_driving.mp3'
25end2627defcrash_sound28@@crash_sound||=Gosu::Sample.new(29$window,Utils.media_path('crash.ogg'))
30end31end
UnlikeExplosionandBullet,Tanksoundsarestateful.Wehavetokeeptrackoftank_driving.mp3,whichisnolongerGosu::Song,butGosu::Sample,likeitshouldhavebeen.
WhenGosu::Sample#playisinvoked,
Gosu::SampleInstanceisreturned,andwehavefullcontroloverit.Nowwearereadytoplaysoundsformorethanonetankatonce.05-refactor/entities/components/player_input.rb1classPlayerInput<Component2definitialize(camera)3super(nil)4@camera=camera5end67defcontrol(obj)8self.object=obj
9end1011defupdate12d_x,d_y=@camera.target_delta_on_screen13atan=Math.atan2(($window.width/2)-d_x-$window.mouse_x,14($window.height/2)-d_y-$window.mouse_y)15object.gun_angle=-atan*180/Math::PI16motion_buttons=[Gosu::KbW,Gosu::KbS,Gosu::KbA,Gosu::KbD]1718ifany_button_down?(*motion_buttons)19object.throttle_down=true
20object.direction=change_angle(object.direction,*motion_buttons)21else22object.throttle_down=false23end2425ifUtils.button_down?(Gosu::MsLeft)26object.shoot(*@camera.mouse_coords
27end28end2930private3132defany_button_down?(*buttons)33buttons.eachdo|b|
34returntrueifUtils.button_down?(b)35end36false37end3839defchange_angle(previous_angle,up,down,right,left)40ifUtils.button_down?(up)41angle=0.042angle+=45.0ifUtils.button_down?(left)43angle-=45.0ifUtils.button_down?(right)44elsifUtils.button_down?(down)45angle=180.046angle-=45.0ifUtils.button_down?(left)
47angle+=45.0ifUtils.button_down?(right)48elsifUtils.button_down?(left)49angle=90.050angle+=45.0ifUtils.button_down?(up)51angle-=45.0ifUtils.button_down?(down)52elsifUtils.button_down?(right)53angle=270.054angle-=45.0ifUtils.button_down?(up)55angle+=45.0ifUtils.button_down?(down)56end57angle=(angle+360)%360ifangle&&angle<058(angle||previous_angle)
59end60end
WefinallycometoaplacewherekeyboardandmouseinputishandledandconvertedtoTankcommands.WecouldhaveusedCommandpatterntodecoupleeverythingevenfurther.
RefactoringPlayState05-refactor/game_states/play_state.rb
1require'ruby-prof'ifENV['ENABLE_PROFILING']2classPlayState<GameState3attr_accessor:update_interval45definitialize6@map=Map.new7@camera=Camera.new8@object_pool=ObjectPool.new(@map)9@tank=Tank.new(@object_pool,PlayerInput.new(@camera))10@camera.target=@tank11end1213defenter14RubyProf.startifENV['ENABLE_PROFILING']15end
1617defleave18ifENV['ENABLE_PROFILING']19result=RubyProf.stop20printer=RubyProf::FlatPrinter.new(result
21printer.print(STDOUT)22end23end2425defupdate26@object_pool.objects.map(&:update
27@object_pool.objects.reject!(&:removable?)28@camera.update29update_caption
30end3132defdraw33cam_x=@camera.x34cam_y=@camera.y35off_x=$window.width/2-cam_x36off_y=$window.height/2-cam_y37viewport=@camera.viewport38$window.translate(off_x,off_y)do39zoom=@camera.zoom40$window.scale(zoom,zoom,cam_x,cam_y)do41@map.draw(viewport)42@object_pool.objects.map{|o|o.draw(viewport)}43end
44end45@camera.draw_crosshair46end4748defbutton_down(id)49ifid==Gosu::KbQ50leave51$window.close52end53ifid==Gosu::KbEscape54GameState.switch(MenuState.instance
55end56end5758private5960defupdate_caption61now=Gosu.milliseconds62ifnow-
(@caption_updated_at||0)>100063$window.caption='TanksPrototype.'<<64"[FPS:#{Gosu.fps}."<<65"Tank@#{@tank.x.round}:#{@tank.y.round}]"66@caption_updated_at=now67end68end69end
ImplementationofPlayStateisnowalsoalittlesimpler.Itdoesn’tupdate@tankor
@bulletsindividuallyanymore.Instead,itusesObjectPoolanddoesallobjectoperationsinbulk.
OtherImprovements05-refactor/main.rb1#!/usr/bin/envruby23require'gosu'45root_dir=File.dirname(__FILE__)6require_pattern=File.join(root_dir,'**/*.rb')7@failed=[]8
9#Dynamicallyrequireeverything10Dir.glob(require_pattern).eachdo|f|11nextiff.end_with?('/main.rb')12begin13require_relativef.gsub("#{root_dir}/",'')14rescue15#Mayfailifparentclassnotrequiredyet16@failed<<f17end18end1920#Retryunresolvedrequires21@failed.eachdo|f|22require_relativef.gsub("#{root_dir}/",'')
23end2425$window=GameWindow.new26GameState.switch(MenuState.instance
27$window.show
Finally,wemadesomeimprovementstomain.rb-itnowrecursivelyrequiresall*.rbfileswithinsamedirectory,sowedon’thavetoworryaboutitinotherclasses.
05-refactor/utils.rb1moduleUtils2defself.media_path(file)3File.join(File.dirname(File.dirname
4__FILE__)),'media',file)5end67defself.track_update_interval8now=Gosu.milliseconds9@update_interval=(now-(@last_update||=0)).to_f10@last_update=now11end1213defself.update_interval14@update_interval||=
$window.update_interval15end1617defself.adjust_speed(speed)18speed*update_interval/33.3319end2021defself.button_down?(button)22@buttons||={}23now=Gosu.milliseconds24now=now-(now%150)25if$window.button_down?(button)26@buttons[button]=now27true28elsif@buttons[button]29ifnow==@buttons[button]
30true31else32@buttons.delete(button)33false34end35end36end37end
AnothernotablechangeisrenamingGamemoduleintoUtils.Thenamefinallymakesmoresense,IhavenoideawhyIpututilitymethodsintoGamemoduleinthefirst
place.Also,Utilsreceivedbutton_down?method,thatsolvestheissueofchangingtankdirectionwhenbuttonisimmediatelyreleased.Itmadeverydifficulttostopatdiagonalangle,becausewhenyoudepressedtwobuttons,16mswasenoughforGosutothink“hereleasedW,andSisstillpressed,solet’schangedirectiontoS”.Utils#button_down?givesasoft150mswindowto
SimulatingPhysics
Tomakethegamemorerealistic,wewillspicethingsupwithsomephysics.Thisisthefeaturesetwearegoingtoimplement:
1. Collisiondetection.Tankwillbumpinto
otherobjects-stationarytanks.Bulletswillnotgothroughthemeither.
2. Terraineffects.Tankwillgofastongrass,sloweronsand.
AddingEnemyObjectsIt’sboringtoplayalone,sowewillmakeaquickchangeandspawnsomestationary
tanksthatwillbedeployedrandomlyaroundthemap.Theywillbestationaryinthebeginning,butwewillstillneedadummyAIclasstoreplacePlayerInput:06-physics/entities/components/ai_input.rb1classAiInput<Component2defcontrol(obj)3self.object=obj4end5end
AquickanddirtywaytospawnsometankswouldbewheninitializingPlayState:
classPlayState<GameState#...definitialize@map=Map.new@camera=Camera.new@object_pool=ObjectPool.new(@map)@tank=Tank.new(@object_pool,PlayerInput.new(@camera))@camera.target=@tank#...50.timesdoTank.new(@object_pool,AiInput.new)
endend#...end
Andunlesswewantallstationarytanksfacesamedirection,wewillrandomizeit:
classTank<GameObject#...definitialize(object_pool,input)#...@direction=rand(0..7)*45@gun_angle=rand(0..360)
end#...end
Fireupthegame,andwanderaroundfrozentanks.Youcanpassthroughthemasiftheywereghosts,butwewillfixthatinamoment.
CollisionsWewantourcollisiondetectiontobepixelperfect,thatmeansweneedtohaveaboundingboxandcheckcolisionsagainstit.Getreadyforsomemath!
First,weneedtofindacorrectwaytoconstructaboundingbox.Tankhasit’sbodyimage,solet’sseehowit’sboundarieslooklike.We
willaddsomecodetoTankGraphicscomponenttoseeit:
classTankGraphics<Componentdefdraw(viewport)#...draw_bounding_boxend
defdraw_bounding_box$window.rotate(object.direction,x,y)dow=@body.widthh=@body.height$window.draw_quad(x-w/2,y-h/2,Gosu::Color::RED,
x+w/2,y-h/2,Gosu::Color::RED,x+w/2,y+h/2,Gosu::Color::RED,x-w/2,y+h/2,Gosu::Color::RED,100)endend#...end
Resultisprettygood,wehavetankshapedbox,sowewillbeusingbodyimagedimensionstodetermineourboundingboxcorners:
Tank’sboundingboxvisualized
Thereisoneproblemherethough.Gosu::Window#rotatedoestherotationmathforus,and
weneedtoperformthesecalculationsonourown.Wehavefourpointsthatwewanttorotatearoundacenterpoint.It’snotverydifficulttofindhowtodothis.HereisaRubymethodforyou:
moduleUtils#...defself.rotate(angle,around_x,around_y,*points)result=[]points.each_slice(2)do|x,y|r_x=Math.cos(angle)*(x-around_x)-
Math.sin(angle)*(y-around_y)+around_xr_y=Math.sin(angle)*(x-around_x)+Math.cos(angle)*(y-around_y)+around_yresult<<r_xresult<<r_yendresultend#...end
Wecannowcalculateedgesofourboundingbox,butweneedonemorefunctionwhichtellsifpointisinsidea
polygon.Thisproblemhasbeensolvedmilliontimesbefore,sojustpoketheinternetforitanddrinkfromtheinformationfirehoseuntilyouunderstandhowtodothis.
Ifyouwasn’tfamiliarwiththetermyet,bynowyoushoulddiscoverwhatvertexis.Ingeometry,avertex(pluralvertices)isaspecialkindofpointthatdescribes
thecornersorintersectionsofgeometricshapes.
Here’swhatIendedupwriting:
moduleUtils#...#http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html
defself.point_in_poly(testx,testy,*poly)nvert=poly.size/2#Numberofverticesinpolyvertx=[]verty=[]poly.each_slice(2)do|x,
y|vertx<<xverty<<yendinside=falsej=nvert-1(0..nvert-1).eachdo|i|if(((verty[i]>testy)!=(verty[j]>testy))&&(testx<(vertx[j]-vertx[i])*(testy-verty[i])/(verty[j]-verty[i])+vertx[i]))inside=!insideendj=iendinsideend#...
ItisJordancurvetheoremreimplementedinRuby.Looksugly,butitactuallyworks,andisprettyfasttoo.
Also,thisworksonmoresophisticatedpolygons,andourtankisshapedmorelikeanHratherthanarectangle,sowecoulddefineapixelperfectpolygon.Somepenandpaperwillhelp.
classTankPhysics<Component#...
#TankboxlookslikeH.Vertices:#1256#34##109#121187defboxw=box_width/2-1h=box_height/2-1tw=8#trackwidthfd=8#frontdepthrd=6#reardepthUtils.rotate(object.direction,x,y,x+w,y+h,#1x+w-tw,y+h,#2
x+w-tw,y+h-fd,#3
x-w+tw,y+h-fd,#4x-w+tw,y+h,#5x-w,y+h,#6
x-w,y-h,#7x-w+tw,y-h,#8x-w+tw,y-h+rd,#9
x+w-tw,y-h+rd,#10x+w-tw,y-h,#11
x+w,y-h,#12)end#...end
Tovisuallyseeit,wewillimproveourdraw_bounding_boxmethod:
classTankGraphics<Component#...DEBUG_COLORS=[Gosu::Color::RED,Gosu::Color::BLUE,Gosu::Color::YELLOW,Gosu::Color::WHITE
]#...defdraw_bounding_boxi=0object.box.each_slice(2)do|x,y|color=DEBUG_COLORS[i]$window.draw_triangle(x-3,y-3,color,x,y,color,x+3,y-3,color,100)i=(i+1)%4endend#...
Nowwecanvisuallytestboundingboxedgesandsee
Highprecisionboundingboxes
TimetopimpourTankPhysicstodetectthosecollisions.Whileouralgorithmisprettyfast,it
doesn’tmakesensetocheckcollisionsforobjectsthatareprettyfarapart.ThisiswhyweneedourObjectPooltoknowhowtoqueryobjectsincloseproximity.
classObjectPool#...defnearby(object,max_distance)@objects.selectdo|obj|distance=Utils.distance_between(obj.x,obj.y,object.x,object.y)obj!=object&&distance<max_distance
endendend
BacktoTankPhysics:
classTankPhysics<Component#...defcan_move_to?(x,y)old_x,old_y=object.x,object.yobject.x=xobject.y=yreturnfalseunless@map.can_move_to?(x,y)@object_pool.nearby(object,100).eachdo|obj|ifcollides_with_poly?(obj.box)#Allowtogetunstuck
old_distance=Utils.distance_between(obj.x,obj.y,old_x,old_y)new_distance=Utils.distance_between(obj.x,obj.y,x,y)returnfalseifnew_distance<old_distanceendendtrueensureobject.x=old_xobject.y=old_yend#...private
defcollides_with_poly?(poly)ifpoly
poly.each_slice(2)do|x,y|returntrueifUtils.point_in_poly(x,y,*box)endbox.each_slice(2)do|x,y|returntrueifUtils.point_in_poly(x,y,*poly)endendfalseend#...end
It’sprobablynotthemostelegantsolutionyoucould
comeupwith,butcan_move_to?temporarilychangesTanklocationtomakeacollisiontest,andthenrevertsoldcoordinatesjustbeforereturningtheresult.Nowourtanksstopwithbangingsoundwhentheyhiteachother.
Rightnowbulletsflyrightthroughourtanks,andwewantthemtocollide.It’saprettysimplechange,whichmostlyaffectsBulletPhysicsclass:
#06-physics/entities/components/bullet_physics.rb
classBulletPhysics<Component#...defupdate#...check_hitobject.explodeifarrived?end#...
private
defcheck_hit@object_pool.nearby(object,50).eachdo|obj|nextifobj==object.source#Don'thitsourcetankifUtils.point_in_poly(x,y,*obj.box)object.target_x=xobject.target_y=yreturnendendend#...end
Tankscannotmaketurnsandgointoreverseatfullspeedwhilekeepingit’sinertia,right?Itiseasytoimplement.Sinceit’srelatedtophysics,wewilldelegatechangingTank’s@directiontoourTankPhysicsclass:
#06-physics/entities/components/player_input.rb
classPlayerInput<Component#...defupdate#...
motion_buttons=[Gosu::KbW,Gosu::KbS,Gosu::KbA,Gosu::KbD]
ifany_button_down?(*motion_buttons)object.throttle_down=trueobject.physics.change_direction(
change_angle(object.direction,*motion_buttons))elseobject.throttle_down=falseend#...end#...
end
#06-physics/entities/components/tank_physics.rb
classTankPhysics<Component#...defchange_direction(new_direction)change=(new_direction-object.direction+360)%360change=360-changeifchange>180ifchange>90@speed=0elsifchange>45@speed*=0.33elsifchange>0@speed*=0.66endobject.direction=
new_directionend#...end
ImplementingTerrainSpeedPenaltiesNow,let’sseehowcanwemaketerraininfluenceourmovement.ItsoundsreasonableforTankPhysics
toconsultwithMapaboutspeedpenaltyofcurrenttile:
#06-physics/entities/map.rbclassMap#...defmovement_penalty(x,y)tile=tile_at(x,y)casetilewhen@sand0.33else0endend#...end
#06-physics/entities/components/tank_physics.rb
classTankPhysics<Component#...defupdate#...speed=apply_movement_penalty(@speed)shift=Utils.adjust_speed(speed)#...end#...
private
defapply_movement_penalty(speed)speed*(1.0-@map.movement_penalty(x,y))end
ImplementingHealthAndDamage
Iknowyouhavebeenwaitingforthis.Wewillbeimplementinghealthsystemandmostimportantly,damage.Soowewillbereadytoblowthingsup.
Toimplementthis,weneedto:
1. AddTankHealthcomponent.Startwith100health.
2. Rendertankhealthnexttotankitself.
3. Inflictdamagetotankwhenitisinexplosionzone
4. Renderdifferentspritefordeadtank.
5. Cutoffplayerinputwhentankisdead.
AddingHealthComponentIfwedidn’thaveComponentsysteminplace,itwouldbewaymoredifficult.Nowwejustkickinanewclass:07-damage/entities/components/tank_health.rb
1classTankHealth<Component2attr_accessor:health34definitialize(object,object_pool)5super(object)6@object_pool=object_pool7@health=1008@health_updated=true9@last_damage=Gosu.milliseconds10end1112defupdate13update_image14end1516defupdate_image17if@health_updated18ifdead?
19text='✝'20font_size=2521else22text=@health.to_s23font_size=1824end25@image=Gosu::Image.from_text(26$window,text,27Gosu.default_font_name,font_size)28@health_updated=false29end30end3132defdead?33@health<134end35
36definflict_damage(amount)37if@health>038@health_updated=true39@health=[@health-amount.to_i,0].max40if@health<141Explosion.new(@object_pool,x,y)42end43end44end4546defdraw(viewport)47@image.draw(48x-@image.width/2,49y-object.graphics.height/2-50@image.height,100)51end52end
Ithooksitselfintothegamerightaway,afterweinitializeitinTankclass:
classTank<GameObjectattr_accessor:health#...definitialize(object_pool,input)#...@health=TankHealth.new(self,object_pool)#..end#..end
InflictingDamageWithBulletsTherearetwowaystoinflictdamage-directlyandindirectly.Whenbullethitsenemytank(collideswithtankboundingbox),weshouldinflictdirectdamage.ItcanbedoneinBulletPhysics#check_hit
methodthatwealreadyhad:
classBulletPhysics<Component#...defcheck_hit@object_pool.nearby(object,50).eachdo|obj|nextifobj==object.source#Don'thitsourcetankifUtils.point_in_poly(x,y,*obj.box)#Directhit-extradamageobj.health.inflict_damage(20)object.target_x=xobject.target_y=yreturnendendend
#...end
Finally,Explosionitselfshouldinflictadditionaldamagetoanythingthatarenearby.Theeffectwillbediminishinganditwillbedeterminedbyobjectdistance.
classExplosion<GameObject#...definitialize(object_pool,x,y)#...
inflict_damageend
private
definflict_damageobject_pool.nearby(self,100).eachdo|obj|ifobj.class==Tankobj.health.inflict_damage(Math.sqrt(3*100-Utils.distance_between(obj.x,obj.y,x,y)))endendendend
Thisisit,wearereadytodealdamage.Butwewanttoseeifweactuallykilledsomebody,soTankGraphicsshouldbeawareofhealthandshoulddrawdifferentsetofspriteswhentankisdead.HereiswhatweneedtochangeinourcurrentTankGraphicstoachievetheresult:
classTankGraphics<Component#...definitialize(game_object)super(game_object)
@body_normal=units.frame('tank1_body.png')@shadow_normal=units.frame('tank1_body_shadow.png'
@gun_normal=units.frame('tank1_dualgun.png')
@body_dead=units.frame('tank1_body_destroyed.png'
@shadow_dead=units.frame('tank1_body_destroyed_shadow.png'
@gun_dead=nilend
defupdateifobject.health.dead?@body=@body_dead@gun=@gun_dead
@shadow=@shadow_deadelse@body=@body_normal@gun=@gun_normal@shadow=@shadow_normalendend
defdraw(viewport)@shadow.draw_rot(x-1,y-1,0,object.direction)@body.draw_rot(x,y,1,object.direction)@gun.draw_rot(x,y,2,object.gun_angle)if@gunend#...end
this,wewillsimplycutoutplayerinputwhenwearedead:
classPlayerInput<Component#...defupdatereturnifobject.health.dead?#...end#...end
Andtopreventtankfromthrottlingforeverifthepedalwasdownbeforeitgotkilled:
classTankPhysics<Component#...defupdateifobject.throttle_down&&!object.health.dead?accelerateelsedecelerateend#...end#...end
That’sit.Allweneedrightnowissomeresistancefromthosebraindeadenemies.We
CreatingArtificialIntelligence
ArtificialIntelligenceisasubjectsovastthatwewillbarelyscratchthesurface.AIinVideoGamesisusuallyheavilysimplifiedandthereforeeasiertoimplement.
ThereisthiswonderfulseriesofarticlescalledDesigningArtificialIntelligenceforGamesthatIhighlyrecommendreadingtogetafeelinghowgameAIshouldbedone.Wewillbecontinuingourworkontopofwhatwealreadyhave,examplecodeforthischapterwillbein08-ai.
DesigningAIUsingFiniteStateMachineNonplayertanksinourgamewillbelonerangers,huntingeverythingthatmoveswhiletryingtosurvive.WewilluseFiniteStateMachinetoimplementtankbehavior.
First,weneedtothink“whatwouldatankdo?”Howaboutthisscenario:
1. Tankwandersaround,mindingit’sownbusiness.
2. Tankencountersanothertank.Itthenstartsdoingevasivemovesandtrieshittingtheenemy.
3. Enemytooksomedamageandstarteddrivingaway.Tankstartschasingtheenemytryingtofinishit.
4. Anothertankappearsandfiresacoupleof
accurateshots,dealingseriousdamage.Ourtankstartsrunningaway,becauseifitkeptreceivingdamageatsuchrate,itwoulddieverysoon.
5. Tankkeepsfleeingandlookingforsafetyuntilitgetscorneredortheopponentlooksdamagedtoo.Thentankgoesintoit’sfinalbattle.
WecannowdrawaFiniteStateMachineusingthisscenario:
VigilanteTankFSM
Ifyouareonapathtobecomeagamedeveloper,FSMshouldnotstandfor
FlyingSpaghettiMonsterforyouanymore.
ImplementingAIVisionTomakeopponentsrealistic,wehavetogivethemsenses.Let’screateaclassforthat:08-ai/entities/components/ai/vision.rb1classAiVision2CACHE_TIMEOUT=5003attr_reader:in_sight
45definitialize(viewer,object_pool,distance)6@viewer=viewer7@object_pool=object_pool8@distance=distance9end1011defupdate12@in_sight=@object_pool.nearby(@viewer,@distance)13end1415defclosest_tank16now=Gosu.milliseconds17@closest_tank=nil18ifnow-(@cache_updated_at||=0)>CACHE_TIMEOUT
19@closest_tank=nil20@cache_updated_at=now21end22@closest_tank||=find_closest_tank23end2425private2627deffind_closest_tank28@in_sight.selectdo|o|29o.class==Tank&&!o.health.dead?30end.sortdo|a,b|31x,y=@viewer.x,@viewer.y32d1=Utils.distance_between(x,y,a.x,a.y)33d2=
Utils.distance_between(x,y,b.x,b.y)34d1<=>d235end.first36end37end
ItusesObjectPooltoputnearbyobjectsinsight,andgetsashorttermfocusononeclosesttank.Closesttankiscachedfor500millisecondsfortworeasons:
1. Performance.UncachedversionwoulddoArray#selectandArray#sort60timespersecond,nowitwilldo2times.
2. Focus.Whenyouchooseatarget,youshouldkeepitalittlelonger.Thisshouldalsoavoid“jitters”,whentankwouldshakebetweentwonearbytargetsthat
arewithinsamedistance.
ControllingTankGunAfterwemadeAiVision,wecannowuseittoautomaticallyaimandshootatclosesttank.Itshouldworklikethis:
1. Everyinstanceofthegunhasit’sownuniquecombinationofspeed,accuracyandaggressiveness.
2. Gunwillautomaticallytargetclosesttankinsight.
3. Ifnoothertankisinsight,gunwilltargetinsamedirectionastank’sbody.
4. Ifothertankisaimedatandwithinshooting
distance,gunwillmakeadecisiononceinawhilewhetheritshouldshootornot,basedonaggressivenesslevel.Aggressivetankswillbetriggerhappyallthetime,whilelessaggressiveoneswillmakesmallrandompausesbetweenshots.
5. Gunwillhavea“desired”anglethatitwillbeautomatically
adjustingto,accordingtoit’sspeed.
Hereistheimplementation:08-ai/entities/components/ai/gun.rb1classAiGun2DECISION_DELAY=10003attr_reader:target,:desired_gun_angle45definitialize(object,vision)6@object=object7@vision=vision8@desired_gun_angle=rand(0..360)9@retarget_speed=
rand(1..5)10@accuracy=rand(0..10)11@aggressiveness=rand(1..5)12end1314defadjust_angle15adjust_desired_angle16adjust_gun_angle17end1819defupdate20if@vision.in_sight.any?21if@vision.closest_tank!=@target22change_target(@vision.closest_tank
23end24else
25@target=nil26end2728if@target29if(0..10-rand(0..@accuracy)).include?(30(@desired_gun_angle-@object.gun_angle).abs.round)31distance=distance_to_target32ifdistance-50<=BulletPhysics::MAX_DIST33target_x,target_y=Utils.point_at_distance(34@object.x,@object.y,@object.gun_angle,35distance+10-rand(0..@accuracy))36ifcan_make_new_decision?&&
@object.can_shoot?&&37should_shoot?38@object.shoot(target_x,target_y)39end40end41end42end43end4445defdistance_to_target46Utils.distance_between(47@object.x,@object.y,@target.x,@target.y)48end495051defshould_shoot?52rand*@aggressiveness>0.5
53end5455defcan_make_new_decision?56now=Gosu.milliseconds57ifnow-(@last_decision||=0)>DECISION_DELAY58@last_decision=now59true60end61end6263defadjust_desired_angle64@desired_gun_angle=if@target65Utils.angle_between(66@object.x,@object.y,@target.x,@target.y)67else
68@object.direction69end70end7172defchange_target(new_target)73@target=new_target74adjust_desired_angle75end7677defadjust_gun_angle78actual=@object.gun_angle79desired=@desired_gun_angle80ifactual>desired81ifactual-desired>180#0->360fix82@object.gun_angle=(actual+@retarget_speed)%360
83if@object.gun_angle<desired84@object.gun_angle=desired#damp85end86else87@object.gun_angle=[actual-@retarget_speed,desired].max88end89elsifactual<desired90ifdesired-actual>180#360->0fix91@object.gun_angle=(360+actual-@retarget_speed)%36092if@object.gun_angle>desired93@object.gun_angle=desired#damp94end
95else96@object.gun_angle=[actual+@retarget_speed,desired].min97end98end99end100end
Thereissomemathinvolved,butitisprettystraightforward.Weneedtofindoutananglebetweentwopoints,toknowwhereourgunshouldpoint,andtheotherthingweneedis
coordinatesofpointwhichisinsomedistanceawayfromsourceatgivenangle.Herearethosefunctions:
moduleUtils#...defself.angle_between(x,y,target_x,target_y)dx=target_x-xdy=target_y-y(180-Math.atan2(dx,dy)*180/Math::PI)+360%360end
defself.point_at_distance(source_x,source_y,angle,distance)angle=(90-angle)*
Math::PI/180x=source_x+Math.cos(angle)*distancey=source_y-Math.sin(angle)*distance[x,y]end#...end
ImplementingAIInputAtthispointourtankscanalreadydefendthemselves,eventhroughmotionisnot
yetimplemented.Let’swireeverythingwehaveinAiInputclassthatwehadpreparedearlier.WewillneedablankTankMotionFSMclasswith3argumentinitializerandemptyupdate,on_collision(with)andon_damage(amount)methodsforittowork:08-ai/entities/components/ai_input.rb1classAiInput<Component2UPDATE_RATE=200#ms
34definitialize(object_pool)5@object_pool=object_pool6super(nil)7@last_update=Gosu.milliseconds8end910defcontrol(obj)11self.object=obj12@vision=AiVision.new(obj,@object_pool,13rand(700..1200))14@gun=AiGun.new(obj,@vision)15@motion=TankMotionFSM.new(obj,@vision,@gun)
16end1718defon_collision(with)19@motion.on_collision(with)20end2122defon_damage(amount)23@motion.on_damage(amount)24end2526defupdate27returnifobject.health.dead?28@gun.adjust_angle29now=Gosu.milliseconds30returnifnow-@last_update<UPDATE_RATE31@last_update=now32@vision.update
33@gun.update34@motion.update35end36end
Itadjustgunangleallthetime,butdoesupdatesatUPDATE_RATEtosaveCPUpower.AIisusuallyoneofthemostCPUintensivethingsingames,soit’sacommonpracticetoexecuteitlessoften.Refreshingenemy
brains5persecondisenoughtomakethemdeadly.
MakesureyouspawnsomeAIcontrolledtanksinPlayStateandtrykillingthemnow.Ibettheywilleventuallygetyouevenwhilestandingstill.YoucanalsomaketanksspawnbelowmousecursorwhenyoupressTkey:
classPlayState<GameState#...
definitialize#...10.timesdo|i|Tank.new(@object_pool,AiInput.new(@object_pool))endend#...defbutton_down(id)#...ifid==Gosu::KbTt=Tank.new(@object_pool,AiInput.new(@object_pool))t.x,t.y=@camera.mouse_coordsend#...end
#...end
ImplementingTankMotionStatesThisistheplacewherewewillneedFiniteStateMachinetogetthingsright.Wewilldesignitlikethis:
1. TankMotionFSMwilldecidewhichmotionstatetankshouldbein,
consideringvariousparameters,e.g.existenceoftargetorlackthereof,health,etc.
2. TherewillbeTankMotionStatebaseclassthatwilloffercommonmethodslikedrive,waitandon_collision.
3. Concretemotionclasseswillimplementupdate,change_directionandothermethods,thatwill
fiddlewithTank#throttle_down
andTank#directiontomakeitmoveandturn.
WewillbeginwithTankMotionState:08-ai/entities/components/ai/tank_motion_state.rb1classTankMotionState2definitialize(object,vision)3@object=object4@vision=vision5end
67defenter8#Overrideifnecessary9end1011defchange_direction12#Override13end1415defwait_time16#Overrideandreturnanumber17end1819defdrive_time20#Overrideandreturnanumber21end2223defturn_time24#Overrideandreturna
number25end2627defupdate28#Override29end3031defwait32@sub_state=:waiting33@started_waiting=Gosu.milliseconds34@will_wait_for=wait_time35@object.throttle_down=false36end3738defdrive39@sub_state=:driving40@started_driving=Gosu.milliseconds
41@will_drive_for=drive_time42@object.throttle_down=true43end4445defshould_change_direction?46returntrueunless@changed_direction_at47Gosu.milliseconds-@changed_direction_at>48@will_keep_direction_for49end5051defsubstate_expired?52now=Gosu.milliseconds53case@sub_state54when:waiting55trueifnow-
@started_waiting>@will_wait_for56when:driving57trueifnow-@started_driving>@will_drive_for58else59true60end61end6263defon_collision(with)64change=caserand(0..100)65when0..3066-9067when30..60689069when60..707013571when80..90
72-13573else7418075end76@object.physics.change_direction
77@object.direction+change)78end79end
Nothingextraordinaryhere,andweneedaconcreteimplementationtogetafeelinghowitwouldwork,thereforelet’sexamine
TankRoamingState.Itwillbethedefaultstatewhichtankwouldbeiniftherewerenoenemiesaround.
TankRoamingState08-ai/entities/components/ai/tank_roaming_state.rb1classTankRoamingState<TankMotionState2definitialize(object,vision)3super4@object=object5@vision=vision6end
78defupdate9change_directionifshould_change_direction?10ifsubstate_expired?11rand>0.3?drive:wait12end13end1415defchange_direction16change=caserand(0..100)17when0..3018-4519when30..60204521when60..70229023when80..9024-90
25else26027end28ifchange!=029@object.physics.change_direction
30@object.direction+change)31end32@changed_direction_at=Gosu.milliseconds33@will_keep_direction_for=turn_time34end3536defwait_time37rand(500..2000)38end3940defdrive_time
41rand(1000..5000)42end4344defturn_time45rand(2000..5000)46end47end
Thelogichere:
1. Tankwillrandomlychangedirectioneveryturn_timeinterval,whichisbetween2and5seconds.
2. Tankwillchoosetodrive(80%chance)ortostandstill(20%chance).
3. Iftankchosetodrive,itwillkeepdrivingfordrive_time,whichisbetween1and5seconds.
4. Samegoeswithwaiting,butwait_time(0.5-2seconds)willbeusedforduration.
5. Directionchangesanddriving/waitingare
independent.
Thiswillmakeanimpressionthatourtankisdrivingaroundlookingforenemies.
TankFightingStateWhentankfinallyseesanopponent,itwillstartfighting.Fightingmotionshouldbemoreenergeticthanroaming,wewillneedasharpersetofchoicesin
change_directionamongotherthings.08-ai/entities/components/ai/tank_fighting_state.rb1classTankFightingState<TankMotionState2definitialize(object,vision)3super4@object=object5@vision=vision6end78defupdate9change_directionifshould_change_direction?10ifsubstate_expired?11rand>0.2?drive:
wait12end13end1415defchange_direction16change=caserand(0..100)17when0..2018-4519when20..40204521when40..60229023when60..8024-9025when80..902613527when90..10028-13529end30
@object.physics.change_direction
31@object.direction+change)32@changed_direction_at=Gosu.milliseconds33@will_keep_direction_for=turn_time34end3536defwait_time37rand(300..1000)38end3940defdrive_time41rand(2000..5000)42end4344defturn_time45rand(500..2500)
46end47end
Wewillhavemuchlesswaitingandmuchmoredrivingandturning.
TankChasingStateIfopponentisfleeing,wewillwanttosetourdirectiontowardstheopponentandhitpedaltothemetal.Nowaitinghere.AiGun#desired_gun_angle
willpointdirectlytoourenemy.08-ai/entities/components/ai/tank_chasing_state.rb1classTankChasingState<TankMotionState2definitialize(object,vision,gun)3super(object,vision)4@object=object5@vision=vision6@gun=gun7end89defupdate10change_directionifshould_change_direction?11drive
12end1314defchange_direction15@object.physics.change_direction
16@gun.desired_gun_angle-17@gun.desired_gun_angle%45)1819@changed_direction_at=Gosu.milliseconds20@will_keep_direction_for=turn_time21end2223defdrive_time241000025end26
27defturn_time28rand(300..600)29end30end
TankFleeingStateNow,ifourhealthislow,wewilldotheoppositeofchasing.Gunwillbepointingandshootingattheopponent,butwewantbodytomoveaway,sowewon’tgetourselveskilled.ItisverysimilartoTankChasingState
wherechange_directionaddsextra180degreestotheequation,butthereisonemorething.Tankcanonlyfleeforawhile.Thenitgetsitselftogetherandgoesintofinalbattle.That’swhyweprovidecan_flee?methodthatTankMotionFSMwillconsultwithbeforeenteringfleeingstate.
Wehaveimplementedallthestates,thatmeansweare
momentsawayfromactuallyplayableprototypewithtankbotsrunningaroundandfightingwithyouandeachother.
WiringTankMotionStatesIntoFiniteStateMachineImplementingTankMotionFSMafterwehaveallmotion
statesreadyissurprisinglyeasy:08-ai/entities/components/ai/tank_motion_fsm.rb1classTankMotionFSM2STATE_CHANGE_DELAY=50034definitialize(object,vision,gun)5@object=object6@vision=vision7@gun=gun8@roaming_state=TankRoamingState.new(object,vision)9@fighting_state=TankFightingState.new(object,vision)
10@fleeing_state=TankFleeingState.new(object,vision,gun)11@chasing_state=TankChasingState.new(object,vision,gun)12set_state(@roaming_state)13end1415defon_collision(with)16@current_state.on_collision(with
17end1819defon_damage(amount)20if@current_state==@roaming_state21set_state(@fighting_state)
22end23end2425defupdate26choose_state27@current_state.update28end2930defset_state(state)31returnunlessstate32returnifstate==@current_state33@last_state_change=Gosu.milliseconds34@current_state=state35state.enter36end3738defchoose_state39returnunlessGosu.milliseconds-
40(@last_state_change)>STATE_CHANGE_DELAY41if@gun.target42if@object.health.health>4043if@gun.distance_to_target>BulletPhysics::MAX_DIST44new_state=@chasing_state45else46new_state=@fighting_state47end48else49if@fleeing_state.can_flee?50new_state=@fleeing_state51else52new_state=
@fighting_state53end54end55else56new_state=@roaming_state57end58set_state(new_state)59end60end
Allthelogicisinchoose_statemethod,whichisprettyuglyandprocedural,butitdoesthejob.Thecodeshouldbeeasytounderstand,
classCamera#...defdraw_crosshairfactor=0.5x=$window.mouse_xy=$window.mouse_yc=crosshairc.draw(x-c.width*factor/2,y-c.height*factor/2,1000,factor,factor)end#...private
defcrosshair@crosshair||=Gosu::Image.new($window,
Utils.media_path('c_dot.png'),false)endend
Howeverthisnewcrosshairdidn’thelpmewin,Igotmyasskickedbadly.Increasinggamewindowsizehelped,butweobviouslyneedtofinetunemanythingsinthisAI,tomakeitsmartandchallengingratherthandumbanddeadlyaccurate.
MakingThePrototypePlayable
Rightnowwehaveasomewhatplayable,butboringprototypewithoutanyscoresorwinningconditions.Youcanjustrunaroundandshootothertanks.Nobody
wouldplayagamelikethis,henceweneedtotoaddthemissingparts.Thereisacrazyamountofthem.Itistimetogiveitathoroughplaythroughandwritedownalltheideasandpainpointsabouttheprototype.
Hereismylist:
1. Enemytanksdonotrespawn.
2. Enemytanksshootatmycurrentlocation,notatwhereIwillbewhenbullethitsme.
3. Enemytanksdon’tavoidcollisions.
4. Randommapsareboringandlackdetail,couldusemoretilesorrandomenvironmentobjects.
5. Bulletsarehardtoseeongreensurface.
6. Hardtotellwhereenemiesarecoming
from,radarwouldhelp.7. Soundsplayatfull
volumeevenwhensomethinghappensacrossthewholemap.
8. Mytankshouldrespawnafterit’sdead.
9. Motionandfiringmechanicsseemclumsy.
10. Mapboundariesarevisiblewhenyoucometotheedge.
11. Enemytankmovementpatternsneedpolishing
andimprovement.12. Bothmytankand
enemiesdon’thaveanyidentity.Sometimeshardtodistinguishwhoiswho.
13. Noideawhohasmostkills.HUDwithscoreandsomestatethatdisplaysscoredetailswouldhelp.
14. Wouldbegreattohaverandompowerupslikehealth,extradamage.
15. Explosionsdon’tleaveatrace.
16. Tankscouldleavetrails.17. Deadtankskeeppiling
upandclutteringthemap.
18. Camerashouldbescoutingaheadofyouwhenyoumove,notdraggingbehind.
19. Bulletsseemtoaccelerate.
Thiswillkeepusbusyforawhile,butintheendwewillprobablyhavesomethingthatwillhopefullybeabletoentertainpeopleformorethan3minutes.
Someitemsonthislistareeasyfixes.AfterplayingaroundwithPixelmatorfor15minutes,Iendedupwithabulletthatisvisibleonbothlightanddarkbackgrounds:
Highlyvisiblebullet
Motionandfiringmechanicswilleitherhavetobetunedsettingbysetting,orrewrittenfromscratch.Implementing
scoresystem,powerupsandimprovingenemyAIdeservetohavechaptersoftheirown.Therestcanbetakencareofrightaway.
DrawingWaterBeyondMapBoundariesWedon’twanttoseedarknesswhenwecometotheedgeofgameworld.
Luckily,itisatrivialfix.InMap#drawwecheckiftileexistsinmapbeforedrawingit.Whentiledoesnotexist,wecandrawwaterinstead.AndwecanalwaysfallbacktowatertileinMap#tile_at:
classMap#...defdraw(viewport)viewport.map!{|p|p/TILE_SIZE}x0,x1,y0,y1=viewport.map(&:to_i)(x0..x1).eachdo|x|(y0..y1).eachdo|y|
row=@map[x]map_x=x*TILE_SIZEmap_y=y*TILE_SIZEifrowtile=@map[x][y]iftiletile.draw(map_x,map_y,0)else@water.draw(map_x,map_y,0)endelse@water.draw(map_x,map_y,0)endendendend#...private
#...deftile_at(x,y)t_x=((x/TILE_SIZE)%TILE_SIZE).floort_y=((y/TILE_SIZE)%TILE_SIZE).floorrow=@map[t_x]row?row[t_y]:@waterend#...end
Nowtheedgelooksmuchbetter:
Tomakethemapmorefuntoplayat,wewillgeneratesometrees.Let’sstartwithTreeclass:09-polishing/entities/tree.rb1classTree<GameObject2attr_reader:x,:y,:health,:graphics34definitialize(object_pool,x,y,seed)5super(object_pool)6@x,@y=x,y7@graphics=TreeGraphics.new(self,seed)8@health=
Health.new(self,object_pool,30,false)9@angle=rand(-15..15)10end1112defon_collision(object)13@graphics.shake(object.direction
14end1516defbox17[x,y]18end19end
Nothingfancyhere,wewantittoshakeoncollision,andit
hasgraphicsandhealth.seedwillusedtogenerateclustersofsimilartrees.Let’stakealookatTreeGraphics:09-polishing/entities/components/tree_graphics.rb1classTreeGraphics<Component2SHAKE_TIME=1003SHAKE_COOLDOWN=2004SHAKE_DISTANCE=[2,1,0,-1,-2,-1,0,1,0,-1,0]5definitialize(object,seed)6super(object)7load_sprite(seed)8end
910defshake(direction)11now=Gosu.milliseconds12returnif@shake_start&&13now-@shake_start<SHAKE_TIME+SHAKE_COOLDOWN14@shake_start=now15@shake_direction=direction16@shaking=true17end1819defadjust_shake(x,y,shaking_for)20elapsed=[shaking_for,SHAKE_TIME].min/SHAKE_TIME.to_f21frame=((SHAKE_DISTANCE.length-1)*elapsed).floor
22distance=SHAKE_DISTANCE[frame]23Utils.point_at_distance(x,y,@shake_direction,distance)24end2526defdraw(viewport)27if@shaking28shaking_for=Gosu.milliseconds-@shake_start29shaking_x,shaking_y=adjust_shake(30center_x,center_y,shaking_for)31@tree.draw(shaking_x,shaking_y,5)32ifshaking_for>=SHAKE_TIME33@shaking=false
34end35else36@tree.draw(center_x,center_y,5)37end38Utils.mark_corners(object.box)if$debug39end4041defheight42@tree.height43end4445defwidth46@tree.width47end4849private5051defload_sprite(seed)
52frame_list=trees.frame_list53frame=frame_list[(frame_list.size*seed).round]54@tree=trees.frame(frame)55end5657defcenter_x58@center_x||=x-@tree.width/259end6061defcenter_y62@center_y||=y-@tree.height/263end6465deftrees66@@trees||=
Gosu::TexturePacker.load_json($window
67Utils.media_path('trees_packed.json'
68end69end
Shakingisprobablythemostinterestingparthere.Whenshakeiscalled,graphicswillstartdrawingtreeshiftedingivendirectionbyamountdefinedinSHAKE_DISTANCEarray.drawwillbestepping
throughSHAKE_DISTANCEdependingonSHAKE_TIME,anditwillnotbeshakenagainforSHAKE_COOLDOWNperiod,toavoidinfiniteshakingwhiledrivingintoit.
WealsoneedsomeadjustmentstoTankPhysicsandTanktobeabletohittrees.First,wewanttocreateanemptyon_collision(object)
methodinGameObjectclass,
soallgameobjectswillbeabletocollide.
Then,TankPhysicsstartscallingTank#on_collisionwhencollisionisdetected:
classTank<GameObject#...defon_collision(object)returnunlessobject#Avoidrecursionifobject.class==Tank#InformAIabouthitobject.input.on_collision(object
else
#Callonlyonnon-tankstoavoidrecursionobject.on_collision(self)end#BulletsshouldnotslowTanksdownifobject.class!=Bullet@sounds.collideif@physics.speed>1endend#...end
ThefinalingredienttoourTreeisHealth,whichisextractedfromTankHealthto
reduceduplication.TankHealthnowextendsit:09-polishing/entities/components/health.rb1classHealth<Component2attr_accessor:health34definitialize(object,object_pool,health,explodes)5super(object)6@explodes=explodes7@object_pool=object_pool8@initial_health=@health=health9@health_updated=true10end11
12defrestore13@health=@initial_health14@health_updated=true15end1617defdead?18@health<119end2021defupdate22update_image23end2425definflict_damage(amount)26if@health>027@health_updated=true28@health=[@health-amount.to_i,0].max29after_deathifdead?30end
31end3233defdraw(viewport)34returnunlessdraw?35@image&&@image.draw(36x-@image.width/2,37y-object.graphics.height/2-38@image.height,100)39end4041protected4243defdraw?44$debug45end4647defupdate_image48returnunlessdraw?49if@health_updated50text=@health.to_s
51font_size=1852@image=Gosu::Image.from_text(53$window,text,54Gosu.default_font_name,font_size)55@health_updated=false56end57end5859defafter_death60if@explodes61ifThread.list.count<862Thread.newdo63sleep(rand(0.1..0.3))64Explosion.new(@object_pool,x,
y)65sleep0.366object.mark_for_removal67end68else69Explosion.new(@object_pool,x,y)70object.mark_for_removal71end72else73object.mark_for_removal74end75end76end
Yes,youcanmaketreeexplodewhenit’sdestroyed.Anditcausescoolchainreactionsblowingupwholetreeclusters.Butlet’snotdothat,becausewewilladdsomethingmoreappropriateforexplosions.
OurTreeisreadytofillthelandscape.WewilldoitinMapclass,whichwillnowneedtoknowabout
ObjectPool,becausetreeswillgothere.
classMap#...definitialize(object_pool)load_tiles@object_pool=object_poolobject_pool.map=self@map=generate_mapgenerate_treesend#...defgenerate_treesnoises=Perlin::Noise.new(2)contrast=Perlin::Curve.contrast(Perlin::Curve::CUBIC,2)trees=0
target_trees=rand(300..500)whiletrees<target_treesdox=rand(0..MAP_WIDTH*TILE_SIZE)y=rand(0..MAP_HEIGHT*TILE_SIZE)n=noises[x*0.001,y*0.001]n=contrast.call(n)iftile_at(x,y)==@grass&&n>0.5Tree.new(@object_pool,x,y,n*2-1)trees+=1endendend#...end
Perlinnoiseisusedinsimilarfashionasitwaswhenwegeneratedmaptiles.Weallowcreatingtreesonlyifnoiselevelisabove0.5,andusenoiselevelasseedvalue-n*2-1willbeanumberbetween0and1whennisin0.5..1range.Andweonlyallowcreatingtreesongrasstiles.
Nowourmaplooksalittlebetter:
Treesaregreat,butwewantmoredetail.Let’sspicethingsupwithexplosiveboxesandbarrels.Theywillbeusingthesameclasswithsinglespritesheet,sowhilethespritewillbechosenrandomly,behaviorwillbethesame.ThisnewclasswillbecalledBox:09-polishing/entities/box.rb1classBox<GameObject2attr_reader:x,:y,:health,:graphics,:angle
34definitialize(object_pool,x,y)5super(object_pool)6@x,@y=x,y7@graphics=BoxGraphics.new(self)8@health=Health.new(self,object_pool,10,true)9@angle=rand(-15..15)10end1112defon_collision(object)13returnunlessobject.physics.speed>1.014@x,@y=Utils.point_at_distance(@x,@y,object.direction,2)15@box=nil16end
1718defbox19return@boxif@box20w=@graphics.width/221h=@graphics.height/222#Boundingboxadjustedtotrimshadows23@box=[x-w+4,y-h+8,24x+w,y-h+8,25x+w,y+h,26x-w+4,y+h]27@box=Utils.rotate(@angle,@x,@y,*@box)28end29end
Itwillbegeneratedwithslightrandomangle,topreserverealisticshadowsbutgiveanimpressionofchaoticplacement.Tankswillalsobeabletopushboxesalittleoncollision,butonlywhengoingfastenough.HealthcomponentisthesameonethatTreehas,butinitializedwithlesshealthandexplosiveflagistrue,sotheboxwillblowupafteronehitanddeal
extradamagetothesurroundings.
BoxGraphicsisnothingfancy,itjustloadsrandomspriteuponinitialization:09-polishing/entities/components/box_graphics.rb1classBoxGraphics<Component2definitialize(object)3super(object)4load_sprite5end67defdraw(viewport)
8@box.draw_rot(x,y,0,object.angle)9Utils.mark_corners(object.box)if$debug10end1112defheight13@box.height14end1516defwidth17@box.width18end1920private2122defload_sprite23frame=boxes.frame_list.sample24@box=
boxes.frame(frame)25end2627defcenter_x28@center_x||=x-width/229end3031defcenter_y32@center_y||=y-height/233end3435defboxes36@@boxes||=Gosu::TexturePacker.load_json($window
37Utils.media_path('boxes_barrels.json'
38end39end
TimetogenerateboxesinourMap.Itwillbesimilartotrees,butwewon’tneedPerlinnoise,sincetherewillbewayfewerboxesthantrees,sowedon’tneedtoformpatterns.Allweneedtodoistocheckifwe’renotgeneratingboxonwater.
classMap#...
definitialize(object_pool)#...generate_boxesend#...defgenerate_boxesboxes=0target_boxes=rand(10..30)whileboxes<target_boxesdox=rand(0..MAP_WIDTH*TILE_SIZE)y=rand(0..MAP_HEIGHT*TILE_SIZE)iftile_at(x,y)!=@waterBox.new(@object_pool,x,y)boxes+=1endend
Withallthevisualnoiseitisgettingincreasinglydifficulttoseeenemytanks.That’swhywewillimplementaRadartohelpourselves.09-polishing/entities/radar.rb1classRadar2UPDATE_FREQUENCY=10003WIDTH=1504HEIGHT=1005PADDING=106#Blackwith33%transparency7BACKGROUND=Gosu::Color.new(255*0.33,0,0,0)8attr_accessor:target
910definitialize(object_pool,target)11@object_pool=object_pool12@target=target13@last_update=014end1516defupdate17ifGosu.milliseconds-@last_update>UPDATE_FREQUENCY18@nearby=nil19end20@nearby||=@object_pool.nearby(@target,2000).selectdo|o|21o.class==Tank&&!o.health.dead?22end23end
2425defdraw26x1,x2,y1,y2=radar_coords27$window.draw_quad(28x1,y1,BACKGROUND,29x2,y1,BACKGROUND,30x2,y2,BACKGROUND,31x1,y2,BACKGROUND,32200)33draw_tank(@target,Gosu::Color::GREEN)34@nearby&&@nearby.eachdo|t|35draw_tank(t,Gosu::Color::RED)36end37end3839private40
41defdraw_tank(tank,color)42x1,x2,y1,y2=radar_coords43tx=x1+WIDTH/2+(tank.x-@target.x)/2044ty=y1+HEIGHT/2+(tank.y-@target.y)/2045if(x1..x2).include?(tx)&&(y1..y2).include?(ty)46$window.draw_quad(47tx-2,ty-2,color,48tx+2,ty-2,color,49tx+2,ty+2,color,50tx-2,ty+2,color,51300)52end53end
5455defradar_coords56x1=$window.width-WIDTH-PADDING57x2=$window.width-PADDING58y1=$window.height-HEIGHT-PADDING59y2=$window.height-PADDING60[x1,x2,y1,y2]61end62end
Radar,likeCamera,alsohasatarget.ItusesObjectPooltoquerynearbyobjectsandfiltersoutinstancesofalive
Tank.Thenitdrawsatransparentblackbackgroundandsmalldotsforeachtank,greenfortarget,redfortherest.
ToavoidqueryingObjectPooltoooften,Radarupdatesitselfonlyonceeverysecond.
Itisinitialized,updatedanddrawninPlayState,rightafterCamera:
classPlayState<GameState#...definitialize#...@camera.target=@tank@radar=Radar.new(@object_pool,@tank)#...end#...defupdate#...@camera.update@radar.update#...end#...defdraw#...@camera.draw_crosshair@radar.draw
Wehaveimprovedthevisuals,butsoundisstillterrible.Likesomesuperhero,youcanheareverythingthathappensinthemap,anditcandriveyouinsane.Wewillfixthatinamoment.
Theideaistomakeeverythingthathappensfurtherawayfromcameratargetsoundlessloud,untilthesoundfadesawaycompletely.Tomakeplayer’s
experiencemoreimmersive,wewillalsotakeadvantageofstereospeakers-soundsshouldappeartobecomingfromtherightdirection.
Unfortunately,Gosu::Sample#play_pan
doesnotworkasonewouldexpectitto.Ifyouplaythesamplewithjustalittlepanning,itcompletelycutsofftheoppositechannel,meaningthatifyouplaya
samplewithpanlevelof0.1(10%totheright),youwouldexpecttohearsomethinginleftspeakeraswell.Theactualbehavioristhatsoundplaysthroughtherightspeakerprettyloudly,andifyouincreasepanlevelto,say,0.7,youwillhearthesoundthroughrightspeakeragain,butitwillbewaymoresilent.
Toimplementrealisticstereosoundsthatcomethrough
bothspeakerswhenpanned,weneedtoplaytwosampleswithoppositepanlevel.Aftersomeexperimenting,Idiscoveredthatfiddlingwithpanlevelmakesthingssoundweird,whileplayingwithvolumeproducessofter,moresubtleeffect.ThisiswhatIendeduphaving:09-polishing/misc/stereo_sample.rb1classStereoSample2@@all_instances=[]
34defself.register_instances(instances
5@@all_instances<<instances6end78defself.cleanup9@@all_instances.eachdo|instances|10instances.eachdo|key,instance|11unlessinstance.playing?||instance.paused?12instances.delete(key)13end14end15end
16end1718definitialize(window,sound_l,sound_r=sound_l)19@sound_l=Gosu::Sample.new(window,sound_l)20#Usesamesampleinmono->stereo21ifsound_l==sound_r22@sound_r=@sound_l23else24@sound_r=Gosu::Sample.new(window,sound_r)25end26@instances={}27self.class.register_instances(@instances
28end
2930defpaused?(id=:default)31i=@instances["#{id}_l"]32i&&i.paused?33end3435defplaying?(id=:default)36i=@instances["#{id}_l"]37i&&i.playing?38end3940defstopped?(id=:default)41@instances["#{id}_l"].nil?42end4344defplay(id=:default,
pan=0,45volume=1,speed=1,looping=false)46@instances["#{id}_l"]=@sound_l.play_pan(47-0.2,0,speed,looping)48@instances["#{id}_r"]=@sound_r.play_pan(490.2,0,speed,looping)50volume_and_pan(id,volume,pan)51end5253defpause(id=:default)54@instances["#{id}_l"].pause55@instances["#{id}_r"].pause56end
5758defresume(id=:default)59@instances["#{id}_l"].resume60@instances["#{id}_r"].resume61end6263defstop64@instances.delete("#{id}_l").stop65@instances.delete("#{id}_r").stop66end6768defvolume_and_pan(id,volume,pan)69ifpan>070pan_l=1-pan*271pan_r=172else
73pan_l=174pan_r=1+pan*275end76pan_l*=volume77pan_r*=volume78@instances["#{id}_l"].volume=[pan_l,0.05].max79@instances["#{id}_r"].volume=[pan_r,0.05].max80end81end
StereoSamplemanagesstereoplaybackofsampleinstances,andtoavoidmemoryleaks,ithascleanup
thatscansallsampleinstancesandremovessamplesthathavefinishedplaying.Forthisremovaltowork,weneedtoplaceacalltoStereoSample.cleanupinsidePlayState#updatemethod.
Todeterminecorrectpanandvolume,wewillcreatesomehelpermethodsinUtilsmodule:
moduleUtilsHEARING_DISTANCE=1000.0#...defself.volume(object,camera)return1ifobject==camera.targetdistance=Utils.distance_between(camera.target.x,camera.target.y,object.x,object.y)distance=[(HEARING_DISTANCE-distance),0].maxdistance/HEARING_DISTANCEend
defself.pan(object,camera)return0ifobject==camera.target
pan=object.x-camera.target.xsig=pan>0?1:-1pan=(pan%HEARING_DISTANCE)/HEARING_DISTANCEifsig>0panelse-1+panendend
defself.volume_and_pan(object,camera)[volume(object,camera),pan(object,camera)]endend
Apparently,havingaccesstoCameraisnecessaryforcalculatingsoundvolumeandpan,sowewilladdattr_accessor:cameratoObjectPoolclassandassignitinPlayStateconstructor.Youmaywonderwhywedidn’tuseCamera#targetrightaway.Theansweristhatcameracanchangeit’starget.E.g.whenyourtankdies,newinstancewillbegeneratedwhenyourespawn,
soifallotherobjectswouldstillhavethereferencetoyouroldtank,guesswhatyouwouldhear?
RemasteredTankSoundscomponentisprobablythemostelaborateexampleofhowStereoSampleshouldbeused:09-polishing/entities/components/tank_sounds.rb1classTankSounds<Component2definitialize(object,
object_pool)3super(object)4@object_pool=object_pool5end67defupdate8id=object.object_id9ifobject.physics.moving?10move_volume=Utils.volume(11object,@object_pool.camera)12pan=Utils.pan(object,@object_pool.camera)13ifdriving_sound.paused?(id)14driving_sound.resume(id)
15elsifdriving_sound.stopped?(id)16driving_sound.play(id,pan,0.5,1,true)17end18driving_sound.volume_and_pan(id,move_volume*0.5,pan)19else20ifdriving_sound.playing?(id)21driving_sound.pause(id)22end23end24end2526defcollide27vol,pan=Utils.volume_and_pan(
28object,@object_pool.camera)29crash_sound.play(self.object_id,pan,vol,1,false)30end3132private3334defdriving_sound35@@driving_sound||=StereoSample.new(36$window,Utils.media_path('tank_driving.mp3'
37end3839defcrash_sound40@@crash_sound||=StereoSample.new(41$window,
Utils.media_path('metal_interaction2.wav'
42end43end
AndthisishowstaticExplosionSoundslookslike:09-polishing/entities/components/explosion_sounds.rb1classExplosionSounds2class<<self3defplay(object,camera)4volume,pan=Utils.volume_and_pan(object,camera)5sound.play(object.object_id,
pan,volume)6end78private910defsound11@@sound||=StereoSample.new(12$window,Utils.media_path('explosion.mp3'
13end14end15end
Afterwiringeverythingsothatsoundcomponentshave
accesstoObjectPool,therestisstraightforward.
GivingEnemiesIdentityWouldn’titbegreatifyoucouldtellyourselfapartfromtheenemies.Moreover,enemiescouldhavenames,soyouwouldknowwhichoneismoreaggressiveorhave,you
know,personalissueswithsomeone.
Todothatweneedtoasktheplayertoinputanickname,andchoosesomefunnynamesforeachenemyAI.Hereisanicelistwewillgrab:http://www.paulandstorm.com/wha/clown-names/
Wefirstcompileeverythingintoatextfiledcalled
names.txt,thatlookslikethis:media/names.txtStrippyBoffoBuffoDrips...
Nowweneedaclasstoparsethelistandgiveoutrandomnamesfromit.Wealsowanttolimitnamelengthto
somethingthatdisplaysnicely.09-polishing/misc/names.rb1classNames2definitialize(file)3@names=File.read(file).split("\n").rejectdo|n|4n.size>125end6end78defrandom9name=@names.sample10@names.delete(name)11name12end13end
Thenweneedtoplacethosenamessomewhere.Wecouldassignthemtotanks,butthinkahead-ifourplayerandAIenemieswillrespawn,weshouldgivenamestoinputs,becauseTankisreplaceable,driverisnot.Well,itis,butlet’snotgettoodeepintoit.
FornowwejustaddnameparametertoPlayerInputandAiInputinitializers,save
itin@nameinstancevariable,andthenadddraw(viewport)methodtomakeitrenderbelowthetank:
#09-polishing/entities/components/player_input.rb
classPlayerInput<Component#DarkgreenNAME_COLOR=Gosu::Color.argb(0xee084408)
definitialize(name,camera)super(nil)@name=name@camera=cameraend#...
defdraw(viewport)@name_image||=Gosu::Image.from_text($window,@name,Gosu.default_font_name,20)@name_image.draw(x-@name_image.width/2-1,y+object.graphics.height/2,100,1,1,Gosu::Color::WHITE)@name_image.draw(x-@name_image.width/2,y+object.graphics.height/2,100,1,1,NAME_COLOR)end#...
end
#09-polishing/entities/components/ai_input.rb
classAiInput<Component#DarkredNAME_COLOR=Gosu::Color.argb(0xeeb10000)
definitialize(name,object_pool)super(nil)@object_pool=object_pool@name=name@last_update=Gosu.millisecondsend#...defdraw(viewport)@motion.draw(viewport)
@gun.draw(viewport)@name_image||=Gosu::Image.from_text($window,@name,Gosu.default_font_name,20)@name_image.draw(x-@name_image.width/2-1,y+object.graphics.height/2,100,1,1,Gosu::Color::WHITE)@name_image.draw(x-@name_image.width/2,y+object.graphics.height/2,100,1,1,NAME_COLOR)end
#...end
WecanseethatgenericInputclasscanbeeasilyextracted,butlet’sfollowtheRuleofthreeandnotdoprematurerefactoring.
Instead,runthegameandenjoydyingfromabunchofmadclowns.
OnesToimplementrespawningwecoulduseMap#find_spawn_pointeverytimewewantedtorespawn,butitmaygetslow,becauseitbruteforcesthemapforrandomspotsthatarenotwater.Wedon’twantourgametostartfreezingwhentanksarerespawning,sowewillchangehowtankspawningworks.Insteadof
lookingforanewrespawnpointallthetime,wewillpre-generateseveralofthemforreuse.
classMap#...defspawn_points(max)@spawn_points=(0..max).mapdofind_spawn_pointend@spawn_points_pointer=0end
defspawn_point@spawn_points[(@spawn_points_pointer+=1)%@spawn_points.size]
end#...end
Herewehavespawn_pointsmethodthatpreparesanumberofspawnpointsandstoresthemin@spawn_pointsinstancevariable,andspawn_pointmethodthatcyclesthroughall@spawn_pointsandreturnsthemonebyone.find_spawn_pointcannowbecomeprivate.
WewilluseMap#spawn_pointswheninitializingPlayStateandpassObjectPooltoPlayerInput(AiInputalreadyhasit),sothatwewillbeabletocall@object_pool.map.spawn_point
whenneeded.
classPlayState<GameState#...definitialize#...@map=Map.new(@object_pool)
@map.spawn_points(15)@tank=Tank.new(@object_pool,PlayerInput.new('Player',@camera,@object_pool))#...10.timesdo|i|Tank.new(@object_pool,AiInput.new(@names.random,@object_pool))endend#...end
Whentankdies,wewantittostaydeadfor5secondsandthenrespawninoneofour
predefinedspawnpoints.WewillachievethatbyaddingrespawnmethodandcallingitinPlayerInput#updateandAiInput#updateiftankisdead.
#09-polishing/entities/components/player_input.rb
classPlayerInput<Component#...defupdatereturnrespawnifobject.health.dead?#...end#...
private
defrespawnifobject.health.should_respawn?object.health.restoreobject.x,object.y=@object_pool.map.spawn_point@camera.x,@camera.y=x,yPlayerSounds.respawn(object,@camera)endend#...end
#09-polishing/entities/components/ai_input.rb
classAiInput<Component#...defupdatereturnrespawnifobject.health.dead?#...end#...private
defrespawnifobject.health.should_respawn?object.health.restoreobject.x,object.y=@object_pool.map.spawn_pointPlayerSounds.respawn(object,@object_pool.camera)end
endend
WeneedsomechangesinTankHealthclasstoo:
classTankHealth<HealthRESPAWN_DELAY=5000#...defshould_respawn?Gosu.milliseconds-@death_time>RESPAWN_DELAYend#...defafter_death@death_time=Gosu.milliseconds#...end
end
classHealth<Component#...defrestore@health=@initial_health@health_updated=trueend#...end
Itshouldn’tbehardtoputeverythingtogetherandenjoytheneverendinggameplay.
Youmayhavenoticedthatwealsoaddedasoundthat
willbeplayedwhenplayerrespawns.Anice“whoosh”.09-polishing/entities/components/player_sounds.rb1classPlayerSounds2class<<self3defrespawn(object,camera)4volume,pan=Utils.volume_and_pan(object,camera)5respawn_sound.play(object.object_idpan,volume*0.5)6end78private9
10defrespawn_sound11@@respawn||=StereoSample.new(12$window,Utils.media_path('respawn.wav'))
13end14end15end
DisplayingExplosionDamageTrailsWhensomethingblowsup,youexpectittoleaveatrail,right?Inourcaseexplosions
disappearasifnothinghaseverhappened,andwejustcan’tleaveitlikethis.Let’sintroduceDamagegameobjectthatwillberesponsiblefordisplayingexplosionresidueonsandandgrass:09-polishing/entities/damage.rb1classDamage<GameObject2MAX_INSTANCES=1003attr_accessor:x,:y4@@instances=[]56definitialize(object_pool,x,y)7super(object_pool)
8DamageGraphics.new(self)9@x,@y=x,y10track(self)11end1213defeffect?14true15end1617private1819deftrack(instance)20if@@instances.size<MAX_INSTANCES21@@instances<<instance22else23out=@@instances.shift24out.mark_for_removal25@@instances<<
instance26end27end28end
Damagetracksit’sinstancesandstartsremovingoldoneswhenMAX_INSTANCESarereached.Withoutthisoptimization,thegamewouldgetincreasinglyslowereverytimesomebodyshoots.
Wehavealsoaddedanewgameobjecttrait-effect?
returnstrueonDamageandExplosion,falseonTank,Tree,BoxandBullet.ThatwaywecanfilterouteffectswhenqueryingObjectPool#nearbyforcollisionsorenemies.09-polishing/entities/object_pool.rb1classObjectPool2attr_accessor:objects,:map,:camera34definitialize5@objects=[]
6end78defnearby(object,max_distance)9non_effects.selectdo|obj|10obj!=object&&11(obj.x-object.x).abs<max_distance&&12(obj.y-object.y).abs<max_distance&&13Utils.distance_between(14obj.x,obj.y,object.x,object.y)<max_distance15end16end1718defnon_effects19
@objects.reject(&:effect?)20end21end
Whenitcomestorenderinggraphics,tomakeanimpressionofrandomness,wewillcyclethroughseveraldifferentdamageimagesanddrawthemrotated:09-polishing/entities/components/damage_graphics.rb1classDamageGraphics<Component
2definitialize(object_pool)3super4@image=images.sample5@angle=rand(0..360)6end78defdraw(viewport)9@image.draw_rot(x,y,0,@angle)10end1112private1314defimages15@@images||=(1..4).mapdo|i|16Gosu::Image.new($window,17Utils.media_path("damage#
{i}.png"),false)18end19end20end
ExplosionwillberesponsibleforcreatingDamageinstancesonsolidground,justbeforeexplosionanimationstarts:
classExplosion<GameObjectdefinitialize(object_pool,x,y)#...if@object_pool.map.can_move_to?
Whenplayingthegame,thereisafeelingthatbulletsstartoutslowwhenfiredandgainspeedastimegoes.Let’sreviewBulletPhysics#updateandthinkwhythisishappening:
classBulletPhysics<Component#...defupdatefly_speed=Utils.adjust_speed(object.speed)
fly_distance=(Gosu.milliseconds-object.fired_at)*
0.001*fly_speed/2object.x,object.y=point_at_distance(fly_distance)check_hitobject.explodeifarrived?end#...end
Flawhereisveryobvious.Gosu.milliseconds-
object.fired_atwillbeincreasinglybiggerastimegoes,thusincreasingfly_distance.Thefixisstraightforward-wewantto
calculatefly_distanceusingtimepassedbetweencallstoBulletPhysics#update,likethis:
classBulletPhysics<Component#...defupdatefly_speed=Utils.adjust_speed(object.speed)
now=Gosu.milliseconds@last_update||=object.fired_atfly_distance=(now-@last_update)*0.001*fly_speedobject.x,object.y=point_at_distance(fly_distance)
@last_update=nowcheck_hitobject.explodeifarrived?end#...end
Butifyouwouldrunthegamenow,bulletswouldflysoslow,thatyouwouldfeellikeNeoinTheMatrix.Tofixthat,wewillhavetotellourtanktofirebulletsalittlefaster.
classTank<GameObject#...defshoot(target_x,target_y)ifcan_shoot?@last_shot=Gosu.millisecondsBullet.new(object_pool,@x,@y,target_x,target_y).fire(self,1500)#Oldvaluewas100endend#...end
Nowbulletsflyliketheyaresupposedto.Icanonlywonderwhyhaven’tInoticed
thisbugintheverybeginning.
MakingCameraLookAheadOneofthemostannoyingthingswithcurrentstateofprototypeisthatCameraisdraggingbehindinsteadofshowingwhatisinthedirectionyouaremoving.Tofixtheissue,weneedto
changethewayhowCameramovesaround.FirstweneedtoknowwhereCamerawantstobe.WewilluseUtils.point_at_distancetochooseaspotaheadoftheTank.Then,Camera#updateneedstoberewritten,soCameracandynamicallyadjusttoit’sdesiredspot.Herearethechanges:
classCamera#...defdesired_spot
if@target.physics.moving?Utils.point_at_distance(@target.x,@target.y,@target.direction,@target.physics.speed.ceil*25)else[@target.x,@target.y]endend#...defupdatedes_x,des_y=desired_spotshift=Utils.adjust_speed(@target.physics.speed).floor+1if@x<des_xifdes_x-@x<shift@x=des_x
else@x+=shiftendelsif@x>des_xif@x-des_x<shift@x=des_xelse@x-=shiftendendif@y<des_yifdes_y-@y<shift@y=des_yelse@y+=shiftendelsif@y>des_yif@y-des_y<shift@y=des_yelse@y-=shift
endend#...end#...end
Itwouldn’twincodestyleawards,butitdoesthejob.Gameisnowmuchmoreplayable.
ReviewingTheChanges
Let’sgetbacktoourlistofimprovementstoseewhatwehavedone:
1. Enemytanksdonotrespawn.
2. Randommapsareboringandlackdetail,couldusemoretilesorrandomenvironmentobjects.
3. Bulletsarehardtoseeongreensurface.
4. Hardtotellwhereenemiesarecoming
from,radarwouldhelp.5. Soundsplayatfull
volumeevenwhensomethinghappensacrossThewholemap.
6. Mytankshouldrespawnafterit’sdead.
7. Mapboundariesarevisiblewhenyoucometotheedge.
8. Bothmytankandenemiesdon’thaveanyidentity.Sometimeshard
todistinguishwhoiswho.
9. Explosionsdon’tleaveatrace.
10. Deadtankskeeppilingupandclutteringthemap.
11. Camerashouldbescoutingaheadofyouwhenyoumove,notdraggingbehind.
12. Bulletsseemtoaccelerate.
Notbadforastart.Thisiswhatwestillneedtocoverinnextcoupleofchapters:
1. Enemytanksshootatmycurrentlocation,notatwhereIwillbewhenbullethitsme.
2. Enemytanksdon’tavoidcollisions.
3. Enemytankmovementpatternsneedpolishingandimprovement.
4. Noideawhohasmostkills.HUDwithscoreandsomestatethatdisplaysscoredetailswould
5. Wouldbegreattohaverandompowerupslikehealth,extradamage.
6. Motionandfiringmechanicsseemclumsy.help.
7. Tankscouldleavetrails.
Iwilladd“OptimizeObjectPoolperformance”,becausegamestartsslowingdownwhentoomanyobjectsareaddedtothepool,andprofilingshowsthatArray#select,whichistheheartofObjectPool#nearby,isthemaincause.Speedisoneofmostimportantfeaturesofanygame,solet’snothesitatetoimproveit.
DealingWithThousandsOfGameObjects
Gosuisblazingfastwhenitcomestodrawing,buttherearemorethingsgoingon.Namely,weuseObjectPool#nearbyquiteoftentoloopthrough
thousandsofobjects60timespersecondtomeasuredistancesamongthem.Thisslowseverythingdownwhenobjectpoolgrows.
Todemonstratetheeffect,wewillgenerate1500trees,30tanks,~100boxesandleave1000damagetrailsfromexplosions.ItwasenoughtodropFPSbelow30:
Thereisasolutionforthisparticularproblemis“SpatialPartitioning”,andtheessenceofitisthatyouhavetouseatree-likedatastructurethatdividesspaceintoregions,placesobjectsthereandletsyouqueryitselfinlogarithmictime,omittingobjectsthatfalloutofqueryregion.SpatialPartitioningisexplainedwellinGameProgrammingPatterns.
Probablythemostappropriatedatastructureforour2Dgameisquadtree.ToquoteWikipedia,“quadtreesaremostoftenusedtopartitionatwo-dimensionalspacebyrecursivelysubdividingitintofourquadrantsorregions.”Hereishowitlookslike:
ImplementingAQuadtreeTherearesomeimplementationsofquadtreeavailableforRuby-rquad,rubyquadtreeandrubyquad,butitseemseasytoimplement,sowewillbuildonetailored(read:closelycoupled)toourgameusingthepseudocodefromWikipedia.
AxisAlignedBoundingBoxOneofprerequisitesofquadtreeisAxisalignedboundingbox,sometimesreferredtoas“AABB”.Itissimplyaboxthatsurroundstheshapebuthasedgesthatareinparallelwiththeaxesofunderlyingcoordinatesystem.Theadvantageofthisboxisthatitgivesaroughestimatewheretheshapeisandisveryefficientwhenit
Axisalignedboundingboxwithcenterpointandhalfdimension
Todefineaxisalignedboundingbox,weneedit’scenterpointandhalfdimensionvector,which
pointsfromcenterpointtooneofthecornersofthebox,andtwomethods,onethattellsifAABBcontainsapoint,andonethattellsifAABBintersectswithanotherAABB.Thisishowourimplementationlookslike:10-partitioning/misc/axis_aligned_bounding_box.rb1classAxisAlignedBoundingBox2attr_reader:center,:half_dimension3definitialize(center,half_dimension)
4@center=center5@half_dimension=half_dimension6@dhx=(@half_dimension[0]-@center[0]).abs7@dhy=(@half_dimension[1]-@center[1]).abs8end910defcontains?(point)11returnfalseunless(@center[0]+@dhx)>=point[0]12returnfalseunless(@center[0]-@dhx)<=point[0]13returnfalseunless(@center[1]+@dhy)>=point[1]14returnfalseunless(@center[1]-@dhy)<=point[1]15true
16end1718defintersects?(other)19ocx,ocy=other.center20ohx,ohy=other.half_dimension21odhx=(ohx-ocx).abs22returnfalseunless(@center[0]+@dhx)>=(ocx-odhx)23returnfalseunless(@center[0]-@dhx)<=(ocx+odhx)24odhy=(ohy-ocy).abs25returnfalseunless(@center[1]+@dhy)>=(ocy-odhy)26returnfalseunless(@center[1]-@dhy)<=(ocy+odhy)27true
28end2930defto_s31"c:#{@center},h:#{@half_dimension}"32end33end
Ifyoudigin10-partitioning/specs,youwillfindtestsforthisimplementationtoo.
ThemathusedinAxisAlignedBoundingBox#contains?
and
AxisAlignedBoundingBox#intersects?
isfairlysimpleandhopefullyveryfast,becausethesemethodswillbecalledbillionsoftimesthroughoutthegame.
QuadTreeForGameObjectsToimplementthegloriousQuadTreeitself,weneedtoinitializeitwithboundary,thatisdefinedbyaninstance
ofAxisAlignedBoundingBoxandprovidemethodsforinserting,removingandqueryingthetree.PrivateQuadTree#subdividemethodwillbecalledwhenwetrytoinsertanobjectintoatreethathasmoreobjectsthanit’sNODE_CAPACITY.10-partitioning/misc/quad_tree.rb1classQuadTree2NODE_CAPACITY=123attr_accessor:ne,:nw,:se,:sw,:objects4
5definitialize(boundary)6@boundary=boundary7@objects=[]8end910definsert(game_object)11returnfalseunless@boundary.contains?(12game_object.location)1314if@objects.size<NODE_CAPACITY15@objects<<game_object16returntrue17end1819subdivideunless@nw2021returntrueif@nw.insert(game_object)
22returntrueif@ne.insert(game_object)23returntrueif@sw.insert(game_object)24returntrueif@se.insert(game_object)2526#shouldneverhappen27raise"Failedtoinsert#{game_object}"28end2930defremove(game_object)31returnfalseunless@boundary.contains?(32game_object.location)33if@objects.delete(game_object)34returntrue35end36returnfalseunless@nw
37returntrueif@nw.remove(game_object)38returntrueif@ne.remove(game_object)39returntrueif@sw.remove(game_object)40returntrueif@se.remove(game_object)41false42end4344defquery_range(range)45result=[]46unless@boundary.intersects?(range)47returnresult48end4950@objects.eachdo|o|51ifrange.contains?(o.location)
52result<<o53end54end5556#Notsubdivided57returnresultunless@ne5859result+=@nw.query_range(range)60result+=@ne.query_range(range)61result+=@sw.query_range(range)62result+=@se.query_range(range)6364result65end6667private68
69defsubdivide70cx,cy=@boundary.center71hx,hy=@boundary.half_dimension72hhx=(cx-hx).abs/2.073hhy=(cy-hy).abs/2.074@nw=QuadTree.new(75AxisAlignedBoundingBox.new(76[cx-hhx,cy-hhy],77[cx,cy]))78@ne=QuadTree.new(79AxisAlignedBoundingBox.new(80[cx+hhx,cy-hhy],81[cx,cy]))
82@sw=QuadTree.new(83AxisAlignedBoundingBox.new(84[cx-hhx,cy+hhy],85[cx,cy]))86@se=QuadTree.new(87AxisAlignedBoundingBox.new(88[cx+hhx,cy+hhy],89[cx,cy]))90end91end
ThisisavanillaquadtreethatstoresinstancesofGameObjectanduses
GameObject#locationforindexingobjectsinspace.Italsohasspecsthatyoucanfindincodesamples.
YoucanexperimentwithQuadTree#NODE_CAPACITY,butIfoundthatvaluesbetween8and16worksbest,soIsettledwith12.
IntegratingObjectPoolWith
QuadTreeWehaveimplementedaQuadTree,butitisnotyetincorporatedintoourgame.Todothat,wewillhookitintoObjectPoolandtrytokeeptheoldinterfaceintact,sothatObjectPool#nearbywillstillworkasusual,butwillbeabletocopewithwaymoreobjectsthanbefore.10-partitioning/entities/object_pool.rb
1classObjectPool2attr_accessor:map,:camera,:objects34defsize5@objects.size6end78definitialize(box)9@tree=QuadTree.new(box)10@objects=[]11end1213defadd(object)14@objects<<object15@tree.insert(object)16end1718deftree_remove(object)
19@tree.remove(object)20end2122deftree_insert(object)23@tree.insert(object)24end2526defupdate_all27@objects.map(&:update)28@objects.reject!do|o|29ifo.removable?30@tree.remove(o)31true32end33end34end3536defnearby(object,max_distance)37cx,cy=object.location38hx,hy=cx+
max_distance,cy+max_distance39#Fast,roughresults40results=@tree.query_range(41AxisAlignedBoundingBox.new([cx,cy],[hx,hy]))42#Siftthroughtoselectfine-grainedresults43results.selectdo|o|44o!=object&&45Utils.distance_between(46o.x,o.y,object.x,object.y)<=max_distance47end48end4950defquery_range(box)51@tree.query_range(box)
52end53end
Anoldfashionedarrayofallobjectsisstillused,becausewestillneedtoloopthrougheverythingandinvokeGameObject#update.ObjectPool#query_range
wasintroducedtoquicklygrabobjectsthathavetoberenderedonscreen,andObjectPool#nearbynowqueriestreeandmeasures
distancesonlyonroughresultset.
Thisishowwewillrenderthingsfromnowon:
classPlayState<GameState#...defdrawcam_x=@camera.xcam_y=@camera.yoff_x=$window.width/2-cam_xoff_y=$window.height/2-cam_yviewport=@camera.viewportx1,x2,y1,y2=viewportbox=
AxisAlignedBoundingBox.new([x1+(x2-x1)/2,y1+(y2-y1)/2],[x1-Map::TILE_SIZE,y1-Map::TILE_SIZE])$window.translate(off_x,off_y)dozoom=@camera.zoom$window.scale(zoom,zoom,cam_x,cam_y)do@map.draw(viewport)@object_pool.query_range(box).mapdo|o|o.draw(viewport)endendend@camera.draw_crosshair@radar.drawend
#...end
MovingObjectsInQuadTreeThereisonemoreerrandwenowhavetotakecareof.Everythingworksfinewhenthingsarestatic,butwhentanksandbulletsmove,weneedtoupdatetheminourQuadTree.That’swhyObjectPoolhastree_remove
andtree_insert,whicharecalledfromGameObject#move.Fromnowon,theonlywaytochangeobject’slocationwillbebyusingGameObject#move:
classGameObjectattr_reader:x,:y,:location,:componentsdefinitialize(object_pool,x,y)@x,@y=x,y@location=[x,y]@components=[]@object_pool=object_pool@object_pool.add(self)end
defmove(new_x,new_y)returnifnew_x==@x&&new_y==@y@object_pool.tree_remove(self)@x=new_x@y=new_y@location=[new_x,new_y]@object_pool.tree_insert(self)end#...end
Atthispointwehavetogothroughallthegameobjectsandchangehowthey
initializetheirbaseclassandupdatexandycoordinates,butwewon’tcoverthathere.Ifindoubt,refertocodeat10-partitioning.
Finally,FPSisbacktostable60andwecanfocusongameplayagain.
ImplementingPowerups
Gamewouldbecomemorestrategiciftherewerewaystorepairyourdamagedtank,boostit’sspeedorincreaserateoffirebypickingupvariouspowerups.Thisshouldnotbetoodifficultto
implement.Wewillusesomeoftheseimages:
Powerups
Fornow,therewillbefourkindsofpowerups:
1. Repairdamage.Wrenchbadgewillrestoredamagedtank’shealthbackto100whenpickedup.
2. Healthboost.Green+1badgewilladd25health,upto200total,ifyoukeeppickingthemup.
3. Fireboost.Doublebulletbadgewillincreasereloadspeedby25%,upto200%ifyoukeeppickingthemup.
4. Speedboost.Airplanebadgewillincreasemovementspeedby10%,upto150%ifyoukeeppickingthemup
Thesepowerupswillbeplacedrandomlyaroundthemap,andwillautomaticallyrespawn30secondsafterpickup.
ImplementingBasePowerupBeforerushingforwardtoimplementthis,wehavetodosomeresearchandthinkhowtoelegantlyintegratethisintothewholegame.First,let’sagreethatPowerupisaGameObject.Itwillhavegraphics,soundsandit’scoordinates.Effectscanbyappliedbyharnessing
GameObject#on_collision-whenTankcollideswithPowerup,itgetsit.11-powerups/entities/powerups/powerup.rb1classPowerup<GameObject2definitialize(object_pool,x,y)3super4PowerupGraphics.new(self,graphics)5end67defbox8[x-8,y-8,9x+8,y-8,
10x+8,y+8,11x-8,y+8]12end1314defon_collision(object)15ifpickup(object)16PowerupSounds.play(object,object_pool.camera)17remove18end19end2021defpickup(object)22#overrideandimplementapplication23end2425defremove26object_pool.powerup_respawn_queue
27respawn_delay,28self.class,x,y)29mark_for_removal30end3132defrespawn_delay333034end35end
IgnorePowerup#remove,wewillgettoitwhenimplementingPowerupRespawnQueue.The
restshouldbestraightforward.
ImplementingPowerupGraphicsAllpowerupswillusethesamespritesheet,sotherecouldbeasinglePowerupGraphicsclassthatwillberenderinggivenspritetype.Wewillusegosu-texture-packergem,since
spritesheetisconvenientlypackedalready.11-powerups/entities/components/powerup_graphics.rb1classPowerupGraphics<Component2definitialize(object,type)3super(object)4@type=type5end67defdraw(viewport)8image.draw(x-12,y-12,1)9Utils.mark_corners(object.box)if$debug
10end1112private1314defimage15@image||=images.frame("#{@type}.png")16end1718defimages19@@images||=Gosu::TexturePacker.load_json(20$window,Utils.media_path('pickups.json'))
21end22end
ImplementingPowerupSoundsIt’sevensimplerwithsounds.Allpowerupswillemitamellow“bleep”whenpickedup,soPowerupSoundscanbecompletelystatic,likeExplosionSoundsorBulletSounds:11-powerups/entities/components/powerup_sounds.rb
1classPowerupSounds2class<<self3defplay(object,camera)4volume,pan=Utils.volume_and_pan(object,camera)5sound.play(object.object_id,pan,volume)6end78private910defsound11@@sound||=StereoSample.new(12$window,Utils.media_path('powerup.mp3'))
13end
14end15end
ImplementingRepairDamagePowerupRepairingbrokentankisprobablythemostimportantpowerupofthemall,solet’simplementitfirst:11-powerups/entities/powerups/repair_powerup.rb1classRepairPowerup<Powerup
2defpickup(object)3ifobject.class==Tank4ifobject.health.health<1005object.health.restore6end7true8end9end1011defgraphics12:repair13end14end
Thiswasincrediblysimple.Health#restorealready
existedsincewehadtorespawnourtanks.Wecanonlyhopeotherpowerupsareassimpletoimplementasthisone.
ImplementingHealthBoostRepairingdamageisgreat,buthowaboutboostingsomeextrahealthforupcoming
battles?Healthboosttotherescue:11-powerups/entities/powerups/health_powerup.rb1classHealthPowerup<Powerup2defpickup(object)3ifobject.class==Tank4object.health.increase(25)5true6end7end89defgraphics10:life_up11end12end
ThistimewehavetoimplementHealth#increase,butitisprettysimple:
classHealth<Component#...defincrease(amount)@health=[@health+25,@initial_health*2].min@health_updated=trueend#...end
SinceTankhas@initial_healthequalto100,increasinghealthwon’tgoover200,whichisexactlywhatwewant.
ImplementingFireRateBoostHowaboutboostingtank’sfirerate?11-powerups/entities/powerups/fire_rate_powerup.rb
1classFireRatePowerup<Powerup2defpickup(object)3ifobject.class==Tank4ifobject.fire_rate_modifier<25object.fire_rate_modifier+=0.256end7true8end9end1011defgraphics12:straight_gun13end14end
Weneedtointroduce@fire_rate_modifierinTankclassanduseitwhencallingTank#can_shoot?:
classTank<GameObject#...attr_accessor:fire_rate_modifier#...defcan_shoot?Gosu.milliseconds-(@last_shot||0)>(SHOOT_DELAY/@fire_rate_modifier)end#...defreset_modifiers@fire_rate_modifier=1
end#...end
Tank#reset_modifiershouldbecalledwhenrespawning,sincewewanttankstolosetheirpowerupswhentheydie.ItcanbedoneinTankHealth#after_death:
classTankHealth<Health#...defafter_deathobject.reset_modifiers#...
endend
ImplementingTankSpeedBoostTankspeedboostisverysimilartofireratepowerup:11-powerups/entities/powerups/tank_speed_powerup.rb1classTankSpeedPowerup<Powerup2defpickup(object)3ifobject.class==Tank4if
object.speed_modifier<1.55object.speed_modifier+=0.106end7true8end9end1011defgraphics12:wingman13end14end
Wehavetoadd@speed_modifiertoTankclassanduseitinTankPhysics#updatewhen
calculatingmovementdistance.
#11-powerups/entities/tank.rbclassTank<GameObject#...attr_accessor:speed_modifier#...defreset_modifiers#...@speed_modifier=1end#...end
#11-powerups/entities/components/tank_physics.rb
classTankPhysics<Component#...
defupdate#...new_x,new_y=x,yspeed=apply_movement_penalty(@speed)shift=Utils.adjust_speed(speed)*object.speed_modifier#...end#...end
Camera#updatehasalsorefertoTank#speed_modifier,otherwisetheoperatorwillfailtocatchupandcamerawillbelaggingbehind.
classCamera#...defupdate#...shift=Utils.adjust_speed(@target.physics.speed).floor*@target.speed_modifier+1#...end#...end
SpawningPowerupsOnMap
Powerupsareimplemented,butnotyetspawned.Wewillspawn20-30randompowerupswhengeneratingthemap:
classMap#...definitialize(object_pool)#...generate_powerupsend#...defgenerate_powerupspups=0target_pups=rand(20..30)whilepups<target_pupsdox=rand(0..MAP_WIDTH*
TILE_SIZE)y=rand(0..MAP_HEIGHT*TILE_SIZE)iftile_at(x,y)!=@waterrandom_powerup.new(@object_pool,x,y)pups+=1endendend
defrandom_powerup[HealthPowerup,RepairPowerup,FireRatePowerup,TankSpeedPowerup].sampleend#...end
Thecodeisverysimilartogeneratingboxes.It’sprobablynotthebestwaytodistributepowerupsonmap,butitwillhavetodofornow.
RespawningPowerupsAfterPickupWhenwepickupapowerup,wewantittoreappearinsamespot30secondslater.A
thought“wecanstartanewThreadwithsleepandinitializethesamepowerupthere”soundsverybad,butIhaditforafewseconds.ThenPowerupRespawnQueuewasborn.
First,let’srecallhowPowerup#removemethodlookslike:
classPowerup<GameObject#...defremove
object_pool.powerup_respawn_queue
respawn_delay,self.class,x,y)mark_for_removalend#...end
Powerupenqueuesitselfforrespawnwhenpickedup,providingit’sclassandcoordinates.PowerupRespawnQueueholdsthisdataandrespawns
powerupsatrighttimewithhelpofObjectPool:11-powerups/entities/powerups/powerup_respawn_queue.rb1classPowerupRespawnQueue2RESPAWN_DELAY=10003definitialize4@respawn_queue={}5@last_respawn=Gosu.milliseconds6end78defenqueue(delay_seconds,type,x,y)9respawn_at=Gosu.milliseconds+delay_seconds*100010
@respawn_queue[respawn_at.to_i]=[type,x,y]11end1213defrespawn(object_pool)14now=Gosu.milliseconds15returnifnow-@last_respawn<RESPAWN_DELAY16@respawn_queue.keys.eachdo|k|17nextifk>now#notyet18type,x,y=@respawn_queue.delete(k)19type.new(object_pool,x,y)20end21@last_respawn=now22end23end
PowerupRespawnQeueue#respawn
iscalledfromObjectPool#update_all,butisthrottledtorunoncepersecondforbetterperformance.
classObjectPool#...attr_accessor:powerup_respawn_queue#...defupdate_all#...@powerup_respawn_queue.respawn(self
end
#...end
Thisisit,thegameshouldnowcontainrandomlyplacedpowerupsthatrespawn30secondsafterpickedup.Timetoenjoytheresult.
Playingwithpowerups
Wehaven’tdoneanychangestoAIthough,thatmeansenemieswillonlybepickingthosepowerupsbyaccident,
sonowyouhaveasignificantadvantageandthegamehassuddenlybecametooeasytoplay.Don’tworry,wewillbefixingthatwhenoverhaulingtheAI.
ImplementingHeadsUpDisplay
Inordertoknowwhat’shappening,weneedsomesortofHUD.Wealreadyhavecrosshairandradar,buttheyarescatteredaroundincode.Nowwewanttodisplay
activepowerupmodifiers,soyouwouldknowwhatisyourfirerateandspeed,andifit’sworthgettingonemorepowerupbeforegoingintothenextfight.
DesignConsiderationsWhilecreatingourHUDclass,wewillhavetostartbuildinggamestats,because
wewanttodisplaynumberofkillsourtankmade.Statstopicwillbecoveredindepthlater,butfornowlet’sassumethat@tank.input.stats.killsgivesusthekillcount,whichwewanttodrawintop-leftcornerofthescreen,alongwithplayerhealthandmodifiervalues.
HUDwillalsoberesponsiblefordrawingcrosshairand
radar.
RenderingTextWithCustomFontPreviously,alltextwererenderedwithGosu.default_font_name,andwewantsomethingmorefancyandmorethematic,probablyadirtystencilbasedfontlikethisone:
ArmaliteRiflefont
Andonemorefancyfontwillmakeourgametitlelookgood.Toobadwedon’thaveatitleyet,but“TanksPrototype”writeninathematicwaystilllooksprettygood.
Tohaveconvenientaccesstothesefonts,wewilladdahelpermethodsinUtils:
moduleUtils#...defself.title_fontmedia_path('top_secret.ttf')end
defself.main_fontmedia_path('armalite_rifle.ttf')
end#...end
UseitinsteadofGosu.default_font_name:
size=20Gosu::Image.from_text($window,"Yourtext",Utils.main_font,size)
ImplementingHUDClassAfterwehaveputeverythingtogether,wewillgetHUDclass:12-stats/entities/hud.rb
1classHUD2attr_accessor:active3definitialize(object_pool,tank)4@object_pool=object_pool5@tank=tank6@radar=Radar.new(@object_pool,tank)7end89defplayer=(tank)10@tank=tank11@radar.target=tank12end1314defupdate15@radar.update16end17
18defhealth_image19if@health.nil?||@tank.health.health!=@health20@health=@tank.health.health21@health_image=Gosu::Image.from_text(22$window,"Health:#{@health}",Utils.main_font,20)23end24@health_image25end2627defstats_image28stats=@tank.input.stats29if@stats_image.nil?||stats.changed_at<=Gosu.milliseconds30@stats_image=
Gosu::Image.from_text(31$window,"Kills:#{stats.kills}",Utils.main_font,20)32end33@stats_image34end3536deffire_rate_image37if@tank.fire_rate_modifier>138if@fire_rate!=@tank.fire_rate_modifier39@fire_rate=@tank.fire_rate_modifier40@fire_rate_image=Gosu::Image.from_text(41$window,"Firerate:#{@fire_rate.round(2)}X",42Utils.main_font,20)
43end44else45@fire_rate_image=nil46end47@fire_rate_image48end4950defspeed_image51if@tank.speed_modifier>152if@speed!=@tank.speed_modifier53@speed=@tank.speed_modifier54@speed_image=Gosu::Image.from_text(55$window,"Speed:#{@speed.round(2)}X",56Utils.main_font,20)57end
58else59@speed_image=nil60end61@speed_image62end6364defdraw65if@active66@object_pool.camera.draw_crosshair
67end68@radar.draw69offset=2070health_image.draw(20,offset,1000)71stats_image.draw(20,offset+=30,1000)72iffire_rate_image73fire_rate_image.draw(20,offset
+=30,1000)74end75ifspeed_image76speed_image.draw(20,offset+=30,1000)77end78end79end
Touseit,weneedtohookintoPlayState:
classPlayState<GameState#...definitialize#...@hud=HUD.new(@object_pool,@tank)
end
defupdate#...@hud.updateend
defdraw#...@hud.drawend#...end
Assumingyouhavemocked@tank.input.stats.killsinHUD,youshouldgetaneatviewshowinginteresting
ImplementingGameStatistics
Gameslikeonewearebuildingareallaboutcompetition,andyoucannotcompeteifyoudon’tknowthescore.Letusintroduceaclassthatwillberesponsibleforkeepingtabsonvariousstatisticsofeverytank.
12-stats/misc/stats.rb1classStats2attr_reader:name,:kills,:deaths,:shots,:changed_at3definitialize(name)4@name=name5@kills=@deaths=@shots=@damage=@damage_dealt=06changed7end89defadd_kill(amount=1)10@kills+=amount11changed12end1314defadd_death15@deaths+=116changed
17end1819defadd_shot20@shots+=121changed22end2324defadd_damage(amount)25@damage+=amount26changed27end2829defdamage30@damage.round31end3233defadd_damage_dealt(amount)34@damage_dealt+=amount35changed36end
3738defdamage_dealt39@damage_dealt.round40end4142defto_s43"[kills:#{@kills},"\44"deaths:#{@deaths},"\45"shots:#{@shots},"\46"damage:#{damage},"\47"damage_dealt:#{damage_dealt}]"48end4950private5152defchanged53@changed_at=Gosu.milliseconds
54end55end
WhilebuildingtheHUD,weestablishedthatStatsshouldbelongtoTank#input,becauseitdefineswhoiscontrollingthetank.So,everyinstanceofPlayerInputandAiInputhastohaveit’sownStats:
#12-stats/entities/components/player_input.rb
classPlayerInput<Component#...attr_reader:stats
definitialize(name,camera,object_pool)#...@stats=Stats.new(name)end#...defon_damage(amount)@stats.add_damage(amount)end#...end
#12-stats/entities/components/ai_input.rb
classAiInput<Component#...
attr_reader:stats
definitialize(name,object_pool)#...@stats=Stats.new(name)end
defon_damage(amount)#...@stats.add_damage(amount)endend
ThatitchtoextractabaseclassfromPlayerInputandAiInputisgettingstronger,
butwewillhavetoresisttheurge,fornow.
TrackingKills,DeathsandDamageTobegintrackingkills,weneedtoknowwhomdoeseverybulletbelongto.Bulletalreadyhassourceattribute,whichcontainsthetankthatfiredit,therewillbenotroubletofindoutwho
wastheshooterwhenbulletgetsadirecthit.Buthowaboutexplosions?Bulletsthathitthegroundnearbyatankdealsindirectdamagefromtheexplosion.
Solutionissimple,weneedtopassthesourceoftheBullettotheExplosionwhenit’sbeinginitialized.
classBullet<GameObject#...defexplode
Explosion.new(object_pool,@x,@y,@source)#...end#...end
MakingDamagePersonalNowthatwehavethesourceofeveryBulletandExplosiontheytrigger,wecanstartpassingthecauseofdamageto
Health#inflict_damageandincrementingtheappropriatestats.
#12-stats/entities/components/health.rb
classHealth<Component#...definflict_damage(amount,cause)if@health>0@health_updated=trueifobject.respond_to?(:input)object.input.stats.add_damage(amount
#Don'tcountdamageto
treesandboxesifcause.respond_to?(:input)&&cause!=objectcause.input.stats.add_damage_dealt
endend@health=[@health-amount.to_i,0].maxafter_death(cause)ifdead?endend#...end
#12-stats/entities/components/tank_health.rb
classTankHealth<Health
#...defafter_death(cause)#...object.input.stats.add_deathkill=object!=cause?1:-1cause.input.stats.add_kill(kill)
#...end#...end
TrackingDamageFromChain
ReactionsThereisonemorewaytocausedamage.Whenyoushootatree,boxorbarrel,itexplodes,probablytriggeringachainreactionofexplosionsaroundit.Ifthoseexplosionskillsomebody,itwouldonlybefairtoaccountthatkillforthetankthattriggeredthischainreaction.
Tosolvethis,simplypassthecauseofdeathtotheExplosionthatgetstriggeredafterwards.
#12-stats/entities/components/health.rb
classHealth<Component#...defafter_death(cause)if@explodesThread.newdo#...Explosion.new(@object_pool,x,y,cause)#...end
#...endendend
#12-stats/entities/components/tank_health.rb
classTankHealth<Health#...defafter_death(cause)#...Thread.newdo#...Explosion.new(@object_pool,x,y,cause)endendend
Noweverybitofdamagegetsaccountedfor.
DisplayingGameScoreHavingallthedataisuselessunlesswedisplayitsomehow.Forthis,let’srethinkourgamestates.NowwehaveMenuStateandPlayState.Bothofthemcanswitchoneintoanother.What
ifweintroducedaPauseState,whichwouldfreezethegameanddisplaythelistofalltanksalongwiththeirkills.ThenMenuStatewouldswitchtoPlayState,andfromPlayStateyouwouldbeabletogettoPauseState.
Let’sbeginbyimplementingScoreDisplay,thatwouldprintasortedlistoftankkillsalongwiththeirnames.
12-stats/entities/score_display.rb1classScoreDisplay2definitialize(object_pool)3tanks=object_pool.objects.selectdo|o|4o.class==Tank5end6stats=tanks.map(&:input).map(&:stats)7stats.sort!do|stat1,stat2|8stat2.kills<=>stat1.kills9end10create_stats_image(stats)11end12
13defcreate_stats_image(stats)14text=stats.mapdo|stat|15"#{stat.kills}:#{stat.name}"16end.join("\n")17@stats_image=Gosu::Image.from_text(18$window,text,Utils.main_font,30)19end2021defdraw22@stats_image.draw(23$window.width/2-@stats_image.width/2,24$window.height/4+30,251000)
26end27end
WewillhavetoinitializeScoreDisplayeverytimewhenwewanttoshowtheupdatedscore.TimetocreatethePauseStatethatwouldshowthescore.12-stats/game_states/pause_state.rb1require'singleton'2classPauseState<GameState3includeSingleton4attr_accessor:play_state5
6definitialize7@message=Gosu::Image.from_text(8$window,"GamePaused",9Utils.title_font,60)10end1112defenter13music.play(true)14music.volume=115@score_display=ScoreDisplay.new(@play_state.object_pool
16@mouse_coords=[$window.mouse_x,$window.mouse_y]17end1819defleave20music.volume=0
21music.stop22$window.mouse_x,$window.mouse_y=@mouse_coords23end2425defmusic26@@music||=Gosu::Song.new(27$window,Utils.media_path('menu_music.mp3'
28end2930defdraw31@play_state.draw32@message.draw(33$window.width/2-@message.width/2,34$window.height/4-@message.height,351000)
36@score_display.draw37end3839defbutton_down(id)40$window.closeifid==Gosu::KbQ41ifid==Gosu::KbC&&@play_state42GameState.switch(@play_state)43end44ifid==Gosu::KbEscape45GameState.switch(@play_state)46end47end48end
YouwillnoticethatPauseStateinvokesPlayState#draw,butwithoutPlayState#updatethiswillbeastillimage.Wemakesurewehidethecrosshairandrestorepreviousmouselocationwhenresumingplaystate.Thatwayplayerwouldnotbeabletocheatbypausingthegame,targetingthetankwhilenothingmovesandthenunpausingreadytodealdamage.OurHUDhad
attr_accessor:active
exactlyforthisreason,butweneedtoswitchitonandoffinPlayState#enterandPlayState#leave.
classPlayState<GameState#...defbutton_down(id)#...ifid==Gosu::KbEscapepause=PauseState.instancepause.play_state=selfGameState.switch(pause)end#...end
#...defleaveStereoSample.stop_all@hud.active=falseend
defenter@hud.active=trueend#...end
Timeforatestdrive.
Pausingthegametoseethescore
Fornow,scoringmostkillsisrelativelysimple.Thisshouldchangewhenwewilltell
BuildingAdvancedAI
TheAIwehaverightnowcankicksomeass,butitistoodumbforanyseasonedgamertocompetewith.Thisisthelistofcurrentflaws:
1. Itdoesnotnavigatewell,getsstuckamongtrees
orsomewherenearwater.
2. Itisnotawareofpowerups.
3. Itcoulddobetterjobatshooting.
4. It’sfieldofvisionistoosmall,comparedtoplayer’s,whoisequippedwithradar.
Wewilltackletheseissuesincurrentchapter.
ImprovingTankNavigationTanksshouldn’tbehavelikeRoombas,randomlydrivingaroundandbumpingintothings.Theycouldbenavigatinglikethis:
1. ConsultwithcurrentAIstateandfindorupdatedestinationpoint.
2. Ifdestinationhaschanged,calculate
shortestpathtodestination.
3. Movealongthecalculatedpath.
4. Repeat.
Ifthislookseasy,letmeassureyou,itwouldprobablyrequirerewritingthemajorityofAIandMapcodewehaveatthispoint,anditisprettytrickytoimplementwithprocedurallygeneratedmaps,becausenormallyyouwould
useamapeditortosetupwaypoints,navigationmeshorotherhintsforAIsoitdoesn’tgetstuck.Sometimesitisbettertohavesomethingworkingimperfectlyoveraperfectsolutionthatneverhappens,thuswewillusesimplethingsthatwillmakeasmuchimpactaspossiblewithoutrewritinghalfofthecode.
GeneratingFriendlierMaps
Oneofmainreasonswhytanksgetstuckisbadplacementofspawnpoints.Theydon’ttaketreesandboxesintoaccount,soenemytankcanspawninthemiddleofaforest,withnochanceofgettingoutwithoutblowingthingsup.AsimplefixwouldbetoconsultwithObjectPoolbeforeplacingaspawnpointonlywheretherearenoothergameobjects
aroundin,say,150pixelradius:
classMap#...deffind_spawn_pointwhiletruex=rand(0..MAP_WIDTH*TILE_SIZE)y=rand(0..MAP_HEIGHT*TILE_SIZE)ifcan_move_to?(x,y)&&@object_pool.nearby_point(x,y,150).empty?return[x,y]endendend
#...end
Howaboutpowerups?Theycanalsospawninthemiddleofaforest,andwhiletanksarenotseekingthemyet,wewillbeimplementingthisbehavior,andleadingtanksintowildernessoftreesisnotthebestideaever.Let’sfixittoo:
classMap#...
defgenerate_powerupspups=0target_pups=rand(20..30)whilepups<target_pupsdox=rand(0..MAP_WIDTH*TILE_SIZE)y=rand(0..MAP_HEIGHT*TILE_SIZE)iftile_at(x,y)!=@water&&@object_pool.nearby_point(x,y,150).empty?random_powerup.new(@object_pool,x,y)pups+=1endendend
#...end
Wecouldalsoreducetreecount,butthatwouldmakethemaplookworse,sowearegoingtokeepthisinourpocketasameanoflastresort.
ImplementingDemoStateToObserveAI
ProbablythebestwaytofigureoutifourAIisanygoodistotargetoneofAItankswithourgamecameraandseehowitplays.ItwillgiveusagreatvisualtestingtoolthatwillallowtweakingAIsettingsandseeingiftheyperformbetterorworse.ForthatwewillintroduceDemoStatewhereonlyAItankswillbepresentinthemap,andwewillbeabletoswitchcamerafromonetank
toanother.
DemoStateisverysimilartoPlayState,themaindifferenceisthatthereisnoplayer.Wewillextractcreate_tanksmethodthatwillbeoverriddeninDemoState.
classPlayState<GameStateattr_accessor:update_interval,:object_pool,:tank
definitialize
#...@camera=Camera.new@object_pool.camera=@cameracreate_tanks(4)end#...private
defcreate_tanks(amount)@map.spawn_points(amount*3)@tank=Tank.new(@object_pool,PlayerInput.new('Player',@camera,@object_pool))amount.timesdo|i|Tank.new(@object_pool,AiInput.new(@names.random,@object_pool))
end@camera.target=@tank@hud=HUD.new(@object_pool,@tank)end#...end
Wewillalsowanttodisplayasmallerversionofscoreintop-rightcornerofthescreen,solet’saddsomeadjustmentstoScoreDisplay:
classScoreDisplaydefinitialize(object_pool,font_size=30)
@font_size=font_size#...end
defcreate_stats_image(stats)#...@stats_image=Gosu::Image.from_text($window,text,Utils.main_font,@font_size)end#...defdraw_top_right@stats_image.draw($window.width-@stats_image.width-20,20,1000)endend
AndhereistheextendedDemoState:13-advanced-ai/game_states/demo_state.rb1classDemoState<PlayState2attr_accessor:tank34defenter5#PreventreactivatingHUD6end78defupdate9super10@score_display=ScoreDisplay.new(11object_pool,20)12end
1314defdraw15super16@score_display.draw_top_right17end1819defbutton_down(id)20super21ifid==Gosu::KbSpace22target_tank=@tanks.rejectdo|t|23t==@camera.target24end.sample25switch_to_tank(target_tank)26end27end2829private30
31defcreate_tanks(amount)32@map.spawn_points(amount*3)33@tanks=[]34amount.timesdo|i|35@tanks<<Tank.new(@object_pool,AiInput.new(36@names.random,@object_pool))37end38target_tank=@tanks.sample39@hud=HUD.new(@object_pool,target_tank)40@hud.active=false41switch_to_tank(target_tank)42end43
44defswitch_to_tank(tank)45@camera.target=tank46@hud.player=tank47self.tank=tank48end49end
TohaveapossibilitytoenterDemoState,weneedtochangeMenuStatealittle:
classMenuState<GameState#...defupdatetext="Q:Quit\nN:NewGame\nD:Demo"#...end
#...defbutton_down(id)#...ifid==Gosu::KbD@play_state=DemoState.newGameState.switch(@play_state)endendend
Now,mainmenuhastheoptiontoenterdemostate:
AfterwatchingAIbehaviorindemomodeforawhile,Iwasterrified.Whenplayinggamenormally,youusuallyseetanksin“fighting”state,whichworksprettywell,butwhentanksgoroaming,it’sacompletedisaster.Theygetstuckeasily,theydon’tgotoofarfromtheoriginallocation,theywaittoomuch.
Somethingscouldbeimprovedjustbychanging
wait_time,turn_timeanddrive_timetodifferentvalues,butwecertainlyhavetodobiggerchangesthanthat.
Ontheotherhand,“observeAIinaction,tweak,repeat”cycleprovedtobeveryeffective,Iwilldefinitelyusethistechniqueinallmyfuturegames.
Tomakevisualdebuggingeasier,buildyourselfsometooling.Onewaytodoitistohaveglobal$debugvariablewhichyoucantogglebypressingsomebutton:
classPlayState<GameState#...defbutton_down(id)#...ifid==Gosu::KbF1$debug=!$debugend#...end#...end
Thenaddextradrawinginstructionstoyourobjectsandtheircomponents.Forexample,thiswillmakeTankdisplayit’scurrentTankMotionState
implementationclassbeneathit:
classTankMotionFSM#...defset_state(state)#...if$debug@image=
Gosu::Image.from_text($window,state.class.to_s,Gosu.default_font_name,18)endend#...defdraw(viewport)if$debug@image&&@image.draw(@object.x-@image.width/2,@object.y+@object.graphics.height/2-@image.height,100)endend#...end
Tomarktank’sdesiredgunangleasbluelineandactualgunangleasredline,youcandothis:
classAiGun#...defdraw(viewport)if$debugcolor=Gosu::Color::BLUEx,y=@object.x,@object.yt_x,t_y=Utils.point_at_distance(x,y,@desired_gun_angle,BulletPhysics::MAX_DIST)$window.draw_line(x,y,color,t_x,t_y,color,1001)
color=Gosu::Color::REDt_x,t_y=Utils.point_at_distance(x,y,@object.gun_angle,BulletPhysics::MAX_DIST)$window.draw_line(x,y,color,t_x,t_y,color,1000)endend#...end
Finally,youcanautomaticallymarkcollisionboxcornersonyourgraphicscomponents.Let’stakeBoxGraphicsforexample:
#13-advanced-ai/misc/utils.rbmoduleUtils#...defself.mark_corners(box)i=0box.each_slice(2)do|x,y|color=DEBUG_COLORS[i]$window.draw_triangle(x-3,y-3,color,x,y,color,x+3,y-3,color,100)i=(i+1)%4endend#...end
#13-advanced-ai/entities/components/box_graphics.rb
classBoxGraphics<Component#..defdraw(viewport)@box.draw_rot(x,y,0,object.angle)Utils.mark_corners(object.box)if$debugend#...end
Asadeveloper,youcanmakeyourselfseenearlyeverythingyouwant,makeuseofit.
VisualdebuggingofAIbehavior
Althoughithurtstheframeratealittle,itisveryusefulwhenbuildingnotonlyAI,buttherestofthegame
too.UsingthisvisualdebuggingtogetherwithDemomode,youcantweakalltheAIvaluestomakeitshootmoreoften,fightbetter,andbemoreagile.Wewon’tgothroughthisminortuning,butyoucanfindthechangesbyviewingchangesintroducedin13-advanced-ai.
MakingAICollectPowerupsToevenouttheodds,wehavetomakeAIseekpowerupswhentheyarerequired.Thelogicbehinditcanbeimplementedusingacoupleofsimplesteps:
1. AIwouldknowwhatpowerupsarecurrentlyneeded.Thismayvaryfromstatetostate,i.e.
speedandfireratepowerupsarenicetohavewhenroaming,butnotthatimportantwhenfleeingaftertakingheavydamage.Andwedon’twantAItowastetimeandcollectspeedpowerupswhenspeedmodifierisalreadymaxedout.
2. AiVisionwouldreturnclosestvisiblepowerup,
filteredbyacceptablepoweruptypes.
3. SomeTankMotionStateimplementationwouldadjusttankdirectiontowardsclosestvisiblepowerupinchange_direction
method.
FindingPowerupsInSightToimplementchangesinAiVision,wewillintroduce
closest_powerupmethod.Itwillqueryobjectsinsightandfilterthemoutbytheirclassanddistance.
classAiVision#...POWERUP_CACHE_TIMEOUT=50#...defclosest_powerup(*suitable)now=Gosu.milliseconds@closest_powerup=nilifnow-(@powerup_cache_updated_at||=0)>POWERUP_CACHE_TIMEOUT@closest_powerup=nil@powerup_cache_updated_at=now
end@closest_powerup||=find_closest_powerup(*suitable)end
private
deffind_closest_powerup(*suitable)ifsuitable.empty?suitable=[FireRatePowerup,HealthPowerup,RepairPowerup,TankSpeedPowerup]end@in_sight.selectdo|o|suitable.include?
(o.class)end.sortdo|a,b|x,y=@viewer.x,@viewer.yd1=Utils.distance_between(x,y,a.x,a.y)d2=Utils.distance_between(x,y,b.x,b.y)d1<=>d2end.firstend#...end
ItisverysimilartoAiVision#closest_tank,andpartsshouldprobablybe
extractedtokeepthecodedry,butwewillnotbother.
SeekingPowerupsWhileRoamingRoamingiswhenmostpickingshouldhappen,becauseTankseesnoenemiesinsightandneedstoprepareforupcomingbattles.Let’sseehowcanweimplementthisbehaviorwhileleveragingthenewly
madeAiVision#closest_powerup:
classTankRoamingState<TankMotionState#...defrequired_powerupsrequired=[]health=@object.health.healthif@object.fire_rate_modifier<2&&health>50required<<FireRatePowerupendif@object.speed_modifier<1.5&&health>50required<<TankSpeedPowerup
endifhealth<100required<<RepairPowerupendifhealth<190required<<HealthPowerupendrequiredend
defchange_directionclosest_powerup=@vision.closest_powerup(*required_powerups)ifclosest_powerup@seeking_powerup=trueangle=Utils.angle_between(@object.x,@object.y,closest_powerup.x,closest_powerup.y)
@object.physics.change_direction
angle-angle%45)else@seeking_powerup=false#...chooserandomdirectionend@changed_direction_at=Gosu.milliseconds@will_keep_direction_for=turn_timeend#...defturn_timeif@seeking_poweruprand(100..300)elserand(1000..3000)end
endend
Itissimpleasthat,andourAItanksarenowgettingbuffedontheirsparetime.
SeekingHealthPowerupsAfterHeavyDamageToseekhealthwhendamaged,weneedtochangeTankFleeingState#change_direction
classTankFleeingState<TankMotionState#...defchange_directionclosest_powerup=@vision.closest_powerup(RepairPowerup,HealthPowerup)ifclosest_powerupangle=Utils.angle_between(@object.x,@object.y,closest_powerup.x,closest_powerup.y)@object.physics.change_direction
angle-angle%45)else#...reversefromenemyend
@changed_direction_at=Gosu.milliseconds@will_keep_direction_for=turn_timeend#...end
ThissmallchangetellsAItopickuphealthwhilefleeing.TheinterestingpartisthatwhentankpicksupRepairPowerup,it’shealthgetsfullyrestoredandAIshouldswitchbacktoTankFightingState.This
simplethingisamajorimprovementinAIbehavior.
EvadingCollisionsAndGettingUnstuckWhileobservingAInavigation,itwasnoticeablethattanksoftengotstuck,eveninsimplesituations,likedrivingintoatreeandhittingitrepeatedlyforadozenofseconds.Toreducethe
numberofsuchoccasions,wewillintroduceTankNavigatingState,whichwouldhelpavoidcollisions,andTankStuckState,whichwouldberesponsiblefordrivingoutofdeadendsasquicklyaspossible.
Toimplementthesestates,weneedtohaveawaytotelliftankcangoforwardandawayofgettingadirectionwhichisnotblockedbyother
objects.Let’saddacoupleofmethodstoAiVision:
classAiVision#...defcan_go_forward?in_front=Utils.point_at_distance(*@viewer.location,@viewer.direction,40)@object_pool.map.can_move_to?(*in_front)&&@object_pool.nearby_point(*in_front40,@viewer).reject{|o|o.is_a?Powerup}.empty?end
defclosest_free_path(away_from=nil)paths=[]5.timesdo|i|ifpaths.any?returnfarthest_from(paths,away_from)endradius=55-i*5range_x=range_y=[-radius,0,radius]range_x.shuffle.eachdo|x|range_y.shuffle.eachdo|y|x=@viewer.x+xy=@viewer.y+yif@object_pool.map.can_move_to?(x,y)&&
@object_pool.nearby_point(x,y,radius,@viewer).reject{|o|o.is_a?Powerup}.empty?ifaway_frompaths<<[x,y]elsereturn[x,y]endendendendendfalseend
alias:closest_free_path_away_from:closest_free_path#...
private
deffarthest_from(paths,away_from)paths.sortdo|p1,p2|Utils.distance_between(*p1,*away_from)<=>Utils.distance_between(*p2,*away_from)end.firstend#...end
AiVision#can_go_forward?
tellsiftankcanmoveahead,and
AiVision#closest_free_path
findsapointwheretankcanmovewithoutobstacles.YoucanalsocallAiVision#closest_free_path_away_from
andprovidecoordinatesyouaretryingtogetawayfrom.
Wewilluseclosest_free_pathmethodsinnewlyimplementedtankmotionstates,andcan_go_forward?inTankMotionFSM,tomakea
decisionwhentojumpintonavigatingorstuckstate.
Thosenewstatesarenothingfancy:13-advanced-ai/entities/components/ai/tank_navigating_state.rb1classTankNavigatingState<TankMotionState2definitialize(object,vision)3@object=object4@vision=vision5end67defupdate8change_directionif
should_change_direction?9drive10end1112defchange_direction13closest_free_path=@vision.closest_free_path14ifclosest_free_path15@object.physics.change_direction
16Utils.angle_between(17@object.x,@object.y,*closest_free_path))18end19@changed_direction_at=Gosu.milliseconds20@will_keep_direction_for=turn_time21end22
23defwait_time24rand(10..100)25end2627defdrive_time28rand(1000..2000)29end3031defturn_time32rand(300..1000)33end34end
TankNavigatingStatesimplychoosesarandomfreepath,changesdirectiontoitandkeepsdriving.
13-advanced-ai/entities/components/ai/tank_stuck_state.rb1classTankNavigatingState<TankMotionState2definitialize(object,vision)3@object=object4@vision=vision5end67defupdate8change_directionifshould_change_direction?9drive10end1112defchange_direction13closest_free_path=@vision.closest_free_path14ifclosest_free_path
15@object.physics.change_direction
16Utils.angle_between(17@object.x,@object.y,*closest_free_path))18end19@changed_direction_at=Gosu.milliseconds20@will_keep_direction_for=turn_time21end2223defwait_time24rand(10..100)25end2627defdrive_time28rand(1000..2000)29end30
31defturn_time32rand(300..1000)33end34end
TankStuckStateisnearlythesame,butitkeepsdrivingawayfrom@stuck_atpoint,whichissetbyTankMotionFSMupontransitiontothisstate.
classTankMotionFSMSTATE_CHANGE_DELAY=500LOCATION_CHECK_DELAY=5000
definitialize(object,vision,gun)#...@stuck_state=TankStuckState.new(object,vision,gun)@navigating_state=TankNavigatingState.new(object,vision)set_state(@roaming_state)end#...defchoose_stateunless@vision.can_go_forward?unless@current_state==@stuck_stateset_state(@navigating_state)endend
#Keepunstuckingitselfforawhilechange_delay=STATE_CHANGE_DELAYif@current_state==@stuck_statechange_delay*=5endnow=Gosu.millisecondsreturnunlessnow-@last_state_change>change_delayif@last_location_update.nil?@last_location_update=now@last_location=@object.locationendifnow-@last_location_update>
LOCATION_CHECK_DELAYputs"checkinlocation"unless@last_location.nil?||@current_state.waiting?ifUtils.distance_between(*@last_location*@object.location)<20set_state(@stuck_state)@stuck_state.stuck_at=@object.locationreturnendend@last_location_update=now@last_location=@object.locationend#...
end#...end
Whatthisdoesisautomaticallychangestatetonavigatingwhentankisabouttohitanobstacle.Italsotrackstanklocation,andiftankhasn’tmoved20pixelsawayfromit’soriginaldirectionfor5seconds,itentersTankStuckState,whichdeliberatelytriesto
navigateawayfromthestock_atspot.
AInavigationhasjustgotsignificantlybetter,anditdidn’ttakethatmanychanges.
WrappingItUp
Ourjourneyintotheworldofgamedevelopmenthascometoanend.Wehavelearnedenoughtoproducedaplayablegame,yetonlyscratchedthesurface.Writingthisbookwasaveryenlighteningexperience,andhopefullyreadingitinspired
orhelpedsomeonetogetastart.
LessonsLearnedBuildingthissmalltanksgameandlearningaboutgamedevelopmentwithRubycertainlyhadsomenastybumpsalongtheway,someofthemmademyheadhittheceiling.
RubyIsSlow
Thisshouldn’tbeashocker,becauseRubyisadynamic,interpretedlanguage,buthowexactlyslowitisatsomepointswasastaggeringdiscovery.ProbablythebestevidenceisthatdrawingmaptilesoffscreenusingnativeextensionswasactuallyfasterthandoingCamera#can_view?checksthatinvolvesimpleintegerarithmeticandrangechecks.
Ifyourgameisgoingtodealwithlargenumberofentities,Rubywillstartlettingyoudown.Dreamingaboutgoingpro?GoforC++,youwon’tmakeamistakehere.
Knowingthis,keepinmindthatRubyisawonderfullanguage,thathasit’sownstrengths.It’sgreatforprototypinganddynamicthings.Some5-10linesofRubycouldtranslateinto50-
100linesofC++.Also,knowingmultiplelanguagesmakesyouabetterdeveloper.
PackagingRubyGamesSucksUnlessyouarereleasingyourgamefortechsavvyguyswhocangeminstallit,getreadytogothroughhell.Thereisnoniceandeasywaytocreateastandaloneexecutableapplicationfrom
Rubycodethatinvolvesnativeextensions.Andyouwillgothroughhellonceforeveryoperatingsystemyouwanttopublishyourgamefor.
That’snoteverything.WanttousethelatestRubyversion?CheckifyoucanmakeapackageforitinyourtargetOSbeforeyoustartcoding.Thinkingofusingsomethingthatrelieson
ImageMagick?Toobad,youprobablywon’tbeabletopackagethegameintoanativestandaloneapp,atleastonOSX.Ifyouareplanningonreleasingthegame,packageearlyandpackageoften,foreveryOS,andcheckiftherewillbenoproblemswithnativeextensions.
PlanNetworkedMultiplayerEarly
Ifyouaregoingtobuildagame,don’tmakeamistakeofthinking“I’lljustmakeitmultiplayerlater”,startattheverybeginning.ThiswasalessonIlearnedthehardway.TherehadtobeachapterinthisbookaboutturningTanksintomultiplayer,butitdidn’thappen,becauseitwouldrequireamajorrewriteofthecode.
CreatingAWellPolishedGameRequiresExtraordinaryEffortHackinguparoughprototypeisextremelyfun.Yougettobuildanengine,wireeverythingtogether.Itdefinitelygivesasenseofachievement.Turningitintoagreatgame,however,isadifferentstory.Youcanspendhoursorevendaystweakinghowgamecontrols
workandstillremainunsatisfied.Everytinydetailcanbepushedfurther.Preferqualityoverquantity,andrememberthatyouprobablycannotaffordbothandactuallyfinishitwithinnextcoupleofyears.
StartSmall,TakeBabyStepsYourfirstfewgamesshouldbesmallexperiments,
prototypesordemos.Don’tattempttobuildagameyouwantedtobuildforeverwithyourfirstshot.TryreimplementingTetris,PacmanorBejeweledinstead.Youwillfindittobechallengingenough,andwhenyouwillfeelyouhavetheskillstodosomethingbigger,practicejustalittlemore.
Don’tReinventTheWheel
Beforedoinganything,research.YouwillprobablynotgetpointinpolycollisiondetectionbetterthanW.RandolphFranklindiditinhisresearch.Evenifyouthinkyoucandoitonyourown,learnwhatothersdiscoveredbeforeyou.Learnfromother’smistakes,notyourown.
SpecialThanks
IwouldliketothankJulianRaschkeforcreatingandmaintainingGosuandforallthehelponIRC,GosuforumsandGitHub.ThisbookwouldnotexistwithoutyourenormouscontributiontoRubygamedevelopmentscene.
ShoutoutgoestoShawnAnderson,creatorofGamebox.Thankyouformoralsupportandencouragement.StudyingGameboxsourcecodetaughtmemanythingsaboutGosuandgamedevelopment.
YoucanfindJulian,Shawnandmoregamedevelopmententhusiastsin#gosuonFreeNode.
Recommended