842

Developing Games With - DropPDF1.droppdf.com/files/tmMfD/developing-games-with-ruby-for-those-wh… · Developing Games With Ruby For those who write ... Getting Gosu to run on Mac

Embed Size (px)

Citation preview

DevelopingGamesWithRuby

Forthosewhowritecodeforliving

TomasVaraneckas

Thisbookisforsaleathttp://leanpub.com/developing-games-with-ruby

Thisversionwaspublishedon2014-12-16

*****

ThisisaLeanpubbook.LeanpubempowersauthorsandpublisherswiththeLeanPublishingprocess.LeanPublishingistheactofpublishinganin-progressebookusinglightweighttoolsandmanyiterationstogetreaderfeedback,pivotuntilyouhavetherightbookandbuildtractiononceyoudo.

*****

©2014TomasVaraneckas

TableofContents

ABoyWhoWantedToCreateWorlds

WhyRuby?

WhatYouShouldKnowBeforeReadingThisBook

WhatAreWeGoingToBuild?

GraphicsGameDevelopmentLibrary

ThemeAndMechanics

PreparingTheTools

GettingGosutorunonMacOsX

GettingTheSampleCode

OtherTools

GosuBasicsHelloWorldScreenCoordinatesAndDepthMainLoopMovingThingsWithKeyboardImagesAndAnimationMusicAndSound

WarmingUpUsingTilesetsIntegratingWithTexturePackerCombiningTilesIntoAMapUsingTiledToCreateMapsLoadingTiledMapsWith

GosuGeneratingRandomMapWithPerlinNoisePlayerMovementWithKeyboardAndMouseGameCoordinateSystem

PrototypingTheGame

SwitchingBetweenGameStatesImplementingMenuStateImplementingPlayStateImplementingWorldMap

ImplementingFloatingCameraImplementingTheTankImplementingBulletsAndExplosionsRunningThePrototype

OptimizingGame

PerformanceProfilingRubyCodeToFindBottlenecksAdvancedProfilingTechniquesOptimizingInefficientCodeProfilingOnDemand

AdjustingGameSpeedForVariablePerformanceFrameSkipping

RefactoringThePrototype

GameProgrammingPatterns

WhatIsWrongWithCurrentDesignDecouplingUsingComponentPattern

SimulatingPhysics

AddingEnemy

ObjectsAddingBoundingBoxesAndDetectingCollisionsCatchingBulletsImplementingTurnSpeedPenaltiesImplementingTerrainSpeed

Penalties

ImplementingHealthAndDamage

AddingHealthComponentInflictingDamageWithBullets

CreatingArtificialIntelligence

DesigningAIUsingFiniteStateMachineImplementingAIVisionControllingTankGunImplementingAIInput

ImplementingTankMotionStatesWiringTankMotionStatesIntoFiniteStateMachine

MakingThePrototypePlayable

DrawingWaterBeyond

MapBoundariesGeneratingTreeClustersGeneratingRandomObjectsImplementingARadarDynamicSoundVolumeAndPanning

GivingEnemiesIdentityRespawningTanksAndRemovingDeadOnesDisplayingExplosionDamageTrailsDebuggingBulletPhysics

MakingCameraLookAheadReviewingTheChanges

DealingWithThousandsOfGameObjects

SpatialPartitioningImplementingAQuadtree

IntegratingObjectPoolWithQuadTreeMovingObjectsInQuadTree

ImplementingPowerups

ImplementingBasePowerup

ImplementingPowerupGraphicsImplementingPowerupSoundsImplementingRepairDamagePowerupImplementingHealthBoost

ImplementingFireRateBoostImplementingTankSpeedBoostSpawningPowerupsOnMapRespawningPowerupsAfterPickup

ImplementingHeadsUpDisplay

DesignConsiderationsRenderingTextWithCustomFontImplementingHUDClass

ImplementingGameStatistics

TrackingKills,DeathsandDamageMakingDamagePersonalTrackingDamageFromChainReactions

DisplayingGameScore

BuildingAdvancedAI

ImprovingTankNavigationImplementingDemoStateToObserveAI

VisualAIDebuggingMakingAICollectPowerupsSeekingHealthPowerupsAfterHeavyDamageEvadingCollisions

AndGettingUnstuck

WrappingItUpLessonsLearned

SpecialThanks

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~/[email protected]: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)[email protected](10,10,0)13end14end1516window=GameWindow.new17window.show

Runthecode:

