DevelopingGamesWithRuby
Forthosewhowritecodeforliving
TomasVaraneckas
Thisbookisforsaleathttp://leanpub.com/developing-games-with-ruby
Thisversionwaspublishedon2014-12-16
*****
ThisisaLeanpubbook.LeanpubempowersauthorsandpublisherswiththeLeanPublishingprocess.LeanPublishingistheactofpublishinganin-progressebookusinglightweighttoolsandmanyiterationstogetreaderfeedback,pivotuntilyouhavetherightbookandbuildtractiononceyoudo.
*****
©2014TomasVaraneckas
TableofContents
ABoyWhoWantedToCreateWorlds
WhyRuby?
WhatYouShouldKnowBeforeReadingThisBook
WhatAreWeGoingToBuild?GraphicsGameDevelopmentLibraryThemeAndMechanics
PreparingTheToolsGettingGosutorunonMacOsX
GettingTheSampleCode
OtherTools
GosuBasicsHelloWorldScreenCoordinatesAndDepthMainLoopMovingThingsWithKeyboardImagesAndAnimationMusicAndSound
WarmingUpUsingTilesetsIntegratingWithTexturePackerCombiningTilesIntoAMapUsingTiledToCreateMapsLoadingTiledMapsWithGosuGeneratingRandomMapWithPerlinNoisePlayerMovementWithKeyboardAndMouseGameCoordinateSystem
PrototypingTheGameSwitchingBetweenGameStatesImplementingMenuStateImplementingPlayStateImplementingWorldMapImplementingFloatingCameraImplementingTheTankImplementingBulletsAndExplosions
RunningThePrototype
OptimizingGamePerformanceProfilingRubyCodeToFindBottlenecksAdvancedProfilingTechniquesOptimizingInefficientCodeProfilingOnDemandAdjustingGameSpeedForVariablePerformanceFrameSkipping
RefactoringThePrototypeGameProgrammingPatternsWhatIsWrongWithCurrentDesignDecouplingUsingComponentPattern
SimulatingPhysicsAddingEnemyObjectsAddingBoundingBoxesAndDetectingCollisionsCatchingBulletsImplementingTurnSpeedPenaltiesImplementingTerrainSpeedPenalties
ImplementingHealthAndDamageAddingHealthComponentInflictingDamageWithBullets
CreatingArtificialIntelligenceDesigningAIUsingFiniteStateMachineImplementingAIVisionControllingTankGunImplementingAIInputImplementingTankMotionStatesWiringTankMotionStatesIntoFiniteStateMachine
MakingThePrototypePlayableDrawingWaterBeyondMapBoundariesGeneratingTreeClustersGeneratingRandomObjectsImplementingARadarDynamicSoundVolumeAndPanningGivingEnemiesIdentityRespawningTanksAndRemovingDeadOnesDisplayingExplosionDamageTrailsDebuggingBulletPhysicsMakingCameraLookAheadReviewingTheChanges
DealingWithThousandsOfGameObjectsSpatialPartitioningImplementingAQuadtreeIntegratingObjectPoolWithQuadTreeMovingObjectsInQuadTree
ImplementingPowerupsImplementingBasePowerupImplementingPowerupGraphicsImplementingPowerupSoundsImplementingRepairDamagePowerupImplementingHealthBoostImplementingFireRateBoostImplementingTankSpeedBoostSpawningPowerupsOnMapRespawningPowerupsAfterPickup
ImplementingHeadsUpDisplayDesignConsiderationsRenderingTextWithCustomFontImplementingHUDClass
ImplementingGameStatisticsTrackingKills,DeathsandDamageMakingDamagePersonalTrackingDamageFromChainReactionsDisplayingGameScore
BuildingAdvancedAIImprovingTankNavigationImplementingDemoStateToObserveAIVisualAIDebuggingMakingAICollectPowerupsSeekingHealthPowerupsAfterHeavyDamageEvadingCollisionsAndGettingUnstuck
WrappingItUpLessonsLearned
SpecialThanks
ABoyWhoWantedToCreateWorlds
Oncetherewasaboywhofellinlovewiththismagicaldevicethatcouldbringthingstolifeinsideaglaringscreen.Hespentendlesshoursexploringimaginaryworlds,fightingstrangecreatures,shootingpixelatedspaceships,racingboxycars.Theboykeptpondering.“Howisthismade?Iwanttocreatemyownworlds…”.
Thenhediscoveredprogramming.“Icanfinallydoit!”-hethought.Andhetried.Andfailed.Thenhetriedharder.Hefailedagainandagain.Hewastoonaivetorealizethatthoseworldshewastryingtocreateweretoosophisticated,andhisknowledgewastoolimited.Hegaveupcreatingthoseworlds.
Whathedidn’tgiveupiswritingcodeforthismagicaldevice.Herealizedheisn’tsmartenoughtocreateworlds,yethefoundouthecouldcreatesimplerthingslikesmallapplications-web,desktop,serversideorwhatnot.Fewyearslaterhefoundhimselfgettingpaidtomakethose.
Applicationsgotincreasinglybigger,theyspannedacrossmultipleservers,integratedwitheachother,becamepatsofhugeinfrastructures.Theboy,nowagrownman,wasallintoit.Itwasfunandchallengingenoughtospendover10000hourslearningandbuildingwhatotherswantedhimtobuild.
Someofthesethingswereuseful,somewhereboringandpointless.Somewereneverfinished.Therewerethingshewasproudof,therewereothersthathewouldn’twanttotalkabout,nonethelesseverythinghebuiltmadehimabetterbuilder.Yetheneverfoundthetime,courageorreasontobuildwhathereallywantedtobuildsincehewasalittleboy-hisownworlds.
Untilonedayherealizedthatnoonecanstophimfromfollowinghisdream.Hefeltthatequippedwithhiscurrentknowledgeandexperiencehewillbeabletolearntocreateworldsofhisown.Andhewentforit.
Thisboymustliveinmanysoftwaredevelopers,whodreamaboutcreatinggames,butinsteadselltheirsoftwarecraftsmanshipskillstothosewhoneedsomethingelse.Thisboyisme,andyou.Andit’stimetosethimfree.
Welcometotheworldofgamedevelopmentthatwaswaitingforyoualltheseyears.
WhyRuby?
Whenitcomestogamedevelopment,everyonewilltellyouthatyoushouldgowithC++orsomeotherstaticallytypedlanguagethatcompilesdowntobaremetalinstructions.OrthatyoushouldgowithfullblowngamedevelopmentplatformlikeUnity.Slow,dynamiclanguageslikeRubyseemlikethelastchoiceanysanegamedeveloperwouldgofor.
Afriendofminesaid“There’slittlereasontodevelopadesktopgamewithRuby”,andhewasabsolutelyright.Perhapsthisisthereasonwhytherearenobooksaboutit.Allthecasualgameactionhappensinmobiledevices,anddesktopgamesareforseasonedgamerswhodemandfastanddetailed3Dgraphics,motion-capturedanimationsandsophisticatedgamemechanics-thingsweknowwearenotgoingtobeabletobuildonourown,withoutmillionsfromVCpocketsandHollywoodgradeequipment.
Now,bearwithme.Yourgamewillnotbea3DMMORPGsetinhuge,photorealisticrepresentationofMiddle-earth.Let’sleavethosethingstoBethesda,UbisoftandRockstarGames.Afterall,everyonehastostartsomewhere,andyouhavetobesmartenoughtounderstand,thateventhoughthatlittleboyinyouwantstocreateanimprovedversionofGrandTheftAutoV,wewillhavetogoforsomethingthatresembleslesserknownSuperNintendotitlesinstead.
Whynotgomobilethen?Thosedevicesseemperfectforsimplergames.Ifyouareatruegameratheart,youwillagreethattouchscreengamesyoufindinmodernphonesandtabletsareonlygoodforkilling10minutesofyourtimewhiletakingadump.Youhavetofeeltheresistancewhenyouclickabutton!Screensizealsodoesmatter.Playinganythingonmobilephoneisatortureforthosewhoknowwhatplayingrealgamesshouldfeellike.
So,yourgamewillhavetobesmallenoughforyoutobeabletocompleteit,itwillhavetohavesimple2Dgraphics,andwouldnotrequirethelatestGeForcewithatleast512MBofRAM.Thisfactgivesyouthebenefitofchoice.Youdon’thavetoworryaboutperformancethatmuch.Youcanchooseafriendlyandproductivelanguagethatisdesignedforprogrammerhappiness.AndthisiswhereRubystartstoshine.It’sbeautiful,simpleandelegant.Itisclosetopoetry.
WhatYouShouldKnowBeforeReadingThisBook
Asyoucanreadonthecover,thisbookis“forthosewhowritecodeforliving”.It’snotarequirement,andyouwillmostlikelybeabletounderstandeverythingevenifyouareastudentorhobbyist,butthisbookwillnotteachyouhowtobeagoodprogrammer.Ifyouwanttolearnthat,startwithtimelessclassic:ThePragmaticProgrammer:FromJourneymantoMaster.
YoushouldunderstandRubyatleasttosomeextent.Thereareplentyofbooksandresourcescoveringthatsubject.TryWhy’sPoignantGuideToRubyorEloquentRuby.Youcanalsolearnitwhilereadingthisbook.Itshouldn’tbetoohard,especiallyifyoualreadywritecodeforliving.Afterallprogramminglanguageismerelyatool,andwhenyoulearnone,othersarerelativelyeasytoswitchto.
Youshouldknowhowtousethecommandline.BasicknowledgeofGitcanalsobehandy.
Youdon’thavetoknowhowtodraworcomposemusic.Wewillusemediathatisavailableforfree.However,knowledgeofgraphicsandaudioeditingsoftwarewon’thurt.
WhatAreWeGoingToBuild?
Thisquestionisofparamountimportance.Theanswerwillusuallydetermineifyouwilllikelytosucceed.Ifyouwanttooverstepyourboundaries,youwillfail.Itshouldn’tbetooeasyeither.Ifyouknowsomethingaboutprogrammingalready,IbetyoucanimplementTicTacToe,butwillyoufeelproudaboutit?Willyoubeabletosay“I’vebuiltaworld!”.Iwouldn’t.
GraphicsTobeginwith,weneedtoknowwhatkindofgraphicsweareaimingfor.Wewillinstantlyruleout3Dforseveralreasons:
Wedon’twanttoincreasethescopeandcomplexityRubymaynotbefastenoughfor3DgamesLearningproper3Dgraphicsprogrammingrequiresreadingaseparatebookthatisseveraltimesthickerthanthisone.
Now,wehavetoswallowourprideandacceptthefactthatthegamewillhavesimple2Dgraphics.Therearethreechoicestogofor:
ParallelProjectionTopDownSide-Scrolling
ParallelProjection(thinkFallout1&2)isprettycloseto3Dgraphics,itrequiresdetailedartifyouwantittolookdecent,sowewouldhavearoughstartifwewentforit.
TopDownview(oldtitlesofLegendofZelda)offersplentyoffreedomtoexploretheenvironmentinalldirectionsandrequireslessgraphicaldetail,sincethingslooksimplerfromabove.
SideScrollinggames(SuperMarioBros.)usuallyinvolvesomephysicsrelatedtojumpingandrequiremoreefforttolookgood.Feelingofexplorationislimited,sinceyouusuallymovefromlefttorightmostofthetime.
GoingwithTopDownviewwillgiveusachancetocreateourgameworldasopenforexplorationaspossible,whilehavingsimplegraphicsandmovementmechanics.Soundslikethebestchoiceforus.
IfyouareasbadatdrawingthingsasIam,youcouldstillwonderhowwearegoingtogetourgraphics.Thankfully,thereisthisopengameart.org.It’slikeGitHubofgamemedia,wewillsurelyfindsomethingthere.Italsocontainsaudiosamplesandtracks.
GameDevelopmentLibrary
Implementitallyourselforharnessthepowerofsomegamedevelopmentlibrarythatoffersyouboilerplatesandconvenientaccesstocommonfunctions?Ifyou’relikeme,youwoulddefinitelywanttoimplementitallyourself,butthatmaybethereasonwhyIfailedtomakeadecentgamesomanytimes.
Ifyouwilltrytoimplementitallyourself,youwillmostlikelyendupreimplementingsomeexistinggamelibrary,poorly.Itwon’ttakelongwhileyoureachapointwhereyouneedtointerfacewithunderlyingoperatingsystemlibrariestogetgraphics.Andguessifthosebindingswillworkinadifferentoperatingsystem?
So,swallowyourprideagain,becausewearegoingtouseanexistinggamedevelopmentlibrary.Goodnewsisthatyouwillbeabletoactuallyfinishthegame,anditwillbeportabletoWindows,MacandLinux.Wewillstillhavetobuildourowngameengineforourselvesontopofit,sodon’tthinkitwon’tbefun.
ThereareseveralgamelibrariesavailableforRuby,butit’sasimplechoice,becauseGosuisheadandshouldersaboveothers.It’sverymature,hasalargeandactivecommunity,anditismainlywritteninC++buthasfirstclassRubysupport,soitwillbebothfastandconvenienttouse.
ManyofotherRubygamelibrariesarebuiltontopofGosu,soit’sasolidchoice.
ThemeAndMechanicsChoosingtherightthemeisundoubtedlyimportant.Itshouldbesomethingthatappealstoyou,somethingyouwillwanttoplay,anditshouldnotimplydifficultgamemechanics.IloveMMORPGs,andIalwaysdreamedofmakinganopenworldgamewhereyoucanroamaround,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.SystemRubyshouldworkjustfine,butyoumaywanttouseRbenvorRVMtoavoidpollutingsystemRuby.I’vehadtroubleinstallingGosuwithRVM,butyourexperiencemayvary.
Toinstallthegem,simplyrun:$geminstallgosu
YoumayneedtoprefixitwithsudoifyouareusingsystemRuby.
Totestifgemwasinstalledcorrectly,youshouldbeabletorunthistoproduceanemptyblackwindow:$irb
irb(main):001:0>require'gosu'
=>true
irb(main):002:0>Gosu::Window.new(320,240,false).show
=>nil
MostdeveloperswhouseMaceverydaywillalsorecommendinstallingHomebrewpackagemanager,replaceTerminalappwithiTerm2anduseOh-My-ZshtomanageZSHconfiguration.
GettingTheSampleCode
YoucanfindsamplecodeatGitHub:https://github.com/spajus/ruby-gamedev-book-examples.
Cloneittoaconvenientlocation:$cd~/gamedev
[email protected]:spajus/ruby-gamedev-book-examples.git
Thesourcecodeoffinalproductcanbefoundathttps://github.com/spajus/tank_island
OtherTools
Allyouneedforthisadventureisagoodtexteditor,terminalandprobablysomegraphicseditor.TryGIMPifyouwantafreeone.I’musingPixelmator,it’swonderful,butforMaconly.AnoteworthyfactisthatPixelmatorwasbuiltbyfellowLithuanians.
Whenitcomestoeditors,Idon’tleavehomewithoutVim,butaslongaswhatyouusemakesyouproductive,itdoesn’tmakeanydifference.Vim,EmacsorSublimeareallgoodenoughtowritecode,justhavesomegoodpluginsthatsupportRuby,andyou’reset.IfyoureallyfeelyouneedanIDE,whichmaybethecaseifyouarecomingfromastaticlanguage,youcan’tgowrongwithRubyMine.
GosuBasics
BynowGosushouldbeinstalledandreadyforaspin.Butbeforewerushintobuildingourgame,wehavetogetacquaintedwithourlibrary.Wewillgothroughseveralsimpleexamples,familiarizeourselveswithGosuarchitectureandcoreprinciples,andtakeacoupleofbabystepstowardsunderstandinghowtoputeverythingtogether.
Tomakethischaptereasiertoreadandunderstand,IrecommendwatchingWritingGamesWithRubytalkgivenbyMikeMooreatLARubyConference2014.Infact,thistalkpushedmetowardsrethinkingthiscrazyideaofusingRubyforgamedevelopment,sothisbookwouldn’texistwithoutit.Thankyou,Mike.
HelloWorldTohonorthetraditions,wewillstartbywriting“HelloWorld”togetatasteofwhatGosufeelslike.ItisbasedonRubyTutorialthatyoucanfindinGosuWiki.01-hello/hello_world.rb
1require'gosu'
2
3classGameWindow<Gosu::Window
4definitialize(width=320,height=240,fullscreen=false)
5super
6self.caption='Hello'
7@message=Gosu::Image.from_text(
8self,'Hello,World!',Gosu.default_font_name,30)
9end
10
11defdraw
[email protected](10,10,0)
13end
14end
15
16window=GameWindow.new
17window.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-leftcornerofthescreenhascoordinatesof0:0.
zorderinGosuisjustlikez-indexinCSS.Itdoesnotdefinezoomlevel,butincasetwoshapesoverlap,onewithhigherzvaluewillbedrawnontop.
MainLoopTheheartofGosulibraryisthemainloopthathappensinGosu::Window.ItisexplainedfairlywellinGosuwiki,sowewillnotbediscussingithere.
MovingThingsWithKeyboardWewillmodifyour“Hello,World!”exampletolearnhowtomovethingsonscreen.Thefollowingcodewillprintcoordinatesofthemessagealongwithnumberoftimesscreenwasredrawn.ItalsoallowsexitingtheprogrambyhittingEscbutton.01-hello/hello_movement.rb
1require'gosu'
2
3classGameWindow<Gosu::Window
4definitialize(width=320,height=240,fullscreen=false)
5super
6self.caption='HelloMovement'
7@x=@y=10
8@draws=0
9@buttons_down=0
10end
11
12defupdate
13@x-=1ifbutton_down?(Gosu::KbLeft)
14@x+=1ifbutton_down?(Gosu::KbRight)
15@y-=1ifbutton_down?(Gosu::KbUp)
16@y+=1ifbutton_down?(Gosu::KbDown)
17end
18
19defbutton_down(id)
20closeifid==Gosu::KbEscape
21@buttons_down+=1
22end
23
24defbutton_up(id)
25@buttons_down-=1
26end
27
28defneeds_redraw?
29@draws==0||@buttons_down>0
30end
31
32defdraw
33@draws+=1
34@message=Gosu::Image.from_text(
35self,info,Gosu.default_font_name,30)
[email protected](@x,@y,0)
37end
38
39private
40
41definfo
42"[x:#{@x};y:#{@y};draws:#{@draws}]"
43end
44end
45
46window=GameWindow.new
47window.show
Runtheprogramandtrypressingarrowkeys:$ruby01-hello/hello_movement.rb
Themessagewillmovearoundaslongasyoukeeparrowkeyspressed.
Usearrowkeystomovethemessagearound
Wecouldwriteashorterversion,butthepointhereisthatifwewouldn’toverrideneeds_redraw?thisprogramwouldbeslowerbyorderofmagnitude,becauseitwouldcreate@messageobjecteverytimeitwantstoredrawthewindow,eventhoughnothingwouldchange.
Hereisascreenshotoftopdisplayingtwoversionsofthisprogram.Secondscreenhasneeds_redraw?methodremoved.Seethedifference?
RedrawingonlywhennecessaryVSredrawingeverytime
Rubyisslow,soyouhavetouseitwisely.
ImagesAndAnimationIt’stimetomakesomethingmoreexciting.Ourgamewillhavetohaveexplosions,thereforeweneedtolearntoanimatethem.Wewillsetupabackgroundsceneandtriggerexplosionsontopofitwithourmouse.01-hello/hello_animation.rb
1require'gosu'
2
3defmedia_path(file)
4File.join(File.dirname(File.dirname(
5__FILE__)),'media',file)
6end
7
8classExplosion
9FRAME_DELAY=10#ms
10SPRITE=media_path('explosion.png')
11
12defself.load_animation(window)
13Gosu::Image.load_tiles(
14window,SPRITE,128,128,false)
15end
16
17definitialize(animation,x,y)
18@animation=animation
19@x,@y=x,y
20@current_frame=0
21end
22
23defupdate
24@current_frame+=1ifframe_expired?
25end
26
27defdraw
28returnifdone?
29image=current_frame
30image.draw(
[email protected]/2.0,
[email protected]/2.0,
330)
34end
35
36defdone?
37@done||=@[email protected]
38end
39
40private
41
42defcurrent_frame
43@animation[@current_frame%@animation.size]
44end
45
46defframe_expired?
47now=Gosu.milliseconds
48@last_frame||=now
49if(now-@last_frame)>FRAME_DELAY
50@last_frame=now
51end
52end
53end
54
55classGameWindow<Gosu::Window
56BACKGROUND=media_path('country_field.png')
57
58definitialize(width=800,height=600,fullscreen=false)
59super
60self.caption='HelloAnimation'
61@background=Gosu::Image.new(
62self,BACKGROUND,false)
63@animation=Explosion.load_animation(self)
64@explosions=[]
65end
66
67defupdate
[email protected]!(&:done?)
[email protected](&:update)
70end
71
72defbutton_down(id)
73closeifid==Gosu::KbEscape
74ifid==Gosu::MsLeft
76Explosion.new(
77@animation,mouse_x,mouse_y))
78end
79end
80
81defneeds_cursor?
82true
83end
84
85defneeds_redraw?
86!@scene_ready||@explosions.any?
87end
88
89defdraw
90@scene_ready||=true
[email protected](0,0,0)
[email protected](&:draw)
93end
94end
95
96window=GameWindow.new
97window.show
Runitandclickaroundtoenjoythosebeautifulspecialeffects:$ruby01-hello/hello_animation.rb
Multipleexplosionsonscreen
Nowlet’sfigureouthowitworks.OurGameWindowinitializeswith@backgroundGosu::Imageand@animation,thatholdsarrayofGosu::Imageinstances,oneforeachframeofexplosion.Gosu::Image.load_tileshandlesitforus.
Explosion::SPRITEpointsto“tileset”image,whichisjustaregularimagethatcontainsequallysizedsmallerimageframesarrangedinorderedsequence.Rowsofframesarereadlefttoright,likeyouwouldreadabook.
Explosiontileset
Giventhatexplosion.pngtilesetis1024x1024pixelsbig,andithas8rowsof8tilesperrow,itiseasytotellthatthereare64tiles128x128pixelseach.So,@animation[0]holds128x128Gosu::Imagewithtop-lefttile,and@animation[63]-thebottom-rightone.
Gosudoesn’thandleanimation,it’ssomethingyouhavefullcontrolover.Wehavetodraweachtileinasequenceourselves.YoucanalsousetilestoholdmapgraphicsThelogicbehindthisisprettysimple:
1. Explosionknowsit’s@current_framenumber.Itbeginswith0.2. Explosion#frame_expired?checksthelasttimewhen@current_framewas
rendered,andwhenitisolderthanExplosion::FRAME_DELAYmilliseconds,@current_frameisincreased.
3. WhenGameWindow#updateiscalled,@[email protected],explosionsthathavefinishedtheiranimation(displayedthelast
frame)[email protected]. GameWindow#drawdrawsbackgroundimageandall@explosionsdrawtheir
current_frame.5. Again,wearesavingresourcesandnotredrawingwhenthereareno@explosionsin
progress.needs_redraw?handlesit.
Itisimportanttounderstandthatupdateanddraworderisunpredictable,thesemethodscanbecalledbyyoursystematdifferentrate,youcan’ttellwhichonewillbecalledmoreoftenthantheotherone,soupdateshouldonlybeconcernedwithadvancingobjectstate,anddrawshouldonlydrawcurrentstateonscreenifitisneeded.Theonlyreliablethinghereistime,consultGosu.millisecondstoknowhowmuchtimehavepassed.
Ruleofthethumb:drawshouldbeaslightweightaspossible.Prepareallcalculationsinupdateandyouwillhaveresponsive,smoothgraphics.
MusicAndSoundOurpreviousprogramwasclearlymissingasoundtrack,sowewilladdone.Abackgroundmusicwillbelooping,andeachexplosionwillbecomeaudible.01-hello/hello_sound.rb
1require'gosu'
2
3defmedia_path(file)
4File.join(File.dirname(File.dirname(
5__FILE__)),'media',file)
6end
7
8classExplosion
9FRAME_DELAY=10#ms
10SPRITE=media_path('explosion.png')
11
12defself.load_animation(window)
13Gosu::Image.load_tiles(
14window,SPRITE,128,128,false)
15end
16
17defself.load_sound(window)
18Gosu::Sample.new(
19window,media_path('explosion.mp3'))
20end
21
22definitialize(animation,sound,x,y)
23@animation=animation
24sound.play
25@x,@y=x,y
26@current_frame=0
27end
28
29defupdate
30@current_frame+=1ifframe_expired?
31end
32
33defdraw
34returnifdone?
35image=current_frame
36image.draw(
[email protected]/2.0,
[email protected]/2.0,
390)
40end
41
42defdone?
43@done||=@[email protected]
44end
45
46defsound
48end
49
50private
51
52defcurrent_frame
53@animation[@current_frame%@animation.size]
54end
55
56defframe_expired?
57now=Gosu.milliseconds
58@last_frame||=now
59if(now-@last_frame)>FRAME_DELAY
60@last_frame=now
61end
62end
63end
64
65classGameWindow<Gosu::Window
66BACKGROUND=media_path('country_field.png')
67
68definitialize(width=800,height=600,fullscreen=false)
69super
70self.caption='HelloAnimation'
71@background=Gosu::Image.new(
72self,BACKGROUND,false)
73@music=Gosu::Song.new(
74self,media_path('menu_music.mp3'))
[email protected](true)
77@animation=Explosion.load_animation(self)
78@sound=Explosion.load_sound(self)
79@explosions=[]
80end
81
82defupdate
[email protected]!(&:done?)
[email protected](&:update)
85end
86
87defbutton_down(id)
88closeifid==Gosu::KbEscape
89ifid==Gosu::MsLeft
91Explosion.new(
92@animation,@sound,mouse_x,mouse_y))
93end
94end
95
96defneeds_cursor?
97true
98end
99
100defneeds_redraw?
101!@scene_ready||@explosions.any?
102end
103
104defdraw
105@scene_ready||=true
[email protected](0,0,0)
[email protected](&:draw)
108end
109end
110
111window=GameWindow.new
112window.show
Runitandenjoythecinematicexperience.Addingsoundreallymakesadifference.$ruby01-hello/hello_sound.rb
Weonlyaddedcoupleofthingsoverpreviousexample.
72@music=Gosu::Song.new(
73self,media_path('menu_music.mp3'))
[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.
UsingTilesetsAfterplayingaroundwithGosuforawhile,weshouldbecomfortableenoughtoimplementaprototypeoftop-downviewgamemapusingthetilesetofourchoice.Thisgroundtilesetlookslikeagoodplacetostart.
IntegratingWithTexturePackerAfterdownloadingandextractingthetileset,it’sobviousthatGosu::Image#load_tileswillnotsuffice,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,andyoushoulddothesameinsuchasituation.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.rb
1require'json'
2classTileset
3definitialize(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)
9end
10
11defframe(name)
12f=@json['frames'][name]['frame']
13@main_image.subimage(
14f['x'],f['y'],f['w'],f['h'])
15end
16end
IfbynowyouarefamiliarwithGosudocumentation,youwillwonderwhatthehellisGosu::Image#subimage.Atthepointofwritingitwasnotdocumented,andIaccidentallydiscovereditwhilediggingthroughGosusourcecode.
I’mluckythisfunctionexisted,becauseIwasreadytobringouttheheavyartilleryanduseRMagicktoextractthosetiles.WewillprobablyneedRMagickatsomepointoftimelater,butit’sbettertoavoiddependenciesaslongaspossible.
CombiningTilesIntoAMapWithtilesetloadingissueoutoftheway,wecanfinallygetbacktodrawingthatcoolmapofours.
Thefollowingprogramwillfillthescreenwithrandomtiles.02-warmup/random_map.rb
1require'gosu'
2require'gosu_texture_packer'
3
4defmedia_path(file)
5File.join(File.dirname(File.dirname(
6__FILE__)),'media',file)
7end
8
9classGameWindow<Gosu::Window
10WIDTH=800
11HEIGHT=600
12TILE_SIZE=128
13
14definitialize
15super(WIDTH,HEIGHT,false)
16self.caption='RandomMap'
17@tileset=Gosu::TexturePacker.load_json(
18self,media_path('ground.json'),:precise)
19@redraw=true
20end
21
22defbutton_down(id)
23closeifid==Gosu::KbEscape
24@redraw=trueifid==Gosu::KbSpace
25end
26
27defneeds_redraw?
28@redraw
29end
30
31defdraw
32@redraw=false
33(0..WIDTH/TILE_SIZE).eachdo|x|
34(0..HEIGHT/TILE_SIZE).eachdo|y|
[email protected]_list.sample).draw(
37x*(TILE_SIZE),
38y*(TILE_SIZE),
390)
40end
41end
42end
43end
44
45window=GameWindow.new
46window.show
Runit,thenpressspacebartorefillthescreenwithrandomtiles.$ruby02-warmup/random_map.rb
Mapfilledwithrandomtiles
Theresultdoesn’tlookseamless,sowewillhavetofigureoutwhat’swrong.Afterplayingaroundforawhile,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.AndtheworkaroundwastouseRMagick.Iknewwewon’tgettoofarawayfromit.Butourrandommapnowlooksgorgeous:
Mapfilledwithseamlessrandomtiles
UsingTiledToCreateMapsWhilelowlevelapproachtodrawingtilesinscreenmaybeappropriateinsomescenarios,likerandomlygeneratedmaps,wewillexploreanotheralternatives.Oneofthemisthisgreat,opensource,crossplatform,generictilemapeditorcalledTiled.
Ithassomelimitations,forinstance,alltilesintilesethavetobeofsameproportions.Ontheupside,itwouldbeeasytoloadTiledtilesetswithGosu::Image#load_tiles.
Tiled
Tiledusesit’sowncustom,XMLbasedtmxformatforsavingmaps.ItalsoallowsexportingmapstoJSON,whichiswaymoreconvenient,sinceparsingXMLinRubyisusuallydonewithNokogiri,whichisheavierandit’snativeextensionsusuallycausemoretroublethanonesJSONparseruses.So,let’sseehowthatJSONlookslike:02-warmup/tiled_map.json
1{"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":0
33}],
34"orientation":"orthogonal",
35"properties":
36{
37
38},
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":128
55},
56{
57"firstgid":65,
58"image":"media\/water.png",
59"imageheight":128,
60"imagewidth":128,
61"margin":0,
62"name":"water",
63"properties":
64{
65
66},
67"spacing":0,
68"tileheight":128,
69"tilewidth":128
70}],
71"tilewidth":128,
72"version":1,
73"width":10
74}
Therearefollowingthingslistedhere:
Twodifferenttilesets,“ground”and“water”Mapwidthandheightintilecount(10x10)Layerswithdataarraycontainstilenumbers
CoupleofextrathingsthatTiledmapscanhave:
ObjectlayerscontaininglistsofobjectswiththeircoordinatesPropertieshashontilesandobjects
Thisdoesn’tlooktoodifficulttoparse,sowe’regoingtoimplementaloaderforTiledmaps.Andmakeitopensource,ofcourse.
LoadingTiledMapsWithGosuProbablytheeasiestwaytoloadTiledmapistotakeeachlayerandrenderitonscreen,tilebytile,likeacake.Wewillnotcareaboutcachingatthispoint,andtheonly
optimizationwouldbenotdrawingthingsthatareoutofscreenboundaries.
Aftercoupleofdaysoftestdrivendevelopment,I’veendedupwritinggosu_tiledgem,thatallowsyoutoloadTiledmapswithjustafewlinesofcode.
Iwillnotgothroughdescribingtheimplementation,butifyouwanttoexaminethethoughtprocess,takealookatgosu_tiledgem’sgitcommithistory.
Tousethegem,dogeminstallgosu_tiledandexaminethecodethatshowsamapoftheislandthatyoucanscrollaroundwitharrowkeys:02-warmup/island.rb
1require'gosu'
2require'gosu_tiled'
3
4classGameWindow<Gosu::Window
5MAP_FILE=File.join(File.dirname(
6__FILE__),'island.json')
7SPEED=5
8
9definitialize
10super(640,480,false)
11@map=Gosu::Tiled.load_json(self,MAP_FILE)
12@x=@y=0
13@first_render=true
14end
15
16defbutton_down(id)
17closeifid==Gosu::KbEscape
18end
19
20defupdate
21@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"
26end
27
28defdraw
29@first_render=false
[email protected](@x,@y)
31end
32
33defneeds_redraw?
34[Gosu::KbLeft,
35Gosu::KbRight,
36Gosu::KbUp,
37Gosu::KbDown].eachdo|b|
38returntrueifbutton_down?(b)
39end
40@first_render
41end
42end
43
44GameWindow.new.show
Runit,usearrowkeystoscrollthemap.$ruby02-warmup/island.rb
Theresultisquitesatisfying,anditscrollssmoothlywithoutanyoptimizations:
ExploringTiledmapinGosu
GeneratingRandomMapWithPerlinNoiseInsomecasesrandomgeneratedmapsmakeallthedifference.WormsandDiablowouldprobablybejustaveragegamesifitwasn’tforthosealwaysunique,procedurallygeneratedmaps.
Wewilltrytomakeaveryprimitivemapgeneratorourselves.Tobeginwith,wewillbeusingonly3differenttiles-water,sandandgrass.Forimplementingfullytilededges,thegeneratormustbeawareofavailabletilesetsandknowhowtocombinetheminvalidways.Wemaycomebacktoit,butfornowlet’skeepthingssimple.
Now,generatingnaturallylookingrandomnessissomethingworthhavingabookofit’sown,soinsteadoftryingtopoorlyreinventwhatotherpeoplehavealreadydone,wewilluseawellknownalgorithmperfectlysuitedforthistask-Perlinnoise.
IfyouhaveeverusedPhotoshop’sCloudfilter,youalreadyknowhowPerlinnoiselookslike:
Perlinnoise
Now,wecouldimplementthealgorithmourselves,butthereisperlin_noisegemalreadyavailable,itlooksprettysolid,sowewilluseit.
Thefollowingprogramgenerates100x100mapwith30%chanceofwater,15%chanceofsandand55%chanceofgrass:02-warmup/perlin_noise_map.rb
1require'gosu'
2require'gosu_texture_packer'
3require'perlin_noise'
4
5defmedia_path(file)
6File.join(File.dirname(File.dirname(
7__FILE__)),'media',file)
8end
9
10classGameWindow<Gosu::Window
11MAP_WIDTH=100
12MAP_HEIGHT=100
13WIDTH=800
14HEIGHT=600
15TILE_SIZE=128
16
17definitialize
18super(WIDTH,HEIGHT,false)
19load_tiles
20@map=generate_map
21@zoom=0.2
22end
23
24defbutton_down(id)
25closeifid==Gosu::KbEscape
26@map=generate_mapifid==Gosu::KbSpace
27end
28
29defupdate
30adjust_zoom(0.005)ifbutton_down?(Gosu::KbDown)
31adjust_zoom(-0.005)ifbutton_down?(Gosu::KbUp)
32set_caption
33end
34
35defdraw
36tiles_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)
44end
45end
46end
47
48private
49
50defset_caption
51self.caption='PerlinNoise.'<<
52"Zoom:#{'%.2f'%@zoom}."<<
53'UseUp/Downtozoom.Spacetoregenerate.'
54end
55
56defadjust_zoom(delta)
57new_zoom=@zoom+delta
58ifnew_zoom>0.07&&new_zoom<2
59@zoom=new_zoom
60end
61end
62
63defload_tiles
64tiles=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)
70end
71
72deftiles_x
73count=(WIDTH/(TILE_SIZE*@zoom)).ceil+1
74[count,MAP_WIDTH].min
75end
76
77deftiles_y
78count=(HEIGHT/(TILE_SIZE*@zoom)).ceil+1
79[count,MAP_HEIGHT].min
80end
81
82defgenerate_map
83noises=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)
93end
94end
95map
96end
97
98defchoose_tile(val)
99caseval
100when0.0..0.3#30%chance
101@water
102when0.3..0.45#15%chance,wateredges
103@sand
104else#55%chance
105@grass
106end
107end
108
109end
110
111window=GameWindow.new
112window.show
Runtheprogram,zoomwithup/downarrowsandregenerateeverythingwithspacebar.$ruby02-warmup/perlin_noise_map.rb
MapgeneratedwithPerlinnoise
Thisisalittlelongerthanourpreviousexamples,sowewillanalyzesomepartstomakeitclear.81defgenerate_map
82noises=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)
92end
93end
94map
95end
generate_mapistheheartofthisprogram.ItcreatestwodimensionalPerlin::Noisegenerator,thenchoosesarandomtileforeachlocationofthemap,accordingtonoisevalue.Tomakethemapalittlesharper,cubiccontrastisappliedtonoisevaluebeforechoosingthetile.Trycommentingoutcontrastapplication-itwilllooklikeaboringgolfcourse,sincenoisevalueswillkeepbuzzingaroundthemiddle.97defchoose_tile(val)
98caseval
99when0.0..0.3#30%chance
100@water
101when0.3..0.45#15%chance,wateredges
102@sand
103else#55%chance
104@grass
105end
106end
Herewecouldgocrazyifwehadmoredifferenttilestouse.Wecouldadddeepwatersat0.0..0.1,mountainsat0.9..0.95andsnowcapsat0.95..1.0.Andallthiswouldhavebeautifultransitions.
PlayerMovementWithKeyboardAndMouseWehavelearnedtodrawmaps,butweneedaprotagonisttoexplorethem.ItwillbeatankthatyoucanmovearoundtheislandwithWASDkeysanduseyourmousetotargetit’sgunatthings.Thetankwillbedrawnontopofourislandmap,anditwillbeaboveground,butbelowtreelayer,soitcansneakbehindpalmtrees.That’sasclosetorealdealasitgets!02-warmup/player_movement.rb
1require'gosu'
2require'gosu_tiled'
3require'gosu_texture_packer'
4
5classTank
6attr_accessor:x,:y,:body_angle,:gun_angle
7
8definitialize(window,body,shadow,gun)
9@x=window.width/2
10@y=window.height/2
11@window=window
12@body=body
13@shadow=shadow
14@gun=gun
15@body_angle=0.0
16@gun_angle=0.0
17end
18
19defupdate
20atan=Math.atan2([email protected]_x,
22@gun_angle=-atan*180/Math::PI
23@body_angle=change_angle(@body_angle,
24Gosu::KbW,Gosu::KbS,Gosu::KbA,Gosu::KbD)
25end
26
27defdraw
[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
32
33private
34
35defchange_angle(previous_angle,up,down,right,left)
[email protected]_down?(up)
37angle=0.0
[email protected]_down?(left)
[email protected]_down?(right)
[email protected]_down?(down)
41angle=180.0
[email protected]_down?(left)
[email protected]_down?(right)
[email protected]_down?(left)
45angle=90.0
[email protected]_down?(up)
[email protected]_down?(down)
[email protected]_down?(right)
49angle=270.0
[email protected]_down?(up)
[email protected]_down?(down)
52end
53angle||previous_angle
54end
55end
56
57classGameWindow<Gosu::Window
58MAP_FILE=File.join(File.dirname(
59__FILE__),'island.json')
60UNIT_FILE=File.join(File.dirname(File.dirname(
61__FILE__)),'media','ground_units.json')
62SPEED=5
63
64definitialize
65super(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,
[email protected]('tank1_body.png'),
[email protected]('tank1_body_shadow.png'),
[email protected]('tank1_dualgun.png'))
73@x=@y=0
74@first_render=true
75@buttons_down=0
76end
77
78defneeds_cursor?
79true
80end
81
82defbutton_down(id)
83closeifid==Gosu::KbEscape
84@buttons_down+=1
85end
86
87defbutton_up(id)
88@buttons_down-=1
89end
90
91defupdate
92@x-=SPEEDifbutton_down?(Gosu::KbA)
93@x+=SPEEDifbutton_down?(Gosu::KbD)
94@y-=SPEEDifbutton_down?(Gosu::KbW)
95@y+=SPEEDifbutton_down?(Gosu::KbS)
97self.caption="#{Gosu.fps}FPS."<<
98'UseWASDandmousetocontroltank'
99end
100
101defdraw
102@first_render=false
[email protected](@x,@y)
105end
106end
107
108GameWindow.new.show
Tankspriteisrenderedinthemiddleofscreen.Itconsistsofthreelayers,bodyshadow,bodyandgun.Bodyandit’sshadowarealwaysrenderedinsameangle,oneontopofanother.Theangleisdeterminedbykeysthatarepressed.Itsupports8directions.
Gunisalittlebitdifferent.Itfollowsmousecursor.Todeterminetheanglewehadtousesomemath.Theformulatogetangleindegreesisarctan(delta_x/delta_y)*180/PI.Youcanseeitexplainedinmoredetailonstackoverflow.
Runitandstrollaroundtheisland.Youcanstillmoveonwaterandintothedarkness,awayfromthemapitself,butwewillhandleitlater.
$ruby02-warmup/player_movement.rb
Seethattankhidingbetweenthebushes,readytogoin8directionsandblowthingsupwiththatpreciselyaimeddoublecannon?
Tankmovingaroundandaimingguns
GameCoordinateSystemBynowwemaystartrealizing,thatthereisonekeycomponentmissinginourdesigns.Wehaveavirtualmap,whichisbiggerthanourscreenspace,andweshouldperformallcalculationsusingthatmap,andonlythencutouttherequiredpieceandrenderitinourgamewindow.
Therearethreedifferentcoordinatesystemsthathavetomapwitheachother:
1. Gamecoordinates2. Viewportcoordinates3. Screencoordinates
Coordinatesystems
GameCoordinates
Thisiswherealllogicwillhappen.Playerlocation,enemylocations,poweruplocations-allthiswillhavegamecoordinates,anditshouldhavenothingtodowithyourscreenposition.
ViewportCoordinates
Viewportisthepositionofvirtualcamera,thatis“filming”worldinaction.Don’tconfuseitwithscreencoordinates,becauseviewportwillnotnecessarilybemappedpixeltopixeltoyourgamewindow.Imaginethis:youhaveahugeworldmap,yourplayerisstandinginthemiddle,andgamewindowdisplaystheplayerwhileslowlyzoomingin.Inthis
scenario,viewportisconstantlyshrinking,whilegamemapstaysthesame,andgamewindowalsostaysthesame.
ScreenCoordinates
Thisisyourgamedisplay,pixelbypixel.Youwilldrawstaticinformation,likeyourHUDdirectlyonit.
HowToPutItAllTogether
Inourgameswewillwanttoseparategamecoordinatesfromviewportandscreenasmuchaspossible.Basically,wewillprogramourselvesa“cameraman”whowillbebusyfollowingtheaction,zoominginandout,perhapschangingtheviewanglenowandthen.
Let’simplementaprototypethatwillallowustonavigateandzoomaroundabigmap.Wewillonlydrawobjectsthatarevisibleinviewport.Somemathwillbeunavoidable,butinmostcasesit’sprettybasic-that’sthebeautyof2Dgames:02-warmup/coordinate_system.rb
1require'gosu'
2
3classWorldMap
4attr_accessor:on_screen,:off_screen
5
6definitialize(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}",
13Gosu.default_font_name,15)
14@images[x][y]=img
15end
16end
17end
18
19defdraw(camera)
20@on_screen=@off_screen=0
[email protected]|x,row|
22row.eachdo|y,val|
23ifcamera.can_view?(x,y,val)
24val.draw(x,y,0)
25@on_screen+=1
26else
27@off_screen+=1
28end
29end
30end
31end
32end
33
34classCamera
35attr_accessor:x,:y,:zoom
36
37definitialize
38@x=@y=0
39@zoom=1
40end
41
42defcan_view?(x,y,obj)
43x0,x1,y0,y1=viewport
44(x0-obj.width..x1).include?(x)&&
45(y0-obj.height..y1).include?(y)
46end
47
48defviewport
49x0=@x-($window.width/2)/@zoom
50x1=@x+($window.width/2)/@zoom
51y0=@y-($window.height/2)/@zoom
52y1=@y+($window.height/2)/@zoom
53[x0,x1,y0,y1]
54end
55
56defto_s
57"FPS:#{Gosu.fps}."<<
58"#{@x}:#{@y}@#{'%.2f'%@zoom}."<<
59'WASDtomove,arrowstozoom.'
60end
61
62defdraw_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)
69end
70end
71
72
73classGameWindow<Gosu::Window
74SPEED=10
75
76definitialize
77super(800,600,false)
78$window=self
79@map=WorldMap.new(2048,1024)
80@camera=Camera.new
81end
82
83defbutton_down(id)
84closeifid==Gosu::KbEscape
85ifid==Gosu::KbSpace
89end
90end
91
92defupdate
[email protected]=SPEEDifbutton_down?(Gosu::KbA)
[email protected]+=SPEEDifbutton_down?(Gosu::KbD)
[email protected]=SPEEDifbutton_down?(Gosu::KbW)
[email protected]+=SPEEDifbutton_down?(Gosu::KbS)
97
[email protected]>0?0.01:1.0
99
100ifbutton_down?(Gosu::KbUp)
[email protected]=zoom_delta
102end
103ifbutton_down?(Gosu::KbDown)
[email protected]+=zoom_delta
105end
107end
108
109defdraw
[email protected]+width/2
[email protected]+height/2
114translate(off_x,off_y)do
[email protected]_crosshair
117scale(zoom,zoom,cam_x,cam_y)do
[email protected](@camera)
119end
120end
121info='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)
126end
127end
128
129GameWindow.new.show
Runit,useWASDtonavigate,up/downarrowstozoomandspacebartoresetthecamera.$ruby02-warmup/coordinate_system.rb
Itdoesn’tlookimpressive,butunderstandingtheconceptofdifferentcoordinatesystemsandbeingabletostitchthemtogetherisparamounttothesuccessofourfinalproduct.
Prototypeofseparatecoordinatesystems
Luckilyforus,GosuhelpsusbyprovidingGosu::Window#translatethathandlescameraoffset,Gosu::Window#scalethataidszooming,andGosu::Window#rotatethatwasnotusedyet,butwillbegreatforshakingtheviewtoemphasizeexplosions.
PrototypingTheGame
Warmingupwasreallyimportant,butlet’scombineeverythingwelearned,addsomenewchallenges,andbuildasmallprototypewithfollowingfeatures:
1. Cameralooselyfollowstank.2. Camerazoomsautomaticallydependingontankspeed.3. Youcantemporarilyoverrideautomaticcamerazoomusingkeyboard.4. Musicandsoundeffects.5. Randomlygeneratedmap.6. Twomodes:menuandgameplay.7. TankmovementwithWADSkeys.8. Tankaimingandshootingwithmouse.9. Collisiondetection(tanksdon’tswim).10. Explosions,visiblebullettrajectories.11. Bulletrangelimiting.
Soundsfun?Hellyes!However,beforewestart,weshouldplanaheadalittleandthinkhowourgamearchitecturewilllooklike.Wewillalsostructureourcodealittle,soitwillnotbesmashedintoonerubyclass,aswedidinearlierexamples.Booksshouldshowgoodmanners!
SwitchingBetweenGameStatesFirst,let’sthinkhowtohookintoGosu::Window.Sincewewillhavetwogamestates,Statepatternnaturallycomestomind.
So,ourGameWindowclasscouldlooklikethis:03-prototype/game_window.rb
1classGameWindow<Gosu::Window
2
3attr_accessor:state
4
5definitialize
6super(800,600,false)
7end
8
9defupdate
11end
12
13defdraw
15end
16
17defneeds_redraw?
[email protected]_redraw?
19end
20
21defbutton_down(id)
[email protected]_down(id)
23end
24
25end
Ithascurrent@state,andallusualmainloopactionsareexecutedonthatstateinstance.Wewilladdbaseclassthatallgamestateswillextend.Let’snameitGameState:03-prototype/states/game_state.rb
1classGameState
2
3defself.switch(new_state)
4$window.state&&$window.state.leave
5$window.state=new_state
6new_state.enter
7end
8
9defenter
10end
11
12defleave
13end
14
15defdraw
16end
17
18defupdate
19end
20
21defneeds_redraw?
22true
23end
24
25defbutton_down(id)
26end
27end
ThisclassprovidesGameState.switch,thatwillchangethestateforourGosu::Window,andallenterandleavemethodswhenappropriate.Thesemethodswillbeusefulforthingslikeswitchingmusic.
NoticethatGosu::Windowisaccessedusingglobal$windowvariable,whichwillbeconsideredananti-patternbymostgoodprogrammers,butthereissomelogicbehindthis:
1. TherewillbeonlyoneGosu::Windowinstance.2. Itlivesaslongasthegameruns.3. Itisusedinsomewaybynearlyallotherclasses,sowewouldhavetopassitaround
allthetime.4. AccessingitusingSingletonorstaticutilityclasswouldnotgiveanyclearbenefits,
justaddmorecomplexity.
Chingu,anothergameframeworkbuiltontopofGosu,alsousesglobal$window,soit’sprobablynottheworstideaever.
Wewillalsoneedanentrypointthatwouldfireupthegameandenterthefirstgamestate-themenu.03-prototype/main.rb
1require'gosu'
2require_relative'states/game_state'
3require_relative'states/menu_state'
4require_relative'states/play_state'
5require_relative'game_window'
6
7moduleGame
8defself.media_path(file)
9File.join(File.dirname(File.dirname(
10__FILE__)),'media',file)
11end
12end
13
14$window=GameWindow.new
15GameState.switch(MenuState.instance)
16$window.show
InourentrypointwealsohaveasmallhelperwhichwillhelploadingimagesandsoundsusingGame.media_path.
Therestisobvious:wecreateGameWindowinstanceandstoreitin$windowvariable,asdiscussedbefore.ThenweuseGameState.switch)toloadMenuState,andshowthegamewindow.
ImplementingMenuStateThisishowsimpleMenuStateimplementationlookslike:03-prototype/states/menu_state.rb
1require'singleton'
2classMenuState<GameState
3includeSingleton
4attr_accessor:play_state
5
6definitialize
7@message=Gosu::Image.from_text(
8$window,"TanksPrototype",
9Gosu.default_font_name,100)
10end
11
12defenter
13music.play(true)
14music.volume=1
15end
16
17defleave
18music.volume=0
19music.stop
20end
21
22defmusic
23@@music||=Gosu::Song.new(
24$window,Game.media_path('menu_music.mp3'))
25end
26
27defupdate
28continue_text=@play_state?"C=Continue,":""
29@info=Gosu::Image.from_text(
30$window,"Q=Quit,#{continue_text}N=NewGame",
31Gosu.default_font_name,30)
32end
33
34defdraw
36$window.width/[email protected]/2,
37$window.height/[email protected]/2,
3810)
40$window.width/[email protected]/2,
41$window.height/[email protected]/2+200,
4210)
43end
44
45defbutton_down(id)
46$window.closeifid==Gosu::KbQ
47ifid==Gosu::KbC&&@play_state
48GameState.switch(@play_state)
49end
50ifid==Gosu::KbN
51@play_state=PlayState.new
52GameState.switch(@play_state)
53end
54end
55end
It’saSingleton,sowecanalwaysgetitwithMenuState.instance.
Itstartsplayingmenu_music.mp3whenyouenterthemenu,andstopthemusicwhenyouleaveit.InstanceofGosu::Songiscachedin@@musicclassvariabletosaveresources.
Wehavetoknowifplayisalreadyinprogress,sowecanaddapossibilitytogobacktothegame.That’swhyMenuStatehas@play_statevariable,andeitherallowscreatingnewPlayStatewhenNkeyispressed,orswitchestoexisting@play_stateifCkeyispressed.
Herecomestheinterestingpart,implementingtheplaystate.
ImplementingPlayStateBeforewestartimplementingactualgameplay,weneedtothinkwhatgameentitieswewillbebuilding.WewillneedaMapthatwillholdourtilesandprovideworldcoordinatesystem.WewillalsoneedaCamerathatwillknowhowtofloataroundandzoom.TherewillbeBulletsflyingaround,andeachbulletwilleventuallycauseanExplosion.
Havingallthattakencareof,PlayStateshouldlookprettysimple:03-prototype/states/play_state.rb
1require_relative'../entities/map'
2require_relative'../entities/tank'
3require_relative'../entities/camera'
4require_relative'../entities/bullet'
5require_relative'../entities/explosion'
6classPlayState<GameState
7
8definitialize
9@map=Map.new
10@tank=Tank.new(@map)
11@camera=Camera.new(@tank)
12@bullets=[]
13@explosions=[]
14end
15
16defupdate
[email protected](@camera)
18@bullets<<bulletifbullet
[email protected](&:update)
[email protected]!(&:done?)
22$window.caption='TanksPrototype.'<<
23"[FPS:#{Gosu.fps}.Tank@#{@tank.x.round}:#{@tank.y.round}]"
24end
25
26defdraw
29off_x=$window.width/2-cam_x
30off_y=$window.height/2-cam_y
31$window.translate(off_x,off_y)do
33$window.scale(zoom,zoom,cam_x,cam_y)do
[email protected](@camera)
[email protected](&:draw)
37end
38end
[email protected]_crosshair
40end
41
42defbutton_down(id)
43ifid==Gosu::MsLeft
[email protected](*@camera.mouse_coords)
45@bullets<<bulletifbullet
46end
47$window.closeifid==Gosu::KbQ
48ifid==Gosu::KbEscape
49GameState.switch(MenuState.instance)
50end
51end
52
53end
Updateanddrawcallsarepassedtotheunderlyinggameentities,sotheycanhandlethemthewaytheywantitto.Suchencapsulationreducescomplexityofthecodeandallowsdoingeverypieceoflogicwhereitbelongs,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::Imagedrawoperationsaretranslatedbygivenoffset.Gosu::Window#scaledoesthesamewithCamerazoom.
Crosshairisdrawnwithouttranslatingandscalingit,becauseit’srelativetoscreen,nottoworldmap.
Basically,thisdrawmethodistheplacethattakescaredrawingonlywhat@cameracansee.
Ifit’shardtounderstandhowthisworks,getbackto“GameCoordinateSystem”chapterandletitsinkin.
ImplementingWorldMapWewillstartanalyzinggameentitieswithMap.03-prototype/entities/map.rb
1require'perlin_noise'
2require'gosu_texture_packer'
3
4classMap
5MAP_WIDTH=100
6MAP_HEIGHT=100
7TILE_SIZE=128
8
9definitialize
10load_tiles
11@map=generate_map
12end
13
14deffind_spawn_point
15whiletrue
16x=rand(0..MAP_WIDTH*TILE_SIZE)
17y=rand(0..MAP_HEIGHT*TILE_SIZE)
18ifcan_move_to?(x,y)
19return[x,y]
20else
21puts"Invalidspawnpoint:#{[x,y]}"
22end
23end
24end
25
26defcan_move_to?(x,y)
27tile=tile_at(x,y)
28tile&&tile!=@water
29end
30
31defdraw(camera)
[email protected]|x,row|
33row.eachdo|y,val|
34tile=@map[x][y]
35map_x=x*TILE_SIZE
36map_y=y*TILE_SIZE
37ifcamera.can_view?(map_x,map_y,tile)
38tile.draw(map_x,map_y,0)
39end
40end
41end
42end
43
44private
45
46deftile_at(x,y)
47t_x=((x/TILE_SIZE)%TILE_SIZE).floor
48t_y=((y/TILE_SIZE)%TILE_SIZE).floor
49row=@map[t_x]
50row[t_y]ifrow
51end
52
53defload_tiles
54tiles=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)
61end
62
63defgenerate_map
64noises=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)
74end
75end
76map
77end
78
79defchoose_tile(val)
80caseval
81when0.0..0.3#30%chance
82@water
83when0.3..0.45#15%chance,wateredges
84@sand
85else#55%chance
86@grass
87end
88end
89end
ThisimplementationisverysimilartotheMapwehadbuiltin“GeneratingRandomMapWithPerlinNoise”,withsomeextraadditions.can_move_to?verifiesiftileundergivencoordinatesisnotwater.Prettysimple,butit’senoughforourprototype.
Also,whenwedrawthemapwehavetomakesureiftileswearedrawingarecurrentlyvisiblebyourcamera,otherwisewewillendupdrawingoffscreen.camera.can_view?handlesit.Currentimplementationwillprobablybecausingabottleneck,sinceitbruteforcesthroughallthemapratherthancherry-pickingthevisibleregion.Wewillprobablyhavetogetbackandchangeitlater.
find_spawn_pointisonemoreaddition.Itkeepspickingarandompointonmapandverifiesifit’snotwaterusingcan_move_to?.Whensolidtileisfound,itreturnsthecoordinates,soourTankwillbeabletospawnthere.
ImplementingFloatingCameraIfyouplayedtheoriginalGrandTheftAutoorGTA2,youshouldrememberhowfascinatingthecamerawas.Itbackedawaywhenyouweredrivingathighspeeds,closedinwhenyouwerewalkingonfoot,andfloatedaroundasifasmartdronewasfollowingyourprotagonistfromabove.
ThefollowingCameraimplementationisfarinferiortotheoneGTAhadnearlytwodecadesago,butit’sastart:03-prototype/entities/camera.rb
1classCamera
2attr_accessor:x,:y,:zoom
3
4definitialize(target)
5@target=target
6@x,@y=target.x,target.y
7@zoom=1
8end
9
10defcan_view?(x,y,obj)
11x0,x1,y0,y1=viewport
12(x0-obj.width..x1).include?(x)&&
13(y0-obj.height..y1).include?(y)
14end
15
16defmouse_coords
17x,y=target_delta_on_screen
19(x+$window.mouse_x-($window.width/2))/@zoom
21(y+$window.mouse_y-($window.height/2))/@zoom
22[mouse_x_on_map,mouse_y_on_map].map(&:round)
23end
24
25defupdate
26@[email protected]@x<@target.x-$window.width/4
27@[email protected]@x>@target.x+$window.width/4
28@[email protected]@y<@target.y-$window.height/4
29@[email protected]@y>@target.y+$window.height/4
30
31zoom_delta=@zoom>0?0.01:1.0
32if$window.button_down?(Gosu::KbUp)
33@zoom-=zoom_deltaunless@zoom<0.7
34elsif$window.button_down?(Gosu::KbDown)
35@zoom+=zoom_deltaunless@zoom>10
36else
[email protected]>1.1?0.85:1.0
38if@zoom<=(target_zoom-0.01)
39@zoom+=zoom_delta/3
40elsif@zoom>(target_zoom+0.01)
41@zoom-=zoom_delta/3
42end
43end
44end
45
46defto_s
47"FPS:#{Gosu.fps}."<<
48"#{@x}:#{@y}@#{'%.2f'%@zoom}."<<
49'WASDtomove,arrowstozoom.'
50end
51
52deftarget_delta_on_screen
53[(@[email protected])*@zoom,(@[email protected])*@zoom]
54end
55
56defdraw_crosshair
57x=$window.mouse_x
58y=$window.mouse_y
59$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)
65end
66
67private
68
69defviewport
70x0=@x-($window.width/2)/@zoom
71x1=@x+($window.width/2)/@zoom
72y0=@y-($window.height/2)/@zoom
73y1=@y+($window.height/2)/@zoom
74[x0,x1,y0,y1]
75end
76end
OurCamerahas@targetthatittriestofollow,@xand@ythatitcurrentlyislookingat,and@zoomlevel.
Allthemagichappensinupdatemethod.Itkeepstrackofthedistancebetween@targetandadjustitselftostaynearby.Andwhen@target.speedshowssomemovementmomentum,cameraslowlybacksaway.
Cameraalsotelsifyoucan_view?anobjectatsomecoordinates,sowhenotherentitiesdrawthemselves,theycancheckifthereisaneedforthat.
Anothernoteworthymethodismouse_coords.Ittranslatesmousepositiononscreentomousepositiononmap,sothegamewillknowwhereyouaretargetingyourguns.
ImplementingTheTankMostofourtankcodewillbetakenfrom“PlayerMovementWithKeyboardAndMouse”:03-prototype/entities/tank.rb
1classTank
2attr_accessor:x,:y,:body_angle,:gun_angle
3SHOOT_DELAY=500
4
5definitialize(map)
6@map=map
7@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,@[email protected]_spawn_point
13@body_angle=0.0
14@gun_angle=0.0
15@last_shot=0
16sound.volume=0.3
17end
18
19defsound
20@@sound||=Gosu::Song.new(
21$window,Game.media_path('tank_driving.mp3'))
22end
23
24defshoot(target_x,target_y)
25ifGosu.milliseconds-@last_shot>SHOOT_DELAY
26@last_shot=Gosu.milliseconds
27Bullet.new(@x,@y,target_x,target_y).fire(100)
28end
29end
30
31defupdate(camera)
32d_x,d_y=camera.target_delta_on_screen
33atan=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::PI
36new_x,new_y=@x,@y
37new_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_y
43else
44@speed=1.0
45end
46@body_angle=change_angle(@body_angle,
47Gosu::KbW,Gosu::KbS,Gosu::KbA,Gosu::KbD)
48
49ifmoving?
50sound.play(true)
51else
52sound.pause
53end
54
55if$window.button_down?(Gosu::MsLeft)
56shoot(*camera.mouse_coords)
57end
58end
59
60defmoving?
61any_button_down?(Gosu::KbA,Gosu::KbD,Gosu::KbW,Gosu::KbS)
62end
63
64defdraw
[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
69
70defspeed
71@speed||=1.0
72ifmoving?
73@speed+=0.03if@speed<5
74else
75@speed=1.0
76end
77@speed
78end
79
80private
81
82defany_button_down?(*buttons)
83buttons.eachdo|b|
84returntrueif$window.button_down?(b)
85end
86false
87end
88
89defchange_angle(previous_angle,up,down,right,left)
90if$window.button_down?(up)
91angle=0.0
92angle+=45.0if$window.button_down?(left)
93angle-=45.0if$window.button_down?(right)
94elsif$window.button_down?(down)
95angle=180.0
96angle-=45.0if$window.button_down?(left)
97angle+=45.0if$window.button_down?(right)
98elsif$window.button_down?(left)
99angle=90.0
100angle+=45.0if$window.button_down?(up)
101angle-=45.0if$window.button_down?(down)
102elsif$window.button_down?(right)
103angle=270.0
104angle-=45.0if$window.button_down?(up)
105angle+=45.0if$window.button_down?(down)
106end
107angle||previous_angle
108end
109end
TankhastobeawareoftheMaptocheckwhereit’smoving,anditusesCameratofindoutwheretoaimtheguns.Whenitshoots,itproducesinstancesofBullet,thataresimplyreturnedtothecaller.Tankwon’tkeeptrackofthem,it’s“fireandforget”.
ImplementingBulletsAndExplosionsBulletswillrequiresomesimplevectormath.Youhaveapointthatmovesalongthevectorwithsomespeed.Italsoneedstolimitthemaximumvectorlength,soifyoutrytoaimtoofar,thebulletwillonlygoasfarasitcanreach.03-prototype/entities/bullet.rb
1classBullet
2COLOR=Gosu::Color::BLACK
3MAX_DIST=300
4START_DIST=20
5
6definitialize(source_x,source_y,target_x,target_y)
7@x,@y=source_x,source_y
8@target_x,@target_y=target_x,target_y
9@x,@y=point_at_distance(START_DIST)
10iftrajectory_length>MAX_DIST
11@target_x,@target_y=point_at_distance(MAX_DIST)
12end
13sound.play
14end
15
16defdraw
17unlessarrived?
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)
23else
24@explosion||=Explosion.new(@x,@y)
26end
27end
28
29defupdate
30fly_distance=(Gosu.milliseconds-@fired_at)*0.001*@speed
31@x,@y=point_at_distance(fly_distance)
32@explosion&&@explosion.update
33end
34
35defarrived?
36@x==@target_x&&@y==@target_y
37end
38
39defdone?
40exploaded?
41end
42
43defexploaded?
44@explosion&&@explosion.done?
45end
46
47deffire(speed)
48@speed=speed
49@fired_at=Gosu.milliseconds
50self
51end
52
53private
54
55defsound
56@@sound||=Gosu::Sample.new(
57$window,Game.media_path('fire.mp3'))
58end
59
60deftrajectory_length
61d_x=@target_x-@x
62d_y=@target_y-@y
63Math.sqrt(d_x*d_x+d_y*d_y)
64end
65
66defpoint_at_distance(distance)
67return[@target_x,@target_y]ifdistance>trajectory_length
68distance_factor=distance.to_f/trajectory_length
69p_x=@x+(@target_x-@x)*distance_factor
70p_y=@y+(@target_y-@y)*distance_factor
71[p_x,p_y]
72end
73end
PossiblythemostinterestingpartofBulletimplementationispoint_at_distancemethod.Itreturnscoordinatesofpointthatisbetweenbulletsource,whichispointthatbulletwasfiredfrom,andit’starget,whichisthedestinationpoint.Thereturnedpointisasfarawayfromsourcepointasdistancetellsitto.
Afterbullethasdoneflying,itexplodeswithfanfare.InourprototypeExplosionisapartofBullet,becauseit’stheonlythingthattriggersit.ThereforeBullethastwostagesofit’slifecycle.Firstitfliestowardsthetarget,thenit’sexploding.ThatbringsustoExplosion:03-prototype/entities/explosion.rb
1classExplosion
2FRAME_DELAY=10#ms
3
4defanimation
5@@animation||=
6Gosu::Image.load_tiles(
7$window,Game.media_path('explosion.png'),128,128,false)
8end
9
10defsound
11@@sound||=Gosu::Sample.new(
12$window,Game.media_path('explosion.mp3'))
13end
14
15definitialize(x,y)
16sound.play
17@x,@y=x,y
18@current_frame=0
19end
20
21defupdate
22@current_frame+=1ifframe_expired?
23end
24
25defdraw
26returnifdone?
27image=current_frame
28image.draw(
[email protected]/2+3,
[email protected]/2-35,
3120)
32end
33
34defdone?
35@done||=@current_frame==animation.size
36end
37
38private
39
40defcurrent_frame
41animation[@current_frame%animation.size]
42end
43
44defframe_expired?
45now=Gosu.milliseconds
46@last_frame||=now
47if(now-@last_frame)>FRAME_DELAY
48@last_frame=now
49end
50end
51end
Thereisnothingfancyaboutthisimplementation.Mostofitistakenfrom“ImagesAndAnimation”chapter.
RunningThePrototypeWehavewalkedthroughallthecode.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’tthinkingaboutperformancetobeginwith,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,becauseeverycalltoeveryfunctionisbeingrecorded,andafteryouexittheprogram,profilingoutputwillbedumpeddirectlytoyourconsole.
Downsideofthisapproachisthatwearegoingtoprofileeverythingthereis,includingthesuper-slowmapgenerationthatusesPerlinNoise.Wedon’twanttooptimizethat,soinordertofindbottlenecksinourplaystateratherthanmapgeneration,wehavetokeepplayingatdreadful2FPSforatleast30seconds.
Thiswastheoutputoffirst“naive”profilingsession:
Initialprofilingresults
It’sobvious,thatCamera#viewportandCamera#can_view?aretopCPUburners.Thismeanseitherthatourimplementationiseitherverybad,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
#...
defviewport
x0=@x-($window.width/2)/@zoom
x1=@x+($window.width/2)/@zoom
y0=@y-($window.height/2)/@zoom
y1=@y+($window.height/2)/@zoom
[x0,x1,y0,y1]
end
#...
end
Itdoesn’tlookfundamentallybroken,sowewilltryour“checkingisslowerthanrendering”hypothesisbyshort-circuitingcan_view?toreturntrueeverytime:classCamera
#...
defcan_view?(x,y,obj)
returntrue#shortcircuiting
x0,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_SIZE
map_y=y*TILE_SIZE
tile.draw(map_x,map_y,0)
end
end
end
#...
end
AftercompletelyremovingCamera#can_view?,profilingsessionlookslikedead-end-nomorelowhangingfruitsontop:
ProfilingresultsafterremovingCamera#can_view?
Thegamestilldoesn’tfeelfastenough,FPSoccasionallykeepsdroppingdownto~45,sowewillhavetodoprofileourcodeinsmarterway.
AdvancedProfilingTechniquesWewouldgetmoreaccuracywhenprofilingonlywhatwewanttooptimize.InourcaseitiseverythingthathappensinPlayState,exceptforMapgeneration.Thistimewewillhavetouseruby-profAPItohookintoplacesweneed.
MapgenerationhappensinPlayStateinitializer,sowewillleverageGameState#enterandGameState#leavetostartandstopprofiling,sinceithappensafterstateisinitialized.Hereishowwehookin:require'ruby-prof'
classPlayState<GameState
#...
defenter
RubyProf.start
end
defleave
result=RubyProf.stop
printer=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,butweneedittomeasureFPS,sowewillleaveitalone,andfinallywecanseeHash#each,whichisguaranteedtobetheonefromMap#draw,andittriggersallthoseGosu::Image#drawcalls.
OptimizingInefficientCodeAccordingtoprofilingresults,weneedtooptimizethismethod:classMap
#...
defdraw(camera)
@map.eachdo|x,row|
row.eachdo|y,val|
tile=@map[x][y]
map_x=x*TILE_SIZE
map_y=y*TILE_SIZE
tile.draw(map_x,map_y,0)
end
end
end
#...
end
Butwehavetooptimizeitinmorecleverwaythanwedidbefore.Ifinsteadofloopingthroughallmaprowsandcolumnsandblindlyrenderingeverytileorcheckingiftileisvisiblewecouldcalculatetheexactmapcellsthatneedtobedisplayed,wewouldreducemethodcomplexityandgetmajorperformanceboost.Let’sdothat.
WewilluseCamera#viewporttoreturnmapboundariesthatarevisiblebycamera,thendividethoseboundariesbyMap#TILE_SIZEtogettilenumbersinsteadofpixels,andretrievethemfromthemap.classMap
#...
defdraw(camera)
viewport=camera.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]
ifrow
tile=@map[x][y]
map_x=x*TILE_SIZE
map_y=y*TILE_SIZE
tile.draw(map_x,map_y,0)
end
end
end
end
Thisoptimizationyieldedastoundingresults.Wearenowgettingnearlystable60FPSevenwhenprofilingthecode!Comparethatto2FPSwhileprofilingwhenwestarted.
ProfilingresultsforPlayStateafterMap#drawoptimization
NowwejusthavetodosomethingaboutthatGosu::Window#caption,becauseitisconsuming1/3ofourCPUcycles!Eventhoughgameisalreadyflyingsofastthatwewillhavetoreducetankandbulletspeedstomakeitlookmorerealistic,wecannotletourselvesleavethislowhangingfruitremainunpicked.
Wewillupdatethecaptiononcepersecond,itshouldremovethebottleneck:classPlayState<GameState
#...
defupdate
#...
update_caption
end
#...
private
defupdate_caption
now=Gosu.milliseconds
ifnow-(@caption_updated_at||0)>1000
$window.caption='TanksPrototype.'<<
"[FPS:#{Gosu.fps}."<<
"Tank@#{@tank.x.round}:#{@tank.y.round}]"
@caption_updated_at=now
end
end
end
Nowit’sgettinghardtogetFPStodropbelow58,andprofilingresultsshowthattherearenomorebottlenecks:
ProfilingresultsforPlayStateafterintroducingGosu::Window#captioncache
Wecannowsleepwellatnight.
ProfilingOnDemandWhenyoudevelopagame,youmaywanttoturnonprofilingnowandthen.Toavoidcommentingoutoraddingandremovingprofilingeverytimeyouwanttodoso,usethistrick:#...
require'ruby-prof'ifENV['ENABLE_PROFILING']
classPlayState<GameState
#...
defenter
RubyProf.startifENV['ENABLE_PROFILING']
end
defleave
ifENV['ENABLE_PROFILING']
result=RubyProf.stop
printer=RubyProf::FlatPrinter.new(result)
printer.print(STDOUT)
end
end
defbutton_down(id)
#...
ifid==Gosu::KbQ
leave
$window.close
end
end
#...
end
Now,toenableprofiling,simplystartyourgamewithENABLE_PROFILING=1environmentalvariable,likethis:$ENABLE_PROFILING=1ruby-prof03-prototype/main.rb
AdjustingGameSpeedForVariablePerformanceYoushouldhavenoticedthatouroptimizedTanksprototyperunswaytoofast.Tanksandbulletsshouldtravelsamedistancenomatterhowfastorslowthecodeis.
OnewouldexpectGosu::Window#update_intervaltobedesignedexactlyforthatpurpose,butitreturns16.6666inbothoriginalandoptimizedversionoftheprototype,soyoucanguessitisthedesiredinterval,nottheactualone.
Tofindoutactualupdateinterval,wewilluseGosu.millisecondsandcalculateitourselves.Todothat,wewillintroduceGame#track_update_intervalthatwillbecalledinGameWindow#update,andGame#update_intervalwhichwillretrieveactualupdateinterval,sowecanuseittoadjustourrunspeed.
WewillalsoaddGame#adjust_speedmethodthatwilltakearbitraryspeedvalueandshiftitsoisasfastasitwaswhenthegamewasrunningat30FPS.Theformulaissimple,if60FPSexpectstocallGosu::Window#updateevery16.66ms,ourspeedadjustmentwilldivideactualupdateratefrom33.33,whichroughlyequalsto16.66*2.So,ifbulletwouldfly100pixelsperupdatein30FPS,adjustedspeedwillchangeitto50pixelsat60FPS.
Hereistheimplementation:#04-prototype-optimized/main.rb
moduleGame
#...
defself.track_update_interval
now=Gosu.milliseconds
@update_interval=(now-(@last_update||=0)).to_f
@last_update=now
end
defself.update_interval
@update_interval||=$window.update_interval
end
defself.adjust_speed(speed)
speed*update_interval/33.33
end
end
#04-prototype-optimized/game_window.rb
classGameWindow<Gosu::Window
#...
defupdate
Game.track_update_interval
@state.update
end
#...
end
Now,tofixthatspeedproblem,wewillneedtoapplyGame.adjust_speedtotank,bulletandcameramovements.
Hereareallthechangesneededtomakeourgamerunatroughlysamespeedindifferentconditions:
#04-prototype-optimized/entities/tank.rb
classTank
#...
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.rb
classBullet
#...
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.rb
classCamera
#...
defupdate
shift=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.0
zoom_delta=Game.adjust_speed(zoom_delta)
#...
end
#...
end
ThereisonemoretricktomakethegameplayableevenatverylowFPS.Youcansimulatesuchconditionsbyaddingsleep0.3toGameWindow#drawmethod.Atthatframerategamecursorisveryunresponsive,soyoumaywanttostartshowingnativemousecursorwhenthingsgetugly,i.e.whenupdateintervalexceeds200milliseconds:#04-prototype-optimized/game_window.rb
classGameWindow<Gosu::Window
#...
defneeds_cursor?
Game.update_interval>200
end
#...
end
FrameSkippingYouwillseestrangethingshappeningatverylowframerates.Forexample,bulletexplosionsareshowingupframebyframe,soexplosionspeedseemswaytooslowandunrealistic.Toavoidthat,wewillmodifyourExplosionclasstoemployframeskippingifupdaterateistooslow:#04-prototype-optimized/explosion.rb
classExplosion
FRAME_DELAY=16.66#ms
#...
defupdate
advance_frame
end
defdone?
@done||=@current_frame>=animation.size
end
#...
private
#...
defadvance_frame
now=Gosu.milliseconds
delta=now-(@last_frame||=now)
ifdelta>FRAME_DELAY
@last_frame=now
end
@current_frame+=(delta/FRAME_DELAY).floor
end
end
Nowourprototypeisplayableevenatlowerframerates.
RefactoringThePrototype
Atthispointyoumaybethinkingwheretogonext.Wewanttoimplementenemies,collisiondetectionandAI,butdesignofcurrentprototypeisalreadylimiting.Codeisbecomingtightlycoupled,thereisnocleanseparationbetweendifferentdomains.
Ifweweretocontinuebuildingontopofourprototype,thingswouldgetuglyquickly.Thuswewilluntanglethespaghettiandrewritesomepartsfromscratchtoachieveelegance.
GameProgrammingPatternsIwouldliketotipmyhattoRobertNystrom,whowrotethisamazingbookcalledGameProgrammingPatterns.Thebookisavailableonlineforfree,itisarelativelyquickread-I’vedevoureditwithpleasureinroughly4hours.Ifyouareguessingthatthischapterisinspiredbythatbook,youareabsolutelyright.
Componentpatternisespeciallynoteworthy.Wewillbeusingittodomajorhousekeeping,anditisgreattimetodoso,becausewehaven’timplementedmuchofthegameyet.
WhatIsWrongWithCurrentDesignUntilthispointwehavebeenbuildingthecodeinmonolithicfashion.Tankclassholdsthecodethat:
1. Loadsallgroundunitsprites.Ifsomeotherclasshandledit,wecouldreusethecodetoloadotherunits.
2. Handlessoundeffects.3. UsesGosu::Songformovingsounds.Thatlimitsonlyonetankmovementsoundper
wholegame.Basically,weabusedGosuhere.4. Handleskeyboardandmouse.IfweweretocreateAIthatcontrolsthetank,we
wouldnotbeabletoreuseTankclassbecauseofthis.5. Drawsgraphicsonscreen.6. Calculatesphysicalproperties,likespeed,acceleration.7. Detectsmovementcollisions.
Bulletisnotperfecteither:
1. Itrendersit’sgraphics.2. Ithandlesit’smovementtrajectoriesandotherphysics.3. IttreatsExplosionaspartofit’sownlifecycle.4. Drawsgraphicsonscreen.5. Handlessoundeffects.
EventherelativelysmallExplosionclassistoomonolithic:
1. Itloadsit’sgraphics.2. Ithandlesrendering,animationandframeskipping3. Itloadsandplaysit’ssoundeffects.
DecouplingUsingComponentPatternBestdesignseparatesconcernsincodesothateverythinghasit’sownplace,andeveryclasshandlesonlyonething.Let’strysplittingupTankclassintocomponentsthathandlespecificdomains:
DecoupledTank
WewillintroduceGameObjectclasswillcontainsharedfunctionalityforallgameobjects(Tank,Bullet,Explosion),eachofthemwouldhaveit’sownsetofcomponents.Everycomponentwillhaveit’sparentobject,soitwillbeabletointeractwithit,changeit’sattributes,orpossiblyinvokeothercomponentsifitcomestothat.
Gameobjectsandtheircomponents
AlltheseobjectswillbeheldwithinObjectPool,whichwouldnotcaretoknowifobjectisatankorabullet.PurposeofObjectPoolisalittledifferentinRuby,sinceGCwilltakecareofmemoryfragmentationforus,butwestillneedasingleplacethatknowsabouteveryobjectinthegame.
ObjectPool
PlayStatewouldtheniteratethrough@object_pool.objectsandinvokeupdateanddrawmethods.
Now,let’sbeginbyimplementingbaseclassforGameObject:05-refactor/entities/game_object.rb
1classGameObject
2definitialize(object_pool)
3@components=[]
4@object_pool=object_pool
5@object_pool.objects<<self
6end
7
8defcomponents
9@components
10end
11
12defupdate
[email protected](&:update)
14end
15
16defdraw(viewport)
[email protected]{|c|c.draw(viewport)}
18end
19
20defremovable?
21@removable
22end
23
24defmark_for_removal
25@removable=true
26end
27
28protected
29
30defobject_pool
31@object_pool
32end
33end
WhenGameObjectisinitialized,itregistersitselfwithObjectPoolandpreparesempty@componentsarray.ConcreteGameObjectclassesshouldinitializeComponentssothatarraywouldnotbeempty.
updateanddrawmethodswouldcyclethrough@componentsanddelegatethosecallstoeachoftheminasequence.Itisimportanttoupdateallcomponentsfirst,andonlythendrawthem.Keepinmindthat@componentsarrayorderhassignificance.Firstelementswillalwaysbeupdatedanddrawnbeforelastones.
Wewillalsoprovideremovable?methodthatwouldreturntrueforobjectsthatmark_for_removalwasinvokedon.ThiswaywewillbeabletoweedoutoldbulletsandexplosionsandfeedthemtoGC.
Nextup,baseComponentclass:05-refactor/entities/components/component.rb
1classComponent
2definitialize(game_object=nil)
3self.object=game_object
4end
5
6defupdate
7#override
8end
9
10defdraw(viewport)
11#override
12end
13
14protected
15
16defobject=(obj)
17ifobj
18@object=obj
19obj.components<<self
20end
21end
22
23defx
25end
26
27defy
29end
30
31defobject
32@object
33end
34end
ItregistersitselfwithGameObject#components,providessomeprotectedmethodstoaccessparentobjectandit’smostoftencalledproperties-xandy.
RefactoringExplosion
Explosionwasprobablythesmallestclass,sowewillextractit’scomponentsfirst.05-refactor/entities/explosion.rb
1classExplosion<GameObject
2attr_accessor:x,:y
3
4definitialize(object_pool,x,y)
5super(object_pool)
6@x,@y=x,y
7ExplosionGraphics.new(self)
8ExplosionSounds.play
9end
10end
Itismuchcleanerthanbefore.ExplosionGraphicswillbeaComponentthathandlesanimation,andExplosionSoundswillplayasound.05-refactor/entities/components/explosion_graphics.rb
1classExplosionGraphics<Component
2FRAME_DELAY=16.66#ms
3
4definitialize(game_object)
5super
6@current_frame=0
7end
8
9defdraw(viewport)
10image=current_frame
11image.draw(
12x-image.width/2+3,
13y-image.height/2-35,
1420)
15end
16
17defupdate
18now=Gosu.milliseconds
19delta=now-(@last_frame||=now)
20ifdelta>FRAME_DELAY
21@last_frame=now
22end
23@current_frame+=(delta/FRAME_DELAY).floor
24object.mark_for_removalifdone?
25end
26
27private
28
29defcurrent_frame
30animation[@current_frame%animation.size]
31end
32
33defdone?
34@done||=@current_frame>=animation.size
35end
36
37defanimation
38@@animation||=
39Gosu::Image.load_tiles(
40$window,Utils.media_path('explosion.png'),
41128,128,false)
42end
43end
Everythingthatisrelatedtoanimatingtheexplosionisnowclearlyseparated.mark_for_removaliscalledontheexplosionafterit’sanimationisdone.05-refactor/entities/components/explosion_sounds.rb
1classExplosionSounds
2class<<self
3defplay
4sound.play
5end
6
7private
8
9defsound
10@@sound||=Gosu::Sample.new(
11$window,Utils.media_path('explosion.mp3'))
12end
13end
14end
Sinceexplosionsoundsaretriggeredonlyonce,whenitstartstoexplode,ExplosionSoundsisastaticclasswithplaymethod.
RefactoringBullet
Now,let’sgoupalittleandreimplementourBullet:05-refactor/entities/bullet.rb
1classBullet<GameObject
2attr_accessor:x,:y,:target_x,:target_y,:speed,:fired_at
3
4definitialize(object_pool,source_x,source_y,target_x,target_y)
5super(object_pool)
6@x,@y=source_x,source_y
7@target_x,@target_y=target_x,target_y
8BulletPhysics.new(self)
9BulletGraphics.new(self)
10BulletSounds.play
11end
12
13defexplode
14Explosion.new(object_pool,@x,@y)
15mark_for_removal
16end
17
18deffire(speed)
19@speed=speed
20@fired_at=Gosu.milliseconds
21end
22end
Allphysics,graphicsandsoundsareextractedintoindividualcomponents,andinsteadofmanagingExplosion,itjustregistersanewExplosionwithObjectPoolandmarksitselfforremovalinexplodemethod.05-refactor/entities/components/bullet_physics.rb
1classBulletPhysics<Component
2START_DIST=20
3MAX_DIST=300
4
5definitialize(game_object)
6super
7object.x,object.y=point_at_distance(START_DIST)
8iftrajectory_length>MAX_DIST
9object.target_x,object.target_y=point_at_distance(MAX_DIST)
10end
11end
12
13defupdate
14fly_speed=Utils.adjust_speed(object.speed)
15fly_distance=(Gosu.milliseconds-object.fired_at)*0.001*fly_speed
16object.x,object.y=point_at_distance(fly_distance)
17object.explodeifarrived?
18end
19
20deftrajectory_length
21d_x=object.target_x-x
22d_y=object.target_y-y
23Math.sqrt(d_x*d_x+d_y*d_y)
24end
25
26defpoint_at_distance(distance)
27ifdistance>trajectory_length
28return[object.target_x,object.target_y]
29end
30distance_factor=distance.to_f/trajectory_length
31p_x=x+(object.target_x-x)*distance_factor
32p_y=y+(object.target_y-y)*distance_factor
33[p_x,p_y]
34end
35
36private
37
38defarrived?
39x==object.target_x&&y==object.target_y
40end
41end
BulletPhysicsiswherethemostofBulletendedupat.ItdoesallthecalculationsandtriggersBullet#explodewhenready.Whenwewillbeimplementingcollisiondetection,theimplementationwillgosomewherehere.05-refactor/entities/components/bullet_graphics.rb
1classBulletGraphics<Component
2COLOR=Gosu::Color::BLACK
3
4defdraw(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)
10end
11
12end
AfterpullingawayBulletgraphicscode,itlooksverysmallandelegant.Wewillprobablyneverhavetoeditanythinghereagain.05-refactor/entities/components/bullet_sounds.rb
1classBulletSounds
2class<<self
3defplay
4sound.play
5end
6
7private
8
9defsound
10@@sound||=Gosu::Sample.new(
11$window,Utils.media_path('fire.mp3'))
12end
13end
14end
JustlikeExplosionSounds,BulletSoundsarestatelessandstatic.Wecouldmakeitjustlikearegularcomponent,butconsideritourlittleoptimization.
RefactoringTank
TimetotakealookatfreshlydecoupledTank:05-refactor/entities/tank.rb
1classTank<GameObject
2SHOOT_DELAY=500
3attr_accessor:x,:y,:throttle_down,:direction,:gun_angle,:sounds,:physics
4
5definitialize(object_pool,input)
6super(object_pool)
7@input=input
[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.0
13end
14
15defshoot(target_x,target_y)
16ifGosu.milliseconds-(@last_shot||0)>SHOOT_DELAY
17@last_shot=Gosu.milliseconds
18Bullet.new(object_pool,@x,@y,target_x,target_y).fire(100)
19end
20end
21end
Tankclasswasreducedover5times.WecouldgofurtherandextractGuncomponent,butfornowit’ssimpleenoughalready.Now,thecomponents.05-refactor/entities/components/tank_physics.rb
1classTankPhysics<Component
2attr_accessor:speed
3
4definitialize(game_object,object_pool)
5super(game_object)
6@object_pool=object_pool
7@map=object_pool.map
8game_object.x,[email protected]_spawn_point
9@speed=0.0
10end
11
12defcan_move_to?(x,y)
[email protected]_move_to?(x,y)
14end
15
16defmoving?
17@speed>0
18end
19
20defupdate
21ifobject.throttle_down
22accelerate
23else
24decelerate
25end
26if@speed>0
27new_x,new_y=x,y
28shift=Utils.adjust_speed(@speed)
30when0
31new_y-=shift
32when45
33new_x+=shift
34new_y-=shift
35when90
36new_x+=shift
37when135
38new_x+=shift
39new_y+=shift
40when180
41new_y+=shift
42when225
43new_y+=shift
44new_x-=shift
45when270
46new_x-=shift
47when315
48new_x-=shift
49new_y-=shift
50end
51ifcan_move_to?(new_x,new_y)
52object.x,object.y=new_x,new_y
53else
54object.sounds.collideif@speed>1
55@speed=0.0
56end
57end
58end
59
60private
61
62defaccelerate
63@speed+=0.08if@speed<5
64end
65
66defdecelerate
67@speed-=0.5if@speed>0
68@speed=0.0if@speed<0.01#damp
69end
70end
Whilewehadtoripplayerinputawayfromit’smovement,wegotourselvesabenefit-tanknowbothacceleratesanddecelerates.Whendirectionalbuttonsarenolongerpressed,tankkeepsmovinginlastdirection,butquicklydeceleratesandstops.AnotheradditionthatwouldhavebeenmoredifficulttoimplementonpreviousTankiscollisionsound.WhenTankabruptlystopsbyhittingsomething(fornowit’sonlywater),collisionsoundisplayed.Wewillhavetofixthat,becausemetalbangisnotappropriatewhenyoustopontheedgeofariver,butwenowdiditforthesakeofscience.05-refactor/entities/components/tank_graphics.rb
1classTankGraphics<Component
2definitialize(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')
7end
8
9defdraw(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)
13end
14
15private
16
17defunits
18@@units=Gosu::TexturePacker.load_json(
19$window,Utils.media_path('ground_units.json'),:precise)
20end
21end
Again,graphicsareneatlypackedandseparatedfromeverythingelse.Eventuallyweshouldoptimizedrawtotakeviewportintoconsideration,butit’sgoodenoughfornow,especiallywhenwehaveonlyonetankinthegame.05-refactor/entities/components/tank_sounds.rb
1classTankSounds<Component
2defupdate
3ifobject.physics.moving?
4if@driving&&@driving.paused?
7@driving=driving_sound.play(1,1,true)
8end
9else
10if@driving&&@driving.playing?
12end
13end
14end
15
16defcollide
17crash_sound.play(1,0.25,false)
18end
19
20private
21
22defdriving_sound
23@@driving_sound||=Gosu::Sample.new(
24$window,Utils.media_path('tank_driving.mp3'))
25end
26
27defcrash_sound
28@@crash_sound||=Gosu::Sample.new(
29$window,Utils.media_path('crash.ogg'))
30end
31end
UnlikeExplosionandBullet,Tanksoundsarestateful.Wehavetokeeptrackoftank_driving.mp3,whichisnolongerGosu::Song,butGosu::Sample,likeitshouldhavebeen.
WhenGosu::Sample#playisinvoked,Gosu::SampleInstanceisreturned,andwehavefullcontroloverit.Nowwearereadytoplaysoundsformorethanonetankatonce.05-refactor/entities/components/player_input.rb
1classPlayerInput<Component
2definitialize(camera)
3super(nil)
4@camera=camera
5end
6
7defcontrol(obj)
8self.object=obj
9end
10
11defupdate
12d_x,[email protected]_delta_on_screen
13atan=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::PI
16motion_buttons=[Gosu::KbW,Gosu::KbS,Gosu::KbA,Gosu::KbD]
17
18ifany_button_down?(*motion_buttons)
19object.throttle_down=true
20object.direction=change_angle(object.direction,*motion_buttons)
21else
22object.throttle_down=false
23end
24
25ifUtils.button_down?(Gosu::MsLeft)
26object.shoot(*@camera.mouse_coords)
27end
28end
29
30private
31
32defany_button_down?(*buttons)
33buttons.eachdo|b|
34returntrueifUtils.button_down?(b)
35end
36false
37end
38
39defchange_angle(previous_angle,up,down,right,left)
40ifUtils.button_down?(up)
41angle=0.0
42angle+=45.0ifUtils.button_down?(left)
43angle-=45.0ifUtils.button_down?(right)
44elsifUtils.button_down?(down)
45angle=180.0
46angle-=45.0ifUtils.button_down?(left)
47angle+=45.0ifUtils.button_down?(right)
48elsifUtils.button_down?(left)
49angle=90.0
50angle+=45.0ifUtils.button_down?(up)
51angle-=45.0ifUtils.button_down?(down)
52elsifUtils.button_down?(right)
53angle=270.0
54angle-=45.0ifUtils.button_down?(up)
55angle+=45.0ifUtils.button_down?(down)
56end
57angle=(angle+360)%360ifangle&&angle<0
58(angle||previous_angle)
59end
60end
WefinallycometoaplacewherekeyboardandmouseinputishandledandconvertedtoTankcommands.WecouldhaveusedCommandpatterntodecoupleeverythingevenfurther.
RefactoringPlayState05-refactor/game_states/play_state.rb
1require'ruby-prof'ifENV['ENABLE_PROFILING']
2classPlayState<GameState
3attr_accessor:update_interval
4
5definitialize
6@map=Map.new
7@camera=Camera.new
8@object_pool=ObjectPool.new(@map)
9@tank=Tank.new(@object_pool,PlayerInput.new(@camera))
[email protected]=@tank
11end
12
13defenter
14RubyProf.startifENV['ENABLE_PROFILING']
15end
16
17defleave
18ifENV['ENABLE_PROFILING']
19result=RubyProf.stop
20printer=RubyProf::FlatPrinter.new(result)
21printer.print(STDOUT)
22end
23end
24
25defupdate
26@object_pool.objects.map(&:update)
27@object_pool.objects.reject!(&:removable?)
29update_caption
30end
31
32defdraw
35off_x=$window.width/2-cam_x
36off_y=$window.height/2-cam_y
38$window.translate(off_x,off_y)do
40$window.scale(zoom,zoom,cam_x,cam_y)do
[email protected](viewport)
42@object_pool.objects.map{|o|o.draw(viewport)}
43end
44end
[email protected]_crosshair
46end
47
48defbutton_down(id)
49ifid==Gosu::KbQ
50leave
51$window.close
52end
53ifid==Gosu::KbEscape
54GameState.switch(MenuState.instance)
55end
56end
57
58private
59
60defupdate_caption
61now=Gosu.milliseconds
62ifnow-(@caption_updated_at||0)>1000
63$window.caption='TanksPrototype.'<<
64"[FPS:#{Gosu.fps}."<<
65"Tank@#{@tank.x.round}:#{@tank.y.round}]"
66@caption_updated_at=now
67end
68end
69end
ImplementationofPlayStateisnowalsoalittlesimpler.Itdoesn’tupdate@[email protected],itusesObjectPoolanddoesallobjectoperationsinbulk.
OtherImprovements05-refactor/main.rb
1#!/usr/bin/envruby
2
3require'gosu'
4
5root_dir=File.dirname(__FILE__)
6require_pattern=File.join(root_dir,'**/*.rb')
7@failed=[]
8
9#Dynamicallyrequireeverything
10Dir.glob(require_pattern).eachdo|f|
11nextiff.end_with?('/main.rb')
12begin
13require_relativef.gsub("#{root_dir}/",'')
14rescue
15#Mayfailifparentclassnotrequiredyet
16@failed<<f
17end
18end
19
20#Retryunresolvedrequires
22require_relativef.gsub("#{root_dir}/",'')
23end
24
25$window=GameWindow.new
26GameState.switch(MenuState.instance)
27$window.show
Finally,wemadesomeimprovementstomain.rb-itnowrecursivelyrequiresall*.rbfileswithinsamedirectory,sowedon’thavetoworryaboutitinotherclasses.05-refactor/utils.rb
1moduleUtils
2defself.media_path(file)
3File.join(File.dirname(File.dirname(
4__FILE__)),'media',file)
5end
6
7defself.track_update_interval
8now=Gosu.milliseconds
9@update_interval=(now-(@last_update||=0)).to_f
10@last_update=now
11end
12
13defself.update_interval
14@update_interval||=$window.update_interval
15end
16
17defself.adjust_speed(speed)
18speed*update_interval/33.33
19end
20
21defself.button_down?(button)
22@buttons||={}
23now=Gosu.milliseconds
24now=now-(now%150)
25if$window.button_down?(button)
26@buttons[button]=now
27true
28elsif@buttons[button]
29ifnow==@buttons[button]
30true
31else
[email protected](button)
33false
34end
35end
36end
37end
AnothernotablechangeisrenamingGamemoduleintoUtils.Thenamefinallymakesmoresense,IhavenoideawhyIpututilitymethodsintoGamemoduleinthefirstplace.Also,Utilsreceivedbutton_down?method,thatsolvestheissueofchangingtankdirectionwhenbuttonisimmediatelyreleased.Itmadeverydifficulttostopatdiagonalangle,becausewhenyoudepressedtwobuttons,16mswasenoughforGosutothink“hereleasedW,andSisstillpressed,solet’schangedirectiontoS”.Utils#button_down?givesasoft150mswindowtosynchronizebuttonrelease.Nowcontrolsfeelmorenatural.
SimulatingPhysics
Tomakethegamemorerealistic,wewillspicethingsupwithsomephysics.Thisisthefeaturesetwearegoingtoimplement:
1. Collisiondetection.Tankwillbumpintootherobjects-stationarytanks.Bulletswillnotgothroughthemeither.
2. Terraineffects.Tankwillgofastongrass,sloweronsand.
AddingEnemyObjectsIt’sboringtoplayalone,sowewillmakeaquickchangeandspawnsomestationarytanksthatwillbedeployedrandomlyaroundthemap.Theywillbestationaryinthebeginning,butwewillstillneedadummyAIclasstoreplacePlayerInput:06-physics/entities/components/ai_input.rb
1classAiInput<Component
2defcontrol(obj)
3self.object=obj
4end
5end
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.timesdo
Tank.new(@object_pool,AiInput.new)
end
end
#...
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
AddingBoundingBoxesAndDetectingCollisionsWewantourcollisiondetectiontobepixelperfect,thatmeansweneedtohaveaboundingboxandcheckcolisionsagainstit.Getreadyforsomemath!
First,weneedtofindacorrectwaytoconstructaboundingbox.Tankhasit’sbodyimage,solet’sseehowit’sboundarieslooklike.WewilladdsomecodetoTankGraphicscomponenttoseeit:classTankGraphics<Component
defdraw(viewport)
#...
draw_bounding_box
end
defdraw_bounding_box
$window.rotate(object.direction,x,y)do
$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)
end
end
#...
end
Resultisprettygood,wehavetankshapedbox,sowewillbeusingbodyimagedimensionstodetermineourboundingboxcorners:
Tank’sboundingboxvisualized
Thereisoneproblemherethough.Gosu::Window#rotatedoestherotationmathforus,andweneedtoperformthesecalculationsonourown.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_x
r_y=Math.sin(angle)*(x-around_x)+
Math.cos(angle)*(y-around_y)+around_y
result<<r_x
result<<r_y
end
result
end
#...
end
Wecannowcalculateedgesofourboundingbox,butweneedonemorefunctionwhichtellsifpointisinsideapolygon.Thisproblemhasbeensolvedmilliontimesbefore,sojustpoketheinternetforitanddrinkfromtheinformationfirehoseuntilyouunderstandhowtodothis.
Ifyouwasn’tfamiliarwiththetermyet,bynowyoushoulddiscoverwhatvertexis.Ingeometry,avertex(pluralvertices)isaspecialkindofpointthatdescribesthecornersorintersectionsofgeometricshapes.
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#Numberofverticesinpoly
vertx=[]
verty=[]
poly.each_slice(2)do|x,y|
vertx<<x
verty<<y
end
inside=false
j=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=!inside
end
j=i
end
inside
end
#...
ItisJordancurvetheoremreimplementedinRuby.Looksugly,butitactuallyworks,andisprettyfasttoo.
Also,thisworksonmoresophisticatedpolygons,andourtankisshapedmorelikeanHratherthanarectangle,sowecoulddefineapixelperfectpolygon.Somepenandpaperwillhelp.classTankPhysics<Component
#...
#TankboxlookslikeH.Vertices:
#1256
#34
#
#109
#121187
defbox
w=box_width/2-1
h=box_height/2-1
tw=8#trackwidth
fd=8#frontdepth
rd=6#reardepth
Utils.rotate(object.direction,x,y,
x+w,y+h,#1
x+w-tw,y+h,#2
x+w-tw,y+h-fd,#3
x-w+tw,y+h-fd,#4
x-w+tw,y+h,#5
x-w,y+h,#6
x-w,y-h,#7
x-w+tw,y-h,#8
x-w+tw,y-h+rd,#9
x+w-tw,y-h+rd,#10
x+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_box
i=0
object.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)%4
end
end
#...
Nowwecanvisuallytestboundingboxedgesandseethattheyactuallyarewheretheybelong.
Highprecisionboundingboxes
TimetopimpourTankPhysicstodetectthosecollisions.Whileouralgorithmisprettyfast,itdoesn’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
end
end
end
BacktoTankPhysics:classTankPhysics<Component
#...
defcan_move_to?(x,y)
old_x,old_y=object.x,object.y
object.x=x
object.y=y
[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_distance
end
end
true
ensure
object.x=old_x
object.y=old_y
end
#...
private
defcollides_with_poly?(poly)
ifpoly
poly.each_slice(2)do|x,y|
returntrueifUtils.point_in_poly(x,y,*box)
end
box.each_slice(2)do|x,y|
returntrueifUtils.point_in_poly(x,y,*poly)
end
end
false
end
#...
end
It’sprobablynotthemostelegantsolutionyoucouldcomeupwith,butcan_move_to?temporarilychangesTanklocationtomakeacollisiontest,andthenrevertsoldcoordinatesjustbeforereturningtheresult.Nowourtanksstopwithbangingsoundwhentheyhiteachother.
Tankscolliding
CatchingBulletsRightnowbulletsflyrightthroughourtanks,andwewantthemtocollide.It’saprettysimplechange,whichmostlyaffectsBulletPhysicsclass:#06-physics/entities/components/bullet_physics.rb
classBulletPhysics<Component
#...
defupdate
#...
check_hit
object.explodeifarrived?
end
#...
private
defcheck_hit
@object_pool.nearby(object,50).eachdo|obj|
nextifobj==object.source#Don'thitsourcetank
ifUtils.point_in_poly(x,y,*obj.box)
object.target_x=x
object.target_y=y
return
end
end
end
#...
end
Nowbulletsfinallyhit,butdon’tdoanydamageyet.Wewillcomebacktothatsoon.
Bullethittingenemytank
ImplementingTurnSpeedPenaltiesTankscannotmaketurnsandgointoreverseatfullspeedwhilekeepingit’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=true
object.physics.change_direction(
change_angle(object.direction,*motion_buttons))
else
object.throttle_down=false
end
#...
end
#...
end
#06-physics/entities/components/tank_physics.rb
classTankPhysics<Component
#...
defchange_direction(new_direction)
change=(new_direction-object.direction+360)%360
change=360-changeifchange>180
ifchange>90
@speed=0
elsifchange>45
@speed*=0.33
elsifchange>0
@speed*=0.66
end
object.direction=new_direction
end
#...
end
ImplementingTerrainSpeedPenaltiesNow,let’sseehowcanwemaketerraininfluenceourmovement.ItsoundsreasonableforTankPhysicstoconsultwithMapaboutspeedpenaltyofcurrenttile:#06-physics/entities/map.rb
classMap
#...
defmovement_penalty(x,y)
tile=tile_at(x,y)
casetile
when@sand
0.33
else
0
end
end
#...
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. Inflictdamagetotankwhenitisinexplosionzone4. Renderdifferentspritefordeadtank.5. Cutoffplayerinputwhentankisdead.
AddingHealthComponentIfwedidn’thaveComponentsysteminplace,itwouldbewaymoredifficult.Nowwejustkickinanewclass:07-damage/entities/components/tank_health.rb
1classTankHealth<Component
2attr_accessor:health
3
4definitialize(object,object_pool)
5super(object)
6@object_pool=object_pool
7@health=100
8@health_updated=true
9@last_damage=Gosu.milliseconds
10end
11
12defupdate
13update_image
14end
15
16defupdate_image
17if@health_updated
18ifdead?
19text='✝'20font_size=25
21else
23font_size=18
24end
25@image=Gosu::Image.from_text(
26$window,text,
27Gosu.default_font_name,font_size)
28@health_updated=false
29end
30end
31
32defdead?
33@health<1
34end
35
36definflict_damage(amount)
37if@health>0
38@health_updated=true
39@health=[@health-amount.to_i,0].max
40if@health<1
41Explosion.new(@object_pool,x,y)
42end
43end
44end
45
46defdraw(viewport)
49y-object.graphics.height/2-
[email protected],100)
51end
52end
Ithooksitselfintothegamerightaway,afterweinitializeitinTankclass:classTank<GameObject
attr_accessor:health
#...
definitialize(object_pool,input)
#...
@health=TankHealth.new(self,object_pool)
#..
end
#..
end
InflictingDamageWithBulletsTherearetwowaystoinflictdamage-directlyandindirectly.Whenbullethitsenemytank(collideswithtankboundingbox),weshouldinflictdirectdamage.ItcanbedoneinBulletPhysics#check_hitmethodthatwealreadyhad:classBulletPhysics<Component
#...
defcheck_hit
@object_pool.nearby(object,50).eachdo|obj|
nextifobj==object.source#Don'thitsourcetank
ifUtils.point_in_poly(x,y,*obj.box)
#Directhit-extradamage
obj.health.inflict_damage(20)
object.target_x=x
object.target_y=y
return
end
end
end
#...
end
Finally,Explosionitselfshouldinflictadditionaldamagetoanythingthatarenearby.Theeffectwillbediminishinganditwillbedeterminedbyobjectdistance.classExplosion<GameObject
#...
definitialize(object_pool,x,y)
#...
inflict_damage
end
private
definflict_damage
object_pool.nearby(self,100).eachdo|obj|
ifobj.class==Tank
obj.health.inflict_damage(
Math.sqrt(3*100-Utils.distance_between(
obj.x,obj.y,x,y)))
end
end
end
end
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=nil
end
defupdate
ifobject.health.dead?
@body=@body_dead
@gun=@gun_dead
@shadow=@shadow_dead
else
@body=@body_normal
@gun=@gun_normal
@shadow=@shadow_normal
end
end
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@gun
end
#...
end
Nowwecanblowthemupandenjoytheview:
Targetpractice
Butwhatifweblowourselvesupbyshootingnearby?Wewouldstillbeabletomovearound.Tofixthis,wewillsimplycutoutplayerinputwhenwearedead:classPlayerInput<Component
#...
defupdate
returnifobject.health.dead?
#...
end
#...
end
Andtopreventtankfromthrottlingforeverifthepedalwasdownbeforeitgotkilled:classTankPhysics<Component
#...
defupdate
ifobject.throttle_down&&!object.health.dead?
accelerate
else
decelerate
end
#...
end
#...
end
That’sit.Allweneedrightnowissomeresistancefromthosebraindeadenemies.Wewillsparksomelifeintotheminnextchapter.
CreatingArtificialIntelligence
ArtificialIntelligenceisasubjectsovastthatwewillbarelyscratchthesurface.AIinVideoGamesisusuallyheavilysimplifiedandthereforeeasiertoimplement.
ThereisthiswonderfulseriesofarticlescalledDesigningArtificialIntelligenceforGamesthatIhighlyrecommendreadingtogetafeelinghowgameAIshouldbedone.Wewillbecontinuingourworkontopofwhatwealreadyhave,examplecodeforthischapterwillbein08-ai.
DesigningAIUsingFiniteStateMachineNonplayertanksinourgamewillbelonerangers,huntingeverythingthatmoveswhiletryingtosurvive.WewilluseFiniteStateMachinetoimplementtankbehavior.
First,weneedtothink“whatwouldatankdo?”Howaboutthisscenario:
1. Tankwandersaround,mindingit’sownbusiness.2. Tankencountersanothertank.Itthenstartsdoingevasivemovesandtrieshittingthe
enemy.3. Enemytooksomedamageandstarteddrivingaway.Tankstartschasingtheenemy
tryingtofinishit.4. Anothertankappearsandfiresacoupleofaccurateshots,dealingseriousdamage.
Ourtankstartsrunningaway,becauseifitkeptreceivingdamageatsuchrate,itwoulddieverysoon.
5. Tankkeepsfleeingandlookingforsafetyuntilitgetscorneredortheopponentlooksdamagedtoo.Thentankgoesintoit’sfinalbattle.
WecannowdrawaFiniteStateMachineusingthisscenario:
VigilanteTankFSM
Ifyouareonapathtobecomeagamedeveloper,FSMshouldnotstandforFlyingSpaghettiMonsterforyouanymore.
ImplementingAIVisionTomakeopponentsrealistic,wehavetogivethemsenses.Let’screateaclassforthat:08-ai/entities/components/ai/vision.rb
1classAiVision
2CACHE_TIMEOUT=500
3attr_reader:in_sight
4
5definitialize(viewer,object_pool,distance)
6@viewer=viewer
7@object_pool=object_pool
8@distance=distance
9end
10
11defupdate
12@in_sight=@object_pool.nearby(@viewer,@distance)
13end
14
15defclosest_tank
16now=Gosu.milliseconds
17@closest_tank=nil
18ifnow-(@cache_updated_at||=0)>CACHE_TIMEOUT
19@closest_tank=nil
20@cache_updated_at=now
21end
22@closest_tank||=find_closest_tank
23end
24
25private
26
27deffind_closest_tank
28@in_sight.selectdo|o|
29o.class==Tank&&!o.health.dead?
30end.sortdo|a,b|
31x,[email protected],@viewer.y
32d1=Utils.distance_between(x,y,a.x,a.y)
33d2=Utils.distance_between(x,y,b.x,b.y)
34d1<=>d2
35end.first
36end
37end
ItusesObjectPooltoputnearbyobjectsinsight,andgetsashorttermfocusononeclosesttank.Closesttankiscachedfor500millisecondsfortworeasons:
1. Performance.UncachedversionwoulddoArray#selectandArray#sort60timespersecond,nowitwilldo2times.
2. Focus.Whenyouchooseatarget,youshouldkeepitalittlelonger.Thisshouldalsoavoid“jitters”,whentankwouldshakebetweentwonearbytargetsthatarewithinsamedistance.
ControllingTankGunAfterwemadeAiVision,wecannowuseittoautomaticallyaimandshootatclosesttank.Itshouldworklikethis:
1. Everyinstanceofthegunhasit’sownuniquecombinationofspeed,accuracyandaggressiveness.
2. Gunwillautomaticallytargetclosesttankinsight.3. Ifnoothertankisinsight,gunwilltargetinsamedirectionastank’sbody.4. Ifothertankisaimedatandwithinshootingdistance,gunwillmakeadecisiononce
inawhilewhetheritshouldshootornot,basedonaggressivenesslevel.Aggressivetankswillbetriggerhappyallthetime,whilelessaggressiveoneswillmakesmallrandompausesbetweenshots.
5. Gunwillhavea“desired”anglethatitwillbeautomaticallyadjustingto,accordingtoit’sspeed.
Hereistheimplementation:08-ai/entities/components/ai/gun.rb
1classAiGun
2DECISION_DELAY=1000
3attr_reader:target,:desired_gun_angle
4
5definitialize(object,vision)
6@object=object
7@vision=vision
8@desired_gun_angle=rand(0..360)
9@retarget_speed=rand(1..5)
10@accuracy=rand(0..10)
11@aggressiveness=rand(1..5)
12end
13
14defadjust_angle
15adjust_desired_angle
16adjust_gun_angle
17end
18
19defupdate
[email protected]_sight.any?
[email protected]_tank!=@target
22change_target(@vision.closest_tank)
23end
24else
25@target=nil
26end
27
28if@target
29if(0..10-rand(0..@accuracy)).include?(
30(@[email protected]_angle).abs.round)
31distance=distance_to_target
32ifdistance-50<=BulletPhysics::MAX_DIST
33target_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?&&
37should_shoot?
[email protected](target_x,target_y)
39end
40end
41end
42end
43end
44
45defdistance_to_target
46Utils.distance_between(
[email protected],@object.y,@target.x,@target.y)
48end
49
50
51defshould_shoot?
52rand*@aggressiveness>0.5
53end
54
55defcan_make_new_decision?
56now=Gosu.milliseconds
57ifnow-(@last_decision||=0)>DECISION_DELAY
58@last_decision=now
59true
60end
61end
62
63defadjust_desired_angle
64@desired_gun_angle=if@target
65Utils.angle_between(
[email protected],@object.y,@target.x,@target.y)
67else
69end
70end
71
72defchange_target(new_target)
73@target=new_target
74adjust_desired_angle
75end
76
77defadjust_gun_angle
[email protected]_angle
79desired=@desired_gun_angle
80ifactual>desired
81ifactual-desired>180#0->360fix
[email protected]_angle=(actual+@retarget_speed)%360
[email protected]_angle<desired
[email protected]_angle=desired#damp
85end
86else
[email protected]_angle=[actual-@retarget_speed,desired].max
88end
89elsifactual<desired
90ifdesired-actual>180#360->0fix
[email protected]_angle=(360+actual-@retarget_speed)%360
[email protected]_angle>desired
[email protected]_angle=desired#damp
94end
95else
[email protected]_angle=[actual+@retarget_speed,desired].min
97end
98end
99end
100end
Thereissomemathinvolved,butitisprettystraightforward.Weneedtofindoutananglebetweentwopoints,toknowwhereourgunshouldpoint,andtheotherthingweneediscoordinatesofpointwhichisinsomedistanceawayfromsourceatgivenangle.Herearethosefunctions:moduleUtils
#...
defself.angle_between(x,y,target_x,target_y)
dx=target_x-x
dy=target_y-y
(180-Math.atan2(dx,dy)*180/Math::PI)+360%360
end
defself.point_at_distance(source_x,source_y,angle,distance)
angle=(90-angle)*Math::PI/180
x=source_x+Math.cos(angle)*distance
y=source_y-Math.sin(angle)*distance
[x,y]
end
#...
end
ImplementingAIInputAtthispointourtankscanalreadydefendthemselves,eventhroughmotionisnotyetimplemented.Let’swireeverythingwehaveinAiInputclassthatwehadpreparedearlier.
WewillneedablankTankMotionFSMclasswith3argumentinitializerandemptyupdate,on_collision(with)andon_damage(amount)methodsforittowork:08-ai/entities/components/ai_input.rb
1classAiInput<Component
2UPDATE_RATE=200#ms
3
4definitialize(object_pool)
5@object_pool=object_pool
6super(nil)
7@last_update=Gosu.milliseconds
8end
9
10defcontrol(obj)
11self.object=obj
12@vision=AiVision.new(obj,@object_pool,
13rand(700..1200))
14@gun=AiGun.new(obj,@vision)
15@motion=TankMotionFSM.new(obj,@vision,@gun)
16end
17
18defon_collision(with)
[email protected]_collision(with)
20end
21
22defon_damage(amount)
[email protected]_damage(amount)
24end
25
26defupdate
27returnifobject.health.dead?
[email protected]_angle
29now=Gosu.milliseconds
30returnifnow-@last_update<UPDATE_RATE
31@last_update=now
35end
36end
Itadjustgunangleallthetime,butdoesupdatesatUPDATE_RATEtosaveCPUpower.AIisusuallyoneofthemostCPUintensivethingsingames,soit’sacommonpracticetoexecuteitlessoften.Refreshingenemybrains5persecondisenoughtomakethemdeadly.
MakesureyouspawnsomeAIcontrolledtanksinPlayStateandtrykillingthemnow.Ibettheywilleventuallygetyouevenwhilestandingstill.YoucanalsomaketanksspawnbelowmousecursorwhenyoupressTkey:classPlayState<GameState
#...
definitialize
#...
10.timesdo|i|
Tank.new(@object_pool,AiInput.new(@object_pool))
end
end
#...
defbutton_down(id)
#...
ifid==Gosu::KbT
t=Tank.new(@object_pool,
AiInput.new(@object_pool))
t.x,[email protected]_coords
end
#...
end
#...
end
ImplementingTankMotionStatesThisistheplacewherewewillneedFiniteStateMachinetogetthingsright.Wewilldesignitlikethis:
1. TankMotionFSMwilldecidewhichmotionstatetankshouldbein,consideringvariousparameters,e.g.existenceoftargetorlackthereof,health,etc.
2. TherewillbeTankMotionStatebaseclassthatwilloffercommonmethodslikedrive,waitandon_collision.
3. Concretemotionclasseswillimplementupdate,change_directionandothermethods,thatwillfiddlewithTank#throttle_downandTank#directiontomakeitmoveandturn.
WewillbeginwithTankMotionState:08-ai/entities/components/ai/tank_motion_state.rb
1classTankMotionState
2definitialize(object,vision)
3@object=object
4@vision=vision
5end
6
7defenter
8#Overrideifnecessary
9end
10
11defchange_direction
12#Override
13end
14
15defwait_time
16#Overrideandreturnanumber
17end
18
19defdrive_time
20#Overrideandreturnanumber
21end
22
23defturn_time
24#Overrideandreturnanumber
25end
26
27defupdate
28#Override
29end
30
31defwait
32@sub_state=:waiting
33@started_waiting=Gosu.milliseconds
34@will_wait_for=wait_time
[email protected]_down=false
36end
37
38defdrive
39@sub_state=:driving
40@started_driving=Gosu.milliseconds
41@will_drive_for=drive_time
[email protected]_down=true
43end
44
45defshould_change_direction?
46returntrueunless@changed_direction_at
47Gosu.milliseconds-@changed_direction_at>
48@will_keep_direction_for
49end
50
51defsubstate_expired?
52now=Gosu.milliseconds
53case@sub_state
54when:waiting
55trueifnow-@started_waiting>@will_wait_for
56when:driving
57trueifnow-@started_driving>@will_drive_for
58else
59true
60end
61end
62
63defon_collision(with)
64change=caserand(0..100)
65when0..30
66-90
67when30..60
6890
69when60..70
70135
71when80..90
72-135
73else
74180
75end
[email protected]_direction(
[email protected]+change)
78end
79end
Nothingextraordinaryhere,andweneedaconcreteimplementationtogetafeelinghowitwouldwork,thereforelet’sexamineTankRoamingState.Itwillbethedefaultstatewhichtankwouldbeiniftherewerenoenemiesaround.
TankRoamingState08-ai/entities/components/ai/tank_roaming_state.rb
1classTankRoamingState<TankMotionState
2definitialize(object,vision)
3super
4@object=object
5@vision=vision
6end
7
8defupdate
9change_directionifshould_change_direction?
10ifsubstate_expired?
11rand>0.3?drive:wait
12end
13end
14
15defchange_direction
16change=caserand(0..100)
17when0..30
18-45
19when30..60
2045
21when60..70
2290
23when80..90
24-90
25else
260
27end
28ifchange!=0
[email protected]_direction(
[email protected]+change)
31end
32@changed_direction_at=Gosu.milliseconds
33@will_keep_direction_for=turn_time
34end
35
36defwait_time
37rand(500..2000)
38end
39
40defdrive_time
41rand(1000..5000)
42end
43
44defturn_time
45rand(2000..5000)
46end
47end
Thelogichere:
1. Tankwillrandomlychangedirectioneveryturn_timeinterval,whichisbetween2and5seconds.
2. Tankwillchoosetodrive(80%chance)ortostandstill(20%chance).3. Iftankchosetodrive,itwillkeepdrivingfordrive_time,whichisbetween1and5
seconds.4. Samegoeswithwaiting,butwait_time(0.5-2seconds)willbeusedforduration.5. Directionchangesanddriving/waitingareindependent.
Thiswillmakeanimpressionthatourtankisdrivingaroundlookingforenemies.
TankFightingState
Whentankfinallyseesanopponent,itwillstartfighting.Fightingmotionshouldbemoreenergeticthanroaming,wewillneedasharpersetofchoicesinchange_directionamongotherthings.08-ai/entities/components/ai/tank_fighting_state.rb
1classTankFightingState<TankMotionState
2definitialize(object,vision)
3super
4@object=object
5@vision=vision
6end
7
8defupdate
9change_directionifshould_change_direction?
10ifsubstate_expired?
11rand>0.2?drive:wait
12end
13end
14
15defchange_direction
16change=caserand(0..100)
17when0..20
18-45
19when20..40
2045
21when40..60
2290
23when60..80
24-90
25when80..90
26135
27when90..100
28-135
29end
[email protected]_direction(
[email protected]+change)
32@changed_direction_at=Gosu.milliseconds
33@will_keep_direction_for=turn_time
34end
35
36defwait_time
37rand(300..1000)
38end
39
40defdrive_time
41rand(2000..5000)
42end
43
44defturn_time
45rand(500..2500)
46end
47end
Wewillhavemuchlesswaitingandmuchmoredrivingandturning.
TankChasingState
Ifopponentisfleeing,wewillwanttosetourdirectiontowardstheopponentandhitpedaltothemetal.Nowaitinghere.AiGun#desired_gun_anglewillpointdirectlytoourenemy.08-ai/entities/components/ai/tank_chasing_state.rb
1classTankChasingState<TankMotionState
2definitialize(object,vision,gun)
3super(object,vision)
4@object=object
5@vision=vision
6@gun=gun
7end
8
9defupdate
10change_directionifshould_change_direction?
11drive
12end
13
14defchange_direction
[email protected]_direction(
[email protected]_gun_angle-
[email protected]_gun_angle%45)
18
19@changed_direction_at=Gosu.milliseconds
20@will_keep_direction_for=turn_time
21end
22
23defdrive_time
2410000
25end
26
27defturn_time
28rand(300..600)
29end
30end
TankFleeingState
Now,ifourhealthislow,wewilldotheoppositeofchasing.Gunwillbepointingandshootingattheopponent,butwewantbodytomoveaway,sowewon’tgetourselveskilled.ItisverysimilartoTankChasingStatewherechange_directionaddsextra180degreestotheequation,butthereisonemorething.Tankcanonlyfleeforawhile.Thenitgetsitselftogetherandgoesintofinalbattle.That’swhyweprovidecan_flee?methodthatTankMotionFSMwillconsultwithbeforeenteringfleeingstate.
Wehaveimplementedallthestates,thatmeanswearemomentsawayfromactuallyplayableprototypewithtankbotsrunningaroundandfightingwithyouandeachother.
WiringTankMotionStatesIntoFiniteStateMachineImplementingTankMotionFSMafterwehaveallmotionstatesreadyissurprisinglyeasy:08-ai/entities/components/ai/tank_motion_fsm.rb
1classTankMotionFSM
2STATE_CHANGE_DELAY=500
3
4definitialize(object,vision,gun)
5@object=object
6@vision=vision
7@gun=gun
8@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)
13end
14
15defon_collision(with)
16@current_state.on_collision(with)
17end
18
19defon_damage(amount)
20if@current_state==@roaming_state
21set_state(@fighting_state)
22end
23end
24
25defupdate
26choose_state
27@current_state.update
28end
29
30defset_state(state)
31returnunlessstate
32returnifstate==@current_state
33@last_state_change=Gosu.milliseconds
34@current_state=state
35state.enter
36end
37
38defchoose_state
39returnunlessGosu.milliseconds-
40(@last_state_change)>STATE_CHANGE_DELAY
[email protected]_to_target>BulletPhysics::MAX_DIST
44new_state=@chasing_state
45else
46new_state=@fighting_state
47end
48else
49if@fleeing_state.can_flee?
50new_state=@fleeing_state
51else
52new_state=@fighting_state
53end
54end
55else
56new_state=@roaming_state
57end
58set_state(new_state)
59end
60end
Allthelogicisinchoose_statemethod,whichisprettyuglyandprocedural,butitdoesthejob.Thecodeshouldbeeasytounderstand,soinsteadofdescribingit,hereisapictureworththousandwords:
Firstrealbattle
Youmaynoticeanewcrosshair,whichreplacedtheoldonethatwasnevervisible:classCamera
#...
defdraw_crosshair
factor=0.5
x=$window.mouse_x
y=$window.mouse_y
c=crosshair
c.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)
end
end
Howeverthisnewcrosshairdidn’thelpmewin,Igotmyasskickedbadly.Increasinggamewindowsizehelped,butweobviouslyneedtofinetunemanythingsinthisAI,tomakeitsmartandchallengingratherthandumbanddeadlyaccurate.
MakingThePrototypePlayable
Rightnowwehaveasomewhatplayable,butboringprototypewithoutanyscoresorwinningconditions.Youcanjustrunaroundandshootothertanks.Nobodywouldplayagamelikethis,henceweneedtotoaddthemissingparts.Thereisacrazyamountofthem.Itistimetogiveitathoroughplaythroughandwritedownalltheideasandpainpointsabouttheprototype.
Hereismylist:
1. Enemytanksdonotrespawn.2. Enemytanksshootatmycurrentlocation,notatwhereIwillbewhenbullethitsme.3. Enemytanksdon’tavoidcollisions.4. Randommapsareboringandlackdetail,couldusemoretilesorrandom
environmentobjects.5. Bulletsarehardtoseeongreensurface.6. Hardtotellwhereenemiesarecomingfrom,radarwouldhelp.7. Soundsplayatfullvolumeevenwhensomethinghappensacrossthewholemap.8. Mytankshouldrespawnafterit’sdead.9. Motionandfiringmechanicsseemclumsy.10. Mapboundariesarevisiblewhenyoucometotheedge.11. Enemytankmovementpatternsneedpolishingandimprovement.12. Bothmytankandenemiesdon’thaveanyidentity.Sometimeshardtodistinguish
whoiswho.13. Noideawhohasmostkills.HUDwithscoreandsomestatethatdisplaysscore
detailswouldhelp.14. Wouldbegreattohaverandompowerupslikehealth,extradamage.15. Explosionsdon’tleaveatrace.16. Tankscouldleavetrails.17. Deadtankskeeppilingupandclutteringthemap.18. Camerashouldbescoutingaheadofyouwhenyoumove,notdraggingbehind.19. Bulletsseemtoaccelerate.
Thiswillkeepusbusyforawhile,butintheendwewillprobablyhavesomethingthatwillhopefullybeabletoentertainpeopleformorethan3minutes.
Someitemsonthislistareeasyfixes.AfterplayingaroundwithPixelmatorfor15minutes,Iendedupwithabulletthatisvisibleonbothlightanddarkbackgrounds:
Highlyvisiblebullet
Motionandfiringmechanicswilleitherhavetobetunedsettingbysetting,orrewrittenfromscratch.Implementingscoresystem,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_SIZE
map_y=y*TILE_SIZE
ifrow
tile=@map[x][y]
iftile
tile.draw(map_x,map_y,0)
else
@water.draw(map_x,map_y,0)
end
else
@water.draw(map_x,map_y,0)
end
end
end
end
#...
private
#...
deftile_at(x,y)
t_x=((x/TILE_SIZE)%TILE_SIZE).floor
t_y=((y/TILE_SIZE)%TILE_SIZE).floor
row=@map[t_x]
row?row[t_y]:@water
end
#...
end
Nowtheedgelooksmuchbetter:
Mapedge
GeneratingTreeClustersTomakethemapmorefuntoplayat,wewillgeneratesometrees.Let’sstartwithTreeclass:09-polishing/entities/tree.rb
1classTree<GameObject
2attr_reader:x,:y,:health,:graphics
3
4definitialize(object_pool,x,y,seed)
5super(object_pool)
6@x,@y=x,y
7@graphics=TreeGraphics.new(self,seed)
8@health=Health.new(self,object_pool,30,false)
9@angle=rand(-15..15)
10end
11
12defon_collision(object)
[email protected](object.direction)
14end
15
16defbox
17[x,y]
18end
19end
Nothingfancyhere,wewantittoshakeoncollision,andithasgraphicsandhealth.seedwillusedtogenerateclustersofsimilartrees.Let’stakealookatTreeGraphics:09-polishing/entities/components/tree_graphics.rb
1classTreeGraphics<Component
2SHAKE_TIME=100
3SHAKE_COOLDOWN=200
4SHAKE_DISTANCE=[2,1,0,-1,-2,-1,0,1,0,-1,0]
5definitialize(object,seed)
6super(object)
7load_sprite(seed)
8end
9
10defshake(direction)
11now=Gosu.milliseconds
12returnif@shake_start&&
13now-@shake_start<SHAKE_TIME+SHAKE_COOLDOWN
14@shake_start=now
15@shake_direction=direction
16@shaking=true
17end
18
19defadjust_shake(x,y,shaking_for)
20elapsed=[shaking_for,SHAKE_TIME].min/SHAKE_TIME.to_f
21frame=((SHAKE_DISTANCE.length-1)*elapsed).floor
22distance=SHAKE_DISTANCE[frame]
23Utils.point_at_distance(x,y,@shake_direction,distance)
24end
25
26defdraw(viewport)
27if@shaking
28shaking_for=Gosu.milliseconds-@shake_start
29shaking_x,shaking_y=adjust_shake(
30center_x,center_y,shaking_for)
[email protected](shaking_x,shaking_y,5)
32ifshaking_for>=SHAKE_TIME
33@shaking=false
34end
35else
[email protected](center_x,center_y,5)
37end
38Utils.mark_corners(object.box)if$debug
39end
40
41defheight
43end
44
45defwidth
47end
48
49private
50
51defload_sprite(seed)
52frame_list=trees.frame_list
53frame=frame_list[(frame_list.size*seed).round]
54@tree=trees.frame(frame)
55end
56
57defcenter_x
58@center_x||[email protected]/2
59end
60
61defcenter_y
62@center_y||[email protected]/2
63end
64
65deftrees
66@@trees||=Gosu::TexturePacker.load_json($window,
67Utils.media_path('trees_packed.json'))
68end
69end
Shakingisprobablythemostinterestingparthere.Whenshakeiscalled,graphicswillstartdrawingtreeshiftedingivendirectionbyamountdefinedinSHAKE_DISTANCEarray.drawwillbesteppingthroughSHAKE_DISTANCEdependingonSHAKE_TIME,anditwillnotbeshakenagainforSHAKE_COOLDOWNperiod,toavoidinfiniteshakingwhiledrivingintoit.
WealsoneedsomeadjustmentstoTankPhysicsandTanktobeabletohittrees.First,wewanttocreateanemptyon_collision(object)methodinGameObjectclass,soallgameobjectswillbeabletocollide.
Then,TankPhysicsstartscallingTank#on_collisionwhencollisionisdetected:classTank<GameObject
#...
defon_collision(object)
returnunlessobject
#Avoidrecursion
ifobject.class==Tank
#InformAIabouthit
object.input.on_collision(object)
else
#Callonlyonnon-tankstoavoidrecursion
object.on_collision(self)
end
#BulletsshouldnotslowTanksdown
ifobject.class!=Bullet
end
end
#...
end
ThefinalingredienttoourTreeisHealth,whichisextractedfromTankHealthtoreduceduplication.TankHealthnowextendsit:09-polishing/entities/components/health.rb
1classHealth<Component
2attr_accessor:health
3
4definitialize(object,object_pool,health,explodes)
5super(object)
6@explodes=explodes
7@object_pool=object_pool
8@initial_health=@health=health
9@health_updated=true
10end
11
12defrestore
13@health=@initial_health
14@health_updated=true
15end
16
17defdead?
18@health<1
19end
20
21defupdate
22update_image
23end
24
25definflict_damage(amount)
26if@health>0
27@health_updated=true
28@health=[@health-amount.to_i,0].max
29after_deathifdead?
30end
31end
32
33defdraw(viewport)
34returnunlessdraw?
35@image&&@image.draw(
37y-object.graphics.height/2-
[email protected],100)
39end
40
41protected
42
43defdraw?
44$debug
45end
46
47defupdate_image
48returnunlessdraw?
49if@health_updated
51font_size=18
52@image=Gosu::Image.from_text(
53$window,text,
54Gosu.default_font_name,font_size)
55@health_updated=false
56end
57end
58
59defafter_death
60if@explodes
61ifThread.list.count<8
62Thread.newdo
63sleep(rand(0.1..0.3))
64Explosion.new(@object_pool,x,y)
65sleep0.3
66object.mark_for_removal
67end
68else
69Explosion.new(@object_pool,x,y)
70object.mark_for_removal
71end
72else
73object.mark_for_removal
74end
75end
76end
Yes,youcanmaketreeexplodewhenit’sdestroyed.Anditcausescoolchainreactionsblowingupwholetreeclusters.Butlet’snotdothat,becausewewilladdsomethingmoreappropriateforexplosions.
OurTreeisreadytofillthelandscape.WewilldoitinMapclass,whichwillnowneedtoknowaboutObjectPool,becausetreeswillgothere.classMap
#...
definitialize(object_pool)
load_tiles
@object_pool=object_pool
object_pool.map=self
@map=generate_map
generate_trees
end
#...
defgenerate_trees
noises=Perlin::Noise.new(2)
contrast=Perlin::Curve.contrast(
Perlin::Curve::CUBIC,2)
trees=0
target_trees=rand(300..500)
whiletrees<target_treesdo
x=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.5
Tree.new(@object_pool,x,y,n*2-1)
trees+=1
end
end
end
#...
end
Perlinnoiseisusedinsimilarfashionasitwaswhenwegeneratedmaptiles.Weallowcreatingtreesonlyifnoiselevelisabove0.5,andusenoiselevelasseedvalue-n*2-1willbeanumberbetween0and1whennisin0.5..1range.Andweonlyallowcreatingtreesongrasstiles.
Nowourmaplooksalittlebetter:
Hidingamongprocedurallygeneratedtrees
GeneratingRandomObjectsTreesaregreat,butwewantmoredetail.Let’sspicethingsupwithexplosiveboxesandbarrels.Theywillbeusingthesameclasswithsinglespritesheet,sowhilethespritewillbechosenrandomly,behaviorwillbethesame.ThisnewclasswillbecalledBox:09-polishing/entities/box.rb
1classBox<GameObject
2attr_reader:x,:y,:health,:graphics,:angle
3
4definitialize(object_pool,x,y)
5super(object_pool)
6@x,@y=x,y
7@graphics=BoxGraphics.new(self)
8@health=Health.new(self,object_pool,10,true)
9@angle=rand(-15..15)
10end
11
12defon_collision(object)
13returnunlessobject.physics.speed>1.0
14@x,@y=Utils.point_at_distance(@x,@y,object.direction,2)
15@box=nil
16end
17
18defbox
19return@boxif@box
22#Boundingboxadjustedtotrimshadows
23@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)
28end
29end
Itwillbegeneratedwithslightrandomangle,topreserverealisticshadowsbutgiveanimpressionofchaoticplacement.Tankswillalsobeabletopushboxesalittleoncollision,butonlywhengoingfastenough.HealthcomponentisthesameonethatTreehas,butinitializedwithlesshealthandexplosiveflagistrue,sotheboxwillblowupafteronehitanddealextradamagetothesurroundings.
BoxGraphicsisnothingfancy,itjustloadsrandomspriteuponinitialization:09-polishing/entities/components/box_graphics.rb
1classBoxGraphics<Component
2definitialize(object)
3super(object)
4load_sprite
5end
6
7defdraw(viewport)
[email protected]_rot(x,y,0,object.angle)
9Utils.mark_corners(object.box)if$debug
10end
11
12defheight
14end
15
16defwidth
18end
19
20private
21
22defload_sprite
23frame=boxes.frame_list.sample
24@box=boxes.frame(frame)
25end
26
27defcenter_x
28@center_x||=x-width/2
29end
30
31defcenter_y
32@center_y||=y-height/2
33end
34
35defboxes
36@@boxes||=Gosu::TexturePacker.load_json($window,
37Utils.media_path('boxes_barrels.json'))
38end
39end
TimetogenerateboxesinourMap.Itwillbesimilartotrees,butwewon’tneedPerlinnoise,sincetherewillbewayfewerboxesthantrees,sowedon’tneedtoformpatterns.Allweneedtodoistocheckifwe’renotgeneratingboxonwater.classMap
#...
definitialize(object_pool)
#...
generate_boxes
end
#...
defgenerate_boxes
boxes=0
target_boxes=rand(10..30)
whileboxes<target_boxesdo
x=rand(0..MAP_WIDTH*TILE_SIZE)
y=rand(0..MAP_HEIGHT*TILE_SIZE)
iftile_at(x,y)!=@water
Box.new(@object_pool,x,y)
boxes+=1
end
end
end
#...
end
Nowgiveitago.Beautiful,isn’tit?
Boxesandbarrelsinthejungle
ImplementingARadarWithallthevisualnoiseitisgettingincreasinglydifficulttoseeenemytanks.That’swhywewillimplementaRadartohelpourselves.09-polishing/entities/radar.rb
1classRadar
2UPDATE_FREQUENCY=1000
3WIDTH=150
4HEIGHT=100
5PADDING=10
6#Blackwith33%transparency
7BACKGROUND=Gosu::Color.new(255*0.33,0,0,0)
8attr_accessor:target
9
10definitialize(object_pool,target)
11@object_pool=object_pool
12@target=target
13@last_update=0
14end
15
16defupdate
17ifGosu.milliseconds-@last_update>UPDATE_FREQUENCY
18@nearby=nil
19end
20@nearby||=@object_pool.nearby(@target,2000).selectdo|o|
21o.class==Tank&&!o.health.dead?
22end
23end
24
25defdraw
26x1,x2,y1,y2=radar_coords
27$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)
36end
37end
38
39private
40
41defdraw_tank(tank,color)
42x1,x2,y1,y2=radar_coords
43tx=x1+WIDTH/2+([email protected])/20
44ty=y1+HEIGHT/2+([email protected])/20
45if(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)
52end
53end
54
55defradar_coords
56x1=$window.width-WIDTH-PADDING
57x2=$window.width-PADDING
58y1=$window.height-HEIGHT-PADDING
59y2=$window.height-PADDING
60[x1,x2,y1,y2]
61end
62end
Radar,likeCamera,alsohasatarget.ItusesObjectPooltoquerynearbyobjectsandfiltersoutinstancesofaliveTank.Thenitdrawsatransparentblackbackgroundandsmalldotsforeachtank,greenfortarget,redfortherest.
ToavoidqueryingObjectPooltoooften,Radarupdatesitselfonlyonceeverysecond.
Itisinitialized,updatedanddrawninPlayState,rightafterCamera:classPlayState<GameState
#...
definitialize
#...
@camera.target=@tank
@radar=Radar.new(@object_pool,@tank)
#...
end
#...
defupdate
#...
@camera.update
@radar.update
#...
end
#...
defdraw
#...
@camera.draw_crosshair
@radar.draw
end
#...
end
Timetoenjoytheresults.
Radarinaction
DynamicSoundVolumeAndPanningWehaveimprovedthevisuals,butsoundisstillterrible.Likesomesuperhero,youcanheareverythingthathappensinthemap,anditcandriveyouinsane.Wewillfixthatinamoment.
Theideaistomakeeverythingthathappensfurtherawayfromcameratargetsoundlessloud,untilthesoundfadesawaycompletely.Tomakeplayer’sexperiencemoreimmersive,wewillalsotakeadvantageofstereospeakers-soundsshouldappeartobecomingfromtherightdirection.
Unfortunately,Gosu::Sample#play_pandoesnotworkasonewouldexpectitto.Ifyouplaythesamplewithjustalittlepanning,itcompletelycutsofftheoppositechannel,meaningthatifyouplayasamplewithpanlevelof0.1(10%totheright),youwouldexpecttohearsomethinginleftspeakeraswell.Theactualbehavioristhatsoundplaysthroughtherightspeakerprettyloudly,andifyouincreasepanlevelto,say,0.7,youwillhearthesoundthroughrightspeakeragain,butitwillbewaymoresilent.
Toimplementrealisticstereosoundsthatcomethroughbothspeakerswhenpanned,weneedtoplaytwosampleswithoppositepanlevel.Aftersomeexperimenting,Idiscovered
thatfiddlingwithpanlevelmakesthingssoundweird,whileplayingwithvolumeproducessofter,moresubtleeffect.ThisiswhatIendeduphaving:09-polishing/misc/stereo_sample.rb
1classStereoSample
2@@all_instances=[]
3
4defself.register_instances(instances)
5@@all_instances<<instances
6end
7
8defself.cleanup
9@@all_instances.eachdo|instances|
10instances.eachdo|key,instance|
11unlessinstance.playing?||instance.paused?
12instances.delete(key)
13end
14end
15end
16end
17
18definitialize(window,sound_l,sound_r=sound_l)
19@sound_l=Gosu::Sample.new(window,sound_l)
20#Usesamesampleinmono->stereo
21ifsound_l==sound_r
22@sound_r=@sound_l
23else
24@sound_r=Gosu::Sample.new(window,sound_r)
25end
26@instances={}
27self.class.register_instances(@instances)
28end
29
30defpaused?(id=:default)
31i=@instances["#{id}_l"]
32i&&i.paused?
33end
34
35defplaying?(id=:default)
36i=@instances["#{id}_l"]
37i&&i.playing?
38end
39
40defstopped?(id=:default)
41@instances["#{id}_l"].nil?
42end
43
44defplay(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)
51end
52
53defpause(id=:default)
54@instances["#{id}_l"].pause
55@instances["#{id}_r"].pause
56end
57
58defresume(id=:default)
59@instances["#{id}_l"].resume
60@instances["#{id}_r"].resume
61end
62
63defstop
[email protected]("#{id}_l").stop
[email protected]("#{id}_r").stop
66end
67
68defvolume_and_pan(id,volume,pan)
69ifpan>0
70pan_l=1-pan*2
71pan_r=1
72else
73pan_l=1
74pan_r=1+pan*2
75end
76pan_l*=volume
77pan_r*=volume
78@instances["#{id}_l"].volume=[pan_l,0.05].max
79@instances["#{id}_r"].volume=[pan_r,0.05].max
80end
81end
StereoSamplemanagesstereoplaybackofsampleinstances,andtoavoidmemoryleaks,ithascleanupthatscansallsampleinstancesandremovessamplesthathavefinishedplaying.Forthisremovaltowork,weneedtoplaceacalltoStereoSample.cleanupinsidePlayState#updatemethod.
Todeterminecorrectpanandvolume,wewillcreatesomehelpermethodsinUtilsmodule:moduleUtils
HEARING_DISTANCE=1000.0
#...
defself.volume(object,camera)
return1ifobject==camera.target
distance=Utils.distance_between(
camera.target.x,camera.target.y,
object.x,object.y)
distance=[(HEARING_DISTANCE-distance),0].max
distance/HEARING_DISTANCE
end
defself.pan(object,camera)
return0ifobject==camera.target
pan=object.x-camera.target.x
sig=pan>0?1:-1
pan=(pan%HEARING_DISTANCE)/HEARING_DISTANCE
ifsig>0
pan
else
-1+pan
end
end
defself.volume_and_pan(object,camera)
[volume(object,camera),pan(object,camera)]
end
end
Apparently,havingaccesstoCameraisnecessaryforcalculatingsoundvolumeandpan,sowewilladdattr_accessor:cameratoObjectPoolclassandassignitinPlayStateconstructor.Youmaywonderwhywedidn’tuseCamera#targetrightaway.Theansweristhatcameracanchangeit’starget.E.g.whenyourtankdies,newinstancewillbegeneratedwhenyourespawn,soifallotherobjectswouldstillhavethereferencetoyouroldtank,guesswhatyouwouldhear?
RemasteredTankSoundscomponentisprobablythemostelaborateexampleofhowStereoSampleshouldbeused:09-polishing/entities/components/tank_sounds.rb
1classTankSounds<Component
2definitialize(object,object_pool)
3super(object)
4@object_pool=object_pool
5end
6
7defupdate
8id=object.object_id
9ifobject.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)
17end
18driving_sound.volume_and_pan(id,move_volume*0.5,pan)
19else
20ifdriving_sound.playing?(id)
21driving_sound.pause(id)
22end
23end
24end
25
26defcollide
27vol,pan=Utils.volume_and_pan(
28object,@object_pool.camera)
29crash_sound.play(self.object_id,pan,vol,1,false)
30end
31
32private
33
34defdriving_sound
35@@driving_sound||=StereoSample.new(
36$window,Utils.media_path('tank_driving.mp3'))
37end
38
39defcrash_sound
40@@crash_sound||=StereoSample.new(
41$window,Utils.media_path('metal_interaction2.wav'))
42end
43end
AndthisishowstaticExplosionSoundslookslike:09-polishing/entities/components/explosion_sounds.rb
1classExplosionSounds
2class<<self
3defplay(object,camera)
4volume,pan=Utils.volume_and_pan(object,camera)
5sound.play(object.object_id,pan,volume)
6end
7
8private
9
10defsound
11@@sound||=StereoSample.new(
12$window,Utils.media_path('explosion.mp3'))
13end
14end
15end
AfterwiringeverythingsothatsoundcomponentshaveaccesstoObjectPool,therestisstraightforward.
GivingEnemiesIdentityWouldn’titbegreatifyoucouldtellyourselfapartfromtheenemies.Moreover,enemiescouldhavenames,soyouwouldknowwhichoneismoreaggressiveorhave,youknow,personalissueswithsomeone.
Todothatweneedtoasktheplayertoinputanickname,andchoosesomefunnynamesforeachenemyAI.Hereisanicelistwewillgrab:http://www.paulandstorm.com/wha/clown-names/
Wefirstcompileeverythingintoatextfiledcallednames.txt,thatlookslikethis:media/names.txt
Strippy
Boffo
Buffo
Drips
...
Nowweneedaclasstoparsethelistandgiveoutrandomnamesfromit.Wealsowanttolimitnamelengthtosomethingthatdisplaysnicely.09-polishing/misc/names.rb
1classNames
2definitialize(file)
3@names=File.read(file).split("\n").rejectdo|n|
4n.size>12
5end
6end
7
8defrandom
[email protected](name)
11name
12end
13end
Thenweneedtoplacethosenamessomewhere.Wecouldassignthemtotanks,butthinkahead-ifourplayerandAIenemieswillrespawn,weshouldgivenamestoinputs,becauseTankisreplaceable,driverisnot.Well,itis,butlet’snotgettoodeepintoit.
FornowwejustaddnameparametertoPlayerInputandAiInputinitializers,saveitin@nameinstancevariable,andthenadddraw(viewport)methodtomakeitrenderbelowthetank:#09-polishing/entities/components/player_input.rb
classPlayerInput<Component
#Darkgreen
NAME_COLOR=Gosu::Color.argb(0xee084408)
definitialize(name,camera)
super(nil)
@name=name
@camera=camera
end
#...
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
#Darkred
NAME_COLOR=Gosu::Color.argb(0xeeb10000)
definitialize(name,object_pool)
super(nil)
@object_pool=object_pool
@name=name
@last_update=Gosu.milliseconds
end
#...
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
RespawningTanksAndRemovingDeadOnesToimplementrespawningwecoulduseMap#find_spawn_pointeverytimewewantedtorespawn,butitmaygetslow,becauseitbruteforcesthemapforrandomspotsthatarenotwater.Wedon’twantourgametostartfreezingwhentanksarerespawning,sowewillchangehowtankspawningworks.Insteadoflookingforanewrespawnpointallthetime,wewillpre-generateseveralofthemforreuse.classMap
#...
defspawn_points(max)
@spawn_points=(0..max).mapdo
find_spawn_point
end
@spawn_points_pointer=0
end
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_pointwhenneeded.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))
end
end
#...
end
Whentankdies,wewantittostaydeadfor5secondsandthenrespawninoneofourpredefinedspawnpoints.WewillachievethatbyaddingrespawnmethodandcallingitinPlayerInput#updateandAiInput#updateiftankisdead.#09-polishing/entities/components/player_input.rb
classPlayerInput<Component
#...
defupdate
returnrespawnifobject.health.dead?
#...
end
#...
private
defrespawn
ifobject.health.should_respawn?
object.health.restore
object.x,object.y=@object_pool.map.spawn_point
@camera.x,@camera.y=x,y
PlayerSounds.respawn(object,@camera)
end
end
#...
end
#09-polishing/entities/components/ai_input.rb
classAiInput<Component
#...
defupdate
returnrespawnifobject.health.dead?
#...
end
#...
private
defrespawn
ifobject.health.should_respawn?
object.health.restore
object.x,object.y=@object_pool.map.spawn_point
PlayerSounds.respawn(object,@object_pool.camera)
end
end
end
WeneedsomechangesinTankHealthclasstoo:classTankHealth<Health
RESPAWN_DELAY=5000
#...
defshould_respawn?
Gosu.milliseconds-@death_time>RESPAWN_DELAY
end
#...
defafter_death
@death_time=Gosu.milliseconds
#...
end
end
classHealth<Component
#...
defrestore
@health=@initial_health
@health_updated=true
end
#...
end
Itshouldn’tbehardtoputeverythingtogetherandenjoytheneverendinggameplay.
Youmayhavenoticedthatwealsoaddedasoundthatwillbeplayedwhenplayerrespawns.Anice“whoosh”.09-polishing/entities/components/player_sounds.rb
1classPlayerSounds
2class<<self
3defrespawn(object,camera)
4volume,pan=Utils.volume_and_pan(object,camera)
5respawn_sound.play(object.object_id,pan,volume*0.5)
6end
7
8private
9
10defrespawn_sound
11@@respawn||=StereoSample.new(
12$window,Utils.media_path('respawn.wav'))
13end
14end
15end
DisplayingExplosionDamageTrailsWhensomethingblowsup,youexpectittoleaveatrail,right?Inourcaseexplosionsdisappearasifnothinghaseverhappened,andwejustcan’tleaveitlikethis.Let’sintroduceDamagegameobjectthatwillberesponsiblefordisplayingexplosionresidueonsandandgrass:09-polishing/entities/damage.rb
1classDamage<GameObject
2MAX_INSTANCES=100
3attr_accessor:x,:y
4@@instances=[]
5
6definitialize(object_pool,x,y)
7super(object_pool)
8DamageGraphics.new(self)
9@x,@y=x,y
10track(self)
11end
12
13defeffect?
14true
15end
16
17private
18
19deftrack(instance)
20if@@instances.size<MAX_INSTANCES
21@@instances<<instance
22else
23out=@@instances.shift
24out.mark_for_removal
25@@instances<<instance
26end
27end
28end
Damagetracksit’sinstancesandstartsremovingoldoneswhenMAX_INSTANCESarereached.Withoutthisoptimization,thegamewouldgetincreasinglyslowereverytimesomebodyshoots.
Wehavealsoaddedanewgameobjecttrait-effect?returnstrueonDamageandExplosion,falseonTank,Tree,BoxandBullet.ThatwaywecanfilterouteffectswhenqueryingObjectPool#nearbyforcollisionsorenemies.09-polishing/entities/object_pool.rb
1classObjectPool
2attr_accessor:objects,:map,:camera
3
4definitialize
5@objects=[]
6end
7
8defnearby(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_distance
15end
16end
17
18defnon_effects
[email protected](&:effect?)
20end
21end
Whenitcomestorenderinggraphics,tomakeanimpressionofrandomness,wewillcyclethroughseveraldifferentdamageimagesanddrawthemrotated:09-polishing/entities/components/damage_graphics.rb
1classDamageGraphics<Component
2definitialize(object_pool)
3super
4@image=images.sample
5@angle=rand(0..360)
6end
7
8defdraw(viewport)
[email protected]_rot(x,y,0,@angle)
10end
11
12private
13
14defimages
15@@images||=(1..4).mapdo|i|
16Gosu::Image.new($window,
17Utils.media_path("damage#{i}.png"),false)
18end
19end
20end
ExplosionwillberesponsibleforcreatingDamageinstancesonsolidground,justbeforeexplosionanimationstarts:classExplosion<GameObject
definitialize(object_pool,x,y)
#...
if@object_pool.map.can_move_to?(x,y)
Damage.new(@object_pool,@x,@y)
end
#...
end
#...
end
Andthisishowtheresultlookslike:
Damagedbattlefield
DebuggingBulletPhysicsWhenplayingthegame,thereisafeelingthatbulletsstartoutslowwhenfiredandgainspeedastimegoes.Let’sreviewBulletPhysics#updateandthinkwhythisishappening:classBulletPhysics<Component
#...
defupdate
fly_speed=Utils.adjust_speed(object.speed)
fly_distance=(Gosu.milliseconds-object.fired_at)*
0.001*fly_speed/2
object.x,object.y=point_at_distance(fly_distance)
check_hit
object.explodeifarrived?
end
#...
end
Flawhereisveryobvious.Gosu.milliseconds-object.fired_atwillbeincreasinglybiggerastimegoes,thusincreasingfly_distance.Thefixisstraightforward-wewanttocalculatefly_distanceusingtimepassedbetweencallstoBulletPhysics#update,likethis:classBulletPhysics<Component
#...
defupdate
fly_speed=Utils.adjust_speed(object.speed)
now=Gosu.milliseconds
@last_update||=object.fired_at
fly_distance=(now-@last_update)*0.001*fly_speed
object.x,object.y=point_at_distance(fly_distance)
@last_update=now
check_hit
object.explodeifarrived?
end
#...
end
Butifyouwouldrunthegamenow,bulletswouldflysoslow,thatyouwouldfeellikeNeoinTheMatrix.Tofixthat,wewillhavetotellourtanktofirebulletsalittlefaster.classTank<GameObject
#...
defshoot(target_x,target_y)
ifcan_shoot?
@last_shot=Gosu.milliseconds
Bullet.new(object_pool,@x,@y,target_x,target_y)
.fire(self,1500)#Oldvaluewas100
end
end
#...
end
Nowbulletsflyliketheyaresupposedto.Icanonlywonderwhyhaven’tInoticedthisbugintheverybeginning.
MakingCameraLookAheadOneofthemostannoyingthingswithcurrentstateofprototypeisthatCameraisdraggingbehindinsteadofshowingwhatisinthedirectionyouaremoving.Tofixtheissue,weneedtochangethewayhowCameramovesaround.FirstweneedtoknowwhereCamerawantstobe.WewilluseUtils.point_at_distancetochooseaspotaheadoftheTank.Then,Camera#updateneedstoberewritten,soCameracandynamicallyadjusttoit’sdesiredspot.Herearethechanges:classCamera
#...
defdesired_spot
Utils.point_at_distance(
@target.x,@target.y,
@target.direction,
@target.physics.speed.ceil*25)
else
[@target.x,@target.y]
end
end
#...
defupdate
des_x,des_y=desired_spot
shift=Utils.adjust_speed(
@target.physics.speed).floor+1
if@x<des_x
ifdes_x-@x<shift
@x=des_x
else
@x+=shift
end
elsif@x>des_x
if@x-des_x<shift
@x=des_x
else
@x-=shift
end
end
if@y<des_y
ifdes_y-@y<shift
@y=des_y
else
@y+=shift
end
elsif@y>des_y
if@y-des_y<shift
@y=des_y
else
@y-=shift
end
end
#...
end
#...
end
Itwouldn’twincodestyleawards,butitdoesthejob.Gameisnowmuchmoreplayable.
ReviewingTheChangesLet’sgetbacktoourlistofimprovementstoseewhatwehavedone:
1. Enemytanksdonotrespawn.2. Randommapsareboringandlackdetail,couldusemoretilesorrandom
environmentobjects.3. Bulletsarehardtoseeongreensurface.4. Hardtotellwhereenemiesarecomingfrom,radarwouldhelp.5. SoundsplayatfullvolumeevenwhensomethinghappensacrossThewholemap.6. Mytankshouldrespawnafterit’sdead.7. Mapboundariesarevisiblewhenyoucometotheedge.8. Bothmytankandenemiesdon’thaveanyidentity.Sometimeshardtodistinguish
whoiswho.9. Explosionsdon’tleaveatrace.10. Deadtankskeeppilingupandclutteringthemap.11. Camerashouldbescoutingaheadofyouwhenyoumove,notdraggingbehind.12. Bulletsseemtoaccelerate.
Notbadforastart.Thisiswhatwestillneedtocoverinnextcoupleofchapters:
1. Enemytanksshootatmycurrentlocation,notatwhereIwillbewhenbullethitsme.2. Enemytanksdon’tavoidcollisions.3. Enemytankmovementpatternsneedpolishingandimprovement.4. Noideawhohasmostkills.HUDwithscoreandsomestatethatdisplaysscore
detailswould5. Wouldbegreattohaverandompowerupslikehealth,extradamage.6. Motionandfiringmechanicsseemclumsy.help.7. Tankscouldleavetrails.
Iwilladd“OptimizeObjectPoolperformance”,becausegamestartsslowingdownwhentoomanyobjectsareaddedtothepool,andprofilingshowsthatArray#select,whichistheheartofObjectPool#nearby,isthemaincause.Speedisoneofmostimportantfeaturesofanygame,solet’snothesitatetoimproveit.
DealingWithThousandsOfGameObjects
Gosuisblazingfastwhenitcomestodrawing,buttherearemorethingsgoingon.Namely,weuseObjectPool#nearbyquiteoftentoloopthroughthousandsofobjects60timespersecondtomeasuredistancesamongthem.Thisslowseverythingdownwhenobjectpoolgrows.
Todemonstratetheeffect,wewillgenerate1500trees,30tanks,~100boxesandleave1000damagetrailsfromexplosions.ItwasenoughtodropFPSbelow30:
Runningslowwiththousandsofgameobjects
SpatialPartitioningThereisasolutionforthisparticularproblemis“SpatialPartitioning”,andtheessenceofitisthatyouhavetouseatree-likedatastructurethatdividesspaceintoregions,placesobjectsthereandletsyouqueryitselfinlogarithmictime,omittingobjectsthatfalloutofqueryregion.SpatialPartitioningisexplainedwellinGameProgrammingPatterns.
Probablythemostappropriatedatastructureforour2Dgameisquadtree.ToquoteWikipedia,“quadtreesaremostoftenusedtopartitionatwo-dimensionalspacebyrecursivelysubdividingitintofourquadrantsorregions.”Hereishowitlookslike:
Visualrepresentationofquadtree
ImplementingAQuadtreeTherearesomeimplementationsofquadtreeavailableforRuby-rquad,rubyquadtreeandrubyquad,butitseemseasytoimplement,sowewillbuildonetailored(read:closelycoupled)toourgameusingthepseudocodefromWikipedia.
AxisAlignedBoundingBox
OneofprerequisitesofquadtreeisAxisalignedboundingbox,sometimesreferredtoas“AABB”.Itissimplyaboxthatsurroundstheshapebuthasedgesthatareinparallelwiththeaxesofunderlyingcoordinatesystem.Theadvantageofthisboxisthatitgivesaroughestimatewheretheshapeisandisveryefficientwhenitcomestoqueryingifapointisinsideoroutsideit.
Axisalignedboundingboxwithcenterpointandhalfdimension
Todefineaxisalignedboundingbox,weneedit’scenterpointandhalfdimensionvector,whichpointsfromcenterpointtooneofthecornersofthebox,andtwomethods,onethattellsifAABBcontainsapoint,andonethattellsifAABBintersectswithanotherAABB.Thisishowourimplementationlookslike:10-partitioning/misc/axis_aligned_bounding_box.rb
1classAxisAlignedBoundingBox
2attr_reader:center,:half_dimension
3definitialize(center,half_dimension)
4@center=center
5@half_dimension=half_dimension
6@dhx=(@half_dimension[0]-@center[0]).abs
7@dhy=(@half_dimension[1]-@center[1]).abs
8end
9
10defcontains?(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
16end
17
18defintersects?(other)
19ocx,ocy=other.center
20ohx,ohy=other.half_dimension
21odhx=(ohx-ocx).abs
22returnfalseunless(@center[0]+@dhx)>=(ocx-odhx)
23returnfalseunless(@center[0]-@dhx)<=(ocx+odhx)
24odhy=(ohy-ocy).abs
25returnfalseunless(@center[1]+@dhy)>=(ocy-odhy)
26returnfalseunless(@center[1]-@dhy)<=(ocy+odhy)
27true
28end
29
30defto_s
31"c:#{@center},h:#{@half_dimension}"
32end
33end
Ifyoudigin10-partitioning/specs,youwillfindtestsforthisimplementationtoo.
ThemathusedinAxisAlignedBoundingBox#contains?andAxisAlignedBoundingBox#intersects?isfairlysimpleandhopefullyveryfast,becausethesemethodswillbecalledbillionsoftimesthroughoutthegame.
QuadTreeForGameObjects
ToimplementthegloriousQuadTreeitself,weneedtoinitializeitwithboundary,thatisdefinedbyaninstanceofAxisAlignedBoundingBoxandprovidemethodsforinserting,removingandqueryingthetree.PrivateQuadTree#subdividemethodwillbecalledwhenwetrytoinsertanobjectintoatreethathasmoreobjectsthanit’sNODE_CAPACITY.10-partitioning/misc/quad_tree.rb
1classQuadTree
2NODE_CAPACITY=12
3attr_accessor:ne,:nw,:se,:sw,:objects
4
5definitialize(boundary)
6@boundary=boundary
7@objects=[]
8end
9
10definsert(game_object)
12game_object.location)
13
[email protected]<NODE_CAPACITY
15@objects<<game_object
16returntrue
17end
18
19subdivideunless@nw
20
[email protected](game_object)
[email protected](game_object)
[email protected](game_object)
[email protected](game_object)
25
26#shouldneverhappen
27raise"Failedtoinsert#{game_object}"
28end
29
30defremove(game_object)
32game_object.location)
[email protected](game_object)
34returntrue
35end
36returnfalseunless@nw
[email protected](game_object)
[email protected](game_object)
[email protected](game_object)
[email protected](game_object)
41false
42end
43
44defquery_range(range)
45result=[]
[email protected]?(range)
47returnresult
48end
49
51ifrange.contains?(o.location)
52result<<o
53end
54end
55
56#Notsubdivided
57returnresultunless@ne
58
[email protected]_range(range)
[email protected]_range(range)
[email protected]_range(range)
[email protected]_range(range)
63
64result
65end
66
67private
68
69defsubdivide
70cx,[email protected]
71hx,[email protected]_dimension
72hhx=(cx-hx).abs/2.0
73hhy=(cy-hy).abs/2.0
74@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]))
90end
91end
ThisisavanillaquadtreethatstoresinstancesofGameObjectandusesGameObject#locationforindexingobjectsinspace.Italsohasspecsthatyoucanfindincodesamples.
YoucanexperimentwithQuadTree#NODE_CAPACITY,butIfoundthatvaluesbetween8and16worksbest,soIsettledwith12.
IntegratingObjectPoolWithQuadTreeWehaveimplementedaQuadTree,butitisnotyetincorporatedintoourgame.Todothat,wewillhookitintoObjectPoolandtrytokeeptheoldinterfaceintact,sothatObjectPool#nearbywillstillworkasusual,butwillbeabletocopewithwaymoreobjectsthanbefore.10-partitioning/entities/object_pool.rb
1classObjectPool
2attr_accessor:map,:camera,:objects
3
4defsize
6end
7
8definitialize(box)
9@tree=QuadTree.new(box)
10@objects=[]
11end
12
13defadd(object)
14@objects<<object
[email protected](object)
16end
17
18deftree_remove(object)
[email protected](object)
20end
21
22deftree_insert(object)
[email protected](object)
24end
25
26defupdate_all
[email protected](&:update)
[email protected]!do|o|
29ifo.removable?
31true
32end
33end
34end
35
36defnearby(object,max_distance)
37cx,cy=object.location
38hx,hy=cx+max_distance,cy+max_distance
39#Fast,roughresults
[email protected]_range(
41AxisAlignedBoundingBox.new([cx,cy],[hx,hy]))
42#Siftthroughtoselectfine-grainedresults
43results.selectdo|o|
44o!=object&&
45Utils.distance_between(
46o.x,o.y,object.x,object.y)<=max_distance
47end
48end
49
50defquery_range(box)
[email protected]_range(box)
52end
53end
Anoldfashionedarrayofallobjectsisstillused,becausewestillneedtoloopthrougheverythingandinvokeGameObject#update.ObjectPool#query_rangewasintroducedtoquicklygrabobjectsthathavetoberenderedonscreen,andObjectPool#nearbynowqueriestreeandmeasuresdistancesonlyonroughresultset.
Thisishowwewillrenderthingsfromnowon:classPlayState<GameState
#...
defdraw
off_x=$window.width/2-cam_x
off_y=$window.height/2-cam_y
x1,x2,y1,y2=viewport
box=AxisAlignedBoundingBox.new(
[x1+(x2-x1)/2,y1+(y2-y1)/2],
[x1-Map::TILE_SIZE,y1-Map::TILE_SIZE])
$window.translate(off_x,off_y)do
$window.scale(zoom,zoom,cam_x,cam_y)do
@map.draw(viewport)
@object_pool.query_range(box).mapdo|o|
o.draw(viewport)
end
end
end
@camera.draw_crosshair
@radar.draw
end
#...
end
MovingObjectsInQuadTreeThereisonemoreerrandwenowhavetotakecareof.Everythingworksfinewhenthingsarestatic,butwhentanksandbulletsmove,weneedtoupdatetheminourQuadTree.That’swhyObjectPoolhastree_removeandtree_insert,whicharecalledfromGameObject#move.Fromnowon,theonlywaytochangeobject’slocationwillbebyusingGameObject#move:classGameObject
attr_reader:x,:y,:location,:components
definitialize(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
Atthispointwehavetogothroughallthegameobjectsandchangehowtheyinitializetheirbaseclassandupdatexandycoordinates,butwewon’tcoverthathere.Ifindoubt,refertocodeat10-partitioning.
Finally,FPSisbacktostable60andwecanfocusongameplayagain.
Spatialpartitioningsavestheday
ImplementingPowerups
Gamewouldbecomemorestrategiciftherewerewaystorepairyourdamagedtank,boostit’sspeedorincreaserateoffirebypickingupvariouspowerups.Thisshouldnotbetoodifficulttoimplement.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.EffectscanbyappliedbyharnessingGameObject#on_collision-whenTankcollideswithPowerup,itgetsit.11-powerups/entities/powerups/powerup.rb
1classPowerup<GameObject
2definitialize(object_pool,x,y)
3super
4PowerupGraphics.new(self,graphics)
5end
6
7defbox
8[x-8,y-8,
9x+8,y-8,
10x+8,y+8,
11x-8,y+8]
12end
13
14defon_collision(object)
15ifpickup(object)
16PowerupSounds.play(object,object_pool.camera)
17remove
18end
19end
20
21defpickup(object)
22#overrideandimplementapplication
23end
24
25defremove
26object_pool.powerup_respawn_queue.enqueue(
27respawn_delay,
28self.class,x,y)
29mark_for_removal
30end
31
32defrespawn_delay
3330
34end
35end
IgnorePowerup#remove,wewillgettoitwhenimplementingPowerupRespawnQueue.Therestshouldbestraightforward.
ImplementingPowerupGraphicsAllpowerupswillusethesamespritesheet,sotherecouldbeasinglePowerupGraphicsclassthatwillberenderinggivenspritetype.Wewillusegosu-texture-packergem,sincespritesheetisconvenientlypackedalready.11-powerups/entities/components/powerup_graphics.rb
1classPowerupGraphics<Component
2definitialize(object,type)
3super(object)
4@type=type
5end
6
7defdraw(viewport)
8image.draw(x-12,y-12,1)
9Utils.mark_corners(object.box)if$debug
10end
11
12private
13
14defimage
15@image||=images.frame("#{@type}.png")
16end
17
18defimages
19@@images||=Gosu::TexturePacker.load_json(
20$window,Utils.media_path('pickups.json'))
21end
22end
ImplementingPowerupSoundsIt’sevensimplerwithsounds.Allpowerupswillemitamellow“bleep”whenpickedup,soPowerupSoundscanbecompletelystatic,likeExplosionSoundsorBulletSounds:11-powerups/entities/components/powerup_sounds.rb
1classPowerupSounds
2class<<self
3defplay(object,camera)
4volume,pan=Utils.volume_and_pan(object,camera)
5sound.play(object.object_id,pan,volume)
6end
7
8private
9
10defsound
11@@sound||=StereoSample.new(
12$window,Utils.media_path('powerup.mp3'))
13end
14end
15end
ImplementingRepairDamagePowerupRepairingbrokentankisprobablythemostimportantpowerupofthemall,solet’simplementitfirst:11-powerups/entities/powerups/repair_powerup.rb
1classRepairPowerup<Powerup
2defpickup(object)
3ifobject.class==Tank
4ifobject.health.health<100
5object.health.restore
6end
7true
8end
9end
10
11defgraphics
12:repair
13end
14end
Thiswasincrediblysimple.Health#restorealreadyexistedsincewehadtorespawnourtanks.Wecanonlyhopeotherpowerupsareassimpletoimplementasthisone.
ImplementingHealthBoostRepairingdamageisgreat,buthowaboutboostingsomeextrahealthforupcomingbattles?Healthboosttotherescue:11-powerups/entities/powerups/health_powerup.rb
1classHealthPowerup<Powerup
2defpickup(object)
3ifobject.class==Tank
4object.health.increase(25)
5true
6end
7end
8
9defgraphics
10:life_up
11end
12end
ThistimewehavetoimplementHealth#increase,butitisprettysimple:classHealth<Component
#...
defincrease(amount)
@health=[@health+25,@initial_health*2].min
@health_updated=true
end
#...
end
SinceTankhas@initial_healthequalto100,increasinghealthwon’tgoover200,whichisexactlywhatwewant.
ImplementingFireRateBoostHowaboutboostingtank’sfirerate?11-powerups/entities/powerups/fire_rate_powerup.rb
1classFireRatePowerup<Powerup
2defpickup(object)
3ifobject.class==Tank
4ifobject.fire_rate_modifier<2
5object.fire_rate_modifier+=0.25
6end
7true
8end
9end
10
11defgraphics
12:straight_gun
13end
14end
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_death
object.reset_modifiers
#...
end
end
ImplementingTankSpeedBoostTankspeedboostisverysimilartofireratepowerup:11-powerups/entities/powerups/tank_speed_powerup.rb
1classTankSpeedPowerup<Powerup
2defpickup(object)
3ifobject.class==Tank
4ifobject.speed_modifier<1.5
5object.speed_modifier+=0.10
6end
7true
8end
9end
10
11defgraphics
12:wingman
13end
14end
Wehavetoadd@speed_modifiertoTankclassanduseitinTankPhysics#updatewhencalculatingmovementdistance.#11-powerups/entities/tank.rb
classTank<GameObject
#...
attr_accessor:speed_modifier
#...
defreset_modifiers
#...
@speed_modifier=1
end
#...
end
#11-powerups/entities/components/tank_physics.rb
classTankPhysics<Component
#...
defupdate
#...
new_x,new_y=x,y
speed=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
SpawningPowerupsOnMapPowerupsareimplemented,butnotyetspawned.Wewillspawn20-30randompowerupswhengeneratingthemap:classMap
#...
definitialize(object_pool)
#...
generate_powerups
end
#...
defgenerate_powerups
pups=0
target_pups=rand(20..30)
whilepups<target_pupsdo
x=rand(0..MAP_WIDTH*TILE_SIZE)
y=rand(0..MAP_HEIGHT*TILE_SIZE)
iftile_at(x,y)!=@water
random_powerup.new(@object_pool,x,y)
pups+=1
end
end
end
defrandom_powerup
[HealthPowerup,
RepairPowerup,
FireRatePowerup,
TankSpeedPowerup].sample
end
#...
end
Thecodeisverysimilartogeneratingboxes.It’sprobablynotthebestwaytodistributepowerupsonmap,butitwillhavetodofornow.
RespawningPowerupsAfterPickupWhenwepickupapowerup,wewantittoreappearinsamespot30secondslater.Athought“wecanstartanewThreadwithsleepandinitializethesamepowerupthere”soundsverybad,butIhaditforafewseconds.ThenPowerupRespawnQueuewasborn.
First,let’srecallhowPowerup#removemethodlookslike:classPowerup<GameObject
#...
defremove
object_pool.powerup_respawn_queue.enqueue(
respawn_delay,
self.class,x,y)
mark_for_removal
end
#...
end
Powerupenqueuesitselfforrespawnwhenpickedup,providingit’sclassandcoordinates.PowerupRespawnQueueholdsthisdataandrespawnspowerupsatrighttimewithhelpofObjectPool:11-powerups/entities/powerups/powerup_respawn_queue.rb
1classPowerupRespawnQueue
2RESPAWN_DELAY=1000
3definitialize
4@respawn_queue={}
5@last_respawn=Gosu.milliseconds
6end
7
8defenqueue(delay_seconds,type,x,y)
9respawn_at=Gosu.milliseconds+delay_seconds*1000
10@respawn_queue[respawn_at.to_i]=[type,x,y]
11end
12
13defrespawn(object_pool)
14now=Gosu.milliseconds
15returnifnow-@last_respawn<RESPAWN_DELAY
16@respawn_queue.keys.eachdo|k|
17nextifk>now#notyet
18type,x,y=@respawn_queue.delete(k)
19type.new(object_pool,x,y)
20end
21@last_respawn=now
22end
23end
PowerupRespawnQeueue#respawniscalledfromObjectPool#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.Nowwewanttodisplayactivepowerupmodifiers,soyouwouldknowwhatisyourfirerateandspeed,andifit’sworthgettingonemorepowerupbeforegoingintothenextfight.
DesignConsiderationsWhilecreatingourHUDclass,wewillhavetostartbuildinggamestats,becausewewanttodisplaynumberofkillsourtankmade.Statstopicwillbecoveredindepthlater,butfornowlet’[email protected],whichwewanttodrawintop-leftcornerofthescreen,alongwithplayerhealthandmodifiervalues.
HUDwillalsoberesponsiblefordrawingcrosshairandradar.
RenderingTextWithCustomFontPreviously,alltextwererenderedwithGosu.default_font_name,andwewantsomethingmorefancyandmorethematic,probablyadirtystencilbasedfontlikethisone:
ArmaliteRiflefont
Andonemorefancyfontwillmakeourgametitlelookgood.Toobadwedon’thaveatitleyet,but“TanksPrototype”writeninathematicwaystilllooksprettygood.
Tohaveconvenientaccesstothesefonts,wewilladdahelpermethodsinUtils:moduleUtils
#...
defself.title_font
media_path('top_secret.ttf')
end
defself.main_font
media_path('armalite_rifle.ttf')
end
#...
end
UseitinsteadofGosu.default_font_name:size=20
Gosu::Image.from_text($window,"Yourtext",Utils.main_font,size)
ImplementingHUDClassAfterwehaveputeverythingtogether,wewillgetHUDclass:12-stats/entities/hud.rb
1classHUD
2attr_accessor:active
3definitialize(object_pool,tank)
4@object_pool=object_pool
5@tank=tank
6@radar=Radar.new(@object_pool,tank)
7end
8
9defplayer=(tank)
10@tank=tank
[email protected]=tank
12end
13
14defupdate
16end
17
18defhealth_image
[email protected]?||@tank.health.health!=@health
21@health_image=Gosu::Image.from_text(
22$window,"Health:#{@health}",Utils.main_font,20)
23end
24@health_image
25end
26
27defstats_image
29if@stats_image.nil?||stats.changed_at<=Gosu.milliseconds
30@stats_image=Gosu::Image.from_text(
31$window,"Kills:#{stats.kills}",Utils.main_font,20)
32end
33@stats_image
34end
35
36deffire_rate_image
[email protected]_rate_modifier>1
38if@[email protected]_rate_modifier
39@[email protected]_rate_modifier
40@fire_rate_image=Gosu::Image.from_text(
41$window,"Firerate:#{@fire_rate.round(2)}X",
42Utils.main_font,20)
43end
44else
45@fire_rate_image=nil
46end
47@fire_rate_image
48end
49
50defspeed_image
[email protected]_modifier>1
52if@[email protected]_modifier
53@[email protected]_modifier
54@speed_image=Gosu::Image.from_text(
55$window,"Speed:#{@speed.round(2)}X",
56Utils.main_font,20)
57end
58else
59@speed_image=nil
60end
61@speed_image
62end
63
64defdraw
65if@active
66@object_pool.camera.draw_crosshair
67end
69offset=20
70health_image.draw(20,offset,1000)
71stats_image.draw(20,offset+=30,1000)
72iffire_rate_image
73fire_rate_image.draw(20,offset+=30,1000)
74end
75ifspeed_image
76speed_image.draw(20,offset+=30,1000)
77end
78end
79end
Touseit,weneedtohookintoPlayState:classPlayState<GameState
#...
definitialize
#...
@hud=HUD.new(@object_pool,@tank)
end
defupdate
#...
@hud.update
end
defdraw
#...
@hud.draw
end
#...
end
[email protected],youshouldgetaneatviewshowinginterestingthingsintop-leftcornerofthescreen:
ShinynewHUD
ImplementingGameStatistics
Gameslikeonewearebuildingareallaboutcompetition,andyoucannotcompeteifyoudon’tknowthescore.Letusintroduceaclassthatwillberesponsibleforkeepingtabsonvariousstatisticsofeverytank.12-stats/misc/stats.rb
1classStats
2attr_reader:name,:kills,:deaths,:shots,:changed_at
3definitialize(name)
4@name=name
5@kills=@deaths=@shots=@damage=@damage_dealt=0
6changed
7end
8
9defadd_kill(amount=1)
10@kills+=amount
11changed
12end
13
14defadd_death
15@deaths+=1
16changed
17end
18
19defadd_shot
20@shots+=1
21changed
22end
23
24defadd_damage(amount)
25@damage+=amount
26changed
27end
28
29defdamage
31end
32
33defadd_damage_dealt(amount)
34@damage_dealt+=amount
35changed
36end
37
38defdamage_dealt
39@damage_dealt.round
40end
41
42defto_s
43"[kills:#{@kills},"\
44"deaths:#{@deaths},"\
45"shots:#{@shots},"\
46"damage:#{damage},"\
47"damage_dealt:#{damage_dealt}]"
48end
49
50private
51
52defchanged
53@changed_at=Gosu.milliseconds
54end
55end
WhilebuildingtheHUD,weestablishedthatStatsshouldbelongtoTank#input,becauseitdefineswhoiscontrollingthetank.So,everyinstanceofPlayerInputandAiInputhastohaveit’sownStats:#12-stats/entities/components/player_input.rb
classPlayerInput<Component
#...
attr_reader:stats
definitialize(name,camera,object_pool)
#...
@stats=Stats.new(name)
end
#...
defon_damage(amount)
@stats.add_damage(amount)
end
#...
end
#12-stats/entities/components/ai_input.rb
classAiInput<Component
#...
attr_reader:stats
definitialize(name,object_pool)
#...
@stats=Stats.new(name)
end
defon_damage(amount)
#...
@stats.add_damage(amount)
end
end
ThatitchtoextractabaseclassfromPlayerInputandAiInputisgettingstronger,butwewillhavetoresisttheurge,fornow.
TrackingKills,DeathsandDamageTobegintrackingkills,weneedtoknowwhomdoeseverybulletbelongto.Bulletalreadyhassourceattribute,whichcontainsthetankthatfiredit,therewillbenotroubletofindoutwhowastheshooterwhenbulletgetsadirecthit.Buthowaboutexplosions?Bulletsthathitthegroundnearbyatankdealsindirectdamagefromtheexplosion.
Solutionissimple,weneedtopassthesourceoftheBullettotheExplosionwhenit’sbeinginitialized.classBullet<GameObject
#...
defexplode
Explosion.new(object_pool,@x,@y,@source)
#...
end
#...
end
MakingDamagePersonalNowthatwehavethesourceofeveryBulletandExplosiontheytrigger,wecanstartpassingthecauseofdamagetoHealth#inflict_damageandincrementingtheappropriatestats.#12-stats/entities/components/health.rb
classHealth<Component
#...
definflict_damage(amount,cause)
if@health>0
@health_updated=true
ifobject.respond_to?(:input)
object.input.stats.add_damage(amount)
#Don'tcountdamagetotreesandboxes
ifcause.respond_to?(:input)&&cause!=object
cause.input.stats.add_damage_dealt(amount)
end
end
@health=[@health-amount.to_i,0].max
after_death(cause)ifdead?
end
end
#...
end
#12-stats/entities/components/tank_health.rb
classTankHealth<Health
#...
defafter_death(cause)
#...
object.input.stats.add_death
kill=object!=cause?1:-1
cause.input.stats.add_kill(kill)
#...
end
#...
end
TrackingDamageFromChainReactionsThereisonemorewaytocausedamage.Whenyoushootatree,boxorbarrel,itexplodes,probablytriggeringachainreactionofexplosionsaroundit.Ifthoseexplosionskillsomebody,itwouldonlybefairtoaccountthatkillforthetankthattriggeredthischainreaction.
Tosolvethis,simplypassthecauseofdeathtotheExplosionthatgetstriggeredafterwards.#12-stats/entities/components/health.rb
classHealth<Component
#...
defafter_death(cause)
if@explodes
Thread.newdo
#...
Explosion.new(@object_pool,x,y,cause)
#...
end
#...
end
end
end
#12-stats/entities/components/tank_health.rb
classTankHealth<Health
#...
defafter_death(cause)
#...
Thread.newdo
#...
Explosion.new(@object_pool,x,y,cause)
end
end
end
Noweverybitofdamagegetsaccountedfor.
DisplayingGameScoreHavingallthedataisuselessunlesswedisplayitsomehow.Forthis,let’srethinkourgamestates.NowwehaveMenuStateandPlayState.Bothofthemcanswitchoneintoanother.WhatifweintroducedaPauseState,whichwouldfreezethegameanddisplaythelistofalltanksalongwiththeirkills.ThenMenuStatewouldswitchtoPlayState,andfromPlayStateyouwouldbeabletogettoPauseState.
Let’sbeginbyimplementingScoreDisplay,thatwouldprintasortedlistoftankkillsalongwiththeirnames.12-stats/entities/score_display.rb
1classScoreDisplay
2definitialize(object_pool)
3tanks=object_pool.objects.selectdo|o|
4o.class==Tank
5end
6stats=tanks.map(&:input).map(&:stats)
7stats.sort!do|stat1,stat2|
8stat2.kills<=>stat1.kills
9end
10create_stats_image(stats)
11end
12
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)
19end
20
21defdraw
22@stats_image.draw(
23$window.width/2-@stats_image.width/2,
24$window.height/4+30,
251000)
26end
27end
WewillhavetoinitializeScoreDisplayeverytimewhenwewanttoshowtheupdatedscore.TimetocreatethePauseStatethatwouldshowthescore.12-stats/game_states/pause_state.rb
1require'singleton'
2classPauseState<GameState
3includeSingleton
4attr_accessor:play_state
5
6definitialize
7@message=Gosu::Image.from_text(
8$window,"GamePaused",
9Utils.title_font,60)
10end
11
12defenter
13music.play(true)
14music.volume=1
15@score_display=ScoreDisplay.new(@play_state.object_pool)
16@mouse_coords=[$window.mouse_x,$window.mouse_y]
17end
18
19defleave
20music.volume=0
21music.stop
22$window.mouse_x,$window.mouse_y=@mouse_coords
23end
24
25defmusic
26@@music||=Gosu::Song.new(
27$window,Utils.media_path('menu_music.mp3'))
28end
29
30defdraw
31@play_state.draw
33$window.width/[email protected]/2,
34$window.height/[email protected],
351000)
36@score_display.draw
37end
38
39defbutton_down(id)
40$window.closeifid==Gosu::KbQ
41ifid==Gosu::KbC&&@play_state
42GameState.switch(@play_state)
43end
44ifid==Gosu::KbEscape
45GameState.switch(@play_state)
46end
47end
48end
YouwillnoticethatPauseStateinvokesPlayState#draw,butwithoutPlayState#updatethiswillbeastillimage.Wemakesurewehidethecrosshairandrestorepreviousmouselocationwhenresumingplaystate.Thatwayplayerwouldnotbeabletocheatbypausingthegame,targetingthetankwhilenothingmovesandthenunpausingreadytodealdamage.OurHUDhadattr_accessor:activeexactlyforthisreason,butweneedtoswitchitonandoffinPlayState#enterandPlayState#leave.classPlayState<GameState
#...
defbutton_down(id)
#...
ifid==Gosu::KbEscape
pause=PauseState.instance
pause.play_state=self
GameState.switch(pause)
end
#...
end
#...
defleave
StereoSample.stop_all
@hud.active=false
end
defenter
@hud.active=true
end
#...
end
Timeforatestdrive.
Pausingthegametoseethescore
Fornow,scoringmostkillsisrelativelysimple.ThisshouldchangewhenwewilltellenemyAItocollectpowerupswhenappropriate.
BuildingAdvancedAI
TheAIwehaverightnowcankicksomeass,butitistoodumbforanyseasonedgamertocompetewith.Thisisthelistofcurrentflaws:
1. Itdoesnotnavigatewell,getsstuckamongtreesorsomewherenearwater.2. Itisnotawareofpowerups.3. Itcoulddobetterjobatshooting.4. It’sfieldofvisionistoosmall,comparedtoplayer’s,whoisequippedwithradar.
Wewilltackletheseissuesincurrentchapter.
ImprovingTankNavigationTanksshouldn’tbehavelikeRoombas,randomlydrivingaroundandbumpingintothings.Theycouldbenavigatinglikethis:
1. ConsultwithcurrentAIstateandfindorupdatedestinationpoint.2. Ifdestinationhaschanged,calculateshortestpathtodestination.3. Movealongthecalculatedpath.4. Repeat.
Ifthislookseasy,letmeassureyou,itwouldprobablyrequirerewritingthemajorityofAIandMapcodewehaveatthispoint,anditisprettytrickytoimplementwithprocedurallygeneratedmaps,becausenormallyyouwoulduseamapeditortosetupwaypoints,navigationmeshorotherhintsforAIsoitdoesn’tgetstuck.Sometimesitisbettertohavesomethingworkingimperfectlyoveraperfectsolutionthatneverhappens,thuswewillusesimplethingsthatwillmakeasmuchimpactaspossiblewithoutrewritinghalfofthecode.
GeneratingFriendlierMaps
Oneofmainreasonswhytanksgetstuckisbadplacementofspawnpoints.Theydon’ttaketreesandboxesintoaccount,soenemytankcanspawninthemiddleofaforest,withnochanceofgettingoutwithoutblowingthingsup.AsimplefixwouldbetoconsultwithObjectPoolbeforeplacingaspawnpointonlywheretherearenoothergameobjectsaroundin,say,150pixelradius:classMap
#...
deffind_spawn_point
whiletrue
x=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]
end
end
end
#...
end
Howaboutpowerups?Theycanalsospawninthemiddleofaforest,andwhiletanksarenotseekingthemyet,wewillbeimplementingthisbehavior,andleadingtanksintowildernessoftreesisnotthebestideaever.Let’sfixittoo:classMap
#...
defgenerate_powerups
pups=0
target_pups=rand(20..30)
whilepups<target_pupsdo
x=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+=1
end
end
end
#...
end
Wecouldalsoreducetreecount,butthatwouldmakethemaplookworse,sowearegoingtokeepthisinourpocketasameanoflastresort.
ImplementingDemoStateToObserveAIProbablythebestwaytofigureoutifourAIisanygoodistotargetoneofAItankswithourgamecameraandseehowitplays.ItwillgiveusagreatvisualtestingtoolthatwillallowtweakingAIsettingsandseeingiftheyperformbetterorworse.ForthatwewillintroduceDemoStatewhereonlyAItankswillbepresentinthemap,andwewillbeabletoswitchcamerafromonetanktoanother.
DemoStateisverysimilartoPlayState,themaindifferenceisthatthereisnoplayer.Wewillextractcreate_tanksmethodthatwillbeoverriddeninDemoState.classPlayState<GameState
attr_accessor:update_interval,:object_pool,:tank
definitialize
#...
@camera=Camera.new
@object_pool.camera=@camera
create_tanks(4)
end
#...
private
defcreate_tanks(amount)
@map.spawn_points(amount*3)
@tank=Tank.new(@object_pool,
PlayerInput.new('Player',@camera,@object_pool))
amount.timesdo|i|
Tank.new(@object_pool,AiInput.new(
@names.random,@object_pool))
end
@camera.target=@tank
@hud=HUD.new(@object_pool,@tank)
end
#...
end
Wewillalsowanttodisplayasmallerversionofscoreintop-rightcornerofthescreen,solet’saddsomeadjustmentstoScoreDisplay:classScoreDisplay
definitialize(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)
end
end
AndhereistheextendedDemoState:13-advanced-ai/game_states/demo_state.rb
1classDemoState<PlayState
2attr_accessor:tank
3
4defenter
5#PreventreactivatingHUD
6end
7
8defupdate
9super
10@score_display=ScoreDisplay.new(
11object_pool,20)
12end
13
14defdraw
15super
16@score_display.draw_top_right
17end
18
19defbutton_down(id)
20super
21ifid==Gosu::KbSpace
24end.sample
25switch_to_tank(target_tank)
26end
27end
28
29private
30
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))
37end
39@hud=HUD.new(@object_pool,target_tank)
[email protected]=false
41switch_to_tank(target_tank)
42end
43
44defswitch_to_tank(tank)
[email protected]=tank
[email protected]=tank
47self.tank=tank
48end
49end
TohaveapossibilitytoenterDemoState,weneedtochangeMenuStatealittle:classMenuState<GameState
#...
defupdate
text="Q:Quit\nN:NewGame\nD:Demo"
#...
end
#...
defbutton_down(id)
#...
ifid==Gosu::KbD
@play_state=DemoState.new
GameState.switch(@play_state)
end
end
end
Now,mainmenuhastheoptiontoenterdemostate:
Overhauledmainmenu
ObservingAIindemostate
VisualAIDebuggingAfterwatchingAIbehaviorindemomodeforawhile,Iwasterrified.Whenplayinggamenormally,youusuallyseetanksin“fighting”state,whichworksprettywell,butwhentanksgoroaming,it’sacompletedisaster.Theygetstuckeasily,theydon’tgotoofarfromtheoriginallocation,theywaittoomuch.
Somethingscouldbeimprovedjustbychangingwait_time,turn_timeanddrive_timetodifferentvalues,butwecertainlyhavetodobiggerchangesthanthat.
Ontheotherhand,“observeAIinaction,tweak,repeat”cycleprovedtobeveryeffective,Iwilldefinitelyusethistechniqueinallmyfuturegames.
Tomakevisualdebuggingeasier,buildyourselfsometooling.Onewaytodoitistohaveglobal$debugvariablewhichyoucantogglebypressingsomebutton:classPlayState<GameState
#...
defbutton_down(id)
#...
ifid==Gosu::KbF1
$debug=!$debug
end
#...
end
#...
end
Thenaddextradrawinginstructionstoyourobjectsandtheircomponents.Forexample,thiswillmakeTankdisplayit’scurrentTankMotionStateimplementationclassbeneathit:classTankMotionFSM
#...
defset_state(state)
#...
if$debug
@image=Gosu::Image.from_text(
$window,state.class.to_s,
Gosu.default_font_name,18)
end
end
#...
defdraw(viewport)
if$debug
@image&&@image.draw(
@image.height,100)
end
end
#...
end
Tomarktank’sdesiredgunangleasbluelineandactualgunangleasredline,youcandothis:classAiGun
#...
defdraw(viewport)
if$debug
color=Gosu::Color::BLUE
x,[email protected],@object.y
t_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::RED
t_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)
end
end
#...
end
Finally,youcanautomaticallymarkcollisionboxcornersonyourgraphicscomponents.Let’stakeBoxGraphicsforexample:#13-advanced-ai/misc/utils.rb
moduleUtils
#...
defself.mark_corners(box)
i=0
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)%4
end
end
#...
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$debug
end
#...
end
Asadeveloper,youcanmakeyourselfseenearlyeverythingyouwant,makeuseofit.
VisualdebuggingofAIbehavior
Althoughithurtstheframeratealittle,itisveryusefulwhenbuildingnotonlyAI,buttherestofthegametoo.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. SomeTankMotionStateimplementationwouldadjusttankdirectiontowardsclosest
visiblepowerupinchange_directionmethod.
FindingPowerupsInSight
ToimplementchangesinAiVision,wewillintroduceclosest_powerupmethod.Itwillqueryobjectsinsightandfilterthemoutbytheirclassanddistance.classAiVision
#...
POWERUP_CACHE_TIMEOUT=50
#...
defclosest_powerup(*suitable)
now=Gosu.milliseconds
@closest_powerup=nil
ifnow-(@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.y
d1=Utils.distance_between(x,y,a.x,a.y)
d2=Utils.distance_between(x,y,b.x,b.y)
d1<=>d2
end.first
end
#...
end
ItisverysimilartoAiVision#closest_tank,andpartsshouldprobablybeextractedtokeepthecodedry,butwewillnotbother.
SeekingPowerupsWhileRoaming
Roamingiswhenmostpickingshouldhappen,becauseTankseesnoenemiesinsightandneedstoprepareforupcomingbattles.Let’sseehowcanweimplementthisbehaviorwhileleveragingthenewlymadeAiVision#closest_powerup:classTankRoamingState<TankMotionState
#...
defrequired_powerups
required=[]
[email protected]_rate_modifier<2&&health>50
required<<FireRatePowerup
end
[email protected]_modifier<1.5&&health>50
required<<TankSpeedPowerup
end
ifhealth<100
required<<RepairPowerup
end
ifhealth<190
required<<HealthPowerup
end
required
end
defchange_direction
[email protected]_powerup(
*required_powerups)
ifclosest_powerup
@seeking_powerup=true
angle=Utils.angle_between(
@object.x,@object.y,
closest_powerup.x,closest_powerup.y)
@object.physics.change_direction(
angle-angle%45)
else
@seeking_powerup=false
#...chooserandomdirection
end
@changed_direction_at=Gosu.milliseconds
@will_keep_direction_for=turn_time
end
#...
defturn_time
if@seeking_powerup
rand(100..300)
else
rand(1000..3000)
end
end
end
Itissimpleasthat,andourAItanksarenowgettingbuffedontheirsparetime.
SeekingHealthPowerupsAfterHeavyDamageToseekhealthwhendamaged,weneedtochangeTankFleeingState#change_direction:classTankFleeingState<TankMotionState
#...
defchange_direction
[email protected]_powerup(
RepairPowerup,HealthPowerup)
ifclosest_powerup
angle=Utils.angle_between(
@object.x,@object.y,
closest_powerup.x,closest_powerup.y)
@object.physics.change_direction(
angle-angle%45)
else
#...reversefromenemy
end
@changed_direction_at=Gosu.milliseconds
@will_keep_direction_for=turn_time
end
#...
end
ThissmallchangetellsAItopickuphealthwhilefleeing.TheinterestingpartisthatwhentankpicksupRepairPowerup,it’shealthgetsfullyrestoredandAIshouldswitchbacktoTankFightingState.ThissimplethingisamajorimprovementinAIbehavior.
EvadingCollisionsAndGettingUnstuckWhileobservingAInavigation,itwasnoticeablethattanksoftengotstuck,eveninsimplesituations,likedrivingintoatreeandhittingitrepeatedlyforadozenofseconds.Toreducethenumberofsuchoccasions,wewillintroduceTankNavigatingState,whichwouldhelpavoidcollisions,andTankStuckState,whichwouldberesponsiblefordrivingoutofdeadendsasquicklyaspossible.
Toimplementthesestates,weneedtohaveawaytotelliftankcangoforwardandawayofgettingadirectionwhichisnotblockedbyotherobjects.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_front,40,@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)
end
radius=55-i*5
range_x=range_y=[-radius,0,radius]
range_x.shuffle.eachdo|x|
range_y.shuffle.eachdo|y|
if@object_pool.map.can_move_to?(x,y)&&
@object_pool.nearby_point(x,y,radius,@viewer)
.reject{|o|o.is_a?Powerup}.empty?
ifaway_from
paths<<[x,y]
else
return[x,y]
end
end
end
end
end
false
end
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.first
end
#...
end
AiVision#can_go_forward?tellsiftankcanmoveahead,andAiVision#closest_free_pathfindsapointwheretankcanmovewithoutobstacles.YoucanalsocallAiVision#closest_free_path_away_fromandprovidecoordinatesyouaretryingtogetawayfrom.
Wewilluseclosest_free_pathmethodsinnewlyimplementedtankmotionstates,andcan_go_forward?inTankMotionFSM,tomakeadecisionwhentojumpintonavigatingorstuckstate.
Thosenewstatesarenothingfancy:13-advanced-ai/entities/components/ai/tank_navigating_state.rb
1classTankNavigatingState<TankMotionState
2definitialize(object,vision)
3@object=object
4@vision=vision
5end
6
7defupdate
8change_directionifshould_change_direction?
9drive
10end
11
12defchange_direction
[email protected]_free_path
14ifclosest_free_path
[email protected]_direction(
16Utils.angle_between(
[email protected],@object.y,*closest_free_path))
18end
19@changed_direction_at=Gosu.milliseconds
20@will_keep_direction_for=turn_time
21end
22
23defwait_time
24rand(10..100)
25end
26
27defdrive_time
28rand(1000..2000)
29end
30
31defturn_time
32rand(300..1000)
33end
34end
TankNavigatingStatesimplychoosesarandomfreepath,changesdirectiontoitandkeepsdriving.13-advanced-ai/entities/components/ai/tank_stuck_state.rb
1classTankNavigatingState<TankMotionState
2definitialize(object,vision)
3@object=object
4@vision=vision
5end
6
7defupdate
8change_directionifshould_change_direction?
9drive
10end
11
12defchange_direction
[email protected]_free_path
14ifclosest_free_path
[email protected]_direction(
16Utils.angle_between(
[email protected],@object.y,*closest_free_path))
18end
19@changed_direction_at=Gosu.milliseconds
20@will_keep_direction_for=turn_time
21end
22
23defwait_time
24rand(10..100)
25end
26
27defdrive_time
28rand(1000..2000)
29end
30
31defturn_time
32rand(300..1000)
33end
34end
TankStuckStateisnearlythesame,butitkeepsdrivingawayfrom@stuck_atpoint,whichissetbyTankMotionFSMupontransitiontothisstate.classTankMotionFSM
STATE_CHANGE_DELAY=500
LOCATION_CHECK_DELAY=5000
definitialize(object,vision,gun)
#...
@stuck_state=TankStuckState.new(object,vision,gun)
@navigating_state=TankNavigatingState.new(object,vision)
set_state(@roaming_state)
end
#...
defchoose_state
[email protected]_go_forward?
unless@current_state==@stuck_state
set_state(@navigating_state)
end
end
#Keepunstuckingitselfforawhile
change_delay=STATE_CHANGE_DELAY
if@current_state==@stuck_state
change_delay*=5
end
now=Gosu.milliseconds
returnunlessnow-@last_state_change>change_delay
if@last_location_update.nil?
@last_location_update=now
end
ifnow-@last_location_update>LOCATION_CHECK_DELAY
puts"checkinlocation"
unless@last_location.nil?||@current_state.waiting?
ifUtils.distance_between(*@last_location,*@object.location)<20
set_state(@stuck_state)
return
end
end
@last_location_update=now
end
#...
end
#...
end
Whatthisdoesisautomaticallychangestatetonavigatingwhentankisabouttohitanobstacle.Italsotrackstanklocation,andiftankhasn’tmoved20pixelsawayfromit’soriginaldirectionfor5seconds,itentersTankStuckState,whichdeliberatelytriestonavigateawayfromthestock_atspot.
AInavigationhasjustgotsignificantlybetter,anditdidn’ttakethatmanychanges.
WrappingItUp
Ourjourneyintotheworldofgamedevelopmenthascometoanend.Wehavelearnedenoughtoproducedaplayablegame,yetonlyscratchedthesurface.Writingthisbookwasaveryenlighteningexperience,andhopefullyreadingitinspiredorhelpedsomeonetogetastart.
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.
PackagingRubyGamesSucks
Unlessyouarereleasingyourgamefortechsavvyguyswhocangeminstallit,getreadytogothroughhell.ThereisnoniceandeasywaytocreateastandaloneexecutableapplicationfromRubycodethatinvolvesnativeextensions.Andyouwillgothroughhellonceforeveryoperatingsystemyouwanttopublishyourgamefor.
That’snoteverything.WanttousethelatestRubyversion?CheckifyoucanmakeapackageforitinyourtargetOSbeforeyoustartcoding.ThinkingofusingsomethingthatreliesonImageMagick?Toobad,youprobablywon’tbeabletopackagethegameintoanativestandaloneapp,atleastonOSX.Ifyouareplanningonreleasingthegame,packageearlyandpackageoften,foreveryOS,andcheckiftherewillbenoproblemswithnativeextensions.
PlanNetworkedMultiplayerEarly
Ifyouaregoingtobuildagame,don’tmakeamistakeofthinking“I’lljustmakeitmultiplayerlater”,startattheverybeginning.ThiswasalessonIlearnedthehardway.TherehadtobeachapterinthisbookaboutturningTanksintomultiplayer,butitdidn’thappen,becauseitwouldrequireamajorrewriteofthecode.
CreatingAWellPolishedGameRequiresExtraordinaryEffort
Hackinguparoughprototypeisextremelyfun.Yougettobuildanengine,wireeverythingtogether.Itdefinitelygivesasenseofachievement.Turningitintoagreatgame,however,isadifferentstory.Youcanspendhoursorevendaystweakinghowgamecontrolsworkandstillremainunsatisfied.Everytinydetailcanbepushedfurther.Preferqualityoverquantity,andrememberthatyouprobablycannotaffordbothandactuallyfinishitwithinnextcoupleofyears.
StartSmall,TakeBabySteps
Yourfirstfewgamesshouldbesmallexperiments,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!