$ruby01-hello/hello_world.rb

Youshouldseeaneatsmallwindowwithyourmessage:

HelloWorld

Seehoweasythatwas?Nowlet’strytounderstandwhatjusthappenedhere.

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)[email protected](@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([email protected]/2.0,[email protected]/2.0,330)34end3536defdone?37@done||=@[email protected]

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=[][email protected]!(&:done?)

[email protected](&:update)70end7172defbutton_down(id)73closeifid==Gosu::KbEscape74ifid==Gosu::[email protected](76Explosion.new(77@animation,mouse_x,mouse_y))78end79end8081defneeds_cursor?82true83end8485defneeds_redraw?86!@scene_ready||

@explosions.any?87end8889defdraw90@scene_ready||[email protected](0,0,0)[email protected](&:draw)93end94end9596window=GameWindow.new97window.show

Runitandclickaroundtoenjoythosebeautifulspecialeffects:

$ruby01-hello/hello_animation.rb

Multipleexplosionsonscreen

Nowlet’sfigureouthowitworks.OurGameWindowinitializeswith@backgroundGosu::Imageand

@animation,thatholdsarrayofGosu::Imageinstances,oneforeachframeofexplosion.Gosu::Image.load_tiles

handlesitforus.

Explosion::SPRITEpointsto“tileset”image,whichisjustaregularimagethatcontainsequallysizedsmallerimageframesarrangedinorderedsequence.Rowsofframesare

readlefttoright,likeyouwouldreadabook.

Explosiontileset

Giventhatexplosion.pngtilesetis1024x1024pixels

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,@[email protected],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([email protected]/2.0,[email protected]/2.0,390)40end4142defdone?43@done||=@[email protected]

[email protected]_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'))

[email protected][email protected](true)77@animation=Explosion.load_animation(self)78@sound=Explosion.load_sound(self)79@explosions=[][email protected]!(&:done?)[email protected](&:update)85end8687defbutton_down(id)88closeifid==Gosu::KbEscape89ifid==Gosu::[email protected](

91Explosion.new(92@animation,@sound,mouse_x,mouse_y))93end94end9596defneeds_cursor?97true98end99100defneeds_redraw?101!@scene_ready||@explosions.any?102end103104defdraw105@scene_ready||[email protected](0,0,0)[email protected](&: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'))[email protected][email protected](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:

[email protected]: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|[email protected]([email protected]_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

useRMagick.Iknewwewon’tgettoofarawayfromit.Butourrandommapnowlooksgorgeous:

Mapfilledwithseamlessrandomtiles

UsingTiledToCreateMaps

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@[email protected](@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:

ExploringTiledmapinGosu

GeneratingRandomMapWithPerlin

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:

Perlinnoise

Now,wecouldimplementthealgorithmourselves,butthere

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%

[email protected]#15%chance,wateredges103@sand104else#55%chance105@grass106end107end108109end110111window=GameWindow.new112window.show

Runtheprogram,zoomwithup/downarrowsand

regenerateeverythingwithspacebar.

$ruby02-warmup/perlin_noise_map.rb

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%[email protected]#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([email protected]_x,[email protected]_y)22@gun_angle=-atan*180/Math::PI23@body_angle=change_angle(@body_angle,24Gosu::KbW,Gosu::KbS,Gosu::KbA,Gosu::KbD)[email protected]_rot(@x-1,@y-1,0,@body_angle)[email protected]_rot(@x,@y,1,@body_angle)[email protected]_rot(@x,@y,2,@gun_angle)31end

3233private3435defchange_angle(previous_angle,up,down,right,left)[email protected]_down?(up)[email protected]_down?(left)[email protected]_down?(right)[email protected]_down?(down)[email protected]_down?(left)[email protected]_down?(right)44elsif

@window.button_down?(left)[email protected]_down?(up)[email protected]_down?(down)[email protected]_down?(right)[email protected]_down?(up)[email protected]_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'),[email protected]('tank1_body_shadow.png'

[email protected]('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)[email protected]="#{Gosu.fps}FPS."<<98'UseWASDandmousetocontroltank'99end100

101defdraw102@[email protected](@x,@y)[email protected]()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?

Tankmovingaroundandaimingguns

GameCoordinateSystem

Bynowwemaystartrealizing,thatthereisonekeycomponentmissinginourdesigns.Wehaveavirtualmap,whichisbiggerthanourscreenspace,andweshouldperformallcalculationsusingthatmap,andonlythencutouttherequiredpieceandrenderitinourgamewindow.

Therearethreedifferentcoordinatesystemsthathave

tomapwitheachother:

1. Gamecoordinates2. Viewportcoordinates3. Screencoordinates

Coordinatesystems

GameCoordinates

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=@[email protected]|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::[email protected][email protected][email protected][email protected]=SPEEDifbutton_down?(Gosu::KbA)[email protected]+=SPEEDifbutton_down?(Gosu::KbD)[email protected]=SPEEDif

button_down?(Gosu::KbW)[email protected]+=SPEEDifbutton_down?(Gosu::KbS)[email protected]>0?0.01:1.099100ifbutton_down?(Gosu::KbUp)[email protected]=zoom_delta102end103ifbutton_down?(Gosu::KbDown)[email protected][email protected]_s107end108

[email protected]+width/[email protected]+height/[email protected][email protected](off_x,off_y)[email protected][email protected](zoom,zoom,cam_x,cam_y)[email protected](@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

[email protected]@[email protected]_redraw?19end2021defbutton_down(id)[email protected]_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)[email protected](36$window.width/[email protected]/2,37$window.height/[email protected]/2,3810)[email protected](40$window.width/[email protected]/2,41$window.height/[email protected]/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=[][email protected](@camera)18@bullets<<[email protected](&:update)[email protected]!(&:done?)

[email protected]$window.caption='TanksPrototype.'<<23"[FPS:#{Gosu.fps}.Tank@#{@tank.x.round}:#{@tank.y.round}]"[email protected][email protected]_x=$window.width/2-cam_x30off_y=$window.height/2-cam_y31$window.translate(off_x,off_y)[email protected]$window.scale(zoom,zoom,cam_x,cam_y)[email protected](@camera)

[email protected]@bullets.map(&:draw)[email protected]_crosshair40end4142defbutton_down(id)43ifid==Gosu::[email protected](*@camera.mouse_coords

45@bullets<<bulletifbullet46end47$window.closeifid==Gosu::KbQ48ifid==Gosu::KbEscape49GameState.switch(MenuState.instance

50end51end5253end

Updateanddrawcallsarepassedtotheunderlyinggameentities,sotheycanhandlethemthewaytheywantitto.Suchencapsulationreducescomplexityofthecodeandallowsdoingeverypieceoflogicwhereit

belongs,whilekeepingitshortandsimple.

[email protected]@tank.shootmayproduceanewbullet,ifyourtank’sfirerateisnotexceeded,andifleftmousebuttoniskeptdown,hencetheupdate.Ifbulletisproduced,itisaddedto@bulletsarray,andtheylivetheirownlittlelifecycle,

[email protected]!(&:done?)

cleansupthegarbage.

PlayState#[email protected]@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)[email protected]|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%[email protected]#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,[email protected]+19(x+$window.mouse_x-($window.width/2))/@[email protected]+21(y+$window.mouse_y-($window.height/2))/@zoom22[mouse_x_on_map,

mouse_y_on_map].map(&:round)23end2425defupdate26@[email protected]@x<@target.x-$window.width/427@[email protected]@x>@target.x+$window.width/428@[email protected]@y<@target.y-$window.height/429@[email protected]@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>[email protected]>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[(@[email protected])*@zoom,(@[email protected])*@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@[email protected]('tank1_body.png')10@[email protected]('tank1_body_shadow.png'

11@[email protected]('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)[email protected]_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)[email protected]_rot(@x-1,@y-1,0,@body_angle)[email protected]_rot(@x,@y,1,@body_angle)[email protected]_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)[email protected]_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=@[email protected](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([email protected]/2+

3,[email protected]/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

HitNtostartnewgame.

TanksPrototypemenu

Timetogocrazy!

TanksPrototypegameplay

Onethingshouldbebuggingyouatthispoint.FPSshowsonly30,ratherthan60.Thatmeansourprototypeisslow.

Wewillputitbackto60FPSinnextchapter.

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

FPSevenwhenprofilingthecode!Comparethatto2FPSwhileprofilingwhenwestarted.

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:

ProfilingresultsforPlayStateafterintroducingGosu::Window#caption

cache

Wecannowsleepwellatnight.

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#[email protected]#...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.

ObjectPool

PlayStatewouldtheniteratethrough@object_pool.objectsand

invokeupdateanddrawmethods.

Now,let’sbeginbyimplementingbaseclassforGameObject:05-refactor/entities/game_object.rb1classGameObject2definitialize(object_pool)3@components=[]4@object_pool=object_pool5@object_pool.objects<<self6end

78defcomponents9@[email protected](&:update)14end1516defdraw(viewport)[email protected]{|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<<[email protected]@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@[email protected](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,[email protected]_spawn_point9@speed=0.010end1112defcan_move_to?(x,y)[email protected]_move_to?(x,y)14end1516defmoving?17@speed>018end1920defupdate21ifobject.throttle_down22accelerate23else

24decelerate25end26if@speed>027new_x,new_y=x,y28shift=Utils.adjust_speed(@speed)[email protected]_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)[email protected]_rot(x-1,y-1,0,object.direction)[email protected]_rot(x,y,1,object.direction)[email protected]_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&&@[email protected]@driving.nil?7@driving=driving_sound.play(1,1,true)8end9else10if@driving&&@[email protected]

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,[email protected]_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))[email protected][email protected]['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?)[email protected]_caption

[email protected][email protected]_x=$window.width/2-cam_x36off_y=$window.height/[email protected]$window.translate(off_x,off_y)[email protected]$window.scale(zoom,zoom,cam_x,cam_y)[email protected](viewport)42@object_pool.objects.map{|o|o.draw(viewport)}43end

[email protected]_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#[email protected]|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]

[email protected](button)33false34end35end36end37end

AnothernotablechangeisrenamingGamemoduleintoUtils.Thenamefinallymakesmoresense,IhavenoideawhyIpututilitymethodsintoGamemoduleinthefirst

place.Also,Utilsreceivedbutton_down?method,thatsolvestheissueofchangingtankdirectionwhenbuttonisimmediatelyreleased.Itmadeverydifficulttostopatdiagonalangle,becausewhenyoudepressedtwobuttons,16mswasenoughforGosutothink“hereleasedW,andSisstillpressed,solet’schangedirectiontoS”.Utils#button_down?givesasoft150mswindowto

synchronizebuttonrelease.Nowcontrolsfeelmorenatural.

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.

Braindeadenemies

AddingBoundingBoxesAndDetecting

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)[email protected][email protected]$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

thattheyactuallyarewheretheybelong.

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,[email protected]_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.

Tankscolliding

CatchingBullets

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

Nowbulletsfinallyhit,butdon’tdoanydamageyet.Wewillcomebacktothatsoon.

Bullethittingenemytank

ImplementingTurnSpeedPenalties

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)[email protected]#...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*([email protected]_penalty(x,y))end

#...end

Thismakesalltanksmove33%sloweronsand.

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='✝'[email protected]_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)[email protected]([email protected]/2,49y-object.graphics.height/[email protected],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

Nowwecanblowthemupandenjoytheview:

Targetpractice

Butwhatifweblowourselvesupbyshootingnearby?Wewouldstillbeabletomovearound.Tofix

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

willsparksomelifeintotheminnextchapter.

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,[email protected],@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@[email protected]_tank!=@target22change_target(@vision.closest_tank

23end24else

25@target=nil26end2728if@target29if(0..10-rand(0..@accuracy)).include?(30(@[email protected]_angle).abs.round)31distance=distance_to_target32ifdistance-50<=BulletPhysics::MAX_DIST33target_x,target_y=Utils.point_at_distance([email protected],@object.y,@object.gun_angle,35distance+10-rand(0..@accuracy))36ifcan_make_new_decision?&&

@object.can_shoot?&&[email protected](target_x,target_y)39end40end41end42end43end4445defdistance_to_target46Utils.distance_between([email protected],@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@[email protected]_between([email protected],@object.y,@target.x,@target.y)67else

[email protected]_target(new_target)73@target=new_target74adjust_desired_angle75end7677defadjust_gun_angle78actual=@object.gun_angle79desired=@desired_gun_angle80ifactual>desired81ifactual-desired>180#0->[email protected]_angle=(actual+@retarget_speed)%360

[email protected]_angle<[email protected]_angle=desired#[email protected]_angle=[actual-@retarget_speed,desired].max88end89elsifactual<desired90ifdesired-actual>180#360->[email protected]_angle=(360+actual-@retarget_speed)%[email protected]_angle>[email protected]_angle=desired#damp94end

[email protected]_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)[email protected]_collision(with)20end2122defon_damage(amount)[email protected]_damage(amount)[email protected]_angle29now=Gosu.milliseconds30returnifnow-@last_update<UPDATE_RATE31@[email protected]

[email protected]@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,[email protected]_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@[email protected]_down=false36end3738defdrive39@sub_state=:driving40@started_driving=Gosu.milliseconds

41@[email protected]_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

[email protected]_direction

[email protected]+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

[email protected]_direction

[email protected]+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

[email protected]+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

[email protected]_direction

[email protected][email protected]_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)>[email protected]@object.health.health>[email protected]_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,

soinsteadofdescribingit,hereisapictureworththousandwords:

Firstrealbattle

Youmaynoticeanewcrosshair,whichreplacedtheoldonethatwasnevervisible:

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)[email protected](map_x,map_y,0)[email protected](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:

Mapedge

GeneratingTreeClusters

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)[email protected](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)[email protected](shaking_x,shaking_y,5)32ifshaking_for>=SHAKE_TIME33@shaking=false

[email protected](center_x,center_y,5)37end38Utils.mark_corners(object.box)[email protected]@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||[email protected]/259end6061defcenter_y62@center_y||[email protected]/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@[email protected]>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([email protected]/2,37y-object.graphics.height/[email protected],100)39end4041protected4243defdraw?44$debug45end4647defupdate_image48returnunlessdraw?49if@[email protected]_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:

Hidingamongprocedurallygeneratedtrees

GeneratingRandomObjects

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@[email protected]/[email protected]/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)

[email protected]_rot(x,y,0,object.angle)9Utils.mark_corners(object.box)[email protected]@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)[email protected](@object_pool,x,y)boxes+=1endend

end#...end

Nowgiveitago.Beautiful,isn’tit?

Boxesandbarrelsinthejungle

ImplementingARadar

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+([email protected])/2044ty=y1+HEIGHT/2+([email protected])/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#[email protected]=@tank@radar=Radar.new(@object_pool,@tank)#...end#...defupdate#[email protected]@radar.update#...end#...defdraw#[email protected][email protected]

end#...end

Timetoenjoytheresults.

Radarinaction

DynamicSoundVolumeAndPanning

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"][email protected]("#{id}_l")[email protected]("#{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>[email protected]@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.

Identitymakesadifference

RespawningTanksAndRemovingDead

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=@[email protected],@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)[email protected]_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?

(x,y)Damage.new(@object_pool,@x,@y)end#...end#...end

Andthisishowtheresultlookslike:

Damagedbattlefield

DebuggingBulletPhysics

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

[email protected]?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:

Runningslowwiththousandsofgameobjects

SpatialPartitioning

Thereisasolutionforthisparticularproblemis“SpatialPartitioning”,andtheessenceofitisthatyouhavetouseatree-likedatastructurethatdividesspaceintoregions,placesobjectsthereandletsyouqueryitselfinlogarithmictime,omittingobjectsthatfalloutofqueryregion.SpatialPartitioningisexplainedwellinGameProgrammingPatterns.

Probablythemostappropriatedatastructureforour2Dgameisquadtree.ToquoteWikipedia,“quadtreesaremostoftenusedtopartitionatwo-dimensionalspacebyrecursivelysubdividingitintofourquadrantsorregions.”Hereishowitlookslike:

Visualrepresentationofquadtree

ImplementingAQuadtreeTherearesomeimplementationsofquadtreeavailableforRuby-rquad,rubyquadtreeandrubyquad,butitseemseasytoimplement,sowewillbuildonetailored(read:closelycoupled)toourgameusingthepseudocodefromWikipedia.

AxisAlignedBoundingBoxOneofprerequisitesofquadtreeisAxisalignedboundingbox,sometimesreferredtoas“AABB”.Itissimplyaboxthatsurroundstheshapebuthasedgesthatareinparallelwiththeaxesofunderlyingcoordinatesystem.Theadvantageofthisboxisthatitgivesaroughestimatewheretheshapeisandisveryefficientwhenit

comestoqueryingifapointisinsideoroutsideit.

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)[email protected]?(12game_object.location)[email protected]<NODE_CAPACITY15@objects<<game_object16returntrue17end1819subdivideunless@[email protected](game_object)

[email protected](game_object)[email protected](game_object)[email protected](game_object)2526#shouldneverhappen27raise"Failedtoinsert#{game_object}"28end2930defremove(game_object)[email protected]?(32game_object.location)[email protected](game_object)34returntrue35end36returnfalseunless@nw

[email protected](game_object)[email protected](game_object)[email protected](game_object)[email protected](game_object)41false42end4344defquery_range(range)45result=[][email protected]?(range)[email protected]|o|51ifrange.contains?(o.location)

52result<<o53end54end5556#Notsubdivided57returnresultunless@[email protected]_range(range)[email protected]_range(range)[email protected]_range(range)[email protected]_range(range)6364result65end6667private68

69defsubdivide70cx,[email protected],[email protected]_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,:[email protected](box)9@tree=QuadTree.new(box)10@objects=[]11end1213defadd(object)14@objects<<[email protected](object)16end1718deftree_remove(object)

[email protected](object)20end2122deftree_insert(object)[email protected](object)[email protected](&:update)[email protected]!do|o|[email protected](o)31true32end33end34end3536defnearby(object,max_distance)37cx,cy=object.location38hx,hy=cx+

max_distance,cy+max_distance39#Fast,[email protected]_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)[email protected]_range(box)

52end53end

Anoldfashionedarrayofallobjectsisstillused,becausewestillneedtoloopthrougheverythingandinvokeGameObject#update.ObjectPool#query_range

wasintroducedtoquicklygrabobjectsthathavetoberenderedonscreen,andObjectPool#nearbynowqueriestreeandmeasures

distancesonlyonroughresultset.

Thisishowwewillrenderthingsfromnowon:

classPlayState<GameState#[email protected][email protected]_x=$window.width/2-cam_xoff_y=$window.height/[email protected],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)[email protected]$window.scale(zoom,zoom,cam_x,cam_y)[email protected](viewport)@object_pool.query_range(box).mapdo|o|o.draw(viewport)[email protected][email protected]

#...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.

Spatialpartitioningsavestheday

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’[email protected],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@[email protected][email protected]

[email protected]?||@tank.health.health!=@health20@[email protected]@health_image=Gosu::Image.from_text(22$window,"Health:#{@health}",Utils.main_font,20)23end24@[email protected]@stats_image.nil?||stats.changed_at<=Gosu.milliseconds30@stats_image=

Gosu::Image.from_text(31$window,"Kills:#{stats.kills}",Utils.main_font,20)32end33@[email protected]_rate_modifier>138if@[email protected]_rate_modifier39@[email protected]_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@[email protected]_modifier>152if@[email protected]_modifier53@[email protected]_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

[email protected]=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#[email protected]

defdraw#[email protected]#...end

[email protected],youshouldgetaneatviewshowinginteresting

thingsintop-leftcornerofthescreen:

ShinynewHUD

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)#[email protected]_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)[email protected]#...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@[email protected](33$window.width/[email protected]/2,34$window.height/[email protected],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

#[email protected]=falseend

[email protected]=trueend#...end

Timeforatestdrive.

Pausingthegametoseethescore

Fornow,scoringmostkillsisrelativelysimple.Thisshouldchangewhenwewilltell

enemyAItocollectpowerupswhenappropriate.

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))

[email protected]=@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::[email protected]|t|[email protected]_to_tank(target_tank)26end27end2829private30

31defcreate_tanks(amount)[email protected]_points(amount*3)33@tanks=[]34amount.timesdo|i|35@tanks<<Tank.new(@object_pool,AiInput.new([email protected],@object_pool))[email protected]@hud=HUD.new(@object_pool,target_tank)[email protected]=false41switch_to_tank(target_tank)42end43

44defswitch_to_tank(tank)[email protected][email protected]=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:

Overhauledmainmenu

ObservingAIindemostate

VisualAIDebugging

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(@[email protected]/2,@[email protected]/[email protected],100)endend#...end

Tomarktank’sdesiredgunangleasbluelineandactualgunangleasredline,youcandothis:

classAiGun#...defdraw(viewport)if$debugcolor=Gosu::Color::BLUEx,[email protected],@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,[email protected],@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=[][email protected]@object.fire_rate_modifier<2&&health>50required<<[email protected]_modifier<1.5&&health>50required<<TankSpeedPowerup

endifhealth<100required<<RepairPowerupendifhealth<190required<<HealthPowerupendrequiredend

[email protected]_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#[email protected]_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|[email protected][email protected]+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([email protected],@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

[email protected]_direction

16Utils.angle_between([email protected],@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#[email protected]_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@[email protected]@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)@[email protected]@last_location_update=now@[email protected]#...

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.

Andmostimportantly,thankyouforreadingthisbook!