tag:blogger.com,1999:blog-41256240261435712092024-03-05T06:37:17.815-08:00Creating a game one post at a timeUnknownnoreply@blogger.comBlogger21125tag:blogger.com,1999:blog-4125624026143571209.post-41696783492657102532013-05-16T19:59:00.003-07:002013-05-16T20:30:17.684-07:00User Interface: Menus with scene2dui and skins - Level 4The code from this post is available <a href="https://code.google.com/p/javagamexyz/source/browse/#svn%2Ftags%2F2013-05-16">here</a>.<br />
<br />
Now that we're done with the AI (actually, I hope <i>you're</i> not done, but at least you're on your own from here on out) It was time to return to human players. Before, they could move and attack, and that was about it, but the enemy AI could cast healing spells, shoot bow-and-arrows, etc! Kinda one sided, don't you think?<br />
<br />
The bulk of this update will cover a much more extensive menu system for controlling PCs. It turns out my <a href="http://javagamexyz.blogspot.com/2013/04/user-interface-menus-using-libgdx.html">old post</a> on menus has garnered a bit of attention (it even got a link on the <a href="http://code.google.com/p/libgdx/wiki/ExternalTutorials" target="_blank">official libgdx external tutorials page!</a>), which is actually kind of sad because the menus we produced there SUCKED! They had dull gray buttons, and that was about it.<br />
<br />
For comparison, here's what we ended with last time<br />
<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEguPPiGfLG2mjb84cUJwMuGAqURJeH7UZLJt8iS38STUX2yKeZIF9kOUgdg1vxGAJ4GNrTBNKylLp3Wt6GBVx8N9KZ7fJRypBMhxngmIFObXXfjFdm9rP1vfjmFOgDhKVJAh6pUNzURN00/s1600/menu.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="311" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEguPPiGfLG2mjb84cUJwMuGAqURJeH7UZLJt8iS38STUX2yKeZIF9kOUgdg1vxGAJ4GNrTBNKylLp3Wt6GBVx8N9KZ7fJRypBMhxngmIFObXXfjFdm9rP1vfjmFOgDhKVJAh6pUNzURN00/s400/menu.png" width="400" /></a></div>
<br />
Compared with what we have now:<br />
<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiQ6x7IltXHeKeXGuhvxEFeHTsh8yaoNbwE_VXqdAgfvN5J_u-hD4OPnmf7yGI6_7S9IeOKR9zzKAIw6C_lzBQgiQGNmCYUwE59751KqrTSZ_JB-t16DivpTQRWZfAOvWm0aD8PP7DSFDU/s1600/modern_menu.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="282" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiQ6x7IltXHeKeXGuhvxEFeHTsh8yaoNbwE_VXqdAgfvN5J_u-hD4OPnmf7yGI6_7S9IeOKR9zzKAIw6C_lzBQgiQGNmCYUwE59751KqrTSZ_JB-t16DivpTQRWZfAOvWm0aD8PP7DSFDU/s400/modern_menu.png" width="400" /></a></div>
<br />
In the after shot, notice the pleasantly textured backgrounds, crisp beveled edges, and cascading design. The first menu is for basic options, the second comes up if they click "Action", giving them the option to attack, or use abilities from either their "Knight" skills, or "Healer" skills, or to use an item, while at the same time graying out the original menu (and disabling it). The third menu has come up by clicking on "Healer", bringing up a list of known healer spells (yes, I know, they're all the same - I was busy enough as it was!) Also notice that the ability menu can be scrolled, though the scrollbars will gradually fade to completely transparent if they're not being used.<br />
<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiRzJ8PHoZxTdIDfafO61CIgfiSQoYD_8kl0XGb1qLHMupJObjtnIL-UTn2WscO7js0Yw5xOhwYM_BUm7hh37SqU9pEwRjTv1q4WVvmnN8VPrBFHxgT5C3FILfLTPaJ4WH7OLFJLMp9CmE/s1600/modern_menu_scrollbar.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="283" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiRzJ8PHoZxTdIDfafO61CIgfiSQoYD_8kl0XGb1qLHMupJObjtnIL-UTn2WscO7js0Yw5xOhwYM_BUm7hh37SqU9pEwRjTv1q4WVvmnN8VPrBFHxgT5C3FILfLTPaJ4WH7OLFJLMp9CmE/s400/modern_menu_scrollbar.png" width="400" /></a></div>
<br />
What you can't see is that at any time the player can hit "ESC" to close a particular menu and refocus on the previous one (if such a previous one exists). Furthermore, once you select an ability and target a cell with it, a dialog box appears asking you to confirm that you want to do it (perhaps someday it will give some statistics about the action).<br />
<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhmgT466Obe1ecO4nN3NEjxjrw3fpTKzj3rEL1epr_m0GmAD4vEAQvk0idMmLvc1fE7m4stGwqR3mp1n434QQPhePbXEhxVWrcVjZ9weB6wh9O3BDYRuFgtU_7BsSKO0Mbrq47-FexLdNI/s1600/modern_menu_dialog.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="283" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhmgT466Obe1ecO4nN3NEjxjrw3fpTKzj3rEL1epr_m0GmAD4vEAQvk0idMmLvc1fE7m4stGwqR3mp1n434QQPhePbXEhxVWrcVjZ9weB6wh9O3BDYRuFgtU_7BsSKO0Mbrq47-FexLdNI/s400/modern_menu_dialog.png" width="400" /></a></div>
<br />
Also, once an action has been taken, or if something costs too much MP, that menu item is grayed out and disabled.<br />
<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhXgR2nQmFp66nq6OInavTc1IXQIuOYs1ZInU1oLqYlP6Y4VTIM-xvsYYv5egT-CPZLGPWjboux7rzfXHrvy-kTU6LLu-SjCxUsSe4veuplQEnkAcu1YGanbpNJrd8dpvzxkurnlYxNg7U/s1600/modern_menu_gray.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="283" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhXgR2nQmFp66nq6OInavTc1IXQIuOYs1ZInU1oLqYlP6Y4VTIM-xvsYYv5egT-CPZLGPWjboux7rzfXHrvy-kTU6LLu-SjCxUsSe4veuplQEnkAcu1YGanbpNJrd8dpvzxkurnlYxNg7U/s400/modern_menu_gray.png" width="400" /></a></div>
<br />
Okay, to get from the hideous, disgusting "before" menu, to the lovely, refined "after" menu, we'll begin with a look at <a href="http://code.google.com/p/libgdx/wiki/Skin" target="_blank">skins</a>. The libgdx-users wiki has an <a href="http://code.google.com/p/libgdx-users/wiki/Skins" target="_blank">entry</a> on skins, which is unfortunately a little out of date. The big thing it gets wrong any more is the distinction between "resources" and "styles". A more up-to-date example is in the libgdx tests directory, <a href="http://libgdx.googlecode.com/svn/trunk/tests/gdx-tests-android/assets/data/uiskin.json">uiskin.json</a>. The first two things it defines are resources - a font and a bunch of colors - after which it defines a bunch of styles. But there's no header "distinction" between them - you just plop them all down. If you follow the pattern in that file, you'll do fine!<br />
<br />
Now there are really only 3 types of resources you can define - Fonts, Colors, and TintedDrawables (all of which you can read about at the official libgdx page for skins - the first link of the previous paragraph). Other than that, you define widget styles. But which widgets can you make styles for? Which ones do you need? What can you specify in the skin file? Where do the images for it all come from? How do you design those images?<br />
<br />
Let's start with what you <i>can</i> do. libgdx offers a bunch of widgets in the <a href="http://code.google.com/p/libgdx/wiki/scene2dui#Widgets">scene2dui</a> package, each of which can be customized a little bit. Some of them have subclasses called _____Style. So for Label, you also need a LabelStyle, for a Button you have ButtonStyle, etc. Not everything has a style - for instance there is no TableStyle. And the closest thing Dialog has to a style is WindowStyle (Dialog extends Window). All of the _____Style's can be described in the skin, and that's it. In my last post on this, we instantiated all those styles manually, which takes a lot of time and space. Skins let us describe them all once, and then if we want a particular LabelStyle we can just call<br />
<pre class="brush:java">skin.get("particular_label_style_I_want", LabelStyle.class);
</pre>
We can define any number of LabelStyles in the Skin, and they are each referenced by a string we give it. If you don't pass skin.get() a String value, it will assume the String you mean is "default". To see what LabelStyle properties you can specify in the Skin, let's pull up Eclipse (or your favorite code completing software).<br />
<br />
First instantiate a new LabelStyle variable:<br />
<pre class="brush:java">LabelStyle ls = new LabelStyle()</pre>
On the next line, just start with<br />
<pre class="brush:java">ls.</pre>
and see what comes up. 3 things jump out at me: a Drawable called "background", a BitmapFont called "font", and a Color called "fontColor". These are the things you can specify in the skin. Say we wanted to make two types of Labels - one with black text and one with gray text, with a background image called "bg.png" and font "myFont.fnt". Here's what such a skin might look like:<br />
<pre class="brush:java">{
com.badlogic.gdx.graphics.g2d.BitmapFont: {
default-font: { file: myFont.fnt }
},
com.badlogic.gdx.graphics.Color: {
black: { a: 1, b: 0, g: 0, r: 0 },
gray: { a: 1, b: 0.5, g: 0.5, r: 0.5 }
},
com.badlogic.gdx.scenes.scene2d.ui.Label$LabelStyle: {
default: { background: bg, font: default-font, fontColor: black },
otherone: { background: bg,
font: default-font,
fontColor: gray }
}
}
</pre>
Now I have two LabelStyles, one referenced by "default", the other referenced by "otherone". If I want to instantiate a Label will this style, I can just say any of the following:<br />
<pre class="brush:java">Label l1 = new Label("H", skin);
Label l2 = new Label("H", skin, "default");
Label l3 = new Label("H", skin.get(LabelStyle.class));
Label l4 = new Label("H", skin.get("default",LabelStyle.class));
Label l5 = new Label("H", skin, "otherone");
Label l6 = new Label("H", skin.get("otherone",LabelStyle.class));
</pre>
All the labels here just have the text "H". Labels 1-4 will all look the same - in fact all those constructors will ultimately call the constructor used in l4. Labels 5-6 will have gray text, but otherwise be the same. The <b>skin.get()</b> command actually returns a _____Style (of whatever type is given in the 2nd argument).<br />
<br />
Well, this is almost good, but how does the skin file know where bg.png is? Especially because it only calls it "bg" (without the .png extension). And what about the font?<br />
<br />
For all the images, we'll use the TexturePacker2 class. A long time ago we made a class called <a href="http://javagamexyz.blogspot.com/2013/02/spaceship-warrior-pt-4-level-1.html">ImagePacker</a> which would be responsible for running libgdx's TexturePacker2. Let's say in the GameXYZ-desktop project I have a folder called "textures", with a subfolder called "uiskin" in which I place all the images associated with the skin. In our case, that's just bg.png, but in most cases you'll have a lot of things. Then we call<br />
<pre class="brush:java">TexturePacker2.process(settings, "textures/uiskin", "resources/uiskin", "uiskin");
</pre>
which grabs everything in "textures/uiskin" and packs it into an atlas in "resources/uiskin", called "uiskin.png" and "uiskin.atlas".<br />
<br />
Remember, this is handy for now, but in your final delivered product you don't want to run the ImagePacker to make the atlas everytime they run the program - just deliver it with the atlas already made.<br />
<br />
In that same "resources/uiskin" directory, add the <i>myFont.png</i> and <i>myFont.fnt</i> files (made by the Hiero tool we talked about last time), as well as our <i>uiskin.json</i> file. Then, when we instantiate the Skin, call<br />
<pre class="brush:java">FileHandle skinFile = new FileHandle("resources/uiskin/uiskin.json");
Skin skin = new Skin(skinFile);
</pre>
Then, because the atlas is also called "uiskin", our Skin will instantly know how to find "bg.png" when you just say "bg". And because our .fnt file is also in there, if we say "file: myFont.fnt" - once again our skin can find it!<br />
<br />
Now let's think about what widgets we need for our menu system, and how to configure them in our Skin. Like before, our general "Menu" will consist of a ScrollPane which scrolls a Table, which has a bunch of Buttons, where each Button has a Label in it (I opted not to use TextButton for this because TextButton aligns the Label in its center, whereas I wanted it lined up along the left - and try as I might I couldn't force TextButton to line it all up like that). So we might need Styles for each of them. Let's look at what we can do for each - again using Eclipse's autocomplete to help us out:<br />
ScrollPane:<br />
<ul>
<li>Drawable background</li>
<li>Drawable corner</li>
<li>Drawable hScroll</li>
<li>Drawable hScrollKnob</li>
<li>Drawable vScroll</li>
<li>Drawable vScrollKnob</li>
</ul>
Button:<br />
<ul>
<li>Drawable checked</li>
<li>Drawable checkedOver</li>
<li>Drawable disabled</li>
<li>Drawable down</li>
<li>Drawable over</li>
<li>float pressedOffsetX</li>
<li>float pressedOffsetY</li>
<li>float unpressedOffsetX</li>
<li>float unpressedOffsetY</li>
<li>Drawable up </li>
</ul>
Label:<br />
<ul>
<li>Drawable background</li>
<li>BitmapFont font</li>
<li>Color fontColor</li>
</ul>
First, the ScrollPane. If we set the background using the ScrollPane, it doesn't actually scroll with the contents - the table/buttons/labels will all scroll over the background, but I want the background following it. I also don't want the user scrolling horizontally, so we can skip all that crap. That pretty much leaves us with vScroll (what the full vertical scrollbar will look like) and vScrollKnob (what the movable piece in the middle of the bar looks like). I just made some tiny little images for these:<br />
<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEio8NgKfCFvddzgW9vidFVEJTTdGfJwcOzXl9C6ciFbEEdr_73Irp_eZoxm0zqn5UsVHzm9LenHr07oqcVOUENjYgiAsWBdv_5sg8vcb65VpaY_2zg47WYyIwcITdVNsutIhXcU1UxAQvM/s1600/scrollBar.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="18" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEio8NgKfCFvddzgW9vidFVEJTTdGfJwcOzXl9C6ciFbEEdr_73Irp_eZoxm0zqn5UsVHzm9LenHr07oqcVOUENjYgiAsWBdv_5sg8vcb65VpaY_2zg47WYyIwcITdVNsutIhXcU1UxAQvM/s200/scrollBar.png" width="200" /></a></div>
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiYezlYj5yHIIa4v2LXeGeBVYO5v3bs_mdRKqHzRBupQl20H_TMAFvGE5jyYQYBEPt5ydTsYdc6jWx-0Zn0UG4xdOK2H6q5G2tEEofb8ZqD0BL16gVPl2BkszpgW9Ymx-I_66zhK0MDhsA/s1600/scrollKnob.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="18" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiYezlYj5yHIIa4v2LXeGeBVYO5v3bs_mdRKqHzRBupQl20H_TMAFvGE5jyYQYBEPt5ydTsYdc6jWx-0Zn0UG4xdOK2H6q5G2tEEofb8ZqD0BL16gVPl2BkszpgW9Ymx-I_66zhK0MDhsA/s200/scrollKnob.png" width="200" /></a></div>
<br />
The width is the width that will actually be used, but the height will be stretched to whatever size it needs (these are <i>quite</i> scaled up - I mean the <i>actual</i> file width will be used). Now we can specify this in our uiskin.json file like so:<br />
<pre class="brush:java">{
com.badlogic.gdx.scenes.scene2d.ui.ScrollPane$ScrollPaneStyle: {
default: { vScroll: scrollBar, vScrollKnob: scrollKnob }
}
}
</pre>
I also don't want the Buttons to specify the background. It would be way too repeated if every button had the same background image. Maybe when they click on it, the button can be highlighted (we can even just reuse our scrollBar.png image for this!), but in general we don't want to do background here. I do however like the pressedOffsets, so the Button Skin will look like this:<br />
<pre class="brush:java">{
com.badlogic.gdx.scenes.scene2d.ui.Button$ButtonStyle: {
default: { pressedOffsetX: 1,
pressedOffsetY: -1,
down: scrollBar}
}
}
</pre>
The font I've been using is irisUPC. We want the default color to be black, and the inactive color to be gray, so for the Label we can say:<br />
<pre class="brush:java">{
com.badlogic.gdx.graphics.g2d.BitmapFont: { default-font: { file: irisUPC.fnt } },
com.badlogic.gdx.graphics.Color: {
black: { a: 1, b: 0, g: 0, r: 0 },
gray: { a: 1, b: 0.5, g: 0.5, r: 0.5 }
},
com.badlogic.gdx.scenes.scene2d.ui.Label$LabelStyle: {
default: { font: default-font, fontColor: black },
inactive: { font: default-font, fontColor: gray }
}
}
</pre>
Putting it all together, our uiskin.json file will now look like this:<br />
<pre class="brush:java">{
com.badlogic.gdx.graphics.g2d.BitmapFont: { default-font: { file: irisUPC.fnt } },
com.badlogic.gdx.graphics.Color: {
black: { a: 1, b: 0, g: 0, r: 0 },
gray: { a: 1, b: 0.5, g: 0.5, r: 0.5 }
},
com.badlogic.gdx.scenes.scene2d.ui.ScrollPane$ScrollPaneStyle: {
default: { vScroll: scrollBar, vScrollKnob: scrollKnob }
},
com.badlogic.gdx.scenes.scene2d.ui.Label$LabelStyle: {
default: { font: default-font, fontColor: black },
inactive: { font: default-font, fontColor: gray }
},
com.badlogic.gdx.scenes.scene2d.ui.Button$ButtonStyle: {
default: { pressedOffsetX: 1,
pressedOffsetY: -1,
down: scrollBar}
}
}
</pre>
The most trouble you'll probably have working with these is making sure all your brackets and commas line up right.<br />
<br />
Of course, this is no good, we still need to have our background! None of these options was appealing for the background - really the only appealing option is the Table, and there is no TableStyle. For the menu texture, I used <a href="http://lodev.org/cgtutor/randomnoise.html">turbulent noise</a> and an <a href="http://introcs.cs.princeton.edu/java/31datatype/EdgeDetector.java.html">edge detector</a> to make a 128 x 128 repeatable Texture. (The code for this is included in the Google Code repository - check the <a href="https://code.google.com/p/javagamexyz/source/browse/#svn%2Ftags%2F2013-05-16%2FGameXYZ%2Fsrc%2Fcom%2Fblogspot%2Fjavagamexyz%2Fgamexyz%2Ftextures">"textures" package</a>).<br />
<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg8bW3gKh_j2mlR00aWcREMcgcOUDPrFIy-wse038QihBpgmfjlRTLWBmi9_Z1CxI0qPxRX_2DS_U7VRhyVhKQcfgionhibQbCaUscyKwRXiQIlhiu7ErWQyfyylZ1dSS36yM4xryGPbU0/s1600/menuTexture.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg8bW3gKh_j2mlR00aWcREMcgcOUDPrFIy-wse038QihBpgmfjlRTLWBmi9_Z1CxI0qPxRX_2DS_U7VRhyVhKQcfgionhibQbCaUscyKwRXiQIlhiu7ErWQyfyylZ1dSS36yM4xryGPbU0/s1600/menuTexture.png" /></a></div>
<br />
I put this image in my "textures/uiskin" directory to be packed with the rest, then in my Table I call<br />
<pre class="brush:java">table.setBackground(skin.getTiledDrawable("menuTexture"));
</pre>
TiledDrawable makes it repeat the background over and over to fill the Table. If the Table is 1.5 x's as wide as the Drawable, it will Draw it once, then another half (but it will stop there). HOWEVER, if your table is 1/2 the size as your Drawable, it will enlarge the Table to where it can draw the ENTIRE Drawable once. In this case you'll have to be careful to keep the ScrollPane from scrolling beyond the bounds of the table you wanted.<br />
<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjDapb0aFF1SOMkjZkW6JMu61uNuJc8AeFTQM_wKFHpHm1PY84AwFN-d5eypMX9j95pvxgZAak7ZR4EfkzNejxVHcnO7S_cCA5WOW71dYcPvdjJgjPhONR2y4C52RkEDzi_WGBqrWcKlD0/s1600/modern_menu_borderless.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="283" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjDapb0aFF1SOMkjZkW6JMu61uNuJc8AeFTQM_wKFHpHm1PY84AwFN-d5eypMX9j95pvxgZAak7ZR4EfkzNejxVHcnO7S_cCA5WOW71dYcPvdjJgjPhONR2y4C52RkEDzi_WGBqrWcKlD0/s400/modern_menu_borderless.png" width="400" /></a></div>
<br />
Unfortunately, this menu ends very abruptly. It has no edge or frame.<br />
<br />
This makes me think of the <a href="http://code.google.com/p/libgdx/wiki/GraphicsNinePatch">NinePatch</a>, which is an image where part of it (a frame) does not stretch as you resize it, but the center part (the main background) does stretch. We don't want our inner part to stretch - we want it to tile like it's doing here, and <a href="http://stackoverflow.com/questions/7016309/creating-a-9-patch-with-repeatining-pattern">unfortunately the NinePatch can't do that</a>. My workaround is kind of hackish - but who cares. It works!<br />
<br />
I created a class called FramedMenu which has a ScrollPane, Table, and Image. The ScrollPane and Table are constructed like we've talked about, and the Image holds a NinePatch for the frame. Based on the size of the ScrollPane, I will stretch the frame to cover it. I chose to draw the Frame on top of the ScrollPane, and give the Frame a solid back border, a whitish/semi-transparent edge along the inside, and a blackish/semi-transparent shadow on the outside. Here's my frame image:<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjwFfbcgSAOq22Bg8P9n4bHn9fk1hNvWliMibAOi6Lnkv5A9ePw4q-UAYkmBxQ1tvlJKAEV4Fp8m6HdiGNhT_ZktPMaHuVhi_ymvU9mLUg3rysJPlQ1QVfzg8bkvF3rDrjMd43fnsiCru4/s1600/frame.9.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjwFfbcgSAOq22Bg8P9n4bHn9fk1hNvWliMibAOi6Lnkv5A9ePw4q-UAYkmBxQ1tvlJKAEV4Fp8m6HdiGNhT_ZktPMaHuVhi_ymvU9mLUg3rysJPlQ1QVfzg8bkvF3rDrjMd43fnsiCru4/s1600/frame.9.png" /></a></div>
The black pixels on the border indicate that the single transparent pixel within their "crosshairs" should be stretched. NinePatch images don't need to be very big for just this reason.<br />
<br />
Now FINALLY, let's look at some code. Here's my FramedMenu.java class:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.ui;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.InputListener;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.Touchable;
import com.badlogic.gdx.scenes.scene2d.ui.Button;
import com.badlogic.gdx.scenes.scene2d.ui.Button.ButtonStyle;
import com.badlogic.gdx.scenes.scene2d.ui.Image;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle;
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.Table;
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener;
public class FramedMenu {
private Image frame;
private ScrollPane scrollPane;
private Table table;
private Skin skin;
// max's represent the largest we want the menu getting
private float maxHeight, maxWidth;
// fontHeight and rows are used to estimate how tall our table is (frustratingly,
// table.getHeight() always gives me 0.0)
private float fontHeight;
private int rows;
// The parent menu is the one we will focus on if the user closes this one
private FramedMenu parent;
public FramedMenu(Skin skin, float preferredWidth, float preferredHeight) {
this(skin, preferredWidth, preferredHeight, null);
}
public FramedMenu(Skin skin, float maxWidth, float maxHeight, FramedMenu parent) {
this.skin = skin;
table = new Table();
this.maxHeight = maxHeight;
this.maxWidth = maxWidth;
fontHeight = skin.getFont("default-font").getCapHeight() + 14;
rows = 0;
this.parent = parent;
}
// Adds a button to the menu
public void addButton(String label, ChangeListener listener, boolean active) {
addButton(label, "", listener, active);
}
// Adds a button to the menu, with a secondary label (like MP cost) aligned to the right
public void addButton(String label, String secondaryLabel, ChangeListener listener, boolean active) {
LabelStyle style;
if (active) style = skin.get(LabelStyle.class);
else style = skin.get("inactive",LabelStyle.class);
Label l = new Label(label, style);
Button b = new Button(skin.get(ButtonStyle.class));
b.addListener(listener);
b.setDisabled(!active);
b.add(l).left().expandX();
b.add(new Label(secondaryLabel, style)).padRight(15f);
table.add(b).left().padLeft(5f).expandX().fillX();
table.row();
rows++;
}
// Adds the frame and scrollpane to the specified stage at the specified location.
// Sizes the scrollpane to the (estimated) table size, up to a maximum given
// in the constructor.
public void addToStage(final Stage stage, float x, float y) {
scrollPane = new ScrollPane(table, skin);
frame = new Image(skin.getPatch("frame"));
// If the user presses "ESC", close this menu and focus on the "parent"
stage.setKeyboardFocus(scrollPane);
scrollPane.addListener(new InputListener() {
public boolean keyDown(InputEvent event, int keycode) {
if (keycode == 131) { //escape
// If this menu is invisible, don't do anything
if (!frame.isVisible()) return false;
// If there is a parent, get rid of this
// menu and focus on it
if (parent != null) {
stage.setKeyboardFocus(parent.scrollPane);
parent.enable();
clear();
}
// Otherwise this must be the last one, so just clear it all
else {
stage.clear();
}
}
return true;
}
});
// Go ahead and add them to the stage
stage.addActor(scrollPane);
stage.addActor(frame);
// If the table does not fill our maximum size, resize it to our
// estimated height, and disable scrolling both x and y
if (rows*fontHeight < maxHeight) {
scrollPane.setScrollingDisabled(true, true);
scrollPane.setHeight(rows*fontHeight);
}
// Otherwise, it's bigger than our maximum size, so we need to
// enable vertical scrolling, and set the height to our max.
else {
scrollPane.setScrollingDisabled(true, false);
scrollPane.setHeight(maxHeight);
}
// For now, no matter what, the width is set to maxWidth
scrollPane.setWidth(maxWidth);
table.setBackground(skin.getTiledDrawable("menuTexture"));
// Move the table to the far left of the scrollPane
table.left();
// Prevent the scrollPane from scrolling (and snapping back) beyond the scroll limits
scrollPane.setOverscroll(false, false);
scrollPane.setFillParent(false);
// If y is negative, center the scrollPane vertically on the stage
if (y < 0) scrollPane.setY((stage.getHeight() - scrollPane.getHeight())/2f);
else scrollPane.setY(y - scrollPane.getHeight());
// If x is negative, do likewise
if (x < 0) scrollPane.setX((stage.getWidth() - scrollPane.getWidth())/2f);
else scrollPane.setX(x);
// Make sure we can't touch the frame - that would make the scrollPane
// inaccessible
frame.setTouchable(Touchable.disabled);
// Now set the Frame's position and size based on the scrollPane's stuff
frame.setX(scrollPane.getX()-1);
frame.setY(scrollPane.getY()-3);
frame.setWidth(maxWidth + 4);
frame.setHeight(scrollPane.getHeight() + 4);
// In case they became invisible earlier, make them visible now
scrollPane.setVisible(true);
frame.setVisible(true);
}
// Wipe all the buttons off, and remove widgets from stage (and reset row count)
public void clear() {
table.clear();
table.setColor(1f, 1f, 1f, 1);
if (scrollPane != null) scrollPane.remove();
if (frame != null) frame.remove();
rows = 0;
}
public float getY() {
return scrollPane.getY();
}
// Make it untouchable, and gray/transparent it out
public void disable() {
scrollPane.setTouchable(Touchable.disabled);
table.setColor(0.7f, 0.7f, 0.7f, 0.7f);
}
// Re-enable
public void enable() {
scrollPane.setTouchable(Touchable.enabled);
table.setColor(1,1,1,1);
}
// Make invisible or visible
public void setVisible(boolean visible) {
if (frame == null) return;
frame.setVisible(visible);
scrollPane.setVisible(visible);
}
// Let someone else know who your parent is - currently used in MenuBuilder
public FramedMenu getParent () {
return parent;
}
}
</pre>
Most of this stuff is self-explanatory, but let's talk about a few things. First, the menu gets a preferred height called maxHeight in the constructor. If I just made everything have this height, then tables with only a few buttons would have a lot of wasted space. To get around this, I want to use <b>table.getHeight()</b>, but unfortunately that seems broken (always gives me a 0). So instead I use rows (where I keep track of how many rows the table has) and fontHeight to estimate how tall the table is - but note, this is not particularly accurate right now.<br />
<br />
Next, look at addButton() starting on line 57. This method is used to create a menu option that has a name (like "Group Heal") and a secondary label, like the MP cost or something. Now, Button actually extends Table, so we can use the Table alignment methods to layout our button. Line 68 adds the first label. <b>.left()</b> tells the label to move to the left of the button. <b>.expandX()</b> tells the cell that the label is in to take up the ENTIRE width of the button (or at least as much as it can take - because as we'll see there will be another label in a cell that gets a teency bit). Line 69 then adds the secondary label. <b>.padRight(15f)</b> tells it to have a 15 pixel border to the right of the label.<br />
<br />
Line 71 then adds the button to the table. <b>.left()</b> again puts it to the left, <b>.padLeft(5f)</b> gives it a small padding on the left though, <b>.expandX()</b> makes the table cell that the button is being placed in take up the entire width of the table, then <b>.fillX()</b> tells the button widget to fill its entire cell (i.e. the entire row width). This makes it so the button takes up the whole width of the table, so the user won't have to click directly on the text "Move" to move, but can click anywhere in that table row.<br />
<br />
Next let's look at Line 80: addToStage(). This is where we actually instantiate the ScrollPane and Image (I wanted to do it earlier, but I ran into the weirdest dang sizing problems, and finally just gave up!) Line 85 makes it so the ScrollPane will receive keyboard input. Someday users may be able to press arrow keys to navigate the ScrollPane, but for now I just let them hit "escape" to close out of that menu. I do this by adding a new InputListener() on line 86.<br />
<br />
Big thing to notice: the <a href="http://libgdx.badlogicgames.com/nightlies/docs/api/com/badlogic/gdx/scenes/scene2d/InputListener.html">InputListener stuff</a> will look a lot like the InputProcessor stuff we did before (touchUp, touchDown, dragged, mouseMoved, etc...) but it's actually all a little bit different. All in all it works <i>relatively</i> similarly though. Here, if they hit key 131 (escape) it closes the menu (unless the menu is invisible, which happens sometimes).<br />
<br />
Lines 114-124 are where we determine how tall to make the ScrollPane, based on the row/fontHeight stuff we talked about earlier. On 132 we make sure the table is pushed all the way to the left within the ScrollPane. 135-142 position the ScrollPane. It set it so that negative positions make it center within the stage.<br />
<br />
On 146 we make the Frame untouchable. Because the frame is drawn on top of the scrollPane, if it were touchable, it would steal all the touch events from the ScrollPane. The frames position and size are set so that it perfectly frames the scrollPane.<br />
<br />
Now for some helper methods. We can clear(), which clears the table and removes the scrollPane and frame from the stage. We can disable, which is what grays out the menus we don't want our user interacting with, or we can just make it invisible (which also makes it untouchable).<br />
<br />
Now, we know there are really only 3 menus we will be displaying for now: Move/Action/etc..., Attack/Item/etc... and Ability1/Ability2/etc... To handle these, I modified the CRAP out of our old MenuBuilder class.<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.ui;
import com.artemis.Entity;
import com.badlogic.gdx.files.FileHandle;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Button;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton;
import com.badlogic.gdx.scenes.scene2d.utils.Align;
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.abilities.Action2;
import com.blogspot.javagamexyz.gamexyz.components.Stats;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.InputManager;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.MenuProcessor;
public class MenuBuilder {
private InputManager inputManager;
private MenuProcessor menuProcessor;
private Stage stage;
private Skin skin;
private FramedMenu turnMenu;
private FramedMenu actionMenu;
private FramedMenu abilityMenu;
private FramedMenu statsMenu;
public MenuBuilder(InputManager inputManager, MenuProcessor menuProcessor, Stage stage) {
this.inputManager = inputManager;
this.menuProcessor = menuProcessor;
this.stage = stage;
FileHandle skinFile = new FileHandle("resources/uiskin/uiskin.json");
skin = new Skin(skinFile);
turnMenu = new FramedMenu(skin, 128, 128);
actionMenu = new FramedMenu(skin, 128, 128, turnMenu);
abilityMenu = new FramedMenu(skin, 230, 200, actionMenu);
statsMenu = new FramedMenu(skin, 128, 128);
}
public void buildTurnMenu(final Entity e) {
turnMenu.clear();
// Move button
ChangeListener moveListener = new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
menuProcessor.move();
}
};
turnMenu.addButton("Move", moveListener, inputManager.canMove());
// Action button
ChangeListener actionListener = new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
buildActionMenu(e, 30, turnMenu.getY());
}
};
turnMenu.addButton("Action", actionListener, inputManager.canAct());
// Wait button
ChangeListener waitListener = new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
menuProcessor.selectWait();
}
};
turnMenu.addButton("Wait", waitListener, true);
// Stats button
ChangeListener statListener = new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
float w = 200f;
float h = 100f;
float x = (stage.getWidth() - w)/2f;
float y = (stage.getHeight() - h)/2f;
buildDialog("", "Status is not yet implemented", x, y, w, h, new TextButton("OK",skin));
}
};
turnMenu.addButton("Status", statListener, true);
turnMenu.addToStage(stage, 30, stage.getHeight() - 30);
}
public void buildActionMenu(Entity e, float x, float y) {
actionMenu.clear();
actionMenu.getParent().disable();
// Stat based actions
final Stats stats = e.getComponent(Stats.class);
if (stats != null) {
// Attack
if (stats.regularAttack != null) {
ChangeListener attack = new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
menuProcessor.action(stats.regularAttack);
}
};
actionMenu.addButton(stats.regularAttack.name, attack, inputManager.canAct());
}
// Primary class
if (stats.primaryClass != null) {
ChangeListener primary = new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
buildAbilityMenu(stats.primaryClass.actions, stats.magic);
}
};
actionMenu.addButton(stats.primaryClass.name, primary, inputManager.canAct());
}
// Secondary class
if (stats.secondaryClass != null) {
ChangeListener secondary = new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
buildAbilityMenu(stats.secondaryClass.actions, stats.magic);
}
};
actionMenu.addButton(stats.secondaryClass.name, secondary, inputManager.canAct());
}
}
// Item
ChangeListener item = new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
float w = 200f;
float h = 100f;
float x = (stage.getWidth() - w)/2f;
float y = (stage.getHeight() - h)/2f;
buildDialog("", "Items have not yet been implemented", x, y, w, h, new TextButton("OK",skin));
}
};
actionMenu.addButton("Item", item, inputManager.canAct());
actionMenu.addToStage(stage, 30, turnMenu.getY()-5);
}
private void buildAbilityMenu(Array<Action2> actions, int mp) {
abilityMenu.clear();
abilityMenu.getParent().disable();
// Loop through all the actions
for (final Action2 action : actions) {
// If they click it, process that action
ChangeListener listener = new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
menuProcessor.action(action);
}
};
// If it has an MP cost, display it as a secondary label
// The button is active if the character has not already attacked,
// and if the MP cost is affordable
if (action.mpCost > 0) abilityMenu.addButton(action.name, ""+action.mpCost, listener, inputManager.canAct() && (action.mpCost < mp));
else abilityMenu.addButton(action.name, listener, inputManager.canAct() && (action.mpCost < mp));
}
abilityMenu.addToStage(stage, -1, -1);
}
public void buildStatsMenu(Entity e) {
statsMenu.clear();
ChangeListener listener = new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
float w = 200f;
float h = 100f;
float x = (stage.getWidth() - w)/2f;
float y = (stage.getHeight() - h)/2f;
buildDialog("", "Status is not yet implemented", x, y, w, h, new TextButton("OK",skin));
}
};
statsMenu.addButton("Status", listener, true);
statsMenu.addToStage(stage, 30, stage.getHeight()-30);
}
public void buildDialog(String title, String message, float x, float y, float width, float height, Button... buttons) {
FramedDialog fd = new FramedDialog(skin, title, message, width, height);
for (Button b : buttons) {
b.align(Align.center);
fd.addButton(b);
}
fd.addToStage(stage, x, y);
}
public TextButton getTextButton(String text, ChangeListener listener) {
TextButton button = new TextButton(text, skin);
if (listener != null) button.addListener(listener);
return button;
}
public void setMenusVisible(boolean visible) {
turnMenu.setVisible(visible);
actionMenu.setVisible(visible);
abilityMenu.setVisible(visible);
statsMenu.setVisible(visible);
}
}
</pre>
Let's go ahead and decompose what this says. First, it has FramedMenus for everything (plus one for stats, which we won't really talk about yet). In the constructor I actually initialize the Skin. Let's focus on the turnMenu for now.<br />
<br />
On line 45, the first thing I do is clear out the old turnMenu. If I don't do this, it will still have all the buttons it originally had, so when I add more, it will now just have duplicates. Lines 48-54 are where I build the "Move" button. The ChangeListener is called when the button is activated, and the resulting code is handled elsewhere in menuProcessor.move(), but suffice it to say, it opens up the movement range and lets the player move.<br />
<br />
Line 54 adds it to the menu using the <b>.addButton()</b> method from FramedMenu. It just goes through and does similar things for each of the other buttons. The "Action" button, though, has its code all right here. If you click it, it immediately builds the actionMenu at coordinates (30, turnMenu.getY()). The coordinates used for drawing the menus base it on the top left corner, and it actually gets drawn a few pixels down, so this command just puts the actionMenu right below the turnMenu.<br />
<br />
Line 87 actually adds the turnMenu to the stage.<br />
<br />
Let's look down at line 148 for the abilityMenu. At the start, we don't just clear the abilityMenu, we also disable the parent menu (the actionMenu). We likewise did this when we built the actionMenu (disabled the turnMenu). turnMenu doesn't have a parent, so we didn't worry about it there. Then we just loop over all the actions, and build buttons accordingly. If an action has an MP cost, we add a secondary label with that MP cost. If an action costs too much MP, or the user has already acted this turn (though, in this case, we <i>shouldn't</i> be here anyway) the button will be disabled (we pass "<b>false</b>" to the addButton method).<br />
<br />
The other "Framed" thing we make is a FramedDialog - a popup window with a row of buttons on the bottom. When a dialog pops up, it becomes the ONLY thing you can click on, so other menus are disabled (though not grayed out, but that's okay with me). Given a set of Buttons, lines 187-194 will build a FramedDialog, for which the code is here:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.ui;
import static com.badlogic.gdx.scenes.scene2d.actions.Actions.fadeOut;
import static com.badlogic.gdx.scenes.scene2d.actions.Actions.sequence;
import com.badlogic.gdx.math.Interpolation;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.Touchable;
import com.badlogic.gdx.scenes.scene2d.actions.Actions;
import com.badlogic.gdx.scenes.scene2d.ui.Button;
import com.badlogic.gdx.scenes.scene2d.ui.Dialog;
import com.badlogic.gdx.scenes.scene2d.ui.Image;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton;
import com.badlogic.gdx.scenes.scene2d.utils.Align;
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener;
public class FramedDialog {
private Dialog dialog;
private Image frame;
private Skin skin;
float width, height;
public FramedDialog(Skin skin, String title, String message, float width, float height) {
this.skin = skin;
this.width = width;
this.height = height;
dialog = new Dialog(title,skin);
dialog.setBackground(skin.getTiledDrawable("menuTexture"));
dialog.getContentTable().defaults().expandX().fillX();
dialog.getButtonTable().defaults().width(50).fillX();
Label label = new Label(message, skin);
label.setAlignment(Align.center);
label.setWrap(true);
dialog.text(label);
frame = new Image(skin.getPatch("frame"));
}
public void addButton(String text, ChangeListener changeListener) {
TextButton button = new TextButton(text, skin);
button.addListener(changeListener);
button.addListener(new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
frame.addAction(sequence(fadeOut(Dialog.fadeDuration, Interpolation.fade), Actions.removeActor()));
}
});
dialog.button(button);
}
public void addButton(Button button) {
button.addListener(new ChangeListener() {
@Override
public void changed(ChangeEvent event, Actor actor) {
frame.addAction(sequence(fadeOut(Dialog.fadeDuration, Interpolation.fade), Actions.removeActor()));
}
});
dialog.button(button);
}
public void addToStage(Stage stage, float x, float y) {
stage.addActor(dialog);
stage.addActor(frame);
dialog.setX(x);
dialog.setY(y);
dialog.setWidth(width);
dialog.setHeight(height);
frame.setX(x-1);
frame.setY(y-3);
frame.setWidth(width + 4);
frame.setHeight(height + 4);
frame.setTouchable(Touchable.disabled);
}
}
</pre>
It's similar in spirit to the menu (though quite a bit simpler!). A Dialog is a Window with a table for content up top, and a table for Buttons on the bottom. When any button is pressed, the Dialog will disappear (actually, it gradually fades out, so we had to match that with the frame). In our constructor, we tell the ContentTable's cells to, by default, fill the whole thing (expandX()), and have the widgets inside those cells also fill the whole thing (fillX()). Also by default, we want our Buttons to have a width of 50, so we set the ButtonTable default cell width to 50, and tell the buttons to fill their cells.<br />
<br />
We also tell our Label to be centrally aligned, and enable textWrap. When we add a Button, here I'm actually fine using TextButton (because I want the text centered anyway). We also add a ChangeListener to all buttons so that when they are pressed, the frame will fade out with the dialog. With that, we've got a FramedDialog.<br />
<br />
To integrate all this into the larger context of my game, I created a few new classes:<br />
<ul>
<li>InputManager</li>
<li>MenuProcesser</li>
<li>CharacterClassFactory </li>
<ul>
<li>Subclass - CharacterClass</li>
</ul>
</ul>
As well as modifying all the controllers, and adding a bit to the Stats component.<br />
<br />
In the interest of NOT making this update <i>too</i> much longer, and because the additions are fairly minor, we'll cover these next bits in a LOT less detail. All the code for these can be found at the Google Code repository <a href="https://code.google.com/p/javagamexyz/source/browse/#svn%2Ftags%2F2013-05-16">here</a>.<br />
<br />
First, I moved all input controls (like the controllers, multiplexer, etc...) away from OverworldScreen and put them in InputManager. I also got rid of the boolean <b>handleScreen</b> - I just handle it all the time. It doesn't cost too much for that wasted cycle when it's empty.<br />
<br />
I moved all the "selectedMove()" kind of stuff to MenuProcesser. They don't immediately call stage.clear() either - instead they just make the menu's invisible. This way, if someone is selecting where to attack, and they click out of the range, it doesn't just reset them to a blank screen, I can bring their old menus right back up.<br />
<br />
Most of the interplay between the controllers and the menus is based on trial-and-error what do I want it doing? For instance, everything the user tries to do gives them a "confirm" dialog box. I know it's kind of annoying - especially regarding "Move", but it works for now. When they choose an attack, and click on a cell to focus it on, not only does the confirm box come up, but also it highlights the cells which will be affected. When they choose "Move" and click on a cell to move to, a "Ghost" entity will show up there, showing them exactly where they'll actually move. You can glance through all the controllers to see how it was all done.<br />
<br />
I will look at CharacterClassFactory though.<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.abilities;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.ObjectMap;
public class CharacterClassFactory {
private static CharacterClass knight_instance, archer_instance, wizard_instance, healer_instance;
public static CharacterClass knight() {
if (knight_instance == null) {
knight_instance = new CharacterClass("Knight", "A badass sword user");
knight_instance.actions.add(ActionFactory.spinAttack("Spin Attack", 6, 90));
knight_instance.actions.add(ActionFactory.cure("First Aid", 2, 0, 0, 1));
knight_instance.actions.add(ActionFactory.cure("First Aid", 2, 0, 0, 1));
knight_instance.actions.add(ActionFactory.cure("First Aid", 2, 0, 0, 1));
knight_instance.actions.add(ActionFactory.cure("First Aid", 2, 0, 0, 1));
}
return knight_instance;
}
public static CharacterClass archer() {
if (archer_instance == null) {
archer_instance = new CharacterClass("Archer", "Shoots stuff");
archer_instance.actions.add(ActionFactory.physicalAttack("Long Range Shot", 6, 70, 5));
archer_instance.actions.add(ActionFactory.magicAttack("Fire Arrow", 4, 0, 90, 3, 2));
}
return archer_instance;
}
public static CharacterClass wizard() {
if (wizard_instance == null) {
wizard_instance = new CharacterClass("Wizard", "Casts spells");
wizard_instance.actions.add(ActionFactory.magicAttack("Magic Missle", 6, 3, 80, 4, 1));
wizard_instance.actions.add(ActionFactory.magicAttack("Fireball", 10, 8, 90, 4, 2));
wizard_instance.actions.add(ActionFactory.magicAttack("Explosion", 7, 5, 80, 4, 2));
}
return wizard_instance;
}
public static CharacterClass healer() {
if (healer_instance == null) {
healer_instance = new CharacterClass("Healer", "...heals?");
healer_instance.actions.add(ActionFactory.cure("Heal", 5, 5, 4, 1));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
healer_instance.actions.add(ActionFactory.cure("Group Heal", 5, 10, 4, 2));
}
return healer_instance;
}
public static class CharacterClass {
// To become this class, you need certain level proficiency in these other classes...
public ObjectMap<CharacterClass,Integer> requirements;
// Each class has a list of actions they can learn, a name, and a description
public Array<Action2> actions;
public String name;
public String description;
public CharacterClass(String name, String description) {
this.name = name;
this.description = description;
actions = new Array<Action2>();
requirements = new ObjectMap<CharacterClass, Integer>();
}
}
}
</pre>
We really only ever need a single instance of a CharacterClass, so we make them all static. The Factory just returns references to those static instances. If the instance is null, create it, otherwise just return the already created one. CharacterClass has an ObjectMap which can someday hold requirements - for instance, maybe you must be a level 3 knight, level 5 archer, and level 5 thief to become an Assassin. It also has an array of Action2s that can be learned by that class (for now, they just instantly know them all). You can see I gave Healers a LOT of the same skill, over and over again (just to test the ScrollPane design).<br />
<br />
Lastly, in Stats, people now have a CharacterClass primaryClass and CharacterClass secondaryClass, as well as an Action2 regularAttack. In the future, I'd like to make regularAttack be obtained from the weapon currently equipped.<br />
<br />
Alright! Woo-hoo! This can actually be played now, almost like a real game. Add some enemies with AI, a few of your own people, make some new abilities and it works alright. Notice a few things though:<br />
<ol>
<li>The "Confirm" dialog box always pops up right in the middle. This is annoying if it blocks the thing you're doing from visibility.</li>
<li>If you make the screen much larger, the menus look dinky and silly. For Android development that's not at all a problem, but for desktop it matters. People like their fullscreen.</li>
</ol>
These are issues that I don't care to deal with now - and maybe not ever in these tutorials. We'll see. I'm much more interested in creating an inventory/equipment system, a character leveling system, a few more classes (plus character management like changing a class to something else), and save/load ability. And probably some other stuff.<br />
<br />
But for now I think we're doing alright. Remember to check out the <a href="https://code.google.com/p/javagamexyz/source/browse/#svn%2Ftags%2F2013-05-16">code</a>!<br />
<br />
<span style="color: #bf9000;"><b>You have gained 300 XP. Progress to Level 5: 550/850</b></span>Unknownnoreply@blogger.com4tag:blogger.com,1999:blog-4125624026143571209.post-3465443164317791962013-05-05T21:53:00.000-07:002013-05-05T21:53:13.418-07:00Artificial Intelligence Part II (Level 4)In today's update we'll work on our AI to produce more complex behavior. For starters, entities will be placed on teams and <i>should not</i> attack their own teammates (unless a lot of enemies happen to be caught in the crossfire too), and if there are no enemies in the near proximity, they will pick an enemy based on who is nearby <b>and</b> a high value target.<br />
<br />
Furthermore, entities will come with customized abilities - including single range melee attacks (like a sword), long range single target attacks (like a bow or magic spell), long range multi-target attacks (like a bigger spell), or single/multi-target cure spells. Spells will cost MP (which is tentatively figured into their calculation of what to do).<br />
<br />
To see a glimpse of how it all looks, I tried to capture a video using <a href="http://camstudio.org/" target="_blank">CamStudio</a> and the <a href="http://www.xvid.org/" target="_blank">Xvid</a> mpeg-4 codec (supposedly a setup which yields smooth desktop videos) but it still came out pretty laggy. Just bare in mind the actual game usually gets 400-500 fps (obviously silly - I need to limit fps at some point) on my little laptop. Camstudio, apparently, does not. The code has also been uploaded to the Google Code repository <a href="https://code.google.com/p/javagamexyz/source/browse/#svn%2Ftags%2F2013-05-05" target="_blank">here</a>.<br />
<br />
<div class="separator" style="clear: both; text-align: center;">
<iframe allowfullscreen='allowfullscreen' webkitallowfullscreen='webkitallowfullscreen' mozallowfullscreen='mozallowfullscreen' width='320' height='266' src='https://www.youtube.com/embed/H1x9phg1A2I?feature=player_embedded' frameborder='0'></iframe></div>
Let's dive in!<br />
<br />
First, I needed to assign entities to teams. Artemis <a href="http://gamadu.com/artemis/manual.html#Managers" target="_blank">managers</a> seemed like a good place to start, and TeamManager sounded just right. It turned out that PlayerManager was closer to what I wanted, but really neither were perfect. I wanted something which knew not only which team an entity was on, but could also return a list of all the teams, so I made my own class (which was based mostly on the TeamManager code, but because it's philosophically more like the PlayerManager I called it PlayerManager2).<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz;
import java.util.HashMap;
import java.util.Map;
import com.artemis.Entity;
import com.artemis.Manager;
import com.artemis.utils.Bag;
import com.artemis.utils.ImmutableBag;
/**
* Designates player teams (e.g. Human, AI_Enemy, AI_Ally, etc...)
* Based off of Artemis: TeamManager.java by Arni Arent
*/
public class PlayerManager2 extends Manager {
private Map<String, Bag<Entity>> entitiesByPlayer;
private Map<Integer, String> playerByEntity;
private Bag<String> players;
public PlayerManager2() {
entitiesByPlayer = new HashMap<String, Bag<Entity>>();
playerByEntity = new HashMap<Integer, String>();
players = new Bag<String>();
}
public ImmutableBag<String> getPlayers() {
return players;
}
@Override
protected void initialize() {
}
public String getPlayer(int entityId) {
return playerByEntity.get(entityId);
}
public void setPlayer(Entity e, String player) {
removeFromPlayer(e);
playerByEntity.put(e.getId(), player);
Bag<Entity> entities = entitiesByPlayer.get(player);
if(entities == null) {
entities = new Bag<Entity>();
entitiesByPlayer.put(player, entities);
players.add(player);
}
entities.add(e);
}
public ImmutableBag<Entity> getEntities(String player) {
return entitiesByPlayer.get(player);
}
public void removeFromPlayer(Entity e) {
String player = playerByEntity.remove(e.getId());
if(player != null) {
Bag<Entity> entities = entitiesByPlayer.get(player);
if(entities != null) {
entities.remove(e);
}
}
}
@Override
public void deleted(Entity e) {
removeFromPlayer(e);
}
}
</pre>
<br />
The big thing I would note about managers is that you DEFINITELY need to remember to include the public void deleted(Entity e) method. I didn't have this at first (because it wasn't in TeamManager - which is really based off of having a PlayerManager anyway) and it caused big trouble.<br />
<br />
Teams (which are here referenced as Players) are denoted by Strings, so you could have a "Red" player, "Blue" player, "Human" player, etc. To assign an entity to a "Player", in EntityFactory just say:<br />
<pre class="brush:java">world.getManager(PlayerManager2.class).setPlayer(e, Players.Blue);
</pre>
<br />
I made a class which would just hold constant Player names in EntityFactory.<br />
<pre class="brush:java">public static class Players {
public static final String Human = "HUMAN_TEAM";
public static final String Computer = "COMPUTER_TEAM";
public static final String Blue = "BLUE_TEAM";
public static final String Red = "RED_TEAM";
public static final String Green = "GREEN_TEAM";
public static final String Yellow = "YELLOW_TEAM";
public static final String Purple = "PURPLE_TEAM";
public static final String Teal = "TEAL_TEAM";
}
</pre>
<br />
What team an entity is on should influence the scorePlan() method from last time, such that hitting an ally with a damage (or cure) attack decreases (or increases) the plan score, whereas they will do the opposite to enemies. The PlayerManager2 allows any number of players, so to make things simple a given entity will just have two lists: allies and enemies. Whether or not some of their enemies are also enemies with each other won't be considered here. Below we'll look at how to compute scores using team information.<br />
<br />
First things first, I want to come up with a measure to score how important a given entity is. On the surface, a good guess would be that entity's level, but consider that a high level enemy who is nowhere near your team members shouldn't really be considered that big a threat (at least not yet) <br />
<br />
To account for that, I "divide" an entity's level by its "average distance" from its enemies. To see how this works, imagine there are 4 teams: A, B, C, and D, and that somebody on team B is taking their turn now. They compile both a list of enemies (everyone from teams A, C, and D) - let's say there are 7 enemies - and a list of allies (everyone else from team B) - let's say there are 3 allies (including the active entity).<br />
<br />
From that we build a 3x7 2D array in which we store the distance from each enemy to each ally:<br />
<br />
<table border="1">
<tbody>
<tr><td></td><td>1</td><td>2</td><td>3</td><td>4</td><td>5</td><td>6</td><td>7</td></tr>
<tr><td>1</td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
<tr><td>2</td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
<tr><td>3</td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
</tbody></table>
<br />
This distance is <i>not</i> based on the actual mobility of anyone - instead it is raw distance, so 5 cells over deep water is indistinguishable from 5 cells of grassy plains.<br />
<br />
Actually, I don't just store the distance, I store (sort of) the "inverse" of the distance. For distances 0 through 4, the entry is just 1/1, for distances 5-9, the entry is 1/2, 10-14 = 1/3, 15-29=1/4, etc...<br />
<br />
Then we compute a weighted average of the "inverse distance", weighted by the level of the ally each distance is measured to. For instance, let's say that enemy 5 is level 2, and its distance from each ally is given below:<br />
<br />
8 cells from Ally 1 (level 3)<br />
12 cells from Ally 2 (level 2)<br />
5 cells from Ally 3 (level 2)<br />
<br />
The inverse distance entries would then be 1/2, 1/3, 1/2 respectively. So the weighted average inverse distances would be<br />
<br />
<i>(3 * 1/2) + (2 * 1/3) + (2 * 1/2) / (3 + 2 + 2) = 0.45</i><br />
<br />
Finally we will take this score and multiply it by Enemy 5's level, to get a final score of 0.45 * 2 = <b>0.9</b>.<br />
<br />
We do this calculation for all enemies, and likewise for all allies (weighted average inverse distance from all enemies). This score represents in some sense which enemies pose the greatest threat to your allies, and which allies pose the greatest threat to your enemies. Find the greatest enemy score, and the greatest ally score, and normalize all enemies and allies by division, and all enemies and allies are now on a scale from 0 to 1 in terms of importance.<br />
<br />
Actually, to make other things simpler, ally scores are made to be negative, so really their scores range from 0 (worst) to -1 (best). <br />
<br />
Here's the code that gets this all done. GroupAI.java:<br />
<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.AI;
import com.artemis.Entity;
import com.artemis.World;
import com.artemis.utils.Bag;
import com.artemis.utils.ImmutableBag;
import com.badlogic.gdx.utils.ObjectMap;
import com.blogspot.javagamexyz.gamexyz.PlayerManager2;
import com.blogspot.javagamexyz.gamexyz.components.Stats;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
public class GroupAI {
private PlayerManager2 playerManager;
public ObjectMap<Integer,Float> entityScores;
private float[][] invDistance;
private GameMap gameMap;
private Bag<Entity> allies;
private Bag<Entity> enemies;
private int[] allyLevels;
private int[] enemyLevels;
private int step;
public GroupAI(World world, GameMap gameMap) {
if (world == null) System.out.println("WTF");
playerManager = world.getManager(PlayerManager2.class);
entityScores = new ObjectMap<Integer,Float>();
this.gameMap = gameMap;
step = 0;
allies = new Bag<Entity>();
enemies = new Bag<Entity>();
}
public boolean processGroup(int entityId) {
String group = playerManager.getPlayer(entityId);
ImmutableBag<String> players = playerManager.getPlayers();
if (step == 0) {
// Load enemies and allies + levels
// Reset stuff from previous runs
allies.clear();
enemies.clear();
entityScores.clear();
// First load all the entities
for (int i = 0; i < players.size(); i++) {
// Load allies
if (players.get(i).compareTo(group) == 0) {
allies.addAll(playerManager.getEntities(players.get(i)));
}
// Load enemies
else {
enemies.addAll(playerManager.getEntities(players.get(i)));
}
}
// If there are no more enemies, this whole thing becomes kind of silly
if (enemies.size() == 0) {
for (int i = 0; i < allies.size(); i++) {
entityScores.put(allies.get(i).getId(), -1f);
}
return true;
}
// Next store their levels
allyLevels = new int[allies.size()];
enemyLevels = new int[enemies.size()];
for (int i = 0; i < allyLevels.length; i++) {
allyLevels[i] = allies.get(i).getComponent(Stats.class).level;
}
for (int i = 0; i < enemyLevels.length; i++) {
enemyLevels[i] = enemies.get(i).getComponent(Stats.class).level;
}
step++;
return false;
}
else if (step == 1) {
// Compute the distance from each enemy to each ally
invDistance = new float[enemies.size()][allies.size()];
Pair enemyPos, allyPos;
// Loop over all enemies and allies, get distance between them
for (int i = 0; i < enemies.size(); i++) {
enemyPos = gameMap.getCoordinatesFor(enemies.get(i).getId());
for (int j = 0; j < allies.size(); j++) {
allyPos = gameMap.getCoordinatesFor(allies.get(j).getId());
invDistance[i][j] = 1f / (1 + MapTools.distance(enemyPos.x, enemyPos.y, allyPos.x, allyPos.y) / 5);
}
}
step++;
return false;
}
else if (step == 2) {
// Compute the weighted scores
float sum;
float weightSum;
/*
* Compute a weighted average for the "threat" posed by
* each enemy (the average level your own allies, weighted
* by their distance from each particular enemy)
* For a given ally, the farther it is from the enemy, the less
* it contributes to the enemy's score.
*
* For 2 allies the same distance away, the higher level one
* is deemed to contribute more to the enemies threat.
*
* It may make sense to someday incorporate additional information,
* like a more injured ally also makes the enemy seem more dangerous.
*
* Also, it's possible to do this iteratively, i.e., say that the first
* estimate of everyone's importance is their level, then we run through
* this once to get a refined estimate of their importance, then run it
* again, etc... until it converges to something? Maybe, I'll consider
* this in more detail another time.
*/
for (int i = 0; i < enemies.size(); i++) {
sum = 0f;
weightSum = 0f;
for (int j = 0; j < allies.size(); j++) {
sum += invDistance[i][j] * allyLevels[j];
weightSum += allyLevels[j];
}
entityScores.put(enemies.get(i).getId(), enemyLevels[i] * sum / weightSum);
}
// Do the same for an allies. This effectively measures the threat that
// your enemy will detect from your allies. The ones your enemies want dead
// should be the ones you want to take care of!
for (int i = 0; i < allies.size(); i++) {
sum = 0f;
weightSum = 0f;
for (int j = 0; j < enemies.size(); j++) {
sum += invDistance[j][i] * enemyLevels[j];
weightSum += enemyLevels[j];
}
entityScores.put(allies.get(i).getId(), -1f*allyLevels[i] * sum / weightSum);
}
step++;
return false;
}
else {
// Normalize the scores - all enemies (and allies) on range from 0 to 1 (or -1)
float bestEnemy=0;
float bestAlly=0;
float score;
for (int x : entityScores.keys()) {
score = entityScores.get(x);
if (score > 0) { //Enemy
if (score > bestEnemy) bestEnemy = score;
}
else if (score < 0) { // Ally
if (score < bestAlly) bestAlly = score;
}
}
for (int x : entityScores.keys()) {
score = entityScores.get(x);
if (score > 0) entityScores.put(x, score/bestEnemy);
else entityScores.put(x, -1f*score/bestAlly);
}
step = 0;
return true;
}
}
public ImmutableBag<Integer> getEnemies(Entity e) {
String player = playerManager.getPlayer(e.getId());
ImmutableBag<String> players = playerManager.getPlayers();
Bag<Integer> enemies = new Bag<Integer>();
for (int i = 0; i < players.size(); i++) {
if (player.compareTo(players.get(i)) == 0) continue;
else {
ImmutableBag<Entity> enemyPlayer = playerManager.getEntities(players.get(i));
for (int j = 0; j < enemyPlayer.size(); j++) enemies.add(enemyPlayer.get(j).getId());
}
}
return enemies;
}
}
</pre>
<br />
The main stuff comes in .process() - you can see that once again we break it into steps, each of which does a particular part of the task:<br />
<ul>
<li>Step 0 - Load all enemy and ally levels into Arrays</li>
<li>Step 1 - Fill in the inverse distance matrix</li>
<li>Step 2 - Compute the weighted scores</li>
<li>Step 3 - Normalize all enemy and ally scores based on the best enemy, and best ally</li>
</ul>
We'll discuss the getEnemies() method later, but first let's see how we can incorporate these scores into AISystem. Previously, we split the AISystem into a series of steps - this GroupAI should count as another step which must be completed before moving on to deciding the plan. The plan we choose should somehow depend on the importance of various entities. To do this, we just make a new flag called groupAiDone, initialize it to false, and set it to true once the groupAI.process() has completed all its steps. This is easy to check, because groupAI.process() returns false, until it's done at which point it returns true.<br />
<pre class="brush:java">@Override
protected void process(Entity e) {
AI ai = AIm.get(e);
if (!ai.active) return;
if (!groupAiDone) {
groupAiDone = groupAI.processGroup(e.getId());
}
else if (!ai.planDone) decidePlan(e, ai);
else { ...
</pre>
<br />
Now we should use the entityScores to help us decide our plans, let's consider more carefully how scorePlan ought to work. In general, different actions ought to have their scores calculated differently - for instance healing actions vs damage actions vs status ailment actions, etc, and should separately consider the caster (for whom there might be an MP cost or something) and the targets. I offloaded this scoring to each individual Action2 using a ScoreCalculator (which we'll discuss later). For now, recall that allies have a negative "importance" score, and enemies have a positive score.<br />
<br />
To score the plan as a whole, we'll take the score from the ScoreCalculator and multiply it by the entityScore. Thus, beneficial actions ought to have a negative score (so that when you cast it on an ally, (negative) x (negative) = positive score), and attack actions ought to have a positive score.<br />
<pre class="brush:java">private int scorePlan(Entity e, Plan plan) {
Action2 action = plan.action;
// Get the stats of the entity doing this action
Stats source = sm.get(e);
// If the action costs too much, we won't even consider this plan
if (source.magic < action.mpCost) return 0;
// get the target field of this action
Array<Pair> field = action.fieldCalculator.getField(plan.actionTarget, action);
// Start a bag of stats of all the entities who will be targeted in this attack, and their IDs
Bag<Stats> targetBag = new Bag<Stats>();
Bag<Integer> targetIds = new Bag<Integer>();
int targetId;
// Loop over all cells in the field
for (Pair cell : field) {
targetId = gameMap.getEntityAt(cell.x, cell.y);
// If there is an entity at that cell, add it to the targetBag and targetIds bag
if (targetId > -1) {
Entity target = world.getEntity(targetId);
targetBag.add(sm.get(target));
targetIds.add(targetId);
plan.targetEntities.add(target);
}
}
// If there are no targets, return 0
if (targetBag.size() == 0) return 0;
// Calculate the score of this action for each target in the target bag, as well as the
// cost to the caster (attacker)
ImmutableBag<Float> scoreBag = action.scoreCalculator.calculateScore(source, targetBag, action);
// Null means that the action exceeded the casters MP, so this action gets a 0 score
if (scoreBag == null) return 0;
// scoreBag(0) is the cost to the caster, then multiplied by casters importance
float score = scoreBag.get(0) * groupAI.entityScores.get(e.getId());
// Add to that the scores*importance for all targets of the spell
for (int i = 1; i < scoreBag.size(); i++) {
score += scoreBag.get(i) * groupAI.entityScores.get(targetIds.get(i-1));
}
// Now we multiply by 100 and make it an int
plan.score = (int)(100*score);
// A negative score means something bad happened, so we don't really want to bother
// with this plan. Otherwise, if the score is positive, and we are acting first
// (which means we get to move later) we should get a bonus of 5 points (to indicate
// that there is some benefit to moving after we act)
if (!plan.moveFirst && plan.score > 0) plan.score+=5;
return (int)plan.score;
}
</pre>
<br />
The first thing I did was make sure the Action2's mpCost doesn't exceed the caster's MP. If it does, the plan gets a score of 0. Beyond that, I made the ScoreCalculator ask for everything up front - the caster's Stats and all the targets' Stats, so we had to make a Bag (I have typically been using the libgdx Array, but I just thought I'd go with the Artemis Bag this time) to hold all the targets' Stats. Along with this, I made a Bag of target IDs so we could multiply the the Action2 score by the groupAI entityScore.<br />
<br />
After we get the Action2 scores, we check to see if it's null (this would indicate that the action couldn't be done in this situation, for whatever reason). If it's good, ScoreCalculator should return a Bag where the first element is the cost to the caster, and each subsequent element is the score for each target (in order). We want to add these all up, multiplied by the entity's importance. I then multiply it by 100 and return.<br />
<br />
<b>Action2</b><br />
<br />
Let's take a closer look at the updated Action2 now. It had to be
extremely general so that different actions could do very different
things. Each action has an integer for mpCost, range, field,
baseProbability (0% to 100%), and strength. But in addition to that, they have special methods for process(), calculateScore(), calculateField(), and calculateRange(). The last 2 are useful for attacks that hit a series of cells in a straight line, or perhaps that can only hit targets 3-4 cells away, but not less.<br />
<br />
In Action2 I defined interfaces for each of those methods, and each action will have instances of them: <br />
<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.abilities;
import com.artemis.Entity;
import com.artemis.utils.Bag;
import com.artemis.utils.ImmutableBag;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.components.Damage;
import com.blogspot.javagamexyz.gamexyz.components.Stats;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
public class Action2 {
public int mpCost, range, field, baseProbability, strength;
public ActionProcessor actionProcessor;
public ScoreCalculator scoreCalculator;
public FieldCalculator fieldCalculator;
public RangeCalculator rangeCalculator;
public interface ActionProcessor {
public void process(Entity source, Array<Entity> targets, Action2 action);
}
public interface ScoreCalculator {
public ImmutableBag<Float> calculateScore(Stats source, ImmutableBag<Stats> target, Action2 action);
}
public interface FieldCalculator {
public Array<Pair> getField(Pair target, Action2 action);
}
public interface RangeCalculator {
public Array<Pair> getRange(Pair source, Action2 action);
}
}
</pre>
<br />
I also built two constructors for Action2: one of which requires that the instance variables be passed in:<br />
<br />
<pre class="brush:java">public Action2(int strength, int mpCost, int baseProbability, int range, int field,
ActionProcessor actionProcessor,
ScoreCalculator scoreCalculator,
FieldCalculator fieldCalculator,
RangeCalculator rangeCalculator) {
this.strength = strength;
this.baseProbability = baseProbability;
this.mpCost = mpCost;
this.range = range;
this.field = field;
this.actionProcessor = actionProcessor;
this.scoreCalculator = scoreCalculator;
this.fieldCalculator = fieldCalculator;
this.rangeCalculator = rangeCalculator;
}
</pre>
<br />
The other builds default interfaces based on mpCost, etc...<br />
<br />
<pre class="brush:java">public Action2(int strength, int mpCost, int baseProbability, int range, int field) {
this.mpCost = mpCost;
this.range = range;
this.field = field;
this.strength = strength;
this.baseProbability = baseProbability;
actionProcessor = new ActionProcessor() {
@Override
public void process(Entity sourceE, Array<Entity> targets, Action2 action) {
Stats source = sourceE.getComponent(Stats.class);
source.magic -= action.mpCost;
boolean hitOnce = false;
for (Entity targetE : targets) {
Stats target = targetE.getComponent(Stats.class);
int damage = 0;
int probability = action.baseProbability; // +source.agility() - target.agility() +blah blah blah...
if (MathUtils.random(100) < probability) { //HIT
if (!hitOnce) {
source.xp += 10;
hitOnce = true;
}
damage = (int)(MathUtils.random(0.8f,1.2f)*(action.strength + source.getStrength() - target.getHardiness()));
if (damage < 1) damage = 1;
}
targetE.addComponent(new Damage(damage));
targetE.changedInWorld();
}
}
};
scoreCalculator = new ScoreCalculator() {
@Override
public ImmutableBag<Float> calculateScore(Stats source, ImmutableBag<Stats> targets, Action2 action) {
// If we can't even cast it, then don't bother
int MP = source.magic;
if (action.mpCost > MP) return null;
Bag<Float> scoreBag = new Bag<Float>();
// Get the cost to the source
scoreBag.add(0.1f*(float)action.mpCost / (float)MP);
// Get the scores for each target
for (int i = 0; i < targets.size(); i++) {
Stats target = targets.get(i);
int HP = target.health;
int damage = (int)(MathUtils.random(0.8f,1.2f)*(action.strength + source.getStrength() - target.getHardiness()));
if (damage < 1) damage = 1;
scoreBag.add((float)action.baseProbability/100f * (float)Math.min(damage, HP) / (float)HP);
}
return scoreBag;
}
};
fieldCalculator = new FieldCalculator() {
@Override
public Array<Pair> getField(Pair target, Action2 action) {
Array<Pair> field = MapTools.getNeighbors(target.x, target.y, action.field-1);
field.add(target);
return field;
}
};
rangeCalculator = new RangeCalculator() {
@Override
public Array<Pair> getRange(Pair source, Action2 action) {
return MapTools.getNeighbors(source.x, source.y, action.range);
}
};
}
</pre>
<br />
The default ActionProcessor reduces the caster's MP by mpCost, then goes through each entity in the target list. If a random roll successfully hits them, the caster gets 10 xp (not that this does anything, and it only happens for the <i>1st</i> target hit - subsequent hits from the same attack don't do diddly), and the action calculates how much damage is dealt (minimum of 1) and adds a Damage component to the target. Since all the probabilities and stats have been taken into account, this new Damage component only carries how much damage was dealt, and is immediately delivered by the DamageSystem. If damage=0, the DamageSystem will interpret it as a miss, damage < 0 is interpreted as a cure. (Scroll down a bit to see the updated Damage and DamageSystem)<br />
<br />
The default ScoreCalculator first checks that it can be cast (i.e. that it has enough MP in this case, but there may later be other restrictions). The first score is equal to 1/10th of the fraction of remaining MP the action costs. Thus, if an entity has 20 MP and checks an action which costs 12 MP, 1/10th of 12/20 = 0.06, which is what we take the "cost" to be. Remember, that will be multiplied by the caster's "importance", which is negative, so ultimately it detracts from the plan's score.<br />
<br />
Beyond that, each target's score becomes the expected value of what percent of the target's remaining HP will be lost. Thus if a target has 20 HP, and will be dealt 13 damage with probability = 90%, the score is 0.90 * 13/20 = 0.9 * 0.65 = 0.585. If it would deal 100 damage, the score is not 0.9 * 100/20 - it really only gets the benefit of 0.9 * 20/20 = 0.9 * 1 = 0.9. For now, I'm just using baseProbability, but in reality it should be based also on the target's agility, etc...<br />
<br />
The default FieldCalculator does exactly what we used to expect - it gets all the neighbors out to the field-1, and adds the central tile as well.<br />
<br />
The default RangeCalcuator gets all cells a distance <i>range</i> away from the source, without including the source's own cell.<br />
<br />
<b>Aside: Updated Damage and DamageSystem</b><br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.components;
import com.artemis.Component;
public class Damage extends Component {
public int damage;
public Damage(int damage) {
this.damage = damage;
}
}
</pre>
<br />
<pre class="brush:java">public class DamageSystem extends EntityProcessingSystem {
@Mapper ComponentMapper<Damage> dm;
@Mapper ComponentMapper<Stats> sm;
@Mapper ComponentMapper<MapPosition> mpm;
private GameMap gameMap;
@SuppressWarnings("unchecked")
public DamageSystem(GameMap gameMap) {
super(Aspect.getAspectForAll(Damage.class, Stats.class));
this.gameMap = gameMap;
}
@Override
protected void process(Entity e) {
Damage damage = dm.get(e);
Stats stats = sm.get(e);
MapPosition position = mpm.getSafe(e); // Useful for displaying damage on screen
if (damage.damage > 0) { // Successful attack
// Update the target's health
stats.health -= damage.damage;
// Display a message
if (position != null) {
EntityFactory.createDamageLabel(world, ""+damage.damage, position.x, position.y).addToWorld();
}
}
else if (damage.damage < 0) { // cure
int cureAmt = MyMath.min(-1*damage.damage,stats.maxHealth-stats.health);
stats.health += cureAmt;
if (position != null) {
EntityFactory.createDamageLabel(world, "+"+cureAmt, position.x, position.y).addToWorld();
}
}
else { // Otherwise they missed
// Create a damage label of "MISS"
if (position != null) {
EntityFactory.createDamageLabel(world, "MISS", position.x, position.y).addToWorld();
}
}
// We've processed the damage, now it's done
e.removeComponent(damage);
e.changedInWorld();
}
...
}
</pre>
<br />
To quickly create an Action2, I made an ActionFactory.<br />
<br />
Right now it can make 3 types of actions - regular physical attacks, magic attacks (which cost MP, but are still based on the strength and hardiness stats because I was too lazy to make it better), and cure spells. I made general cure and spell calculators which can be referenced from the ActionFactory.<br />
<br />
Lastly, I tweaked decideMovement() which we use when we don't have an action (which implies that there are no interesting entity's around, so we need to hunt one down). Using a similar method to groupAI, it looks at all enemies and rates them on their attractiveness to pursue, then finds the shortest path to them, and goes as far along that path as possible.<br />
<br />
It bases its decision on who to chase on their proximity, their entityScore, and the number of other enemies within 6 cells of it. It doesn't count allies within 6 cells, just enemies (using the getEnemies() method from groupAI to tell the difference).<br />
<br />
<pre class="brush:java">/*
* Call this when you have no good plans in your personal range.
* Try to find an enemy to hunt down based on how far it is from you,
* how powerful it is, and how many other enemies are near to it.
*/
private Pair decideMovement(Entity e) {
Pair pos = gameMap.getCoordinatesFor(e.getId());
Movable movable = mm.get(e);
Array<Pair> reachableCells = gameMap.pathFinder.getReachableCells(pos.x, pos.y, movable);
ImmutableBag<Integer> enemies = groupAI.getEnemies(e);
if (enemies.size() == 0) return reachableCells.get(MathUtils.random(reachableCells.size-1));
// The best enemy you are considering chasing and its score
int targetEnemy = -1;
float bestScore = 0f;
// The current enemy you are checking out and its score
int id;
float score;
// How far away is the enemy? How many enemies are within a small radius of it?
int distance, count;
for (int i = 0; i < enemies.size(); i++) {
count = 1;
Pair target = gameMap.getCoordinatesFor(enemies.get(i));
distance = MapTools.distance(pos.x, pos.y, target.x, target.y);
for (Pair cell : MapTools.getNeighbors(target.x, target.y, 6)) {
id = gameMap.getEntityAt(cell.x, cell.y);
if (!enemies.contains(id)) continue;
count++;
}
score = groupAI.entityScores.get(enemies.get(i)) * count / (1 + distance / 5);
if (score > bestScore) {
bestScore = score;
targetEnemy = enemies.get(i);
}
}
if (targetEnemy > -1) {
Pair target = gameMap.getCoordinatesFor(targetEnemy);
Path path = gameMap.pathFinder.findPath(pos.x, pos.y, target.x, target.y, movable, true);
for (int i = 0; i < path.getLength(); i++) {
Step step = path.getStep(i);
Pair p = new Pair(step.getX(),step.getY());
if (reachableCells.contains(p, false)) return p;
}
}
return reachableCells.get(MathUtils.random(reachableCells.size-1));
}
</pre>
<br />
With this, enemies that are far away from one another may decide to pursue each other if nobody else is around.<br />
<br />
To finish up, and to give this a visual representation, I made a set of character graphics for Archer, Wizard, Fighter, and Healer.<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhw4Tmsl6ILVs5wFEFZFiiBbHLuSpxwxeplnqAycNzbDE-inMbc_2c3stnMDkushuDLoCxlXbc6f2XzlN8079kOrgn5nxKMy4zyfTt8Yn_4PCbg7QfaLjcNkhyphenhyphenMLpr3LffC50mTydkW7Hs/s1600/archer.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhw4Tmsl6ILVs5wFEFZFiiBbHLuSpxwxeplnqAycNzbDE-inMbc_2c3stnMDkushuDLoCxlXbc6f2XzlN8079kOrgn5nxKMy4zyfTt8Yn_4PCbg7QfaLjcNkhyphenhyphenMLpr3LffC50mTydkW7Hs/s1600/archer.png" /></a><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhgIuzkhjNESypTMRdmYIvp2sds9nlNKBFflkLcqhG5KVUW8oh-v90oa6ItxuUskiLmUH-4q7j9iiDLbLde0DlZDFjsTRmeqUhnhmOryK-4RY6OCjSJ73c5Ps4ZT-5MD4JJP0k3OwidyOo/s1600/wizard.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhgIuzkhjNESypTMRdmYIvp2sds9nlNKBFflkLcqhG5KVUW8oh-v90oa6ItxuUskiLmUH-4q7j9iiDLbLde0DlZDFjsTRmeqUhnhmOryK-4RY6OCjSJ73c5Ps4ZT-5MD4JJP0k3OwidyOo/s1600/wizard.png" /></a><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiqwqAUoXuym7LWQDaW8_XfELgfe2Sr5lS6pRBq60UU7CY0a0QwwWRVq_eTaqx3mFH87oJ9V6CwtGKFO1lsIrXM3oBWYAbWa8hfmmxBWfd_YwM9-ZvebPQIrP5vccU0u07iRXVuhREh_cc/s1600/fighter.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiqwqAUoXuym7LWQDaW8_XfELgfe2Sr5lS6pRBq60UU7CY0a0QwwWRVq_eTaqx3mFH87oJ9V6CwtGKFO1lsIrXM3oBWYAbWa8hfmmxBWfd_YwM9-ZvebPQIrP5vccU0u07iRXVuhREh_cc/s1600/fighter.png" /></a><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiPuX-Z033BYV-Z7xLj8BXRmhlOwfwF_3bQECpNXPNvQHJIeKk6IfWlSKPZQuy8aOlYb9otDLasYPWi-6ptG-k-1fejDnlTZikLes4BkmdhkNPo-9AAPe4QTdnvCaLp4w0Xx_DN2PZv7Pw/s1600/healer.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiPuX-Z033BYV-Z7xLj8BXRmhlOwfwF_3bQECpNXPNvQHJIeKk6IfWlSKPZQuy8aOlYb9otDLasYPWi-6ptG-k-1fejDnlTZikLes4BkmdhkNPo-9AAPe4QTdnvCaLp4w0Xx_DN2PZv7Pw/s1600/healer.png" /></a></div>
<br />
In EntityFactory I can now createRed, which creates a random NPC for the Red player, and createBlue which creates a random NPC for the blue team.<br />
<pre class="brush:java">public static Entity createBlue(World world, int x, int y, GameMap gameMap) {
Entity e = world.createEntity();
e.addComponent(new MapPosition(x,y));
gameMap.addEntity(e.getId(), x, y);
Sprite sprite = new Sprite("cylinder");
sprite.r = 0;
sprite.g = 0;
sprite.b = 0.4f;
sprite.a = 1f;
sprite.rotation = 0f;
sprite.scaleX = 1f;
sprite.scaleY = 1f;
e.addComponent(sprite);
e.addComponent(new Movable(10f, 0.14f));
Abilities abilities = new Abilities();
int abilityRoll = MathUtils.random(0,3);
if (abilityRoll == 0) {
sprite.name = "fighter";
abilities.actions.add(ActionFactory.physicalAttack(5, 85, 1));
}
else if (abilityRoll == 1) {
sprite.name = "archer";
abilities.actions.add(ActionFactory.physicalAttack(4, 70, 4));
abilities.actions.add(ActionFactory.physicalAttack(2, 80, 1));
}
else if (abilityRoll == 2) {
sprite.name = "wizard";
abilities.actions.add(ActionFactory.magicAttack(3, 4, 80, 3, 2));
abilities.actions.add(ActionFactory.magicAttack(4, 4, 90, 3, 1));
abilities.actions.add(ActionFactory.physicalAttack(2, 70, 1));
}
else {//if (abilityRoll == 3) {
sprite.name = "healer";
abilities.actions.add(ActionFactory.cure(4, 4, 3, 1));
abilities.actions.add(ActionFactory.cure(3, 6, 3, 2));
abilities.actions.add(ActionFactory.physicalAttack(1, 65, 1));
}
e.addComponent(abilities);
e.addComponent(new Stats());
e.addComponent(new AI());
world.getManager(PlayerManager2.class).setPlayer(e, Players.Blue);
return e;
}
public static Entity createRed(World world, int x, int y, GameMap gameMap) {
Entity e = world.createEntity();
e.addComponent(new MapPosition(x,y));
gameMap.addEntity(e.getId(), x, y);
Sprite sprite = new Sprite("cylinder");
sprite.r = 0.4f;
sprite.g = 0;
sprite.b = 0f;
sprite.a = 1f;
sprite.rotation = 0f;
sprite.scaleX = 1f;
sprite.scaleY = 1f;
e.addComponent(sprite);
e.addComponent(new Movable(10f, 0.14f));
Abilities abilities = new Abilities();
int abilityRoll = MathUtils.random(0,3);
if (abilityRoll == 0) {
sprite.name = "fighter";
abilities.actions.add(ActionFactory.physicalAttack(5, 85, 1));
}
else if (abilityRoll == 1) {
sprite.name = "archer";
abilities.actions.add(ActionFactory.physicalAttack(4, 70, 4));
abilities.actions.add(ActionFactory.physicalAttack(2, 80, 1));
}
else if (abilityRoll == 2) {
sprite.name = "wizard";
abilities.actions.add(ActionFactory.magicAttack(3, 4, 80, 3, 2));
abilities.actions.add(ActionFactory.magicAttack(4, 4, 90, 3, 1));
abilities.actions.add(ActionFactory.physicalAttack(2, 70, 1));
}
else {//if (abilityRoll == 3) {
sprite.name = "healer";
abilities.actions.add(ActionFactory.cure(4, 4, 3, 1));
abilities.actions.add(ActionFactory.cure(3, 6, 3, 2));
abilities.actions.add(ActionFactory.physicalAttack(1, 65, 1));
}
e.addComponent(abilities);
e.addComponent(new Stats());
e.addComponent(new AI());
world.getManager(PlayerManager2.class).setPlayer(e, Players.Red);
return e;
}
</pre>
<br />
Now we have two teams which have specialized units which fight to the death, <i>relatively</i> intelligently! I think it's pretty awesome! You can check out all this code from the repository <a href="https://code.google.com/p/javagamexyz/source/browse/#svn%2Ftags%2F2013-05-05" target="_blank">here</a>.<br />
<br />
<span style="color: #bf9000;"><b>You have gained 250 XP. Progress to Level 5: 250/850</b></span>Unknownnoreply@blogger.com0tag:blogger.com,1999:blog-4125624026143571209.post-37749209992163890552013-04-26T18:56:00.000-07:002013-04-26T18:56:04.561-07:00Artificial Intelligence (Level 3)It seems a little silly trying to tackle AI at level 3, but I felt like it was the next part that needed to be built.<br />
<br />
As I started on this part, I realized that AI was going to require at least 2 major parts:<br />
<ol>
<li>The ability for the computer to decide what a good move is</li>
<li>The ability for the computer to execute that move, <b>in an attractive way.</b> </li>
</ol>
That 2nd point makes me think again of Final Fantasy Tactics - consider, for instance, this video:<br />
<div class="separator" style="clear: both; text-align: center;">
<iframe allowfullscreen='allowfullscreen' webkitallowfullscreen='webkitallowfullscreen' mozallowfullscreen='mozallowfullscreen' width='320' height='266' src='https://www.youtube.com/embed/SINx1V9F388?feature=player_embedded' frameborder='0'></iframe></div>
When it's the enemy's turn, it doesn't just instantly flash to the end, it shows it progress gradually. It's like the enemy decides where to move first, then moves, then spends a second deciding where to attack. And you see the full range of displays: the movement range is highlighted, even though the computer doesn't need to "see" it. The cell they select is highlighted. There are pauses in between segments. It all flows quite nicely.<br />
<br />
In this article I will mostly tackle the 2nd point, though the computer <i>will</i> decide what to do, there decision will be pretty lame. But it serves as a framework to expand later.<br />
<br />
<b>Part 1 - Deciding on a plan</b> <br />
Entities which are computer controlled will get a new Component called <b>AI</b>. AI will have a field of type <b>Plan</b>. Plan will contain the information about what the entity will do:<br />
<ul>
<li>Where will it move?</li>
<li>Where will it attack?</li>
<li>What attack will it be using?</li>
<li>Will it move first, or will it attack first and then move?</li>
<li>How "good" is this plan?</li>
</ul>
Plan will also implement Comparable so we can generate an Array of Plans, then sort them based on their score to find the best one.<br />
<br />
To determine the best Plan, the computer will loop over all possible combinations of what it can do. For now let's juts assume that they entity will move first, and act second. Basically, we must loop over:<br />
<ul>
<li>All possible cells you can move to</li>
<li>All possible actions you can do</li>
<li>All cells you can target with that action</li>
<ul>
<li>This may be a single cell, or it may hit a whole field of cells centered on a particular one</li>
</ul>
</ul>
Each of these describes one possible Plan - find some way to score them (this is what we are totally going to skimp on). Then sort the list of Plans and grab the best one. For some semi-pseudo-code, we want it to look like this:<br />
<br />
<pre class="brush:java">Array<Plan> plans = new Array<plan>();
moveFirst = true;
for (Pair moveTarget : reachableCells) {
for (Action action : availableActions) {
for (Pair actionTarget : cellsWithinActionsRange) {
Array<pair> field = getFieldFor(actionTarget, action);
score = scorePlan(moveTarget, action, field);
plans.add(new Plan(moveFirst, moveTarget, actionTarget, action, score);
}
}
}
plans.sort();
plan = plans.get(0);
</pre>
<br />
(Note, in practice we will also want to consider that they can act first, then move later - the loop would look quite similar but there are some additional complications that might arise from this).<br />
<br />
Let's look at these and figure out which ones we already have the foundation for, and which ones need to be developed.<br />
<ul>
<li>reachableCells - DONE</li>
<li>Action and availableActions - We will need to design these</li>
<li>actionTarget and cellsWithinActionsRange - should be easy to implement if we give Action something called range</li>
<li>getFieldFor - in the simplest case, let's say that each action hits just the target cell, or the target cell + it's neighbors, or the target cell + 2 sets of neighbors, etc. If we give Action something called "field" where field=1 means just the center, field=2 means the center+neighbors, etc, this should be easy too!</li>
<li>scorePlan - ???????? </li>
</ul>
So to make this work, we need to develop a class called Action with data like <b>range</b> and <b>field</b>, and also some details of what the action does.<br />
<br />
To accomplish this, I made a class called <b>Action2</b> (Action is a class used in libgdx, so I went with Action2 to avoid any confusion) and a component called <b>Abilities</b> which holds the different actions an entity can do.<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.abilities;
public class Action2 {
public int range; // How far from can this ability be targeted
public int field; // How large an area does it hit (1 = single cell, 2 = middle+neighbors, etc)
public float baseSucessProbability; // How likely is it to hit, on its own
public float damage; // How much damage does it do, on its own
}
</pre>
<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.components;
public class Abilities extends Component {
Action2 attack_sword;
Action2 attack_bow;
public Abilities() {
attack_sword = new Action2();
attack_sword.damage = 4;
attack_sword.baseSucessProbability = 0.9f;
attack_sword.range = 1;
attack_sword.field = 1;
attack_bow = new Action2();
attack_bow.damage = 3;
attack_bow.baseSucessProbability = 0.7f;
attack_bow.range = 4;
attack_bow.field = 1;
}
public Array<action2> getAbilities() {
Array<action2> abilities = new Array<action2>();
abilities.add(attack_sword);
abilities.add(attack_bow);
return abilities;
}
}
</pre>
<br />
Each entity will have 2 attacks: sword and bow. The sword has a range and field of 1, a 90% chance of success, and does 4 damage. The bow has a range of 4, a field of 1, a 70% chance of success, and does 3 damage.<br />
<br />
For now it's kind of silly, but getAbilities() returns an Array containing those two actions. With that, we can build our <b>Plan</b> class<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.AI;
import com.blogspot.javagamexyz.gamexyz.abilities.Action2;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
public class Plan implements Comparable<Plan> {
public boolean moveFirst, moveDecided, moveDone, actionDone;
public Action2 action;
public Pair moveTarget, actionTarget;
public int score;
public Plan(boolean moveFirst, Pair moveTarget, Pair actionTarget, Action2 action, int score) {
this.moveFirst = moveFirst;
this.moveTarget = moveTarget;
this.actionTarget = actionTarget;
this.action = action;
this.score = score;
moveDecided = moveDone = actionDone = false;
}
@Override
public int compareTo(Plan p) {
if (score < p.score) return 1; // p was better
else if (score > p.score) return -1; // p was worse
return 0; // they were equal
}
}
</pre>
<br />
moveFirst is the boolean which, again, lets us know what we're doing first (in the loop I outlined above, everything was predicated upon moving first). It also has an Action2, a Pair indicating where it will move, and a Pair indicating where the action will be targeted. The other booleans will come up later. moveDone and actionDone are to help us time things smoothly (part 2), and moveDecided will be used for when we act first, move second.<br />
<br />
Also, notice it implements Comparable so that we can sort it to find which is the best plan. In reality, if we are strictly going with the BEST plan, then we don't need to sort it, we just need to find the maximum. But to shake things up later, we may want to pick one of the best strategies at random, so that we are harder to predict - in this case we may as well sort it.<br />
<br />
Okay, now let's look at the AI component. It should hold a Plan, and probably a timer (so we can time the execution of the plan). Now, we don't want <i>all</i> of the entities with AI processing all the time, just on their own turn. This could be accomplished at least two ways I can think of:<br />
<ol>
<li>Give each AI a boolean flag for being active or not. Then, each turn during processTurn(), check to see if the next entity has AI - if so, set active to true. At the end of that entity's turn, before they call processTurn() again, set their AI to inactive.</li>
<li>Have a dummy component (CPUControllable?) to indicate that the Entity is controlled by the computer, and each turn during processTurn() check to see if the it has CPUControllable, and if so, add a new AI component. Then, at the end of their turn, remove the AI component.</li>
</ol>
I went with option #1, but I can see pros and cons with each way. Here is my AI component:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.components;
import com.artemis.Component;
import com.blogspot.javagamexyz.gamexyz.AI.Plan;
public class AI extends Component {
public Plan plan;
public float timer;
public boolean active;
public boolean planDone;
public void begin() {
timer = 0;
active = true;
planDone = false;
}
}
</pre>
<br />
The first 3 things we already talked about. We'll talk about planDone soon, but it becomes true as soon as the computer has figured out the plan. I kind of broke the whole "component-as-data-storage-only" paradigm - but it makes it easy for when I want to start someone's AI (i.e. in processTurn()).<br />
<br />
Now we need to look at AISystem. It is responsible for a lot of different things, primarily deciding what Plan to do, but it's also in charge of pacing. We don't want to do everything every time, so it has a series of flags/stops along the way.<br />
<br />
<pre class="brush:java">public class AISystem extends EntityProcessingSystem {
@Mapper ComponentMapper<AI> AIm;
@Mapper ComponentMapper<Stats> sm;
@Mapper ComponentMapper<Movable> mm;
@Mapper ComponentMapper<Abilities> am;
private OverworldScreen screen;
private GameMap gameMap;
private ActionSequencer sequencer;
private boolean startMove, startAction;
@SuppressWarnings("unchecked")
public AISystem(OverworldScreen screen, GameMap gameMap) {
super(Aspect.getAspectForAll(AI.class, Stats.class, Movable.class, Abilities.class));
this.screen = screen;
this.gameMap = gameMap;
startMove = true;
startAction = true;
}
@Override
protected void process(Entity e) {
AI ai = AIm.get(e);
if (!ai.active) return;
if (!ai.planDone) decidePlan(e, ai);
else {
// If moveFirst
if (ai.plan.moveFirst) {
// Move
if (!ai.plan.moveDone) {
if (startMove) {
ai.timer = 0;
startMove = false;
}
sequencer.move(gameMap, screen, ai.timer);
}
// Act
else if (!ai.plan.actionDone) {
if (startAction) {
ai.timer = 0;
startAction = false;
}
sequencer.act(gameMap, screen, ai.timer);
}
}
// Else, actFirst
else {
// Act
if (!ai.plan.actionDone) {
if (startAction) {
ai.timer = 0;
startAction = false;
}
sequencer.act(gameMap, screen, ai.timer);
}
// Decide move location
else if (!ai.plan.moveDecided) decideMovement(e, ai.plan);
// Begin moving to target location
else if (!ai.plan.moveDone) {
if (startMove) {
ai.timer = 0;
startMove = false;
}
sequencer.move(gameMap, screen, ai.timer);
}
}
}
// Don't increment your counter while the camera is moving
// This let's us focus on the character for a second (or so)
// before they start acting.
if (screen.cameraMoving()) return;
ai.timer += world.getDelta();
// If everything is done, process the turn
if (ai.plan.actionDone && ai.plan.moveDone) {
ai.active = false;
startMove = startAction = true;
screen.processTurn();
}
}
</pre>
First things first, for now it's kind of awkward, but I'm telling it to process things not just with AI, but with Stats,Movable, and Abilities (I don't even use stats yet).<br />
<br />
ActionSequencer is a class we'll discuss next, which I use to actually pace things (Part 2). It has two methods right now: move() and act(). They, as you might guess, control the pacing of what those ought to look like.<br />
<br />
In process() it first checks to see that this particular AI is active. If not, it just quits. Next it checks to see if it has already determined the plan. This check prevents us from determining the plan every time. Also, and I think this is a decent strength here, it may take a while to determine the plan. Even if it takes 1 second, this would be no big deal to the player - unless the graphics locked up for that whole second, which is <i>exactly</i> what would happen if we required the plan to be determined in a single go. We don't worry about it yet, but if our needs expand, this will help us so that every time AI is processed, it can refine the plan a little bit more until it has decided, at which point it is ready to act.<br />
<br />
If the plan is decided, we begin enacting it. Here's a plain outline of what this section says:<br />
<ul>
<li>if moveFirst</li>
<ul>
<li>move</li>
<li>act</li>
</ul>
<li>else</li>
<ul>
<li>act</li>
<li>determine where to move</li>
<li>move</li>
</ul>
</ul>
The "determine where to move" step is important. We don't want the computer deciding where to move until it has to. Say they expected their attack to kill the enemy, but missed? Should they stick with the plan where they thought the enemy would be dead? This uncertainty is what will make scoring plans where the player acts first extra difficult. How do you rate the advantage you get by having a move left over? I think it depends on how good your potential moves look, but I haven't even touched that stuff yet.<br />
<br />
Remember again, this thing will be processed hundreds to thousands of times. You don't want to act until you're done moving (or vice versa), and you don't want to determine the plan thousands of times, nor where to move. That is the purpose of those flags, plan.moveDone and plan.actionDone. It will do those things until they are done, then no more. startMove and startAct are flags that tell us that we are on the very first loop where we move (or act). This is helpful for us to reset the timer (so those will execute appropriately). Other than that, we ask our ActionSequencer to do the appropriate action.<br />
<br />
After all that (starting around line 87) we need to increment the timer. However, I don't want the timer moving while the camera is still on its way to focus on the enemy - otherwise they may have already begun their turn and done things we didn't see. So I only update the timer when the camera is not moving.<br />
<br />
Lastly, if both actionDone and moveDone are true, then we have completed the whole turn (timing and all): deactivate the AI, reset the AI system, and processTurn().<br />
<br />
There are still a few things I left vague. For instance, on line 31 I call some mystery method decidePlan(), and on line 70 I call decideMovement(). We already went over the pseudo-code for decidePlan() at the beginning of this article, and for now, decideMovement() just picks a random cell from reachableCells. Here they are:<br />
<pre class="brush:java"> private void decidePlan(Entity e, AI ai) {
Pair pos = gameMap.getCoordinatesFor(e.getId());
Movable movable = mm.get(e);
Abilities abilities = am.get(e);
Array<Pair> reachableCells = gameMap.pathFinder.getReachableCells(pos.x, pos.y, movable);
Array<Action2> availableActions = abilities.getAbilities();
Array<Plan> plans = new Array<Plan>();
// Loop over all possible plans where the entity moves, then acts
// If the action doesn't hit anyone, skip that plan
boolean moveFirst = true;
for (Pair moveTarget : reachableCells) {
for (Action2 action : availableActions) {
for (Pair actionTarget : MapTools.getNeighbors(moveTarget.x, moveTarget.y, action.range)) {
Array<Pair> field = MapTools.getNeighbors(actionTarget.x, actionTarget.y, action.field-1);
field.add(actionTarget);
if (!gameMap.containsEntitiesOtherThan(field,e.getId())) continue;
int score = scorePlan(moveTarget, action, field);
plans.add(new Plan(moveFirst, moveTarget, actionTarget, action, score));
}
}
}
// If there were no good plans there, add at least one plan
// where you move at random, and do nothing
if (plans.size == 0) plans.add(new Plan(moveFirst, reachableCells.get(MathUtils.random(reachableCells.size-1)), null, null, 0));
// Now loop over all possible plans where the entity doesn't
// move anywhere, so they still have their move stored up
moveFirst = false;
for (Action2 action : availableActions) {
for (Pair actionTarget : MapTools.getNeighbors(pos.x, pos.y, action.range)) {
Array<Pair> field = MapTools.getNeighbors(actionTarget.x, actionTarget.y, action.field-1);
field.add(actionTarget);
if (!gameMap.containsEntitiesOtherThan(field,e.getId())) continue;
int score = scorePlan(pos, action, field);
plans.add(new Plan(moveFirst, pos, actionTarget, action, score));
}
}
plans.sort();
ai.plan = plans.get(0);
ai.planDone = true;
sequencer = new ActionSequencer(ai.plan, e);
}
</pre>
<pre class="brush:java"> private void decideMovement(Entity e, Plan plan) {
Movable movable = mm.get(e);
Pair pos = gameMap.getCoordinatesFor(e.getId());
Array<Pair> reachableCells = gameMap.pathFinder.getReachableCells(pos.x, pos.y, movable);
plan.moveTarget = reachableCells.get(MathUtils.random(reachableCells.size-1));
plan.moveDecided = true;
}
</pre>
decidePlan has a few very cheap hacks for now. For instance, on lines 21 and 39, I say that if that particular move/act combo has no entities in the action field, skip that plan (this way I don't have to bother scoring or sorting it). Even worse, I had to look for targets <i>other than</i> the acting entity - just think, if they move one cell over and say "aha, there's someone right back where I moved from - I'll attack them!" it would be pretty silly. containsEntitiesOtherThan() is pretty simple - just check the field to see if anyone is there, but don't count it if it's the entity whose ID is being passed.<br />
<br />
In case they don't find <i>any</i> actions that work, on line 30 we give them a dummy option to move to some random location and do nothing (with a default score of 0).<br />
<br />
The next loop is over just the actions, because they will decide where to move later. Again, I don't know how to handle score discrepancies between these two types of Plans. The ability to move later is worth something... probably. But what?<br />
<br />
After that we sort the plans, grab the best one, and tell our AISystem that were are done making the plan. We also create a new ActionSequencer to handle this plan (we'll get to that soon).<br />
<br />
There's still some cop-out: on lines 22 and 40 I magically call some scorePlan() method. For now, this is as simple as can be:<br />
<br />
Go with whichever plan has the higher potential for damage. Don't care who you're attacking. Don't care how safe you are. Don't care 'bout nuthin. In practice, this means that everyone will try to move toward and attack anyone at random who is in some range. They prefer sword (because it's more powerful), but they'll do bow if that's all they have. There's nothing that says if they go for bow range to try to come in close for sword, nor to keep a distance. They just choose some random cell where they can attack from - and if it's possible, it will be right next to them for some swift sword justice.<br />
<br />
decideMove(), like I said before, doesn't factor in anything smart. It just picks some random place to move.<br />
<br />
That's our AISystem. We'll talk about the ActionSequencer in a second, but first let's look at how we want to implement our AISystem. It's just another Artemis system, and we don't want to update it manually (it runs continuously through the enemy's turn) so just add it like a regular System. In OverworldScreen.processTurn(), we need to check the incoming players status: is it controlled by AI? If so, I'd like to disable player control entirely, and begin the AI processing.<br />
<pre class="brush:java"> public void processTurn() {
turnManagementSystem.process();
activeEntity = unitOrder.get(0);
activeEntityCell = gameMap.getCoordinatesFor(activeEntity);
// As long as that entity has a location, focus the camera on them
if (activeEntityCell != null) {
cameraMovementSystem.move(activeEntityCell.x, activeEntityCell.y);
}
// Try to get the next entity's AI
AI ai = world.getEntity(activeEntity).getComponent(AI.class);
// If they don't have AI, give control to the human player
if (ai == null) Gdx.input.setInputProcessor(inputSystem);
// Otherwise take control away, and begin that AI's processing.
else {
Gdx.input.setInputProcessor(null);
ai.begin();
}
</pre>
Whew, we're coming down to the end. That's how the AISystems gets integrated into the rest of the game. Now we just need to talk about the ActionSequencer (plus the few things it messed up).<br />
<br />
The idea for ActionSequencer is to break things down into a few discrete steps. For instance, to handle move, we want to do a few things:<br />
<ol>
<li>Highlight the movable range</li>
<li>Specially highlight the cell they choose to move to</li>
<li>Begin the actual movement (remember, the camera will be moving during this period too, so the timer won't be ticking until it stops)</li>
<li>Unhighlight the target cell</li>
<li>Finish (wrap up anything else that needs to happen at the end)</li>
</ol>
Each of these steps should last a particular amount of time, before moving on to the next one. I broke each step into a series of if (step == 0) { ... } else if (step == 1) { ... }, etc...<br />
<br />
Now, for each of these, the code only needs to run once (for instance, in step 1 you must figure out the reachableCells, then tell OverworldScreen to highlight them), so within each step, we do <b>step++</b>. To stop it from running directly into the next step, we also monitor the AI timer to keep it from getting ahead of itself. Here's how I did it:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.AI;
import com.artemis.Entity;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.components.Damage;
import com.blogspot.javagamexyz.gamexyz.components.Movable;
import com.blogspot.javagamexyz.gamexyz.components.Movement;
import com.blogspot.javagamexyz.gamexyz.components.Stats;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
import com.blogspot.javagamexyz.gamexyz.screens.OverworldScreen;
public class ActionSequencer {
Plan plan;
Entity e;
private int step;
private float m_t;
private float a_t;
private float[] moveTimer;
private float[] actTimer;
public ActionSequencer(Plan plan, Entity e) {
this.plan = plan;
this.e = e;
float t1,t2,t3,t4;
step = 0;
// Move timing constants
m_t = 0.75f; // Time to wait upon focus, before showing movement range
t1 = 1f;
t2 = 0.5f;
t3 = 0.05f;
t4 = 0.25f;
moveTimer = new float[]{t1, t2, t3, t4};
// Act timing constants
a_t = 0.75f; // Time to wait upon focus, before showing action range
t1 = 1f; // Highlight attackable range
t2 = 0.6f; // Highlight target cell
t3 = 0.75f; // Linger to watch damage hit
actTimer = new float[]{t1, t2, t3};
}
...
}
</pre>
This thing has immediate access to the Plan, and the entity who is executing it. It has an <b>int step</b> to keep track of which step it's on (it will only ever <i>either</i> be moving <i>or</i> acting, so we only need one step counter). The moveTimer holds the duration for each step. Look at lines 34-40 in the constructor.<br />
<br />
m_t will be the "accumulated" timer, but for now, it just holds how long the camera should wait before showing the movement range (remember, the timer isn't running at all while the camera is moving, so this is 0.75 seconds <i>after</i> the camera has settled on the entity). t1 says that we will then highlight reachableCells for 1 second. t2 is how long for the next step, etc. An array, moveTimer is created which holds those values and can conveniently be referenced by moveTimer[step] to get the appropriate time.<br />
<br />
Below all that, we actually have the <b>move()</b> method:<br />
<pre class="brush:java"> public void move(GameMap gameMap, OverworldScreen screen, float time) {
if (time < m_t) return;
// Step 1 - Highlight the reachable cells
if (step == 0) {
Movable movable = e.getComponent(Movable.class);
Pair pos = gameMap.getCoordinatesFor(e.getId());
Array<Pair> reachableCells = gameMap.pathFinder.getReachableCells(pos.x, pos.y, movable);
screen.highlightedCells = reachableCells;
screen.highlightMovementRange();
m_t += moveTimer[step];
step++;
return;
}
// Step 2 - Highlight the cell we will move to
else if (step == 1) {
screen.highlightedCells.clear();
screen.setHighlightColor(0.05f, 0.05f, 0.2f, 0.8f);
screen.highlightedCells.add(plan.moveTarget);
m_t += moveTimer[step];
step++;
return;
}
// Step 3 - Begin moving to target cell
else if (step == 2) {
Movable movable = e.getComponent(Movable.class);
Pair pos = gameMap.getCoordinatesFor(e.getId());
e.addComponent(new Movement(gameMap.pathFinder.findPath(pos.x, pos.y, plan.moveTarget.x, plan.moveTarget.y, movable, false)));
e.changedInWorld();
m_t += moveTimer[step];
step++;
return;
}
// Step 4 - We have arrived (plus a brief waiting period), unhighlight the cell
else if (step == 3) {
screen.renderHighlighter = false;
m_t += moveTimer[step];
step++;
return;
}
// Finished: Tell the plan that the "move" is done
else {
plan.moveDone = true;
m_t = 0;
step = 0;
}
}
</pre>
The first thing (line 3) is the most important part. If the AI timer has not exceeded the time limit we have set, then don't even bother doing any more here. This is how we control to make sure we don't jump ahead to steps inappropriately. In the Step 1 block, we get the reachableCells and tell the OverworldScreen to highlight them (I slightly fudged with the way highlighting works - we'll talk about it at the very end).<br />
<br />
Step 2 clears the highlightedCells and replaces them with the moveTarget, along with a less transparent, darker blue color.<br />
<br />
Step 3 actually adds the Movement component.<br />
<br />
Step 4 stops the highlighter.<br />
<br />
Notice that in each step, we increment m_t by the timer for that step. This way, we are always getting caught by that line 3 code. At first, it needs timer to exceed 0.75. But after that, it must exceed 1.75 (1 second beyond what it already had - as added on in line 27). So each step calls m_t += moveTimer[step], and each step also calls step++. Those two lines keep the flow going.<br />
<br />
After that, we're done, so we call plan.moveDone (which helps control how AISystem knows where to delegate its attention) .<br />
<br />
Here's act():<br />
<pre class="brush:java"> public void act(GameMap gameMap, OverworldScreen screen, float time) {
if (plan.action == null) {
plan.actionDone = true;
System.err.println("action = null");
return;
}
if (time < a_t) return;
// Step 1 - Highlight attackable range
if (step == 0) {
Pair pos = gameMap.getCoordinatesFor(e.getId());
screen.highlightedCells = MapTools.getNeighbors(pos.x, pos.y, plan.action.range);
screen.highlightAttackRange();
a_t += actTimer[step];
step++;
}
// Step 2 - Highlight the target cell (the whole field)
else if (step == 1) {
screen.highlightedCells = MapTools.getNeighbors(plan.actionTarget.x, plan.actionTarget.y, plan.action.field-1);
screen.highlightedCells.add(plan.actionTarget);
screen.setHighlightColor(0.2f, 0.05f, 0.05f, 0.6f);
a_t += actTimer[step];
step++;
}
// Step 3 - Add damage and unhighlight target cells
else if (step == 2) {
Damage damage = new Damage(e.getComponent(Stats.class),plan.action);
int entityId;
Array<Pair> field = MapTools.getNeighbors(plan.actionTarget.x, plan.actionTarget.y, plan.action.field-1);
field.add(plan.actionTarget);
for (Pair target : field) {
entityId = gameMap.getEntityAt(target.x, target.y);
screen.addComponent(damage, entityId);
}
screen.renderHighlighter = false;
a_t += actTimer[step];
step++;
}
// Finished
else {
plan.actionDone = true;
a_t = 0;
}
}
</pre>
It basically works the same way as move(), but instead of moving, it handles acting. To add the damage component, it needs access to the target entity, but it only has the ID. It could get the entity from the World, but it doesn't have that either. So I made some generally accessible method on OverworldScreen called addComponent() which will try to add a component to a particular entity.<br />
<br />
Here's the list of silly methods in OverworldScreen I had to add at the end to make some of this come together:<br />
<pre class="brush:java"> public boolean cameraMoving() {
return cameraMovementSystem.active;
}
public void setHighlightColor(float r, float g, float b, float a) {
mapHighlighter.setColor(r,g,b,a);
}
public void highlightMovementRange() {
renderHighlighter = true;
setHighlightColor(0f, 0f, 0.2f, 0.3f);
}
public void highlightAttackRange() {
renderHighlighter = true;
setHighlightColor(0.5f, 0f, 0f, 0.3f);
}
public void addComponent(Component component, int entityId) {
if (entityId < 0) {
System.err.println("No entity");
return;
}
Entity e = world.getEntity(entityId);
e.addComponent(component);
e.changedInWorld();
}
</pre>
I replaced renderAttackRange and renderMovementRange with a general renderHighlighter (it never worked well highlighting both anyway, because they both worked off of the same timer which then went on double speed). I also gave mapHighlighter a setColor method, and it in general remembers what color it's supposed to be working on. <br />
<br />
I also felt like I had to tweak the Damage component a little bit, but with very minor changes. Really this should be another update, but here it is in craptacular form now:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.components;
import com.artemis.Component;
import com.blogspot.javagamexyz.gamexyz.abilities.Action2;
public class Damage extends Component {
public float baseDamage;
public float baseAccuracy;
public int power;
public int accuracy;
public Stats stats;
public Damage(Stats stats) {
this.stats = stats;
power = stats.getStrength();
accuracy = stats.getAgility();
baseDamage = power;
baseAccuracy = accuracy;
}
public Damage(Stats stats, Action2 action) {
this.stats = stats;
this.baseDamage = action.damage;
this.baseAccuracy = action.baseSucessProbability;
accuracy = (int)(baseAccuracy * 100);
power = (int)(3*baseDamage);
}
}
</pre>
Holy crap that was a lot! I hope I got it all (or at least most of it so that it will make sense). Now you can make some computer controlled entities that will duke it out in a Battle Royale! And you can mix it up with a few of your own characters - really the sky is the limit!<br />
<br />
I'd encourage you all to think about how you actually want to tweak the decidePlan() and scorePlan() methods. They are surely crap right now (I didn't really plan on making them perfect just yet though). But whatever I come up with probably won't be that great in the end either, so this is definitely a place for your own logic to shine through! What do <i>you</i> think is important for the computer to consider when deciding on a good plan?<br />
<br />
<span style="color: #bf9000;"><b>You have gained 250 XP. Progress to Level 4: 700/700</b></span><br />
<span style="color: #bf9000;"><b>DING! You have advanced to Level 4, congratulations! </b></span>Unknownnoreply@blogger.com5tag:blogger.com,1999:blog-4125624026143571209.post-32080856413387020472013-04-22T10:43:00.002-07:002013-04-22T10:43:10.724-07:00Combat and Turn Management - Level 3This started out as a nice, short, update. But I kept adding stuff which messed with what I already had, so it kind of grew. You can <a href="https://code.google.com/p/javagamexyz/source/browse/#svn%2Ftags%2F2013-04-22" target="_blank">check out all the code at the repository</a> - I probably won't put it all here verbatim: there were a lot of changes, many of which were totally cosmetic.<br />
<br />
Last time we made a menu which users could select move, and I want to add more controls now. This update will introduce a basic combat system, and simple turn management, plus a few jazzy effects.<br />
<br />
Before we really get into that, there are a few organizational changes in OverworldScreen worth mentioning<br />
<ol>
<li>In the constructor, I separate code out into a few different void methods so it's a little easier to find what I'm looking for.</li>
<li>I also keep instances of each of the Controllers in memory, so I don't have to set the input processor to a <b>new</b> _____Controller every time.</li>
<li>On that note, I had to split up the drag functionality, and the
character select functionality from what used to be called the
OverworldDefaultController. It's been replaced with
OverworldDragController and OverworldSelectorController (I had to do
this because I needed the attack controller to fit between them)</li>
<li>I also added a "remove" method for the input multiplexor (I know it
has a built in one, but for now I feel more like forcing everything to
interface with it through the helper methods I made.</li>
<li>Because it's written with libgdx, it has the potential to be ported
to Android (and even iPhone now). Consequently, not everybody will have
a mouse (or touchscreen) and I started experimenting with a cursor that
users can move around with arrow keys. One problem with that is that
each cell has 6 neighbors, but you only have 4 arrow keys. The cursor,
for now, just looks like an unanimated knight who starts at (0,0), and
you can move it around to see how it works. I don't like it right now,
particularly when you need to go up/left or down/right. I have plans to
fix it someday though.</li>
</ol>
Here's an overview of the topics we'll cover now:<br />
<ul>
<li>Combat System</li>
<ul>
<li>Components: Stats, Damage</li>
<li>Systems: DamageSystem</li>
<li>Controller: OverworldAttackController </li>
</ul>
<li>Damage Label</li>
<ul>
<li>Components: FadingMessage</li>
<li>Systems: FadingMessageRenderSystem</li>
</ul>
<li>Turn Management </li>
<ul>
<li>Systems: TurnManagementSystem</li>
</ul>
<li>Camera Movement</li>
<ul>
<li>Systems: CameraMovementSystem</li>
</ul>
</ul>
<br />
<b>Combat System</b><br />
<br />
For combat to make <i>any</i> sense, characters must now have things like strength and health. I made a <b>Stats</b> component which looks like this:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.components;
import com.artemis.Component;
import com.badlogic.gdx.math.MathUtils;
public class Stats extends Component {
public int level, xp, next_xp;
private int strength, strength_modifier;
private int intelligence, intelligence_modifier;
private int speed, speed_modifier;
private int agility, agility_modifier;
private int charisma, charisma_modifier;
private int hardiness, hardiness_modifier;
private int resistance, resistance_modifier;
public int health, maxHealth, maxHealth_modifier;
public int magic, maxMagic, maxMagic_modifier;
public String name;
public int actionPoints;
public Stats() {
level = 1;
xp = 0;
next_xp = 100;
strength = 15 + MathUtils.random(-3, 3);
intelligence = 15 + MathUtils.random(-3, 3);
speed = 15 + MathUtils.random(-3, 3);
agility = 15 + MathUtils.random(-3, 3);
charisma = 15 + MathUtils.random(-3, 3);
hardiness = 15 + MathUtils.random(-3, 3);
resistance = 15 + MathUtils.random(-3, 3);
health = maxHealth = (int)((5*hardiness + 4*strength + 2*resistance) / 11);
magic = maxMagic = (int)((5*intelligence + 2*resistance) / 7);
strength_modifier = intelligence_modifier = speed_modifier = agility_modifier =
charisma_modifier = hardiness_modifier = resistance_modifier = maxHealth_modifier =
maxMagic_modifier = 0;
name = names[MathUtils.random(names.length-1)];
actionPoints = 0;
}
private final String[] names = {"Rodann","Ranlan","Luhiri","Serl","Polm","Boray","Ostan","Inaes"};
public int getAgility() {
return agility + agility_modifier;
}
public int getHardiness() {
return hardiness + hardiness_modifier;
}
public int getStrength() {
return strength + strength_modifier;
}
public int getCharisma() {
return charisma + charisma_modifier;
}
public int getIntelligence() {
return intelligence + intelligence_modifier;
}
public int getResistance() {
return resistance + resistance_modifier;
}
public int getSpeed() {
return speed + speed_modifier;
}
}
</pre>
<br />
It permits storage of base stats (which are mostly totally self explanatory), plus modifiers which might come from spells, equipment, etc. The list of names was just a short list taken from the <a href="http://www.rinkworks.com/namegen/" target="_blank">Fantasy Name Generator</a> over at RinkWorks.com. HP and MP are derived stats calculated as a weighted average of a few different stats. Right now when an entity gets created with new stats, they are all random ints from 12 to 18.<br />
<br />
The actionPoints stat will be used to determine who gets to move, and will be discussed in more detail further down this post under Turn Management. <br />
<br />
In OverworldScreen, I added a method "selectedAttack()" and "selectedWait()" just like we had "selectedMove()". When they choose "Attack", selectedAttack() puts a new Controller in the mix, OverworldAttackController:<br />
<br />
<pre class="brush:java">public void selectedAttack() {
if (attacked) return;
setInputSystems(controllerDrag,controllerAttack,controllerSelector);
highlightedCells = MapTools.getNeighbors(activeEntityCell.x, activeEntityCell.y);
renderAttackRange = true;
handleStage = false;
stage.clear();
}
public void selectedWait() {
setInputSystems(controllerDrag, controllerSelector);
processTurn();
handleStage = false;
stage.clear();
selectedEntity = -1;
moved = attacked = false;
}
</pre>
<br />
All this really does is highlight the cells that the player can attack. We set the controllers we want - again, order is important! First and foremost they can drag the screen, secondly they can attack, thirdly they can select another entity (perhaps to check its stats). <br />
<br />
We also get the neighbors of activeEntityCell (which is a pair that is determined during the turn management part of things) to find the attackable range, set a flag renderAttackRange to true, quit handling the stage and clear it off.<br />
<br />
In render(), we have to actually highlight these cells<br />
<pre class="brush:java"> if (renderAttackRange) {
mapHighlighter.render(highlightedCells,0.5f,0f,0f,0.3f);
}
</pre>
<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgCPobJFqALpP0ZQLZMrk3njah0ujs-qJsIyCchV_75STwApB9I1T_rvl2z8xrQft-2UsgsVEtGtBDxlfL86HDwxYjw40VuUJijNbLXnYG1I6csD2wZ53vRY61HpLUw8xg4Wmn98FFynUM/s1600/attack_range.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="500" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgCPobJFqALpP0ZQLZMrk3njah0ujs-qJsIyCchV_75STwApB9I1T_rvl2z8xrQft-2UsgsVEtGtBDxlfL86HDwxYjw40VuUJijNbLXnYG1I6csD2wZ53vRY61HpLUw8xg4Wmn98FFynUM/s640/attack_range.png" width="640" /></a></div>
<br />
<br />
But most of the real magic happens over in OverworldAttackController<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.screens.control.overworld;
import com.artemis.Entity;
import com.artemis.World;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.blogspot.javagamexyz.gamexyz.components.Damage;
import com.blogspot.javagamexyz.gamexyz.components.Stats;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
import com.blogspot.javagamexyz.gamexyz.screens.OverworldScreen;
public class OverworldAttackController implements InputProcessor {
private OrthographicCamera camera;
private World world;
private GameMap gameMap;
private OverworldScreen screen;
public OverworldAttackController(OrthographicCamera camera, World world, GameMap gameMap, OverworldScreen screen) {
this.camera = camera;
this.world = world;
this.gameMap = gameMap;
this.screen = screen;
}
@Override
public boolean keyDown(int keycode) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean keyUp(int keycode) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean keyTyped(char character) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean touchUp(int screenX, int screenY, int pointer, int button) {
Pair coords = MapTools.window2world(Gdx.input.getX(), Gdx.input.getY(), camera);
int entityId = gameMap.getEntityAt(coords.x, coords.y);
// Did they click within the attackable range, and a real entity?
if (screen.highlightedCells.contains(coords, false) && entityId > -1) {
Entity source = world.getEntity(screen.selectedEntity);
Entity target = world.getEntity(entityId);
Stats stats = source.getComponent(Stats.class);
Damage damage = new Damage(stats);
target.addComponent(damage);
target.changedInWorld();
// Tell the screen that this entity has attacked this turn
screen.attacked = true;
}
// Wherever they clicked, they are now done with the "attacking" aspect of things
screen.highlightedCells = null;
screen.renderAttackRange = false;
screen.selectedEntity = -1;
screen.removeInputSystems(this);
return true;
}
@Override
public boolean touchDragged(int screenX, int screenY, int pointer) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean mouseMoved(int screenX, int screenY) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean scrolled(int amount) {
// TODO Auto-generated method stub
return false;
}
}
</pre>
<br />
The only thing we really want to process is a touchUp(), where we get the cell they clicked, and the entity at that cell (or -1 if there is no entity). Then if they clicked within the attackable range, and on a real entity, we define source and target entities (source is the attacker, target is the attackee).<br />
<br />
Then we read the stats from the attacker (remember, this isn't an "EntitySystem" so we can't use the @Mapper) and pass it to a new Component called Damage. The idea here is that entities with the Damage component will get processed by the DamageSystem, which will then doll out damage appropriately. I wanted the actual damage calculations to happen outside of the controller, in their own system.<br />
<br />
Then, no matter where they clicked, we're done rendering the attack stuff.<br />
<br />
The damage component is pretty simple for now:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.components;
import com.artemis.Component;
public class Damage extends Component {
public int power;
public int accuracy;
public Stats stats;
public Damage(Stats stats) {
this.stats = stats;
power = stats.getStrength();
accuracy = stats.getAgility();
}
}
</pre>
I know it's redundant, but since I'm sure it will be heavily tweaked later on for different types of attacks, I'm leaving it like this for now.<br />
<br />
The DamageSystem extends EntityProcessingSystem and looks like this:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.systems;
import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.annotations.Mapper;
import com.artemis.systems.EntityProcessingSystem;
import com.badlogic.gdx.math.MathUtils;
import com.blogspot.javagamexyz.gamexyz.EntityFactory;
import com.blogspot.javagamexyz.gamexyz.components.Damage;
import com.blogspot.javagamexyz.gamexyz.components.MapPosition;
import com.blogspot.javagamexyz.gamexyz.components.Stats;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.utils.MyMath;
public class DamageSystem extends EntityProcessingSystem {
@Mapper ComponentMapper<Damage> dm;
@Mapper ComponentMapper<Stats> sm;
@Mapper ComponentMapper<MapPosition> mpm;
private GameMap gameMap;
@SuppressWarnings("unchecked")
public DamageSystem(GameMap gameMap) {
super(Aspect.getAspectForAll(Damage.class, Stats.class));
this.gameMap = gameMap;
}
@Override
protected void process(Entity e) {
Damage damage = dm.get(e);
Stats stats = sm.get(e);
MapPosition position = mpm.getSafe(e); // Useful for displaying damage on screen
// Did the blow hit?
if (damage.accuracy - stats.getAgility() + MathUtils.random(-1,4) > 0) {
// Compute how much damage it did
int dmg = MyMath.max(damage.power - stats.getHardiness() / 2 + MathUtils.random(-5,5), 1);
// Update the target's health accordingly
stats.health -= dmg;
System.out.println(damage.stats.name + " HIT " + stats.name + "! Damage " + dmg + "\t\t Health: " + stats.health + "/" + stats.maxHealth);
// If the target had a MapPosition, create a damage label showing how much damage was done
if (position != null) {
EntityFactory.createDamageLabel(world, ""+dmg, position.x, position.y).addToWorld();
}
}
else { // Otherwise they missed
System.out.println(damage.stats.name + " MISSED " + stats.name +"!");
// Create a damage label of "Miss" to add insult to injury
if (position != null) {
EntityFactory.createDamageLabel(world, "MISS", position.x, position.y).addToWorld();
}
}
// We've processed the damage, now it's done
e.removeComponent(damage);
e.changedInWorld();
}
@Override
protected void removed(Entity e) {
// This is called after the damage gets removed
// We want to see if the target died in the process
Stats stats = sm.get(e);
if (stats.health <= 0) {
// If so, it's toast!
gameMap.removeEntity(e.getId());
world.deleteEntity(e);
}
}
@Override
protected boolean checkProcessing() {
return true;
}
}
</pre>
<br />
It processes entities that have both damage and stats (I figure if they have no stats, they won't be getting damaged!). In process(), we get the Damage and Stats, and also (if applicable) the MapPosition. This last part is useful for actually rendering the damage dealt on the screen.<br />
<br />
First we need to see if they actually hit them. My check here is if source_accuracy - target_agility + rand(-1,4) > 0, then they hit. The damage dealt = source_power - target_hardiness + rand(-5,5). If that damage is less than 1, they just deal 1 damage instead. We update the target's health, and if appropriate, add a damage label.<br />
<br />
If the attack missed, so we just add a "missed" label.<br />
<br />
At the end, we have finished processing this damage, so we remove it (which calls the removed() method). In this method, we check to see if the damage killed the entity. If so, remove it from the world.<br />
<br />
<b>Damage Label - Fading Message</b><br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgSBTKCrbD06YULP-_5F_JbxxykmhCSK2D5yFgSVKwjzYm2ejfVduIfYFfPhZwGblSRtM3saDV3nD3i_QVpz5dUD4qGmah9EuaHs_nCjK7vVJoTnxMshlVZBmz4zhuZ2p539xLhZDpLSIU/s1600/attack_message.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="498" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgSBTKCrbD06YULP-_5F_JbxxykmhCSK2D5yFgSVKwjzYm2ejfVduIfYFfPhZwGblSRtM3saDV3nD3i_QVpz5dUD4qGmah9EuaHs_nCjK7vVJoTnxMshlVZBmz4zhuZ2p539xLhZDpLSIU/s640/attack_message.png" width="640" /></a></div>
<br />
The damage label is totally cosmetic, but it's pretty cool. Let's take a look at it in EntityFactory<br />
<pre class="brush:java"> public static Entity createDamageLabel(World world, String label, float x, float y) {
Entity e = world.createEntity();
e.addComponent(new MapPosition(x,y));
e.addComponent(new FadingMessage(label,1.2f,0f,1.3f));
return e;
}
</pre>
Here we make an entity with MapPosition and a new component FadingMessage. FadingMessage is a general component I created for giving the player a... well... a fading message. The arguments are a String for the actual label, a float for the duration (how long it takes to fade away), and floats for horizontal and vertical velocity to it can move. For our case, it lasts 1.2 seconds, and has a vertical velocity of 1.3 cells per second (notice that's not pixels / seconds, but literally 1.3 hex cells / second).<br />
<br />
Here's the code for FadingMessage (which will get processed AND rendered by FadingMessageRenderSystem)<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.components;
import com.artemis.Component;
public class FadingMessage extends Component {
public String label;
public float duration, currentTime;
public float vx, vy;
public FadingMessage(String label, float duration) {
this(label,duration,0,0);
}
public FadingMessage(String label, float duration, float vx, float vy) {
this.label = label;
this.duration = duration;
this.vx = vx;
this.vy = vy;
currentTime = 0f;
}
}
</pre>
Nothing too surprising, it just holds all that info, plus currentTime which stores how long it has been alive.<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.systems;
import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.annotations.Mapper;
import com.artemis.systems.EntityProcessingSystem;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.Texture.TextureFilter;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.blogspot.javagamexyz.gamexyz.components.FadingMessage;
import com.blogspot.javagamexyz.gamexyz.components.MapPosition;
import com.blogspot.javagamexyz.gamexyz.custom.FloatPair;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
public class FadingMessageRenderSystem extends EntityProcessingSystem {
@Mapper ComponentMapper<MapPosition> mpm;
@Mapper ComponentMapper<FadingMessage> fmm;
private BitmapFont font;
private SpriteBatch batch;
private OrthographicCamera camera;
@SuppressWarnings("unchecked")
public FadingMessageRenderSystem(OrthographicCamera camera, SpriteBatch batch) {
super(Aspect.getAspectForAll(MapPosition.class, FadingMessage.class));
this.batch = batch;
this.camera = camera;
}
@Override
protected void initialize() {
Texture fontTexture = new Texture(Gdx.files.internal("fonts/normal_0.png"));
fontTexture.setFilter(TextureFilter.Linear, TextureFilter.MipMapLinearLinear);
TextureRegion fontRegion = new TextureRegion(fontTexture);
font = new BitmapFont(Gdx.files.internal("fonts/normal.fnt"), fontRegion, false);
font.setUseIntegerPositions(false);
}
@Override
protected void begin() {
batch.setProjectionMatrix(camera.combined);
batch.begin();
batch.setColor(1, 1, 1, 1);
}
@Override
protected void process(Entity e) {
MapPosition position = mpm.get(e);
FadingMessage message = fmm.get(e);
FloatPair drawPosition = MapTools.world2window(position.x, position.y);
float posX = drawPosition.x - message.label.length() * font.getSpaceWidth();
float posY = drawPosition.y;
font.setColor(1, 1, 1, 1 - message.currentTime / message.duration);
font.draw(batch, message.label, posX, posY);
position.x += message.vx * world.getDelta();
position.y += message.vy * world.getDelta();
message.currentTime += world.getDelta();
if (message.currentTime >= message.duration) e.deleteFromWorld();
}
@Override
protected void end() {
batch.end();
}
}
</pre>
Here we have a RenderSystem which is actually an EntityProcessingSystem again, so I modeled it after them. It has its camera and batch, plus a font. In process() it gets the position and does its best to center it (posX = drawPosition.x - message.label.length() * font.getSpaceLength()). The color is set to white, and the alpha goes from 1 to 0 from start to finish. The position is updated (remember, that's not screen position, that's world position - as in what cell it occupies) and currentTime is updated. Then, if currentTime exceeds duration, the message gets deleted from the world.<br />
<br />
So that's all pretty cool - you can now attack someone and have it deal damage (potentially killing them), and the damage is displayed in what I think is a pretty attractive floating/fading message. Of course, there's nothing stopping you from attacking AGAIN AND AGAIN AND AGAIN until they're dead. Obviously in a game you only get to attack once, and then it's the next character's turn.<br />
<br />
To facilitate that, I created flags in OverworldScreen boolean attacked, boolean moved. They are initialized to false, and in selectedAttack() and selectedMove(), we first check them:<br />
<pre class="brush:java">public void selectedMove() {
if (moved) return;
...
}
public void selectedAttack() {
if (attacked) return;
...
}
</pre>
<br />
In the controllers, if the player successfully moves or attacks, they are then set to true. Then, in a new method, selectedWait(), we resent them both to false and "processTurn()". processTurn() is a method which helps establish whose turn it is to move.<br />
<pre class="brush:java"> public void processTurn() {
turnManagementSystem.process();
activeEntity = unitOrder.get(0);
activeEntityCell = gameMap.getCoordinatesFor(activeEntity);
if (activeEntityCell != null) cameraMovementSystem.move(activeEntityCell.x, activeEntityCell.y);
}
</pre>
<br />
The first bit, turnManagementSystem.process() comes up with a list representing the order units are going to be moving in. It assumes nothing ever changes - like no units will die - so it must be run each turn to account for updates. turnManagementSystem.process() updates an Array of ints in OverworldScreen called unitOrder (the order in which units will get their turns). The next bit, activeEntity = unitOrder.get(0) gets the first unit from the list (the one about to go) and activeEntityCell (which we referenced earlier) gets set here. The last bit, cameraMovementSystem, moves the camera to be centered on the new active entity (we'll talk about it next).<br />
<br />
<b>TurnManagementSystem</b> <br />
<br />
Before looking at the code, let's discuss how it works. The idea is that each unit has something called Action Points. Every turn, a unit's action points are incremented by their speed, and once it hits 100, that unit gets a turn. But what if nobody has reached 100 action points?<br />
<br />
In this case, we ask "How many turns will each player need before reaching 100 points?" This can be calculated by taking <i>(100 - actionPoints) / speed</i>. (Note: If an entity has over 100 action points, this will return a negative number of turns - in this case we just want to make it say 0).<br />
<br />
Then, we look at the player with the fewest turns needed before reaching 100 action points. We just "skip" those turns, and instead of incrementing everyone's action points by their speed, we increment it by speed*turnsSkipped.<br />
<br />
We also need to remember who went, because they should have their action points reset to 0 next time.<br />
<br />
Now let's look at how it works in an example case. We have two units: A and B. A has speed 5, B has speed 4. To start with, both have 0 action points. On the first turn, we calculate that A needs (100-0)/5 = 20 turns, and B needs (100-0)/4 = 25 turns. Because A is closer to moving, we "skip" 20 turns, bringing them up to 100 action points, and B up to 4*20 = 80 action points. When A moves,<br />
their action points ought to reset to 0, then we repeat.<br />
<br />
A now needs (100-0)/5 = 20 turns, but B only needs (100-80)/4 = 5 turns. So we skip those 5 turns, giving B 100 action points, and A 5*5 = 25 action points. B moves and is reduced to 0 points.<br />
<br />
Now A needs (100-25)/5 = 15, B needs (100-0)/4 = 25 turns. We skip 15, and A is at 100, B is at 4*15 = 60. A moves and is reduced to 0 points.<br />
<br />
Okay, well that's all well and good, but it may also be nice to project out the next <i>x</i> turns to let the player know who's moving when. We can do that too. In the step where we figure out who moves next, we can just repeat this process over and over again, <i>pretending</i> like we're updating units' action points, and simulating what it will really look like. So without further ado, here's TurnManagementSystem:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.systems;
import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.EntitySystem;
import com.artemis.annotations.Mapper;
import com.artemis.utils.ImmutableBag;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.components.Stats;
import com.blogspot.javagamexyz.gamexyz.utils.MyMath;
public class TurnManagementSystem extends EntitySystem {
@Mapper ComponentMapper<Stats> sm;
private Array<Integer> unitOrder;
private Array<Sorter> sorter;
@SuppressWarnings("unchecked")
public TurnManagementSystem(Array<Integer> unitOrder) {
super(Aspect.getAspectForAll(Stats.class));
this.unitOrder = unitOrder;
sorter = new Array<Sorter>();
}
@Override
protected void processEntities(ImmutableBag<Entity> entities) {
// Get the ID of the last entity to move - because they will get reset to 0
// To be safe, first assume that this is the first turn, so that oldEntity = -1
int oldEntity = -1;
// We'll use this to store how many turns got skipped for the next "real" turn
float turnsSkipped = 1f;
// Then, if there is a list for unitOrder, the first entity was the one that
// moved last turn
if (unitOrder.size > 0) oldEntity = unitOrder.get(0);
// Now we just clear the unit list because it needs to be recalculated
unitOrder.clear();
sorter.clear();
// add the entity to the sorter array
for (int i=0; i<entities.size(); i++) {
Entity e = entities.get(i);
Stats stats = sm.get(e);
// Earlier we stored who moved last turn as oldEntity. Evidently they
// just moved, so we'll reset their actionPoints to 0.
if (e.getId() == oldEntity) stats.actionPoints = 0;
sorter.add(new Sorter(e.getId(), stats.actionPoints, stats.getSpeed(), stats.getAgility()));
}
// Come up with a list of the next 30 entities to move
for (int i = 0; i < 30; i++) {
// Sort the list based on turnsSkipped
sorter.sort();
// The first unit in the sorted list is the next unit to get a turn
unitOrder.add(sorter.get(0).id);
// In case this is the 1st time we're going through the loop, that means
// we're looking at the unit that actually gets to move this turn. Note
// how many turns it had to skip, because that's what we will ACTUALLY
// use to increment unit's actionPoints.
if (i == 0) turnsSkipped = sorter.get(0).turnsSkipped;
// Update everyone's actionPoints
for (int index = 1; index < sorter.size; index++) {
Sorter s = sorter.get(index);
s.actionPoints += (int)(sorter.get(0).turnsSkipped * s.speed);
s.calculateTurnsSkipped();
}
// The first character in the array just had a turn (real or inferred),
// so we'll set their actionPoints to 0.
sorter.get(0).actionPoints = 0;
sorter.get(0).calculateTurnsSkipped();
}
// Now we've made a list of the next 30 moves, but we didn't actually update
// any of the real entity's action points (that was all projecting into the
// future). Now we'll increment them all based on the turnsSkipped of the
// unit that actually gets to move (
for (int i=0; i<entities.size(); i++) {
Entity e = entities.get(i);
Stats stats = sm.get(e);
stats.actionPoints += stats.getSpeed() * turnsSkipped;
}
}
@Override
protected boolean checkProcessing() {
return true;
}
private static class Sorter implements Comparable<Sorter> {
public Sorter(int id, int actionPoints, int speed, int agility) {
this.id = id;
this.actionPoints = actionPoints;
this.speed = speed;
this.agility = agility;
calculateTurnsSkipped();
}
int actionPoints;
int speed;
int agility;
int id;
float turnsSkipped;
@Override
public int compareTo(Sorter other) {
// First try comparing how many "turns" each unit has to wait before its next turn
if (turnsSkipped > other.turnsSkipped) return 1;
if (turnsSkipped < other.turnsSkipped) return -1;
// They are equal, try speed next
if (speed < other.speed) return 1;
if (speed > other.speed) return -1;
// Speed failed, so let's check agility
if (agility < other.agility) return 1;
if (agility > other.agility) return -1;
// Barring all that, screw it
return 0;
}
public void calculateTurnsSkipped() {
turnsSkipped = (float)MyMath.max(0, 100-actionPoints)/(float)speed;
}
}
}
</pre>
<br />
Notice it extends EntitySystem, and processes all entities in the world with Stats. In its constructor, it gets passed the Array<Integer> unitOrder from OverworldScreen, and since Java arguments get passed by reference, anything that happens here happens in OverworldScreen too.<br />
<br />
I wanted to use Array.sort(), which means I needed a temporary class which implements Comparable to hold the relevant data. I (in a most uninspired decision) called this class Sorter, and the Array which will hold this stuff to sort is Array<Sorter> sorter. It holds information I think could be relevant to deciding who goes first. In compareTo(), we first compare turnsSkipped. If that's equal, we compare speed (the faster one gets to go first). If <i>that's</i> equal too, as a last resort we compare agility. If those are all equal, we admit the characters are equal and so the .sort() method just gives preference to whichever one it sees first.<br />
<br />
No let's look at processEntities(). The first thing we do is check to see if there is already a unitList. If there isn't, it means this is the first time we're running this. If there is, it means we ran it once before (to line everyone up at first), but since then somebody has moved. We need to remember who that was, so we store that in oldEntity.<br />
<br />
Next we loop over the entities to put them in Array<Sorter> sorter. In this loop, we grab oldEntity (the one which moved last turn) and set their actionPoints to 0.<br />
<br />
We then simulate the next 30 "real" turns. If we're going through the loop the first time, that means it represents the upcoming turn, so we specifically store how many turns had to be skipped to get here. Other than that, we update all the "dummy" Sorters' actionPoints, and do it again and again. After we get out of that, we need to process the "real" turn, so we loop over the entities again and update all their actionPoints based on the turnsSkipped for the next turn.<br />
<br />
<b>CameraMovementSystem</b><br />
<br />
Now, characters take their turn in which they can move once, attack once, and that's it. Of course, it can be a HUGE pain in the butt to actually figure out whose turn it is!!! I made a system to smoothly move the camera to new locations. My rough idea is that I wanted it to move slowly for a second, speed up to some maximum speed halfway there, then slow down again to a nice, gentle stop. In other words, I wanted each coordinate to look something like this:<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEga_yzuFPBDfB0p42VGIGzxKa-yWz-xwdUpCXTkGW2Nl4Kw7Dl60vI4TthUeW9ejmizuWFr-zvvlFiBYMX92Dku_VJA14OZDzWX9Y5pkq9_WGlEBaUD8eM8S73Hqw5ZvnsjtOgOZQlUVdc/s1600/camera.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEga_yzuFPBDfB0p42VGIGzxKa-yWz-xwdUpCXTkGW2Nl4Kw7Dl60vI4TthUeW9ejmizuWFr-zvvlFiBYMX92Dku_VJA14OZDzWX9Y5pkq9_WGlEBaUD8eM8S73Hqw5ZvnsjtOgOZQlUVdc/s1600/camera.png" /></a></div>
I just needed to know how to adjust the speed to make sure it gets from point A to point B correctly. For that I had to design a crappy heuristic for how long I wanted the transition to take before I could do that - I won't talk about it too much, but it's short for nearby cells, longer for farther away cells, but as you get farther and farther, there's an economy of scale where moving 30 cells takes less than twice the time to move 15 cells.<br />
<br />
I decided I would always try to process CameraMovementSystem, but it would just return unless it had a destination. The biggest problem I ran into was determining which cell the camera started out focused on. At first I used window2world(x,y,camera), but that doesn't actually work. That get's the cell a user clicks on from "window space", but those coordinates have to be unprojected by the camera to fall into the actual coordinate system for the game (not just the coordinate system for the window). But the camera's position isn't in window space, it's in the natural coordinate system for the game - what I will now call "libgdx space". The code for libgdx2world(float x, float y) is the exact same as window2world(float x, float y, camera), but it doesn't use a camera to unproject the coordinates into libgdx space - it assumes the coordinates are already there.<br />
<br />
Here's the code for CameraMovementSystem:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.systems;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.math.Vector3;
import com.blogspot.javagamexyz.gamexyz.custom.FloatPair;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
public class CameraMovementSystem {
private float t;
private float x0, x1, y0, y1;
private OrthographicCamera camera;
private float T;
private boolean active;
public CameraMovementSystem(OrthographicCamera camera) {
this.camera = camera;
active = false;
}
public void move(int x1, int y1) {
Vector3 position = camera.position;
x0 = position.x;
y0 = position.y;
FloatPair p = MapTools.world2window(x1, y1);
this.x1 = p.x;
this.y1 = p.y;
t=0;
Pair start = MapTools.libgdx2world(x0, y0);
// d is used to calculate how long it will take to get to the target cell.
// If it is close, d is small - if it is far, d is large
// Very close by, d is similar to how many cells away it is
// For longer distances, it grows as sqrt(distance)
float d = (float)Math.sqrt(MapTools.distance(start.x, start.y, x1, y1) + 0.25) - 0.5f;
T = 0.4f + d/4f;
active = true;
}
public void process(float delta) {
if (!active) return;
float vx, vy;
float Ax = 6*(x1-x0)/(T*T);
float Ay = 6*(y1-y0)/(T*T);
vx = Ax*(t)*(1-t/T);
vy = Ay*(t)*(1-t/T);
camera.translate(new Vector2(vx*delta,vy*delta));
t += delta;
if (t > T) {
active = false;
}
}
}
</pre>
<br />
In OverworldScreen, you just need to make sure to initialize it and process it every time. I decided to call cameraMovementSystem.move(x,y) every time I process a turn to focus on the new character, as well as every time the player moves a character, to focus on their destination. I did the first in a method called processTurn() in OverworldScreen which basically runs turnManagementSystem, stores the ID and location of the active entity, then runs cameraMovementSystem.move().<br />
<br />
So that's it for this time - there was a little code I didn't exactly specify but I think it should be clear when/where/how to process these new parts. But as a reminder, be sure to <a href="https://code.google.com/p/javagamexyz/source/browse/#svn%2Ftags%2F2013-04-22" target="_blank">check out the code from the repository</a> to stay up to date.<br />
<br />
<span style="color: #bf9000;"><b>You have gained 200 XP. Progress to Level 4: 450 / 700</b></span>Unknownnoreply@blogger.com2tag:blogger.com,1999:blog-4125624026143571209.post-51399202438858713542013-04-05T16:41:00.002-07:002013-05-16T20:24:00.439-07:00User Interface: Menus using libgdx scene2dui<table bgcolor="#DD3030"><tbody>
<tr><td><b><span style="color: black;">UPDATE: I have extended this tutorial to a "part 2" <a href="http://javagamexyz.blogspot.com/2013/05/user-interface-menus-with-scene2dui-and.html">here</a> in which I develop much more interesting menus, as well as discuss <a href="http://code.google.com/p/libgdx/wiki/Skin">Skins</a>. For a comparison, here's a look at the Menu we finish developing here:</span></b><br />
<b><br /></b>
<div class="separator" style="clear: both; text-align: center;">
<b><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEguPPiGfLG2mjb84cUJwMuGAqURJeH7UZLJt8iS38STUX2yKeZIF9kOUgdg1vxGAJ4GNrTBNKylLp3Wt6GBVx8N9KZ7fJRypBMhxngmIFObXXfjFdm9rP1vfjmFOgDhKVJAh6pUNzURN00/s1600/menu.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="250" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEguPPiGfLG2mjb84cUJwMuGAqURJeH7UZLJt8iS38STUX2yKeZIF9kOUgdg1vxGAJ4GNrTBNKylLp3Wt6GBVx8N9KZ7fJRypBMhxngmIFObXXfjFdm9rP1vfjmFOgDhKVJAh6pUNzURN00/s320/menu.png" width="320" /></a></b></div>
<b><br /></b>
<b><span style="color: black;">And here's a static glimpse of how it ends up in the next part:</span></b><br />
<div class="separator" style="clear: both; text-align: center;">
<b><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiRzJ8PHoZxTdIDfafO61CIgfiSQoYD_8kl0XGb1qLHMupJObjtnIL-UTn2WscO7js0Yw5xOhwYM_BUm7hh37SqU9pEwRjTv1q4WVvmnN8VPrBFHxgT5C3FILfLTPaJ4WH7OLFJLMp9CmE/s1600/modern_menu_scrollbar.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="227" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiRzJ8PHoZxTdIDfafO61CIgfiSQoYD_8kl0XGb1qLHMupJObjtnIL-UTn2WscO7js0Yw5xOhwYM_BUm7hh37SqU9pEwRjTv1q4WVvmnN8VPrBFHxgT5C3FILfLTPaJ4WH7OLFJLMp9CmE/s320/modern_menu_scrollbar.png" width="320" /></a></b></div>
<b><br /></b>
<b><span style="color: black;">I would still recommend starting here, because I will assume that you are at least familiar with how to use the widgets, ChangeListeners, etc...</span>
</b></td></tr>
</tbody></table>
<br />
I would never describe myself as an actual programmer, just somebody who knows how to program. And I wouldn't even go that far for considering myself as an artist. I don't know diddly squat about art.<br />
<br />
This all came together to make developing a menu system extremely painful. I found myself not wanting to work on it. I found myself wishing I was doing almost anything else. As a consequence, I did almost nothing on it, and what little I did I hated doing.<br />
<br />
Finally I decided I just have to do the bare minimum and will move on. If this game starts coming together later, I will freakin' deal with it then...<br />
<br />
I ended up using the <a href="https://code.google.com/p/table-layout/" target="_blank">table-layout</a> project combined with libgdx's <a href="http://code.google.com/p/libgdx/wiki/scene2dui" target="_blank">scene2dui</a>. Another, much better, general tutorial for this stuff is over at <a href="http://steigert.blogspot.com/2012/03/4-libgdx-tutorial-tablelayout.html" target="_blank">Andrew Steigert's blog</a>. I'm going to talk about a few more features than he touches on, and show how I implemented it all in JaveGameXYZ.<br />
<br />
First, scene2dui is a pretty cool set of tools, or widgets, that you can place to allow you to handle different kinds of user input. They have<br />
<ul>
<li>Button</li>
<ul>
<li>Empty "Button" (just called Button)</li>
<li>TextButton</li>
<li>ImageButton</li>
<li>ButtonGroup</li>
</ul>
<li>Image </li>
<li>TextLabel</li>
<li>TextField</li>
<li>CheckBox</li>
<li>SelectBox (drop down lists)</li>
<li>Slider</li>
<li>Window (like an actual MS Windows looking box with a title bar)</li>
<li>ScrollPane (like a window with a scrollbar on the side)</li>
<li>SplitPane (like HTML frames) </li>
<li>Touchpad (an onscreen joystick)</li>
<li>Tree (a collapsible list)</li>
<li>Dialog (a window with some place to add text, and some place to add buttons - like "OK" or whatever)</li>
</ul>
Each of these has built in convenience methods, like the ability to check when a button is pressed, click-drag to scroll along a scrollpane (including the fancy smartphone-esque feature where you touch-drag beyond the end of a scrollpane, and it goes a little bit but snaps back to where it should be as soon as you let go - you know what I mean), etc...<br />
<br />
They also each have a "style" which can (when appropriate) set things like the font, the background image, etc. These styles have different settings so that you can assign separate images for when a button is just sitting there, being hovered over, clicked, etc...<br />
<br />
Furthermore, most widgets can have other widgets added to them - like you can add a label to a button, or you can add anything to a ScrollPane, etc.<br />
<br />
You can also lay these widgets out manually, or you can use the table-layout package to make your life a little easier. Whether you add them to a table or not, you must add them (or the table, or whatever) to a Stage. In the libgdx scene2d language, each of these widgets is an "Actor", and actors must be placed on a "Stage".<br />
<br />
My first thought was to build a menu with the following stucture:<br />
ScrollPane {<br />
Table {<br />
Button<br />
Button<br />
...<br />
}<br />
}<br />
<br />
I would assign a default size to the ScrollPane, and if the table became too big (hopefully only too long) the user would be able to scroll along it. They could also click any menu item, which would be a button without really looking like a typical beveled edge button box, and then it would process whatever they had clicked on.<br />
<br />
This would all fit inside a Stage held in OverworldScreen, and depending on the state of the game, I would display the stage and process user clicks.<br />
<br />
I also wanted to be able to build a menu individually depending on which entity was selected. For instance, some entities might not be able to move, or might have different actions available to them. I wanted the menu to reflect specifically what actions they could perform.<br />
<br />
I also wanted them to look maybe <i>kind</i> of cool - like have a nice texture background, similar to Final Fantasy Tactics.<br />
<img height="272" id="irc_mi" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjv-30KiyqMRixsm0-Be1ygj1ezEU-xmo4v863PTTFJGRw3aiMdSc4eIjMzRpiSM_g94KJTGALZoIsrknUkaWHu4twhgcCPgf2_n9h8KsrY5-aEOxaWv8VXdmhcxaxSF35jXLiYXsiiF0g/s1600/fft2.jpg" style="margin-top: 64px;" width="480" /> <br />
<br />
Notice that both the nameplate and menu have a nice, subtle, textured background + beveled edges. Tasteful.<br />
<br />
Well what I got was a far cry from any of that, but the basic architecture was about right.<br />
<br />
First things first, check out the updated OverworldScreen.java<br />
<b><span style="color: red;">Update: Blogger</span></b><span style="color: red;"><b> (or probably "I") somehow buggered the HTML for this set of code up, and consequently most of it got lost. By now, OverworldScreen has changed a bit, and it would be a pain in the but to try to recall exactly what it had been. I will post the full thing here - the parts you are interested in are <i>selectedMove()</i>, and <i>append/set/prepend/removeInputSystems()</i>. This is actually the OverworldScreen used for the next update, so there will be some bits that don't actually work for you yet, but those methods are the ones I wanted you to focus on now.</b></span><br />
<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.screens;
package com.blogspot.javagamexyz.gamexyz.screens;
import com.artemis.Entity;
import com.artemis.World;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputMultiplexer;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.EntityFactory;
import com.blogspot.javagamexyz.gamexyz.GameXYZ;
import com.blogspot.javagamexyz.gamexyz.components.Movable;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
import com.blogspot.javagamexyz.gamexyz.renderers.MapHighlighter;
import com.blogspot.javagamexyz.gamexyz.renderers.MapRenderer;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.OverworldAttackController;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.OverworldDragController;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.OverworldMovingController;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.OverworldSelectorController;
import com.blogspot.javagamexyz.gamexyz.systems.CameraMovementSystem;
import com.blogspot.javagamexyz.gamexyz.systems.DamageSystem;
import com.blogspot.javagamexyz.gamexyz.systems.FadingMessageRenderSystem;
import com.blogspot.javagamexyz.gamexyz.systems.HudRenderSystem;
import com.blogspot.javagamexyz.gamexyz.systems.MovementSystem;
import com.blogspot.javagamexyz.gamexyz.systems.SpriteRenderSystem;
import com.blogspot.javagamexyz.gamexyz.systems.TurnManagementSystem;
public class OverworldScreen extends AbstractScreen {
public static GameMap gameMap;
private OrthographicCamera hudCam;
private SpriteRenderSystem spriteRenderSystem;
private HudRenderSystem hudRenderSystem;
private FadingMessageRenderSystem fadingMessageRenderSystem;
private TurnManagementSystem turnManagementSystem;
private MapRenderer mapRenderer;
private MapHighlighter mapHighlighter;
public int selectedEntity;
public int activeEntity;
public Pair activeEntityCell;
public Array<Pair> highlightedCells;
public boolean renderMap;
public boolean renderMovementRange;
public boolean renderAttackRange;
public InputMultiplexer inputSystem;
public Stage stage;
public boolean handleStage;
public int cursor;
public OverworldDragController controllerDrag;
public OverworldSelectorController controllerSelector;
public OverworldMovingController controllerMoving;
public OverworldAttackController controllerAttack;
public Array<Integer> unitOrder;
public boolean moved = false;
public boolean attacked = false;
public CameraMovementSystem cameraMovementSystem;
private boolean firstShow = true;
public OverworldScreen(GameXYZ game, SpriteBatch batch, World world) {
super(game,world,batch);
cameraMovementSystem = new CameraMovementSystem(camera);
activeEntityCell = new Pair(0,0);
gameMap = new GameMap();
unitOrder = new Array<Integer>();
setupWorld();
setupInputSystems();
fillWorldWithEntities();
selectedEntity = -1;
renderMap = true;
renderMovementRange = false;
renderAttackRange = false;
stage = new Stage();
handleStage = false;
}
@Override
public void render(float delta) {
super.render(delta);
if (firstShow) {
cameraMovementSystem.move(activeEntityCell.x, activeEntityCell.y);
firstShow = false;
}
if (renderMap) {
mapRenderer.render();
spriteRenderSystem.process();
}
if (renderMovementRange) {
mapHighlighter.render(highlightedCells,0f,0f,0.2f,0.3f);
}
fadingMessageRenderSystem.process();
if (renderAttackRange) {
mapHighlighter.render(highlightedCells,0.5f,0f,0f,0.3f);
}
if (handleStage) {
stage.act(delta);
stage.draw();
}
hudRenderSystem.process();
cameraMovementSystem.process(delta);
}
@Override
public void show() {
}
@Override
public void resize(int width, int height) {
super.resize(width, height);
hudCam.setToOrtho(false, width, height);
stage.setViewport(width, height, true);
}
@Override
public void hide() {
// TODO Auto-generated method stub
}
@Override
public void pause() {
// TODO Auto-generated method stub
}
@Override
public void resume() {
// TODO Auto-generated method stub
}
@Override
public void dispose() {
// TODO Auto-generated method stub
world.deleteSystem(hudRenderSystem);
world.deleteSystem(spriteRenderSystem);
world.deleteSystem(world.getSystem(MovementSystem.class));
}
public void selectedMove() {
if (moved) return;
removeInputSystems(stage);
appendInputSystems(controllerMoving);
Entity e = world.getEntity(selectedEntity);
Movable movable = e.getComponent(Movable.class);
highlightedCells = gameMap.pathFinder.getReachableCells(activeEntityCell.x, activeEntityCell.y, movable);
renderMovementRange = true;
handleStage = false;
stage.clear();
}
public void selectedAttack() {
if (attacked) return;
setInputSystems(controllerDrag,controllerAttack,controllerSelector);
highlightedCells = MapTools.getNeighbors(activeEntityCell.x, activeEntityCell.y);
renderAttackRange = true;
handleStage = false;
stage.clear();
}
public void selectedWait() {
setInputSystems(controllerDrag, controllerSelector);
processTurn();
handleStage = false;
stage.clear();
selectedEntity = -1;
moved = attacked = false;
}
public void appendInputSystems(InputProcessor... processors) {
for (int i = 0; i < processors.length; i++) inputSystem.addProcessor(processors[i]);
}
public void setInputSystems(InputProcessor... processors) {
inputSystem = new InputMultiplexer(processors);
Gdx.input.setInputProcessor(inputSystem);
}
public void prependInputSystems(InputProcessor... processors) {
InputMultiplexer newMultiplexer = new InputMultiplexer();
for (int i = 0; i < processors.length; i++) {
newMultiplexer.addProcessor(processors[i]);
}
for (InputProcessor p : inputSystem.getProcessors()) {
newMultiplexer.addProcessor(p);
}
inputSystem = newMultiplexer;
Gdx.input.setInputProcessor(inputSystem);
}
public void removeInputSystems(InputProcessor... processors) {
for (int i = 0; i < processors.length; i++) {
inputSystem.removeProcessor(processors[i]);
}
}
private void setupWorld() {
hudCam = new OrthographicCamera();
mapRenderer = new MapRenderer(camera,batch,gameMap.map);
mapHighlighter = new MapHighlighter(camera, batch);
world.setSystem(new MovementSystem(gameMap));
world.setSystem(new DamageSystem(gameMap));
spriteRenderSystem = world.setSystem(new SpriteRenderSystem(camera,batch), true);
hudRenderSystem = world.setSystem(new HudRenderSystem(hudCam, batch),true);
fadingMessageRenderSystem = world.setSystem(new FadingMessageRenderSystem(camera,batch),true);
turnManagementSystem = world.setSystem(new TurnManagementSystem(unitOrder), true);
world.initialize();
System.out.println("The world is initialized");
}
private void setupInputSystems() {
controllerSelector = new OverworldSelectorController(camera,world,gameMap,this);
controllerMoving = new OverworldMovingController(camera,world,gameMap,this);
controllerDrag = new OverworldDragController(camera);
controllerAttack = new OverworldAttackController(camera,world,gameMap,this);
setInputSystems(controllerDrag, controllerSelector);
}
private void fillWorldWithEntities() {
int x, y;
for (int i=0; i<5; i++) {
do {
x = MathUtils.random(MapTools.width()-1);
y = MathUtils.random(MapTools.height()-1);
} while (gameMap.cellOccupied(x, y));
EntityFactory.createNPC(world,x,y,gameMap).addToWorld();
}
Entity e = EntityFactory.createCursor(world);
cursor = e.getId();
e.addToWorld();
// You have to process the world once to get all the entities loaded up with
// the "Stats" component - I'm not sure why, but if you don't, the bag of entities
// that turnManagementSystem gets is empty?
world.process();
// Running processTurn() once here initializes the unit order, and selects the first
// entity to go
processTurn();
}
public void processTurn() {
turnManagementSystem.process();
activeEntity = unitOrder.get(0);
activeEntityCell = gameMap.getCoordinatesFor(activeEntity);
if (activeEntityCell != null) cameraMovementSystem.move(activeEntityCell.x, activeEntityCell.y);
}
}
</pre>
It has a Stage plus a boolean handleStage. Down in render(), if handleStage is true, it processes the stage (stage.act(delta)) and then draws it. Also, at the end, I added a few convenience methods to try to streamline the input processor management. appendInputSystems() adds an array of InputProcessors to the end. setInputSystems() replaces the current input system with all the ones given. prependInputSystems() puts an array of InputProcessors at the front of the multiplexor.<br />
<br />
Above that, on lines 171-181, I added a method which will be called if the user actually clicks on "Move" from the menu. We'll get to when that happens, but for now, just notice that it removes the stage from the input multiplexer, it adds the movement controller, and finds and displays all the reachable cells, then finally clears the stage out and sets handleStage to false (we want the menu to disappear now that we've made our selection).<br />
<br />
In OverworldDefaultController, instead of assuming the user always means to "Move", we generate a menu. To do that, we tell the Screen to handleStage, we clear the stage (in case there's some old menu hanging around), and add a new menu.
<br />
<pre class="brush:java"> public boolean touchUp(int screenX, int screenY, int pointer, int button) {
if (dragging) {
dragging = false;
return true;
}
// Get the coordinates they clicked on
Vector3 mousePosition = new Vector3(Gdx.input.getX(), Gdx.input.getY(),0);
Pair coords = MapTools.window2world(mousePosition.x, mousePosition.y, camera);
// Check the entityID of the cell they click on
int entityId = gameMap.getEntityAt(coords.x, coords.y);
// If it's an actual entity (not empty) then "select" it (unless it's already selected)
if ((entityId > -1) && (entityId != screen.selectedEntity)) {
// Now select the current entity
screen.selectedEntity = entityId;
EntityFactory.createClick(world, coords.x, coords.y, 0.1f, 4f).addToWorld();
screen.handleStage = true;
screen.stage.clear();
screen.stage.addActor(MenuBuilder.buildMenu(screen));
screen.setInputSystems(screen.stage,this);
screen.renderMovementRange = false;
screen.reachableCells = null;
return true;
}
</pre>
Handling the menu input is easy because a Stage is a special kind of InputProcessor, so we set our multiplexor to <b>stage</b> and <b>this</b>. That means any input will be processed by the menu first (if it can).<br />
<br />
However, if the user click-drags off the menu, or selects any other entity they can see the game will process those clicks. In case a user had already selected "Move", and THEN click on another entity (which is still valid), that entity will be selected and a menu will appear for them.
However, because the movement range for the first entity is still being shown, we have to shut off renderMovementRange, and for good measure, clear the reachable cells we had found (if nothing else, down the line we might get a null-pointer exception if we reference the reachableCells when we shouldn't have - setting it to null might make those problems easier to detect).<br />
<br />
This wasn't a problem before, because every time you selected an entity, it was assumed that you wanted to move, so reachableCells were recalculated and shown. However, now, we aren't jumping to conclusions about that, so reachableCells are no longer automatically recalculated.<br />
<br />
The MenuBuilder.buildMenu(screen) command will hopefully someday look like MenuBuilder.buildMenu(screen, entity) because different entities should have different options on their menu. For now, I don't care. Let's take a look at MenuBuilder:
<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.ui;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.Texture.TextureFilter;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.ui.Button;
import com.badlogic.gdx.scenes.scene2d.ui.Button.ButtonStyle;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.Label.LabelStyle;
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane;
import com.badlogic.gdx.scenes.scene2d.ui.Table;
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener;
import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable;
import com.blogspot.javagamexyz.gamexyz.screens.OverworldScreen;
public class MenuBuilder {
public static ScrollPane buildMenu(final OverworldScreen screen) {
Texture fontTexture = new Texture(Gdx.files.internal("fonts/irisUPC.png"));
fontTexture.setFilter(TextureFilter.Linear, TextureFilter.MipMapLinearLinear);
TextureRegion fontRegion = new TextureRegion(fontTexture);
BitmapFont font = new BitmapFont(Gdx.files.internal("fonts/irisUPC.fnt"), fontRegion, false);
font.setUseIntegerPositions(false);
ButtonStyle style = new ButtonStyle();
style.up= new TextureRegionDrawable(new TextureRegion(new Texture(Gdx.files.internal("textures/misc/button_down.png"))));
style.unpressedOffsetX = 5f;
style.pressedOffsetX = style.unpressedOffsetX + 1f;
style.pressedOffsetY = -1f;
LabelStyle lStyle = new LabelStyle();
lStyle.font = font;
Table mainTable = new Table();
mainTable.defaults().width(80);
ScrollPane scrollPane = new ScrollPane(mainTable);
scrollPane.setFillParent(false);
scrollPane.setX(-10);
scrollPane.setY(screen.stage.getHeight()-250);
Button b1 = new Button(style);
b1.add(new Label("Move",lStyle));
b1.left();
b1.addListener(new ChangeListener() {
public void changed(ChangeEvent event, Actor actor) {
screen.selectedMove();
}
});
mainTable.add(b1);
mainTable.row();
Button b2 = new Button(style);
b2.add(new Label("Attack",lStyle));
b2.left();
b2.addListener(new ChangeListener() {
public void changed(ChangeEvent event, Actor actor) {
System.out.println("Attack");
}
});
mainTable.add(b2);
mainTable.row();
// Make a bunch of filler buttons
for (int i = 0; i < 10; i++) {
Button b3 = new Button(style);
b3.add(new Label("Wait",lStyle));
b3.left();
b3.addListener(new ChangeListener() {
public void changed(ChangeEvent event, Actor actor) {
System.out.println("Wait");
}
});
mainTable.add(b3);
mainTable.row();
}
return scrollPane;
}
}
</pre>
The first block of code is me making a BitmapFont out of a texture and font reference file. I made this BitmapFont using the <a href="http://www.badlogicgames.com/wordpress/?p=1247" target="_blank">HIERO</a> tool which you can get and learn about. I just used a system font, but you can use a TrueTypeFont (.ttf) file instead. It's got lots of cute options for effects - I think I made mine bold, and white.<br />
<br />
The next block sets the style I'm going to use for my buttons. I thought at first to go with TextButtons, but was annoyed that the text was centered on each button. I wanted my menu layout to be left-aligned, and I couldn't find the right TextButton property to force that. So instead I went with regular Buttons, and each button also has a Label actor in it, moved to the left of the button.
So my button style doesn't have much.<br />
<br />
I set a background image as style.up (which can't be a Texture or TextureRegion, but must be a Drawable - or maybe a NinePatch, which is a type of image where the corners maintain a fixed aspect ratio, but the middle parts stretch so you can fill however large an area you need, without losing an attractive/high-res bevel edge, or rounded corner). For now I just made a png with 1 gray pixel. The unpressedOffsetX gives the labels a little buffer on the left sides of words, and the pressed offsets make it easier to see what you actually pressed.<br />
<br />
Below that a make the Label style, which is really just the font.<br />
<br />
Then I declare the Table, and make it so it's children have a default width of 80, so all the buttons will be the same size.<br />
<br />
Next I make the ScrollPane. This is the main container that everything is being put into. It won't take up the entire screen, and it's position is hardcoded (the contents show up under the HUD stuff now).<br />
<br />
Next we make our first Button - I lovingly called it b1. It gets the button style I had previously defined (offsets, background, etc...). I also add a new Label to it with the text "Move" and the given style. Ultimately I'd like to only add this option if the Entity selected actually has the Movable component. b1.left() makes it so the label aligns to the left (which then gets pushed a <i>little</i> to the right by unpressedOffsetX).<br />
<br />
The main thing is adding the changeListener(). This is better than clickListener() because it potentially allows users to fire it using methods other than clicks. In the changeListener(), we call screen.selectedMove() - for convenience I like keeping the code on the screen instead of the menu generator. Notice - screen had to be passed as a <b>final</b> variable for us to be allowed to use it inside the changeListener. If you go to the constructor and remove the final modifier from <b>final</b> OverworldScreen screen it won't work. Since we're not changing screen, that shouldn't be a problem.<br />
<br />
Next I make a button for "Attack", then a boatload of buttons that all say "Wait". I did that just to see how well it handled the scrolling, which is fun to play with. It won't have a scrollbar, if we wanted that we would have to make a ScrollPaneStyle style variable, and set an image for the scrollbar, and tell it to display it, and decide if we wanted it fading out when the users mouse was gone for a few seconds, and whatever else. But I don't Really want it.<br />
<br />
The buildMenu() method returns the scrollPane filled with the table and buttons (which don't look like buttons), which OverworldDefaultController then adds to screen.stage and the input multiplexor.<br />
<br />
I'd like to someday actually make a <a href="http://code.google.com/p/libgdx-users/wiki/Skins" target="_blank">skin </a>file to take care of all the styles more easily, and play with NinePatches. One thing I noticed is that background images stretch, with no anti-aliasing. Thus, if we use a background that's just a 2x2 pixel square, each pixel a different shade, it doesn't stretch up with a nice gradient effect or anything. It just looks like a big stupid 4 color flag. What I'd like to do to get those FFT-esque menus with their nice texture, is design a texture that will repeat-fill the menu, not stretch. I don't really know if that is possible here.<br />
<br />
It's a pretty hideous menu, and not at all specialized to different units' capabilities yet, but at least it's a start and I'm dying to move on!
<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj9ljyN0oWUIr0GgizRpyVliY8Ty1Tz3WRJeVU3vRakPPmLNYaiLpqUnCruPBHL5P1TAeGRNbgdBogjCmIgB5vEHXDx1ZOnLaicyt3UgcXnTgBp9TWbQhRxMJljoiXH-GeNhe5-I6xaXAw/s1600/menu.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="499" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj9ljyN0oWUIr0GgizRpyVliY8Ty1Tz3WRJeVU3vRakPPmLNYaiLpqUnCruPBHL5P1TAeGRNbgdBogjCmIgB5vEHXDx1ZOnLaicyt3UgcXnTgBp9TWbQhRxMJljoiXH-GeNhe5-I6xaXAw/s640/menu.png" width="640" /></a></div>
<br />
Woooooo... I guess.<br />
<br />
<span style="color: #bf9000;"><b>You have gained 50 XP. Progress to Level 4: 250/700</b></span><!--100-->
Unknownnoreply@blogger.com4tag:blogger.com,1999:blog-4125624026143571209.post-89649298309583363082013-03-23T12:19:00.001-07:002013-03-23T12:19:30.302-07:00Player Movement and User Input (Level 3)The goal of this post is to expand to add the following features:<br />
<ul>
<li>Players select an entity by clicking on it</li>
<li>Once an entity is selected, the tiles it can reach are highlighted</li>
<li>If a player selects one of the highlighted cells, the entity smoothly follows a path from the source tile to the target. Afterwords* the player is deselected</li>
<li>If a player clicks anywhere not highlighted, the entity is deselected and the highlighted cells go away</li>
</ul>
*The <i>idea</i> is that it happens afterwords. There is currently a (probably not very bad) bug where before the entity has reached its goal, the player can select that target cell causing the entity to be reselected before reaching it. This has problems if the player then clicks another cell to move to before the entity has finished its first path, and while the entity will move there, its position will still be the original target cell (I know why it happens, and will discuss it in the following code.)<br />
<br />
I'll start by talking about how I handled the smooth movement. We have two components related to motion:<br />
<ul>
<li>Movable</li>
<li>Movement</li>
</ul>
Movement is a temporary component that actually holds the path the entity is following. <br />
<pre class="brush:java">
package com.blogspot.javagamexyz.gamexyz.components;
import com.artemis.Component;
import com.blogspot.javagamexyz.gamexyz.pathfinding.Path;
public class Movement extends Component {
public Path path;
public float elapsedTime;
public Movement(Path path) {
this.path = path;
elapsedTime = 0;
}
}
</pre>
<br />
It also has a field called elapsedTime which will be used to pace the smooth movement animation.<br />
<br />
Movable is the more general component which entities will have if they <i>can</i> be moved. It stores information such as what kinds of tiles the unit can move across, how far it can move, and how quickly it completes the movement animation.<br />
<pre class="brush:java">
package com.blogspot.javagamexyz.gamexyz.components;
import com.artemis.Component;
public class Movable extends Component {
public float energy; // Roughly how far can you go?
public float slowness; // How many seconds it takes to slide from 1 tile to an adjacent
public boolean[] terrainBlocked;
public float[] terrainCost;
public Movable(float energy, float slowness) {
this.energy = energy;
this.slowness = slowness;
terrainBlocked = new boolean[9];
terrainCost = new float[9];
for (int i = 0; i < terrainBlocked.length; i++) {
terrainBlocked[i] = false;
terrainCost[i] = 1.5f*Math.abs(i-4)+1;
}
}
}
</pre>
<br />
Line 7 holds the total accumulated cost this entity can move across (sum of all tile costs for a given path). Line 8 holds how many seconds it takes for the animation to move the entity by one tile. For each terrain type, lines 10-11 tell the entity if it can move to that terrain, and how much energy it costs to do so. For now in the constructor I say that units can move on all cells, and have a cost given by the function on line 22 (high cost for the extremes like deep water or mountain peaks, low cost for midlands like grass).<br />
<br />
I created an "animation" system called MovementSystem to handle the smooth animation.<br />
<pre class="brush:java">
package com.blogspot.javagamexyz.gamexyz.systems;
import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.annotations.Mapper;
import com.artemis.systems.EntityProcessingSystem;
import com.badlogic.gdx.Gdx;
import com.blogspot.javagamexyz.gamexyz.components.MapPosition;
import com.blogspot.javagamexyz.gamexyz.components.Movable;
import com.blogspot.javagamexyz.gamexyz.components.Movement;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.pathfinding.Path;
public class MovementSystem extends EntityProcessingSystem {
@Mapper ComponentMapper<Movement> mm;
@Mapper ComponentMapper<MapPosition> pm;
@Mapper ComponentMapper<Movable> movem;
GameMap gameMap;
@SuppressWarnings("unchecked")
public MovementSystem(GameMap gameMap) {
super(Aspect.getAspectForAll(Movement.class, MapPosition.class, Movable.class));
this.gameMap = gameMap;
}
@Override
protected void inserted(Entity e) {
Path path = mm.get(e).path;
// If the path was null (somehow) remove the movable component and get out of here!
if (path == null) {
e.removeComponent(mm.get(e));
e.changedInWorld();
}
// As far as the gameMap is concerned, move the entity there right away
// (The animation is just for show)
else gameMap.moveEntity(e.getId(), path.getX(0), path.getY(0));
// Here we can also change the NPC's animation to a walking one
}
@Override
protected void process(Entity e) {
Movement movement = mm.get(e);
MapPosition pos = pm.get(e);
// Get the speed with which we move
float slowness = movem.get(e).slowness;
// Read the path and get it's length
Path path = movement.path;
int size = path.getLength();
// Calculate what step we are on (e.g. cell_0 to cell_1, cell_1 to cell_2, etc...)
int step = (int)(movement.elapsedTime/slowness);
// Check to see if they've reached the end / gone beyond)
if (size - 2 - step < 0) {
// At the end of the day, no matter what, make sure the entity ended up where
// it belonged.
pos.x = path.getX(0);
pos.y = path.getY(0);
// Remove the movement component and let them be on their way
e.removeComponent(movement);
e.changedInWorld();
return;
}
// Otherwise we must still be on the way
// Get the coordinates of cell_i and cell_(i+1)
int x0 = path.getX(size - 1 - step);
int y0 = path.getY(size - 1 - step);
int x1 = path.getX(size - 2 - step);
int y1 = path.getY(size - 2 - step);
// Determine how close we are to reaching the next step
float t = movement.elapsedTime/slowness - step;
// Set position to be a linear interpolation between these too coordinates
pos.x = x0 + t * (x1-x0);
pos.y = y0 + t * (y1-y0);
// Increase the time animation has been running
movement.elapsedTime += Gdx.graphics.getDeltaTime();
}
@Override
protected void removed(Entity e) {
// Here we can reset the entity's animation to the default one
}
@SuppressWarnings("unused")
private void changeStep(int x0, int y0, int x1, int y1) {
// Here we can maybe change the animation based on which direction the npc
// is moving. Call MapTools.getDirectionVector(x0,y0,x1,y1) to see
// which direction entity is moving.
}
}
</pre>
<br />
Notice it processes entities that have the Movement (which is a temporary component), Movable, and MapPosition components. It also has a reference to the GameMap so that it can update the entity's position in the map (this part is related to the bug mentioned at the top).<br />
<br />
Upon insertion, the GameMap is immediately updated to believe that the entity has reached its destination on line 40. Entities are "inserted" to the system as soon as they have a combination of all 3 required components. Usually the entity has completes its path and has the Movement component removed naturally at the end. If they got <i>another</i> Movement component before they finish the first path, the 2nd one won't trigger the "inserted" method, and so the GameMap won't be updated for the 2nd path. Everything else will work - the entity will follow a path and <i>look</i> like it has moved. It just won't have according to the map, which is probably more important.<br />
<br />
The idea of the process method is that we compare elapsedTime to slowness to determine how many steps we have gone through. Say we are on the 1st step (or step = 0). That means we want to move from path(size-1) to path(size-2) (remember, the path is stored in reverse so path(0) is the target, path(size-1) is the starting cell). On line 58 we figure out which step we are on.<br />
<br />
We'll skip lines 61-71 for now, but below that we get the coordinates of the tiles we are moving from and to for this particular step. On line 82 we figure out how far along that path we are (scaled from 0 inclusive to 1 exclusive). Remember step was just <i>movement.elapsedTime/slowness</i>, but rounded <b>down</b> to an integer. So by subtracting here, we get just the decimal part that was truncated. In lines 85-86 we just do a linear interpolation between the two tiles. Note that when t=0, we just go to (x0,y0). When t=1, it would be (x1,y1).<br />
<br />
After that we just increase elapsedTime and call it good! Lines 61-71 sort of form a failsafe. I remember when I was working on Spaceship Warrior, I played a lot with the particle explosions. If there were too many, things would get laggy, and a lot of time would elapse between steps. Consequently the particles sometimes jumped <i>WAY</i> farther than they should have ever gone before fading away. This wasn't so bad - they didn't do anything and disappeared soon anyway. But in case that happens here, we'd be accessing path(-1), (-2) or beyond! No WAY do we want to do that! So I included that just to make sure we didn't overshoot out goal. It's also a nice place to go ahead and remove the Movement component, because it's a guarantee that the animation is done.<br />
<br />
I noted in the inserted method that they would be a good spot to switch the entity to a different animation, perhaps a walking one, and then in removed that it would be a good place to switch back the default one. I also put a shell of a method changeStep() which might be called whenever we go from step 0 to 1, 1 to 2, or so on. In it, I reference a bit of code I wrote in MapTools which can get a vector (actually a FloatPair) pointing in the direction from one tile to another, so you can potentially switch animations from an entity moving down/right to moving up/right, or so on.<br />
<pre class="brush:java">
public static FloatPair getDirectionVector(int x1, int y1, int x2, int y2) {
FloatPair cell1 = world2window(x1, y1);
FloatPair cell2 = world2window(x2, y2);
return new FloatPair(cell2.x-cell1.x, cell2.y-cell1.y);
}
</pre>
<br />
Notice in lines 85-86 we have the chance of getting a decimal value for the position. In fact, we really <i>want</i> that for a smooth animation. I had started with MapPosition holding floats for position, changed it to ints, and have now changed it back to floats. To handle that though, I had to update the world2window method to permit honest to goodness floating point world positions.<br />
<pre class="brush:java">
public static FloatPair world2window(float x, float y) {
int x0 = (int)x;
float dx = x - x0; // purely the decimal part
float posX = 5.5f + (x + 0.5f)*col_multiple;
float posY = row_multiple*(y + 0.5f + (x0%2)*(0.5f - dx/2f) + (x0+1)%2 * dx/2f);
return new FloatPair(posX, posY);
}
</pre>
<br />
Okay, so that's all good and well, but we need to let the user control stuff now. I did a LOT of thinking about what I wanted the user experience to be, and what I want to code to be, and how to reconcile to two. Ultimately, I want the user to click on an entity, get a little menu, select "Move" or whatever, and get options based on that.<br />
<br />
I still think the finite state machine method I implemented last update is a good way to think of it, but now I think its a crappy way to implement it. The control class will have to be riddled with crap like <i>if (state == whatever) {} else {} else {} else{} blah blah blah</i>.<br />
<br />
I ultimately decided to split it into multiple controller files, like OverworldDefaultControl, OverworldMovingControl, etc... My initial problem with this was that so much code will have to be copied - like click-dragging - into dozens of control systems, which would TOTALLY suck!<br />
<br />
I ended up using the libgdx <a href="http://libgdx.l33tlabs.org/docs/api/com/badlogic/gdx/InputMultiplexer.html" target="_blank">InputMultiplexer </a>to stack controls, and it was way easier than I had imagined! The documentation on that class was pretty sparse I thought, and there were no good tutorials. It just kind of confused me all around...<br />
<br />
That is, until I actually looked at its <a href="https://github.com/libgdx/libgdx/blob/master/gdx/src/com/badlogic/gdx/InputMultiplexer.java" target="_blank">code</a>. It's <i>WAY </i>simple in hindsight! No wonder nobody felt the need to document it! It's so cool, it literally just is an InputProcessor that doesn't do any input processing on its own. Instead, it has a list of InputProcessors, and it asks each one in turn: "Can you handle this?" "Can you handle it?" "Can you?" As soon as the first one does, it says "Alright, it's handled! Done!" It goes in the order you put them in, but if you manage that you'll be okay!<br />
<br />
Before we get to that code though, there's more we need to discuss. First, I don't like the PlayerSelected component. We're only ever going to have a single entity selected at a time, and it doesn't mean they get any special "processing". Also, the MapRenderSystem I have now isn't really rendering any entities, or doing any Artemis related stuff, so why am I adding it to the Artemis World? I even have to process it manually, so what the heck? So there were a few changes that have been made.<br />
<ul>
<li>MapRenderSystem is now just a regular old class held within OverworldScreen.</li>
<li>To handle that, OverworldScreen now must have the SpriteBatch (in fact I just gave it to AbstractScreen).</li>
<li>In the future, rendering that doesn't render entities will be handled similarly.</li>
<li>OverworldScreen just has an <b>int</b> to hold the ID of whatever player is selected. No components, no nonsense. Just an integer to say which one is the focus.</li>
<li>OverworldScreen has an Array<Pair> to hold the coordinates for all cells which the selected entity can reach, given its energy and terrain functions.</li>
<li>OverworldScreen doesn't just have an InputProcesor, but an InputMultiplexer.</li>
<li>There are presently two input systems, OverworldDefaultController and OverworldMovingController. When the player selects an entity to move, it adds the OverworldMovingController to the InputMultiplexer so that they can move entities. </li>
</ul>
Here's what OverworldScreen looks like now:<br />
<pre class="brush:java">
package com.blogspot.javagamexyz.gamexyz.screens;
import com.artemis.World;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputMultiplexer;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.EntityFactory;
import com.blogspot.javagamexyz.gamexyz.GameXYZ;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
import com.blogspot.javagamexyz.gamexyz.renderers.MapHighlighter;
import com.blogspot.javagamexyz.gamexyz.renderers.MapRenderer;
import com.blogspot.javagamexyz.gamexyz.screens.control.overworld.OverworldDefaultController;
import com.blogspot.javagamexyz.gamexyz.systems.HudRenderSystem;
import com.blogspot.javagamexyz.gamexyz.systems.MovementSystem;
import com.blogspot.javagamexyz.gamexyz.systems.SpriteRenderSystem;
public class OverworldScreen extends AbstractScreen {
public static GameMap gameMap;
private OrthographicCamera hudCam;
private SpriteRenderSystem spriteRenderSystem;
private HudRenderSystem hudRenderSystem;
private MapRenderer mapRenderer;
private MapHighlighter mapHighlighter;
public int selectedEntity;
public Array<Pair> reachableCells;
public boolean renderMap;
public boolean renderMovementRange;
public InputMultiplexer inputSystem;
public OverworldScreen(GameXYZ game, SpriteBatch batch, World world) {
super(game,world,batch);
gameMap = new GameMap();
hudCam = new OrthographicCamera();
mapRenderer = new MapRenderer(camera,batch,gameMap.map);
mapHighlighter = new MapHighlighter(camera, batch);
world.setSystem(new MovementSystem(gameMap));
spriteRenderSystem = world.setSystem(new SpriteRenderSystem(camera,batch), true);
hudRenderSystem = world.setSystem(new HudRenderSystem(hudCam, batch),true);
world.initialize();
this.inputSystem = new InputMultiplexer(new OverworldDefaultController(camera,world,gameMap,this));
Gdx.input.setInputProcessor(inputSystem);
int x, y;
for (int i=0; i<100; i++) {
do {
x = MathUtils.random(MapTools.width()-1);
y = MathUtils.random(MapTools.height()-1);
} while (gameMap.cellOccupied(x, y));
EntityFactory.createNPC(world,x,y,gameMap).addToWorld();
}
selectedEntity = -1;
renderMap = true;
renderMovementRange = false;
}
@Override
public void render(float delta) {
super.render(delta);
if (renderMap) {
mapRenderer.render();
spriteRenderSystem.process();
}
if (renderMovementRange) {
mapHighlighter.render(reachableCells,0.2f,0.2f,0.8f,0.3f);
}
hudRenderSystem.process();
}
@Override
public void show() {
// TODO Auto-generated method stub
}
@Override
public void resize(int width, int height) {
super.resize(width, height);
hudCam.setToOrtho(false, width, height);
}
@Override
public void hide() {
// TODO Auto-generated method stub
}
@Override
public void pause() {
// TODO Auto-generated method stub
}
@Override
public void resume() {
// TODO Auto-generated method stub
}
@Override
public void dispose() {
// TODO Auto-generated method stub
world.deleteSystem(hudRenderSystem);
world.deleteSystem(spriteRenderSystem);
world.deleteSystem(world.getSystem(MovementSystem.class));
}
}
</pre>
<br />
On lines 42-69 I just initialize all the systems, variables, etc. Notice on line 50 I've added MovementSystem to the world here instead of up in Game. It strikes me that I don't want the MovementSystem processing while the map screen isn't showing. On line 55 I create the InputMultiplexer. Initially I pass an argument for the actual InputProcessors I want it focusing on, and at first, I only want the default processor.<br />
<br />
In render() I used boolean flags to mark whether certain renderers should run. mapRenderer.render() draws the tiles, and mapHighlighter.render() is used to highlight the reachable cells (which are set in OverworldDefaultProcessor when the player clicks on an entity). I also pass the color, so this is a highly transparent blue shade. Before we go to the controllers, we'll go over these two renderers:<br />
<pre class="brush:java">
package com.blogspot.javagamexyz.gamexyz.renderers;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.graphics.g2d.TextureAtlas.AtlasRegion;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.custom.FloatPair;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
public class MapRenderer extends AbstractRenderer {
private TextureAtlas atlas;
private Array<AtlasRegion> textures;
private int[][] map;
public MapRenderer(OrthographicCamera camera, SpriteBatch batch, int[][] map) {
super(camera, batch);
this.map = map;
atlas = new TextureAtlas(Gdx.files.internal("textures/maptiles.atlas"),Gdx.files.internal("textures"));
textures = atlas.findRegions(MapTools.name);
}
public void render() {
begin();
TextureRegion reg;
// Get bottom left and top right coordinates of camera viewport and convert
// into grid coordinates for the map
int x0 = MathUtils.floor(camera.frustum.planePoints[0].x / (float)MapTools.col_multiple) - 1;
int y0 = MathUtils.floor(camera.frustum.planePoints[0].y / (float)MapTools.row_multiple) - 1;
int x1 = MathUtils.floor(camera.frustum.planePoints[2].x / (float)MapTools.col_multiple) + 1;
int y1 = MathUtils.floor(camera.frustum.planePoints[2].y / (float)MapTools.row_multiple) + 1;
// Restrict the grid coordinates to realistic values
if (x0 % 2 == 1) x0 -= 1;
if (x0 < 0) x0 = 0;
if (x1 > map.length) x1 = map.length;
if (y0 < 0) y0 = 0;
if (y1 > map[0].length) y1 = map[0].length;
// Loop over everything in the window to draw
for (int row = y0; row < y1; row++) {
for (int col = x0; col < x1; col++) {
reg = textures.get(map[col][row]);
FloatPair position = MapTools.world2window(col,row);
batch.draw(reg, position.x-reg.getRegionWidth()/2, position.y-reg.getRegionHeight()/2);
}
}
// This line can draw a small image of the whole map
//batch.draw(gameMap.texture,0,0);
end();
}
}
</pre>
<pre class="brush:java">
package com.blogspot.javagamexyz.gamexyz.renderers;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.graphics.g2d.TextureAtlas.AtlasRegion;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.custom.FloatPair;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
public class MapRenderer extends AbstractRenderer {
private TextureAtlas atlas;
private Array&lt;AtlasRegion&gt; textures;
private int[][] map;
public MapRenderer(OrthographicCamera camera, SpriteBatch batch, int[][] map) {
super(camera, batch);
this.map = map;
atlas = new TextureAtlas(Gdx.files.internal(&quot;textures/maptiles.atlas&quot;),Gdx.files.internal(&quot;textures&quot;));
textures = atlas.findRegions(MapTools.name);
}
public void render() {
begin();
TextureRegion reg;
// Get bottom left and top right coordinates of camera viewport and convert
// into grid coordinates for the map
int x0 = MathUtils.floor(camera.frustum.planePoints[0].x / (float)MapTools.col_multiple) - 1;
int y0 = MathUtils.floor(camera.frustum.planePoints[0].y / (float)MapTools.row_multiple) - 1;
int x1 = MathUtils.floor(camera.frustum.planePoints[2].x / (float)MapTools.col_multiple) + 1;
int y1 = MathUtils.floor(camera.frustum.planePoints[2].y / (float)MapTools.row_multiple) + 1;
// Restrict the grid coordinates to realistic values
if (x0 % 2 == 1) x0 -= 1;
if (x0 &lt; 0) x0 = 0;
if (x1 &gt; map.length) x1 = map.length;
if (y0 &lt; 0) y0 = 0;
if (y1 &gt; map[0].length) y1 = map[0].length;
// Loop over everything in the window to draw
for (int row = y0; row &lt; y1; row++) {
for (int col = x0; col &lt; x1; col++) {
reg = textures.get(map[col][row]);
FloatPair position = MapTools.world2window(col,row);
batch.draw(reg, position.x-reg.getRegionWidth()/2, position.y-reg.getRegionHeight()/2);
}
}
// This line can draw a small image of the whole map
//batch.draw(gameMap.texture,0,0);
end();
}
}
</pre>
<br />
Also, to make things a little easier, I created an AbstractRenderer class to start and end the batch, plus deal with color and the projection matrix.<br />
<pre class="brush:java">
package com.blogspot.javagamexyz.gamexyz.renderers;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
public abstract class AbstractRenderer {
protected OrthographicCamera camera;
protected SpriteBatch batch;
public AbstractRenderer(OrthographicCamera camera, SpriteBatch batch) {
this.camera = camera;
this.batch = batch;
}
protected void begin() {
batch.setProjectionMatrix(camera.combined);
batch.begin();
}
protected void end() {
batch.end();
batch.setColor(1f,1f,1f,1f);
}
}
</pre>
<br />
In HighlightRenderer.java I load in hex_blank.png, which is just a totally white hex cell I made:<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEi4AQLf1YUx3H7u26wOCbVWnjHZFA9rMnJGcb6miYroC-hmn4BjD-dS4LQzX8UkDVb7REXIYSQZJeTOaRPKpdGngiJu2ZsihDjNMxkJeGFn45DcWQLdWKBn9jUkGdVGCkeditJZD5o8zRY/s1600/hex_blank.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEi4AQLf1YUx3H7u26wOCbVWnjHZFA9rMnJGcb6miYroC-hmn4BjD-dS4LQzX8UkDVb7REXIYSQZJeTOaRPKpdGngiJu2ZsihDjNMxkJeGFn45DcWQLdWKBn9jUkGdVGCkeditJZD5o8zRY/s1600/hex_blank.png" /></a></div>
Since white has all colors, it's perfect to filter using setColor() to make it look like whatever color you want, in our current case, blue.<br />
<br />
OverworldDefaultController looks like this:<br />
<pre class="brush:java">
package com.blogspot.javagamexyz.gamexyz.screens.control.overworld;
import com.artemis.Entity;
import com.artemis.World;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.math.Vector2;
import com.blogspot.javagamexyz.gamexyz.EntityFactory;
import com.blogspot.javagamexyz.gamexyz.components.Movable;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
import com.blogspot.javagamexyz.gamexyz.screens.OverworldScreen;
public class OverworldDefaultController implements InputProcessor {
private OrthographicCamera camera;
private World world;
private GameMap gameMap;
// We need a copy of the screen implementing this controller (which has a copy of
// the Game delegating to it) so we can change screens based on users making selections
//private GameXYZ game;
private OverworldScreen screen;
private boolean dragging;
public OverworldDefaultController(OrthographicCamera camera, World world, GameMap gameMap, OverworldScreen screen) {
this.camera = camera;
this.world = world;
this.gameMap = gameMap;
this.screen = screen;
dragging = false;
}
@Override
public boolean keyDown(int keycode) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean keyUp(int keycode) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean keyTyped(char character) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean touchUp(int screenX, int screenY, int pointer, int button) {
if (dragging) {
dragging = false;
return true;
}
// Otherwise, get the coordinates they clicked on
Pair coords = MapTools.window2world(Gdx.input.getX(), Gdx.input.getY(), camera);
// Check the entityID of the cell they click on
int entityId = gameMap.getEntityAt(coords.x, coords.y);
// If it's an actual entity (not empty) then "select" it (unless it's already selected)
if ((entityId > -1) && (entityId != screen.selectedEntity)) {
// Now select the current entity
screen.selectedEntity = entityId;
EntityFactory.createClick(world, coords.x, coords.y, 0.1f, 4f).addToWorld();
// For now let's just assume they are selecting the entity to move it
// make sure they can really move!
Entity e = world.getEntity(entityId);
Movable movable = e.getComponent(Movable.class);
screen.reachableCells = gameMap.pathFinder.getReachableCells(coords.x, coords.y, movable);
screen.renderMovementRange = true;
screen.inputSystem.addProcessor(new OverworldMovingController(camera,world,gameMap,screen));
return true;
}
// If they didn't click on someone, we didn't process it
return false;
}
@Override
public boolean mouseMoved(int screenX, int screenY) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean touchDragged(int screenX, int screenY, int pointer) {
if (!dragging) dragging = true;
Vector2 delta = new Vector2(-camera.zoom*Gdx.input.getDeltaX(), camera.zoom*Gdx.input.getDeltaY());
camera.translate(delta);
return true;
}
@Override
public boolean scrolled(int amount) {
if ((camera.zoom > 0.2f || amount == 1) && (camera.zoom < 8 || amount == -1)) camera.zoom += amount*0.1;
return true;
}
}
</pre>
<br />
I went back to using a boolean flag to see if the player is click-dragging the screen. touchDragged() and scrolled() look the same, and notice that they return true. That means that it will tell the InputMultiplexer that they <i>did</i> handle the input. If they returned false, the InputMultiplexer would continue asking the other InputProcessers if they handled it.<br />
<br />
In touchUp() we first make sure they're not just releasing a drag (and if they are, we return <b>true</b> saying that we got it). Otherwise we get the coordinates of the cell they clicked on. If they are selecting a new entity, call screen.selectedEntity = entityId to tell the OverworldScreen who we're looking at. Also, for now, we're assuming that clicking on an entity means you want to move it, though in the future we'll probably implement a menu system instead.<br />
<br />
On line 86 it asks our AStarPathFinder to find a set of tiles that this entity can reach. To make sure it's specific to this entity, we have to pass the entity's Movable component along. Up till now, we would have gotten it using an <i>@Mapper ComponentMapper<Movable></i> style command, but we can't use that now. That method is only valid in classes that extend EntitySystem (or some derivative of it). Since I don't <i>really</i> think this controller ought to extend EntitySystem (because I don't want it processing things every game cycle, just adding components when the user clicks) I had to use the slower <i>e.getComponent(Movable.class)</i>. It shouldn't be a big problem - this call should happen infrequently enough that the speed will be unnoticeable.<br />
<br />
On line 87 it tells the OverworldScreen to start rendering the highlighted range, and then on 88 it adds the OverworldMovingController to the multiplexer. Because it is added 2nd (after the default controller) it will be asked to process things only when OverworldDefaultController returns false on something. Since here we've selected an entity, and I'm sure that's what we want to do for now if we click on someone, we return true.<br />
<br />
Outside this loop, if they didn't select a new entity, we return false because this controller doesn't handle anything else. That way OverworldMovingController has a chance to handle that input.<br />
<pre class="brush:java">
package com.blogspot.javagamexyz.gamexyz.screens.control.overworld;
import com.artemis.Entity;
import com.artemis.World;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.blogspot.javagamexyz.gamexyz.components.Movable;
import com.blogspot.javagamexyz.gamexyz.components.Movement;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
import com.blogspot.javagamexyz.gamexyz.screens.OverworldScreen;
public class OverworldMovingController implements InputProcessor {
private OrthographicCamera camera;
private World world;
private GameMap gameMap;
private OverworldScreen screen;
public OverworldMovingController(OrthographicCamera camera, World world, GameMap gameMap, OverworldScreen screen) {
this.camera = camera;
this.world = world;
this.gameMap = gameMap;
this.screen = screen;
}
@Override
public boolean keyDown(int keycode) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean keyUp(int keycode) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean keyTyped(char character) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean touchUp(int screenX, int screenY, int pointer, int button) {
Pair coords = MapTools.window2world(Gdx.input.getX(), Gdx.input.getY(), camera);
// Did they click within the movable range?
if (screen.reachableCells.contains(coords, false)) {
Entity e = world.getEntity(screen.selectedEntity);
Movable movable = e.getComponent(Movable.class);
Pair p = gameMap.getCoordinatesFor(screen.selectedEntity);
e.addComponent(new Movement(gameMap.pathFinder.findPath(p.x, p.y, coords.x, coords.y, movable)));
e.changedInWorld();
}
// Wherever they clicked, they are now done with the "moving" aspect of things
screen.renderMovementRange = false;
screen.selectedEntity = -1;
screen.inputSystem.removeProcessor(this);
return true;
}
@Override
public boolean mouseMoved(int screenX, int screenY) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean touchDragged(int screenX, int screenY, int pointer) {
return false;
}
@Override
public boolean scrolled(int amount) {
return false;
}
}
</pre>
<br />
If we reach touchUp() here, it by default means that we weren't dragging, and we weren't selecting a new entity. Also, this system is only active if an entity has been selected to move, so we know the screen is also currently highlighting a set of cells as potential move targets. If they click within that set (lines 57-63) we want to add a Movement component containing a path from their current location to the clicked target. To find that path, we actually need to know the entity's current location, so I had a choice between calling e.getComponent(Position.class) or storing all entities' locations somewhere else. The GameMap had a map to go from coordinates to entity, so I decided to also have one to go from entity to coordinates.<br />
<br />
On lines 66-68 I say that no matter where they click (within the range or not) we should deselect the entity and stop rendering the movement range. Remember, this can only be called if the default returned false, so if they click on another entity here (within the movement range or not) we will have never seen this code, so players can click on one entity, and then decide they'd rather click on another and it will just be selected automatically. If we did get here though, we consider this deselecting to be the final meaning of the touchUp(), so we return true.<br />
<br />
There are a few more helper functions that we need to go over. In GameMap.java we added a new field and these methods<br />
<pre class="brush:java">
private ObjectMap<Integer,Pair> coordByEntity;
public Pair getCoordinatesFor(int entityId) {
if (coordByEntity.containsKey(entityId)) return coordByEntity.get(entityId);
return null;
}
public boolean cellOccupied(int x, int y) {
return (entityByCoord[x][y] > -1);
}
public void addEntity(int id, int x, int y) {
entityByCoord[x][y] = id;
coordByEntity.put(id, new Pair(x,y));
}
public void moveEntity(int id, int x, int y) {
Pair old = coordByEntity.put(id, new Pair(x,y));
entityByCoord[old.x][old.y] = -1;
entityByCoord[x][y] = id;
}
</pre>
<br />
In AStarPathFinder I had to create a new method called getReachableCells. <a href="http://stackoverflow.com/questions/4903243/2d-grid-reachable-destinations" target="_blank">To accomplish this</a>, I did a <a href="http://en.wikipedia.org/wiki/Breadth-first_search" target="_blank">breadth first search</a> where I stop after cells exceed the mover's energy. This required a special queue class which had the ability to remove arbitrary elements (in addition to reading off/removing the first in line). I also updated the findPath method to use the mover's terrain costs and blocks. Here's AStarPathFinder<br />
<pre class="brush:java">
package com.blogspot.javagamexyz.gamexyz.pathfinding;
import java.util.ArrayList;
import java.util.Collections;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.components.Movable;
import com.blogspot.javagamexyz.gamexyz.custom.MyQueue;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
/**
* A modified path finder implementation starting with Kevin Glass' AStar algorithm
* at http://old.cokeandcode.com/pathfinding. Original header:
*
* A path finder implementation that uses the AStar heuristic based algorithm
* to determine a path.
*
* @author Kevin Glass
*/
public class AStarPathFinder {
/** The set of nodes that have been searched through */
private Array<Node> closed = new Array<Node>();
/** The set of nodes that we do not yet consider fully searched */
private SortedList open = new SortedList();
/** The map being searched */
private GameMap gameMap;
private int[][] map;
/** The maximum depth of search we're willing to accept before giving up */
private int maxSearchDistance;
/** The complete set of nodes across the map */
private Node[][] nodes;
/**
* Create a path finder
*
* @param gameMap The map to be searched
* @param maxSearchDistance The maximum depth we'll search before giving up
*/
public AStarPathFinder(GameMap gameMap, int maxSearchDistance) {
this.gameMap = gameMap;
this.map = gameMap.map;
this.maxSearchDistance = maxSearchDistance;
nodes = new Node[map.length][map[0].length];
for (int x=0;x<map.length;x++) {
for (int y=0;y<map[0].length;y++) {
nodes[x][y] = new Node(x,y);
}
}
}
/**
* @see PathFinder#findPath(int, int, int, int, Movable)
*/
public Path findPath(int sx, int sy, int tx, int ty, Movable mover) {
// easy first check, if the destination is blocked, we can't get there
if (isCellBlocked(tx,ty,mover)) {
return null;
}
// initial state for A*. The closed group is empty. Only the starting
// tile is in the open list and it's cost is zero, i.e. we're already there
nodes[sx][sy].cost = 0;
nodes[sx][sy].depth = 0;
closed.clear();
open.clear();
open.add(nodes[sx][sy]);
nodes[tx][ty].parent = null;
// While we still have more nodes to search and haven't exceeded our max search depth
int maxDepth = 0;
while ((maxDepth < maxSearchDistance) && (open.size() != 0)) {
// pull out the first node in our open list, this is determined to
// be the most likely to be the next step based on our heuristic
Node current = getFirstInOpen();
if (current == nodes[tx][ty]) {
break;
}
removeFromOpen(current);
addToClosed(current);
Array<Pair> neighbors = MapTools.getNeighbors(current.x, current.y);
// search through all the neighbors of the current node evaluating
// them as next steps
for (Pair n : neighbors) {
int xp = n.x;
int yp = n.y;
float nextStepCost = current.cost + mover.terrainCost[map[xp][yp]];//getMovementCost(current.x,current.y,xp,yp);
Node neighbor = nodes[xp][yp];
// If this step exceeds the movers energy, don't even bother with it
if (nextStepCost > mover.energy) continue;
// Check to see if we have found a new shortest route to this neighbor
if (nextStepCost < neighbor.cost) {
if (inOpenList(neighbor)) removeFromOpen(neighbor);
if (inClosedList(neighbor)) removeFromClosed(neighbor);
}
// If it was a new shor
if (!inOpenList(neighbor) && !inClosedList(neighbor)) {
neighbor.cost = nextStepCost;
neighbor.heuristic = (float)MapTools.distance(xp, yp, tx, ty);
maxDepth = Math.max(maxDepth, neighbor.setParent(current));
addToOpen(neighbor);
}
}
}
// since we've got an empty open list or we've run out of search
// there was no path. Just return null
if (nodes[tx][ty].parent == null) {
return null;
}
// At this point we've definitely found a path so we can uses the parent
// references of the nodes to find out way from the target location back
// to the start recording the nodes on the way.
Path path = new Path();
Node target = nodes[tx][ty];
while (target != nodes[sx][sy]) {
path.prependStep(target.x, target.y);
target = target.parent;
}
path.prependStep(sx,sy);
// thats it, we have our path
return path;
}
/**
*
* @param x The x coordinate of the mover
* @param y The y coordinate of the mover
* @return An Array<Pair> containing the coordinates for all cells the mover can reach
*/
public Array<Pair> getReachableCells(int x, int y, Movable mover) {
Array<Pair> reachableCells = new Array<Pair>();
MyQueue<Node> open = new MyQueue<Node>();
closed.clear();
Node start = nodes[x][y];
start.depth = 0;
start.cost = 0;
open.push(start);
while (open.size() > 0) {
// poll() the open queue
Node current = open.poll();
for (Pair n : MapTools.getNeighbors(current.x,current.y)) {
Node neighbor = nodes[n.x][n.y];
float nextStepCost = current.cost + mover.terrainCost[map[n.x][n.y]];
// If the cell is beyond our reach, or otherwise blocked, ignore it
if (nextStepCost > mover.energy || isCellBlocked(n.x,n.y,mover)) continue;
// Check to see if we have found a new shortest route to this neighbor, in
// which case it must be totally reconsidered
if (nextStepCost < neighbor.cost) {
if (inClosedList(neighbor)) removeFromClosed(neighbor);
if (open.contains(neighbor, false)) open.remove(neighbor,false);
}
if (!open.contains(neighbor, false) && !inClosedList(neighbor)) {
neighbor.cost = nextStepCost;
open.push(neighbor);
}
}
addToClosed(current);
}
for (Node n : closed) {
if (n.x != x || n.y != y) reachableCells.add(new Pair(n.x,n.y));
}
return reachableCells;
}
/**
* Get the first element from the open list. This is the next
* one to be searched.
*
* @return The first element in the open list
*/
protected Node getFirstInOpen() {
return (Node) open.first();
}
/**
* Add a node to the open list
*
* @param node The node to be added to the open list
*/
protected void addToOpen(Node node) {
open.add(node);
}
/**
* Check if a node is in the open list
*
* @param node The node to check for
* @return True if the node given is in the open list
*/
protected boolean inOpenList(Node node) {
return open.contains(node);
}
/**
* Remove a node from the open list
*
* @param node The node to remove from the open list
*/
protected void removeFromOpen(Node node) {
open.remove(node);
}
/**
* Add a node to the closed list
*
* @param node The node to add to the closed list
*/
protected void addToClosed(Node node) {
closed.add(node);
}
/**
* Check if the node supplied is in the closed list
*
* @param node The node to search for
* @return True if the node specified is in the closed list
*/
protected boolean inClosedList(Node node) {
return closed.contains(node,false);
}
/**
* Remove a node from the closed list
*
* @param node The node to remove from the closed list
*/
protected void removeFromClosed(Node node) {
closed.removeValue(node,false);
}
/**
* Check if a given location is valid for the supplied mover
*
* @param mover The mover that would hold a given location
* @param sx The starting x coordinate
* @param sy The starting y coordinate
* @param x The x coordinate of the location to check
* @param y The y coordinate of the location to check
* @return True if the location is valid for the given mover
*/
protected boolean isValidLocation(int sx, int sy, int x, int y) {
boolean invalid = (x < 0) || (y < 0) || (x >= map.length) || (y >= map[0].length);
if ((!invalid) && ((sx != x) || (sy != y))) {
//invalid = map.blocked(mover, x, y);
}
return !invalid;
}
/**
* Get the cost to move through a given location
*
* @param mover The entity that is being moved
* @param sx The x coordinate of the tile whose cost is being determined
* @param sy The y coordiante of the tile whose cost is being determined
* @param tx The x coordinate of the target location
* @param ty The y coordinate of the target location
* @return The cost of movement through the given tile
*/
public float getMovementCost(int sx, int sy, int tx, int ty) {
return (float)map[tx][ty]*3f + 1;
}
private boolean isCellBlocked(int x, int y, Movable mover) {
return ((mover.terrainBlocked[map[x][y]]) || gameMap.cellOccupied(x, y));
}
/**
* Get the heuristic cost for the given location. This determines in which
* order the locations are processed.
*
* @param mover The entity that is being moved
* @param x The x coordinate of the tile whose cost is being determined
* @param y The y coordiante of the tile whose cost is being determined
* @param tx The x coordinate of the target location
* @param ty The y coordinate of the target location
* @return The heuristic cost assigned to the tile
*/
public float getHeuristicCost(int x, int y, int tx, int ty) {
return MapTools.distance(x, y, tx, ty);
//return heuristic.getCost(map, mover, x, y, tx, ty);
}
/**
* A simple sorted list
*
* @author kevin
*/
private class SortedList {
/** The list of elements */
private ArrayList<Node> list = new ArrayList<Node>();
/**
* Retrieve the first element from the list
*
* @return The first element from the list
*/
public Object first() {
return list.get(0);
}
/**
* Empty the list
*/
public void clear() {
list.clear();
}
/**
* Add an element to the list - causes sorting
*
* @param o The element to add
*/
public void add(Node o) {
list.add(o);
Collections.sort(list);
}
/**
* Remove an element from the list
*
* @param o The element to remove
*/
public void remove(Object o) {
list.remove(o);
}
/**
* Get the number of elements in the list
*
* @return The number of element in the list
*/
public int size() {
return list.size();
}
/**
* Check if an element is in the list
*
* @param o The element to search for
* @return True if the element is in the list
*/
public boolean contains(Object o) {
return list.contains(o);
}
}
/**
* A single node in the search graph
*/
private class Node implements Comparable<Node> {
/** The x coordinate of the node */
private int x;
/** The y coordinate of the node */
private int y;
/** The path cost for this node */
private float cost;
/** The parent of this node, how we reached it in the search */
private Node parent;
/** The heuristic cost of this node */
private float heuristic;
/** The search depth of this node */
private int depth;
/**
* Create a new node
*
* @param x The x coordinate of the node
* @param y The y coordinate of the node
*/
public Node(int x, int y) {
this.x = x;
this.y = y;
}
/**
* Set the parent of this node
*
* @param parent The parent node which lead us to this node
* @return The depth we have no reached in searching
*/
public int setParent(Node parent) {
depth = parent.depth + 1;
this.parent = parent;
return depth;
}
/**
* @see Comparable#compareTo(Object)
*/
@Override
public int compareTo(Node o) {
//Node o = (Node) other;
float f = heuristic + cost;
float of = o.heuristic + o.cost;
if (f < of) {
return -1;
} else if (f > of) {
return 1;
} else {
return 0;
}
}
/**
* @see Object#equals(Object)
*/
public boolean equals(Object other) {
if (other instanceof Node) {
Node o = (Node) other;
return (o.x == x) && (o.y == y);
}
return false;
}
public String toString() {
return "("+x+","+y+")";
}
}
}
</pre>
<br />
And the special queue I created is<br />
<pre class="brush:java">
package com.blogspot.javagamexyz.gamexyz.pathfinding;
import java.util.ArrayList;
import java.util.Collections;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.components.Movable;
import com.blogspot.javagamexyz.gamexyz.custom.MyQueue;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
/**
* A modified path finder implementation starting with Kevin Glass' AStar algorithm
* at http://old.cokeandcode.com/pathfinding. Original header:
*
* A path finder implementation that uses the AStar heuristic based algorithm
* to determine a path.
*
* @author Kevin Glass
*/
public class AStarPathFinder {
/** The set of nodes that have been searched through */
private Array&lt;Node&gt; closed = new Array&lt;Node&gt;();
/** The set of nodes that we do not yet consider fully searched */
private SortedList open = new SortedList();
/** The map being searched */
private GameMap gameMap;
private int[][] map;
/** The maximum depth of search we're willing to accept before giving up */
private int maxSearchDistance;
/** The complete set of nodes across the map */
private Node[][] nodes;
/**
* Create a path finder
*
* @param gameMap The map to be searched
* @param maxSearchDistance The maximum depth we'll search before giving up
*/
public AStarPathFinder(GameMap gameMap, int maxSearchDistance) {
this.gameMap = gameMap;
this.map = gameMap.map;
this.maxSearchDistance = maxSearchDistance;
nodes = new Node[map.length][map[0].length];
for (int x=0;x&lt;map.length;x++) {
for (int y=0;y&lt;map[0].length;y++) {
nodes[x][y] = new Node(x,y);
}
}
}
/**
* @see PathFinder#findPath(int, int, int, int, Movable)
*/
public Path findPath(int sx, int sy, int tx, int ty, Movable mover) {
// easy first check, if the destination is blocked, we can't get there
if (isCellBlocked(tx,ty,mover)) {
return null;
}
// initial state for A*. The closed group is empty. Only the starting
// tile is in the open list and it's cost is zero, i.e. we're already there
nodes[sx][sy].cost = 0;
nodes[sx][sy].depth = 0;
closed.clear();
open.clear();
open.add(nodes[sx][sy]);
nodes[tx][ty].parent = null;
// While we still have more nodes to search and haven't exceeded our max search depth
int maxDepth = 0;
while ((maxDepth &lt; maxSearchDistance) &amp;&amp; (open.size() != 0)) {
// pull out the first node in our open list, this is determined to
// be the most likely to be the next step based on our heuristic
Node current = getFirstInOpen();
if (current == nodes[tx][ty]) {
break;
}
removeFromOpen(current);
addToClosed(current);
Array&lt;Pair&gt; neighbors = MapTools.getNeighbors(current.x, current.y);
// search through all the neighbors of the current node evaluating
// them as next steps
for (Pair n : neighbors) {
int xp = n.x;
int yp = n.y;
float nextStepCost = current.cost + mover.terrainCost[map[xp][yp]];//getMovementCost(current.x,current.y,xp,yp);
Node neighbor = nodes[xp][yp];
// If this step exceeds the movers energy, don't even bother with it
if (nextStepCost &gt; mover.energy) continue;
// Check to see if we have found a new shortest route to this neighbor
if (nextStepCost &lt; neighbor.cost) {
if (inOpenList(neighbor)) removeFromOpen(neighbor);
if (inClosedList(neighbor)) removeFromClosed(neighbor);
}
// If it was a new shor
if (!inOpenList(neighbor) &amp;&amp; !inClosedList(neighbor)) {
neighbor.cost = nextStepCost;
neighbor.heuristic = (float)MapTools.distance(xp, yp, tx, ty);
maxDepth = Math.max(maxDepth, neighbor.setParent(current));
addToOpen(neighbor);
}
}
}
// since we've got an empty open list or we've run out of search
// there was no path. Just return null
if (nodes[tx][ty].parent == null) {
return null;
}
// At this point we've definitely found a path so we can uses the parent
// references of the nodes to find out way from the target location back
// to the start recording the nodes on the way.
Path path = new Path();
Node target = nodes[tx][ty];
while (target != nodes[sx][sy]) {
path.prependStep(target.x, target.y);
target = target.parent;
}
path.prependStep(sx,sy);
// thats it, we have our path
return path;
}
/**
*
* @param x The x coordinate of the mover
* @param y The y coordinate of the mover
* @return An Array&lt;Pair&gt; containing the coordinates for all cells the mover can reach
*/
public Array&lt;Pair&gt; getReachableCells(int x, int y, Movable mover) {
Array&lt;Pair&gt; reachableCells = new Array&lt;Pair&gt;();
MyQueue&lt;Node&gt; open = new MyQueue&lt;Node&gt;();
closed.clear();
Node start = nodes[x][y];
start.depth = 0;
start.cost = 0;
open.push(start);
while (open.size() &gt; 0) {
// poll() the open queue
Node current = open.poll();
for (Pair n : MapTools.getNeighbors(current.x,current.y)) {
Node neighbor = nodes[n.x][n.y];
float nextStepCost = current.cost + mover.terrainCost[map[n.x][n.y]];
// If the cell is beyond our reach, or otherwise blocked, ignore it
if (nextStepCost &gt; mover.energy || isCellBlocked(n.x,n.y,mover)) continue;
// Check to see if we have found a new shortest route to this neighbor, in
// which case it must be totally reconsidered
if (nextStepCost &lt; neighbor.cost) {
if (inClosedList(neighbor)) removeFromClosed(neighbor);
if (open.contains(neighbor, false)) open.remove(neighbor,false);
}
if (!open.contains(neighbor, false) &amp;&amp; !inClosedList(neighbor)) {
neighbor.cost = nextStepCost;
open.push(neighbor);
}
}
addToClosed(current);
}
for (Node n : closed) {
if (n.x != x || n.y != y) reachableCells.add(new Pair(n.x,n.y));
}
return reachableCells;
}
/**
* Get the first element from the open list. This is the next
* one to be searched.
*
* @return The first element in the open list
*/
protected Node getFirstInOpen() {
return (Node) open.first();
}
/**
* Add a node to the open list
*
* @param node The node to be added to the open list
*/
protected void addToOpen(Node node) {
open.add(node);
}
/**
* Check if a node is in the open list
*
* @param node The node to check for
* @return True if the node given is in the open list
*/
protected boolean inOpenList(Node node) {
return open.contains(node);
}
/**
* Remove a node from the open list
*
* @param node The node to remove from the open list
*/
protected void removeFromOpen(Node node) {
open.remove(node);
}
/**
* Add a node to the closed list
*
* @param node The node to add to the closed list
*/
protected void addToClosed(Node node) {
closed.add(node);
}
/**
* Check if the node supplied is in the closed list
*
* @param node The node to search for
* @return True if the node specified is in the closed list
*/
protected boolean inClosedList(Node node) {
return closed.contains(node,false);
}
/**
* Remove a node from the closed list
*
* @param node The node to remove from the closed list
*/
protected void removeFromClosed(Node node) {
closed.removeValue(node,false);
}
/**
* Check if a given location is valid for the supplied mover
*
* @param mover The mover that would hold a given location
* @param sx The starting x coordinate
* @param sy The starting y coordinate
* @param x The x coordinate of the location to check
* @param y The y coordinate of the location to check
* @return True if the location is valid for the given mover
*/
protected boolean isValidLocation(int sx, int sy, int x, int y) {
boolean invalid = (x &lt; 0) || (y &lt; 0) || (x &gt;= map.length) || (y &gt;= map[0].length);
if ((!invalid) &amp;&amp; ((sx != x) || (sy != y))) {
//invalid = map.blocked(mover, x, y);
}
return !invalid;
}
/**
* Get the cost to move through a given location
*
* @param mover The entity that is being moved
* @param sx The x coordinate of the tile whose cost is being determined
* @param sy The y coordiante of the tile whose cost is being determined
* @param tx The x coordinate of the target location
* @param ty The y coordinate of the target location
* @return The cost of movement through the given tile
*/
public float getMovementCost(int sx, int sy, int tx, int ty) {
return (float)map[tx][ty]*3f + 1;
}
private boolean isCellBlocked(int x, int y, Movable mover) {
return ((mover.terrainBlocked[map[x][y]]) || gameMap.cellOccupied(x, y));
}
/**
* Get the heuristic cost for the given location. This determines in which
* order the locations are processed.
*
* @param mover The entity that is being moved
* @param x The x coordinate of the tile whose cost is being determined
* @param y The y coordiante of the tile whose cost is being determined
* @param tx The x coordinate of the target location
* @param ty The y coordinate of the target location
* @return The heuristic cost assigned to the tile
*/
public float getHeuristicCost(int x, int y, int tx, int ty) {
return MapTools.distance(x, y, tx, ty);
//return heuristic.getCost(map, mover, x, y, tx, ty);
}
/**
* A simple sorted list
*
* @author kevin
*/
private class SortedList {
/** The list of elements */
private ArrayList&lt;Node&gt; list = new ArrayList&lt;Node&gt;();
/**
* Retrieve the first element from the list
*
* @return The first element from the list
*/
public Object first() {
return list.get(0);
}
/**
* Empty the list
*/
public void clear() {
list.clear();
}
/**
* Add an element to the list - causes sorting
*
* @param o The element to add
*/
public void add(Node o) {
list.add(o);
Collections.sort(list);
}
/**
* Remove an element from the list
*
* @param o The element to remove
*/
public void remove(Object o) {
list.remove(o);
}
/**
* Get the number of elements in the list
*
* @return The number of element in the list
*/
public int size() {
return list.size();
}
/**
* Check if an element is in the list
*
* @param o The element to search for
* @return True if the element is in the list
*/
public boolean contains(Object o) {
return list.contains(o);
}
}
/**
* A single node in the search graph
*/
private class Node implements Comparable&lt;Node&gt; {
/** The x coordinate of the node */
private int x;
/** The y coordinate of the node */
private int y;
/** The path cost for this node */
private float cost;
/** The parent of this node, how we reached it in the search */
private Node parent;
/** The heuristic cost of this node */
private float heuristic;
/** The search depth of this node */
private int depth;
/**
* Create a new node
*
* @param x The x coordinate of the node
* @param y The y coordinate of the node
*/
public Node(int x, int y) {
this.x = x;
this.y = y;
}
/**
* Set the parent of this node
*
* @param parent The parent node which lead us to this node
* @return The depth we have no reached in searching
*/
public int setParent(Node parent) {
depth = parent.depth + 1;
this.parent = parent;
return depth;
}
/**
* @see Comparable#compareTo(Object)
*/
@Override
public int compareTo(Node o) {
//Node o = (Node) other;
float f = heuristic + cost;
float of = o.heuristic + o.cost;
if (f &lt; of) {
return -1;
} else if (f &gt; of) {
return 1;
} else {
return 0;
}
}
/**
* @see Object#equals(Object)
*/
public boolean equals(Object other) {
if (other instanceof Node) {
Node o = (Node) other;
return (o.x == x) &amp;&amp; (o.y == y);
}
return false;
}
public String toString() {
return &quot;(&quot;+x+&quot;,&quot;+y+&quot;)&quot;;
}
}
}
</pre>
<br />
So this was a LOT, but I think it payed off in the end. We're now primed to create a richer user experience where they are prompted with menus and can select what action to perform, and I think that's what I'm going to focus on next. I'm torn between using the <a href="http://twl.l33tlabs.org/" target="_blank">Themable Widget Library</a> or doing something more custom, but I like the idea of storing menu structure in an XML or JSON file. In fact, I like the idea of storing as much as possible in stuff like that so the game is easier to change down the line, or even mod (a la Civ). We'll see what happens!<br />
<br />
<span style="color: #bf9000;"><b>You have gained 200 XP. Progress to Level 4: 200/700</b></span>Unknownnoreply@blogger.com0tag:blogger.com,1999:blog-4125624026143571209.post-48156509913878103552013-03-16T23:35:00.001-07:002013-03-16T23:35:37.935-07:00User Input and Screens (Level 2)Up to this point, I've tried to implement what I considered "basic tricky" systems like animation and pathfinding. I still have some work to do on the animation system (see some of the comments on that post), but I feel the fundamental idea is sound enough to continue until I really need to address it.<br />
<br />
Clearly, what we have now does NOT constitute a game, but it's starting to take shape. For this update, I wanted to add more sophisticated controls, so that the player could select one of the little dudes and tell him where to move, and select other dudes and have them decide where to move. The idea seemed simple, but really got me thinking heavily about how to keep the code modular and managable.<br />
<br />
In this update, we will see that done, but I had to redesign the game structure down to its core to make it happen in a way that didn't seem overly forced, and I'm really happy with how it has turned out.<br />
<br />
First, in case you haven't read it, Andrew Steigert has a wonderful set of <a href="http://steigert.blogspot.com/2012/02/2-libgdx-tutorial-game-screens.html" target="_blank">libgdx tutorials</a> where he walks through creating a simple game (not unlike Spaceship Warrior) called Tyrion. One of the main focuses of his tutorials is using Screens to manage your code. The idea is that most games have numerous screens, each of which behave quite differently. For instance, we may end up with a:<br />
<ul>
<li>Logo splash screen</li>
<li>Main menu screen</li>
<li>Load screen</li>
<li>Overworld screen</li>
<li>Battle map screen</li>
<li>Game menu screen</li>
<ul>
<li>Inventory</li>
<li>Character stats</li>
<li>Party stats</li>
</ul>
<li>And who knows what the heck else?</li>
</ul>
Each of these screens out to run very different code: for instance the touchDrag() we have implemented wouldn't really make sense on a logo splash screen, or main menu screen. You really don't want your users dragging those screens around. Similarly, the idea of clicking on a cell and selecting an entity doesn't make sense in most contexts. On the main menu, we really DON'T want to render the GameMap.<br />
<br />
One of the cool things about screens is that each one can contain its own code. For us, this could be really helpful in deciding which rendering systems to process, and setting custom controllers for each screen. As of now, our Launcher.java file extends Game, and our GameXYZ.java implements Screen. libgdx gave us these classes so that a Game can run, and delegate to different screens as needed, but we're not using it that way.<br />
<br />
The first major change I made was to make Launcher.java just a regular class, and no longer extend Game. Instead, I made GameXYZ.java extend Game. The idea here is that GameXYZ.java will now be able to delegate to different screens.<br />
<br />
To clarify the difference between Game and Screen, consider the methods that are part of each:<br />
Game<br />
<ul>
<li>create()</li>
<li>setScreen()</li>
<li>getScreen()</li>
<li>render(), resize(), show(), hide(), pause(), etc...</li>
</ul>
Screen<br />
<ul>
<li>render(), resize(), show(), hide(), pause(), etc...</li>
</ul>
When Game "render()"s, it checks to see if it currently has a screen, and if so, calls screen.render(). In essence, Game is really just a manager for Screens. Each screen ought to have a reference to the Game controlling it so that they can call game.setScreen(some_other_screen) - that is, so you can change screens.<br />
<br />
I created an Abstract class called AbstractScreen.java which holds some things that I expect to be common to all the screens I use, such as an OrthographicCamera, a reference to the Artemis World (so the screens can interact with Entities and process systems), and a reference to GameXYZ. As of now, I just implemented a single Screen called OverworldScreen which extends AbstractScreen. OverworldScreen is more or less a rough copy of the old GameXYZ, because I want it to represent the main Screen I have as of yet. There are a few differences we'll get to.<br />
<br />
Here is the updated and new code for all this:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz;
import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;
import com.blogspot.javagamexyz.gamexyz.utils.ImagePacker;
public class Launcher {
private static final int WIDTH = 1300;
private static final int HEIGHT = 720;
public static void main(String[] args) {
ImagePacker.run();
LwjglApplicationConfiguration cfg = new LwjglApplicationConfiguration();
cfg.width=WIDTH;
cfg.height=HEIGHT;
cfg.useGL20=true;
cfg.title = "GameXYZ";
cfg.vSyncEnabled = false;
cfg.resizable=false;
new LwjglApplication(new GameXYZ(WIDTH,HEIGHT), cfg);
}
}
</pre>
<br />
Two major things to discuss here. First, Launcher no longer extends Game - that's because I'm not using Laucher to control my screens, I'm using GameXYZ to do that. Consequenty, when I declare a new LwjglApplication I don't pass it "this", I pass it a reference to GameXYZ.java (more like the SimpleApp did).<br />
<br />
Second, as a major improvement, I am storing the width and height in the Launcher.java file now. To let my GameXYZ see this, I have to pass it as an argument, but this is no problem! This is helpful because if we make an HTML5 or Android launcher, we will want to set their width and height separately from one another.<br />
<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz;
import com.artemis.World;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.blogspot.javagamexyz.gamexyz.screens.OverworldScreen;
import com.blogspot.javagamexyz.gamexyz.systems.ColorAnimationSystem;
import com.blogspot.javagamexyz.gamexyz.systems.ExpiringSystem;
import com.blogspot.javagamexyz.gamexyz.systems.ScaleAnimationSystem;
import com.blogspot.javagamexyz.gamexyz.systems.SpriteAnimationSystem;
public class GameXYZ extends Game {
public int WINDOW_WIDTH;
public int WINDOW_HEIGHT;
public World world;
private SpriteBatch batch;
public GameXYZ(int width, int height) {
WINDOW_WIDTH = width;
WINDOW_HEIGHT = height;
}
public void create() {
world = new World();
batch = new SpriteBatch();
world.setSystem(new SpriteAnimationSystem());
world.setSystem(new ScaleAnimationSystem());
world.setSystem(new ExpiringSystem());
world.setSystem(new ColorAnimationSystem());
world.initialize();
setScreen(new OverworldScreen(this, batch, world));
}
}
</pre>
<br />
Here we can see we cut out a lot of code. All we have is a constructor, with which we set the width and height, and a method called create(), which is called automatically upon creation. I'm no expert, and I don't really understand the difference between that method and the constructor. But I do know that if you try to jam it all into the constructor, it fails. So I keep it separted and it works like a charm!<br />
<br />
Notice I've set the basic processing systems, but none of the rendering systems. I'm not sure if I want to stick with it this way, but right now each screen will be responsible for its own rendering systems. One reason for this is that everything used to statically reference GameXYZ.gameMap, but that no longer exists. Primarily because different screens may want different maps.<br />
<br />
Note however that GameXYZ has its own SpriteBatch, even though its not doing any of the rendering. All the best practices seem to indicate that it's best to have only one instance of SpriteBatch in your whole game because it's a resource hog. All rendering systems that need it will have it passed to them.<br />
<br />
Line 36 calls the setScreen method, which for now just goes to OverworldScreen. Notice I pass OverworldScreen a reference to this instance of GameXYZ, a reference to the SpriteBatch, and a reference to the World.<br />
<br />
All screens I paln on implementing will extend AbstractScreen.java<br />
<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.screens;
import com.artemis.World;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.GL10;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.blogspot.javagamexyz.gamexyz.GameXYZ;
public abstract class AbstractScreen implements Screen {
protected final GameXYZ game;
protected final World world;
protected final OrthographicCamera camera;
public AbstractScreen(GameXYZ game, World world) {
this.game = game;
this.world = world;
camera = new OrthographicCamera();
}
@Override
public void render(float delta) {
Gdx.gl.glClearColor(0,0,0,1);
Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
camera.update();
world.setDelta(delta);
world.process();
}
@Override
public void show() {
}
@Override
public void hide() {
}
@Override
public void pause() {
}
@Override
public void resume() {
}
@Override
public void resize(int width, int height) {
game.WINDOW_WIDTH = width;
game.WINDOW_HEIGHT = height;
camera.setToOrtho(false, width,height);
}
@Override
public void dispose() {
}
}
</pre>
<br />
Notice it has fields to hold the Game and World passed into it, but it doesn't hold the SpriteBatch. Each screen will also have its own OrthographicCamera (you don't erally want them all sharing the same camera, or zooming out in one screen could influence the way another screen renders).<br />
<br />
The render() method calls some of the basic methods that GameXYZ.java used to. These are things that I could see being generally useful, though I may change my mind about that later and lose the world.process(). Resize changes WINDOW_WIDTH and WINDOW_HEIGHT back in the Game, so all screens should see the updated value.<br />
<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.screens;
import com.artemis.World;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.blogspot.javagamexyz.gamexyz.EntityFactory;
import com.blogspot.javagamexyz.gamexyz.GameXYZ;
import com.blogspot.javagamexyz.gamexyz.input.OverworldControlSystem;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
import com.blogspot.javagamexyz.gamexyz.systems.HudRenderSystem;
import com.blogspot.javagamexyz.gamexyz.systems.MapRenderSystem;
import com.blogspot.javagamexyz.gamexyz.systems.PathRenderingSystem;
import com.blogspot.javagamexyz.gamexyz.systems.SpriteRenderSystem;
public class OverworldScreen extends AbstractScreen {
public static GameMap gameMap;
private OrthographicCamera hudCam;
public SpriteRenderSystem spriteRenderSystem;
public HudRenderSystem hudRenderSystem;
public MapRenderSystem mapRenderSystem;
public PathRenderingSystem pathRenderSystem;
private OverworldControlSystem overworldControlSystem;
public OverworldScreen(GameXYZ game, SpriteBatch batch, World world) {
super(game,world);
gameMap = new GameMap();
hudCam = new OrthographicCamera();
spriteRenderSystem = world.setSystem(new SpriteRenderSystem(camera,batch), true);
mapRenderSystem = world.setSystem(new MapRenderSystem(camera,batch,gameMap),true);
hudRenderSystem = world.setSystem(new HudRenderSystem(hudCam, batch),true);
pathRenderSystem = world.setSystem(new PathRenderingSystem(camera,batch),true);
overworldControlSystem = world.setSystem(new OverworldControlSystem(camera,world,gameMap,game));
Gdx.input.setInputProcessor(overworldControlSystem);
world.initialize();
int x, y;
for (int i=0; i<100; i++) {
do {
x = MathUtils.random(MapTools.width()-1);
y = MathUtils.random(MapTools.height()-1);
} while (gameMap.cellOccupied(x, y));
EntityFactory.createNPC(world,x,y,gameMap).addToWorld();
}
}
@Override
public void render(float delta) {
super.render(delta);
mapRenderSystem.process();
pathRenderSystem.process();
spriteRenderSystem.process();
hudRenderSystem.process();
}
@Override
public void show() {
// TODO Auto-generated method stub
}
@Override
public void resize(int width, int height) {
super.resize(width, height);
hudCam.setToOrtho(false, width, height);
}
@Override
public void hide() {
// TODO Auto-generated method stub
}
@Override
public void pause() {
// TODO Auto-generated method stub
}
@Override
public void resume() {
// TODO Auto-generated method stub
}
@Override
public void dispose() {
// TODO Auto-generated method stub
game.world.deleteSystem(hudRenderSystem);
game.world.deleteSystem(mapRenderSystem);
game.world.deleteSystem(pathRenderSystem);
game.world.deleteSystem(spriteRenderSystem);
}
}
</pre>
<br />
This has a GameMap which is initialized in the constructor. That means that as long as we have THIS screen running around, we'll have that same GameMap. It also has a "hudCam" in addition to the camera defined in AbstractScreen. Because all RenderingSystems now have to share the same SpriteBatch, you get problems if in one rendering system you call batch.setProjectionMatrix(camera.combined), but you don't want to do that for the next rendering system in line. Once it's set for the batch once, it holds for the rest. This runs in to that old problem of zooming out from out hud, and scrolling it away off the screen. This would be silly, so we need a camera which WON'T be changed so the hud can always render from the perspective of that camera.<br />
<br />
All of the RenderingSystems live here, and are initialized in the constructor. Notice they are all given the camera and SpriteBatch we want them to use. Furthermore, mapRenderSystem is given a reference to the gameMap (remember, it can no longer statically get GameXYZ.gameMap).<br />
<br />
We'll skip lines 42-43 for now, but below that we just add a bunch of characters to the world. createNPC() is a lot like createWarrior() from before. Remember, I want to be able to SELECT the character I'm controlling at that moment, so I don't want to automatically assign ONE character to be the player. The render() method isn't too shocking - first we call render() from AbstractScreen, then draw each of our systems in turn. resize() not only calls super.resize(), but also deals with the hudCam.<br />
<br />
Now for lines 42-43. Each Screen can be controlled in its own unique way, so I created a class called OverworldControlSystem. It gets a camera, because it needs to be able to zoom, etc..., it gets a copy of the World because it needs to be able to influence entities, the GameMap because it also had to be able to read what was going on in that. It also has a reference to GameXYZ so that this control system has the power to change screens.<br />
<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.input;
import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.World;
import com.artemis.annotations.Mapper;
import com.artemis.systems.EntityProcessingSystem;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.math.Vector2;
import com.blogspot.javagamexyz.gamexyz.EntityFactory;
import com.blogspot.javagamexyz.gamexyz.GameXYZ;
import com.blogspot.javagamexyz.gamexyz.components.MapPosition;
import com.blogspot.javagamexyz.gamexyz.components.Movement;
import com.blogspot.javagamexyz.gamexyz.components.PlayerSelected;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
public class OverworldControlSystem extends EntityProcessingSystem implements InputProcessor {
@Mapper ComponentMapper<MapPosition> pm;
private OrthographicCamera camera;
private World world;
private GameMap gameMap;
// We need a copy of the screen implementing this controller (which has a copy of
// the Game delegating to it) so we can change screens based on users making selections
private GameXYZ game;
private int selectedEntity;
private Pair pathTarget;
private State state, lastState;
@SuppressWarnings("unchecked")
public OverworldControlSystem(OrthographicCamera camera, World world, GameMap gameMap, GameXYZ game) {
super(Aspect.getAspectForAll(PlayerSelected.class, MapPosition.class));
this.camera = camera;
this.world = world;
this.gameMap = gameMap;
this.game = game;
state = State.DEFAULT;
lastState = State.DEFAULT;
selectedEntity = -1;
}
@Override
protected void process(Entity e) {
// We should only get here if the player has selected an entity and asked for a path
if (state == State.FIND_PATH) {
state = State.ENTITY_SELECTED;
lastState = State.FIND_PATH;
// Get the entity's position
MapPosition pos = pm.getSafe(e);
// Add a Movement component to the entity
Movement movement = new Movement(pos.x,pos.y,pathTarget.x,pathTarget.y, gameMap);
e.addComponent(movement);
e.changedInWorld();
}
}
@Override
public boolean keyDown(int keycode) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean keyUp(int keycode) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean keyTyped(char character) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
// TODO Auto-generated method stub
return false;
}
@Override
public boolean touchUp(int screenX, int screenY, int pointer, int button) {
// Are they releasing from dragging?
if (state == State.DRAGGING) {
state = lastState;
lastState = State.DRAGGING;
return true;
}
// Otherwise, get the coordinates they clicked on
Pair coords = MapTools.window2world(Gdx.input.getX(), Gdx.input.getY(), camera);
// Check the entityID of the cell they click on
int entityId = gameMap.getEntityAt(coords.x, coords.y);
// If it's an actual entity (not empty) then "select" it (unless it's already selected)
if ((entityId > -1) && (entityId != selectedEntity)){
// If there was previously another entity selected, "deselect" it
if (selectedEntity > -1) {
Entity old = world.getEntity(selectedEntity);
old.removeComponent(PlayerSelected.class);
old.removeComponent(Movement.class);
old.changedInWorld();
}
// Now select the current entity
selectedEntity = entityId;
Entity e = world.getEntity(selectedEntity);
e.addComponent(new PlayerSelected());
e.changedInWorld();
System.out.println(e.getId());
EntityFactory.createClick(world, coords.x, coords.y, 0.1f, 4f).addToWorld();
lastState = state;
state = State.ENTITY_SELECTED;
return true;
}
// Are they clicking to find a new path?
else if (state == State.ENTITY_SELECTED) {
lastState = state;
state = State.FIND_PATH;
pathTarget = coords;
return true;
}
return false;
}
@Override
public boolean touchDragged(int screenX, int screenY, int pointer) {
// If it hadn't been dragging, set the current state to dragging
if (state != State.DRAGGING) {
lastState = state;
state = State.DRAGGING;
}
Vector2 delta = new Vector2(-camera.zoom*Gdx.input.getDeltaX(), camera.zoom*Gdx.input.getDeltaY());
camera.translate(delta);
return true;
}
@Override
public boolean mouseMoved(int screenX, int screenY) {
return false;
}
@Override
public boolean scrolled(int amount) {
if ((camera.zoom > 0.2f || amount == 1) && (camera.zoom < 8 || amount == -1)) camera.zoom += amount*0.1;
return true;
}
private enum State {
DEFAULT,
ENTITY_SELECTED,
DRAGGING,
FIND_PATH,
};
}
</pre>
<br />
This should somewhat resemble our old "PlayerInputSystem", but there are some new, neat changes. First, I don't store information about what the player has done as booleans (boolean moving, boolean dragging, etc...) instead I defined an enum called State (lines 172-177). The idea is that the control system will run (roughly) as a Finite State Machine, where the state dictates what it can do.<br />
<br />
For now I've thought of a tentative list of states we may care about, starting in default (just show stuff, leave it open for most anything). Here's how it works:<br />
If you are in DEFAULT you can<br />
<ul>
<li>Select an NPC by clicking on one (ENTITY_SELECTED)</li>
<li>Drag the screen by click dragging (DRAGGING)</li>
</ul>
If you are in DRAGGING you can<br />
<ul>
<li>Return to the previous state you had been in by letting up on the button (lastState)</li>
</ul>
If you are in ENTITY_SELECTED you can<br />
<ul>
<li>Find a path from that entity to any cell by clicking on it (FIND_PATH)</li>
<li>Select another entity by clicking on it (ENTITY_SELECTED)</li>
<li>Drag the screen by click draggin (DRAGGING)</li>
</ul>
If you are in FIND_PATH<br />
<ul>
<li>process() will automatically find the path (if possible) and return you to just (ENTITY_SELECTED)</li>
</ul>
<br />
I imagine expanding this to include more things such as action menus (Move, Attack, Ability, etc...)<br />
<br />
The OverworldControlSystem constantly keeps track of which (if any) entity is selected, which cell you have clicked on (for pathfinding), the current state, and the previous state.<br />
<br />
On line 109 it asks the GameMap for the entity ID of what is in a particular cell. If it's nothing, it gets -1. If there's something there, it gets the ID. Whatever that ID is, it adds a component called PlayerSelected to that entity (I just renamed the old "Player" component to be more appropriate). If there had previously been a selected Entity, it removes PlayerSelected status from it first (and also any path it <i>may</i> have been looking at).<br />
<br />
I updated GameMap to hold a 2D integer array to hold the ID of entities, retrievable by cell coordinates using getEntityAt(x,y). I fear that this will be a pain in the but to maintain, but for now (since nothing is <i>really</i> moving) it's simple enough and works. To keep it up to date, I pass the GameMap into EntityFactory.createNPC() so that it can store the ID into the correct cell of the array upon creation. When things start moving, I'll have to be careful to force that to update the array.<br />
<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.maps;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Texture;
import com.blogspot.javagamexyz.gamexyz.pathfinding.AStarPathFinder;
public class GameMap {
public int[][] map;
public int[][] entityLocations;
public int width, height;
public Pixmap pixmap;
public Texture texture;
public AStarPathFinder pathFinder;
public GameMap() {
HexMapGenerator hmg = new HexMapGenerator();
map = hmg.getDiamondSquare();
width = map.length;
height = map[0].length;
entityLocations = new int[width][height];
pixmap = new Pixmap(width,height,Pixmap.Format.RGBA8888);
for (int i=0; i<width;i++) {
for (int j=0;j<height;j++) {
pixmap.setColor(getColor(map[i][j]));
pixmap.drawPixel(i, j);
entityLocations[i][j] = -1;
}
}
texture = new Texture(pixmap);
pixmap.dispose();
pathFinder = new AStarPathFinder(map, 100);
}
private Color getColor(int color) { // r g b
if (color == 0) return myColor(34 ,53 ,230);
else if (color == 1) return myColor(105 ,179 ,239);
else if (color == 2) return myColor(216 ,209 ,129);
else if (color == 3) return myColor(183 ,245 ,99);
else if (color == 4) return myColor(109 ,194 ,46);
else if (color == 5) return myColor(87 ,155 ,36);
else if (color == 6) return myColor(156 ,114 ,35);
else if (color == 7) return myColor(135 ,48 ,5);
else return new Color(1,1,1,1);
}
private static Color myColor(int r, int g, int b) {
return new Color(r/255f, g/255f, b/255f,1);
}
public int getEntityAt(int x, int y) {
return entityLocations[x][y];
}
public boolean cellOccupied(int x, int y) {
return (entityLocations[x][y] > -1);
}
}
</pre>
<br />
<br />
Other than that the changes were fairly minor. On line 129 of OverworldControlSystem I add a cute click effect for when players select a new character to control. One thing frustrating me about project organization is that OverworldControlSystem does extend EntityProcessingSystem, so it is an Artemis system. But I thought it best to put it in a separate package, com.blogspot.javagamexyz.gamexyz.input.<br />
<br />
That's a heck of an update! I went ahead and posted the full code to the repository, including images. Check it out using SVN from <a href="https://code.google.com/p/javagamexyz/source/browse/#svn%2Ftags%2F2013-03-16">https://code.google.com/p/javagamexyz/source/browse/#svn%2Ftags%2F2013-03-16</a>, or just browse the code.<br />
<br />
<span style="color: #bf9000;"><b>You have gained 150 XP. Progress to Level 3: 600/600</b></span><br />
<span style="color: #bf9000;"><b>DING! You have advanced to Level 3, congratulations!</b></span><br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEi4nIywHbABWg1qsTJ7endRyU5zOwR2ub8Mh7kGClDhhV_FLeNSeGbtrmeeuz2Ux3dr89gJlpB0iwMKpYwvkWurZlq-qR7UIKlTnDOUurXrGREO4uarECLvL4u1PlDYsK03Gk7Hb3C3GiU/s1600/Level3.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="320" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEi4nIywHbABWg1qsTJ7endRyU5zOwR2ub8Mh7kGClDhhV_FLeNSeGbtrmeeuz2Ux3dr89gJlpB0iwMKpYwvkWurZlq-qR7UIKlTnDOUurXrGREO4uarECLvL4u1PlDYsK03Gk7Hb3C3GiU/s320/Level3.png" width="229" /></a></div>
As a Level 3 PC, you have mastered<br />
<ul>
<li>Using animations in a libgdx/Artemis framework</li>
<li>Creating, handling, and drawing 2D tile based maps, even with Hex cells. You can deal with helper functions like getNeighbors() and distance()</li>
</ul>
You have also gained some proficiency at<br />
<ul>
<li>Basic pathfinding using the A* algorithm</li>
<li>Managing Screens using Game to split your code up into manageable chunks</li>
</ul>
Your game is now on the path to becoming something that can actually be played! <br />
<br />
<br />
<br />Unknownnoreply@blogger.com0tag:blogger.com,1999:blog-4125624026143571209.post-17424181198405422272013-03-14T10:36:00.001-07:002013-03-15T17:03:52.686-07:00A Star (A*) PathfindingI decided the next thing I wanted to implement was a path finder. It appears to be a universal standard in game development to use the A* algorithm, so I decided to implement that for my hex map. The good news is that most of the work was already done for me by Kevin Glass over at <a href="http://old.cokeandcode.com/pathfinding" target="_blank">Coke And Code</a> who has written an excellent article with sample code in Java. His code is way more general than I was prepared to implement just yet, so I commented out a bunch of stuff I haven't even started thinking about yet, and changed a few things to work on a hex map, and voila! In the process, I also read a lot about heuristics <a href="http://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html" target="_blank">here</a>, and found some insights to be quite interesting (e.g. not square-rooting Euclidean distance being a bad idea, adding tie-breakers to get more attractive paths, searching for multiple goals and more). I'm sure that if I ever need to make a more sophisticated pathfinder, this website will prove invaluable!<br />
<br />
I'll let you read Kevin Glass' article for an understanding of how the algorithm works, which is a good idea especially considering how I gutted it to get only the essential features to make it work. When I cut the "fluff" (AKA, the stuff that makes it powerful and general) out of his algorithm, I was left with two class:<br />
<ol>
<li>Path.java</li>
<li>AStarPathFinder.java</li>
</ol>
There were two noteworthy changes I had to make to my code, one little, one huge. I'll start with the little one:<br />
<br />
One of the steps in the algorithm is to find all the neighbors of a node, which was nice because I had already created a getNeighbors() method in MapTools to do exactly that. Alas, I had to fix a major bug in mine: it had no problem returning negative coordinates for the neighbors. That's because it was stupid and had no idea where the boundary of the world was, and when asked to find neighbors of cell (0,0), it had no problem going outside the world. My updated algorithm changes it from a fixed size Pair[] array into the libgdx built in class Array. This is nicer because it permits a variable length of array members (a tile at the border has fewer neighbors than a tile at the center).<br />
<br />
Here's what the updated code looks like:<br />
<pre class="brush:java">public static Array<Pair> getNeighbors(int x, int y, int n) {
Array<Pair> coordinates = new Array<Pair>();
int min;
int myrow;
for (int row = y-n; row<y+n+1; row++) {
min = MyMath.min(2*(row-y+n), n, -2*(row-y-n)+1);
for (int col = x-min; col < x+min+1; col++) {
if ((col < 0) || (col >= width())) continue;
if (x==col && y==row) continue;
else if (x % 2 == 0) myrow = 2*y-row;
else myrow = row;
if ((myrow < 0) || (myrow >= height())) continue;
coordinates.add(new Pair(col,myrow));
}
}
return coordinates;
}
</pre>
<br />
<br />
One lines 8 and 12 I check to make sure the coordinates are within the boundary of the world.<br />
<br />
The more major change is that I used to store the GameMap as its own Entity, which was nice because I could make a RenderingSystem which would catch it every cycle, and I was excited about the possibility of making the map dynamic and interact with other entities.<br />
<br />
Unfortunately, any time I want an Entity to find a path from one point to another, that entity has to be able to see the map. In fact, that's a pretty basic requirement for a game... things have to be able to see the things around them. Duh, right?! Well, that wasn't very clean when the GameMap was an Entity.<br />
<br />
Instead, I remembered the old VoidEntitySystem which was a system which will run every time, but doesn't do so for a list of entities. In SpaceshipWarrior we used one to spawn enemy ships. In my game here, I'm going to use one to draw a map.<br />
<br />
So I created a new package called com.blogspot.javagamexyz.gamexyz.maps and added a class GameMap.java into com.blogspot.javagamexyz.gamexyz.maps which looks a lot like the component did (with the addition of a pathFinder field), but now is a stand alone class.<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.maps;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Texture;
import com.blogspot.javagamexyz.gamexyz.pathfinding.AStarPathFinder;
import com.blogspot.javagamexyz.gamexyz.utils.HexMapGenerator;
public class GameMap {
public int[][] map;
public int width, height;
public Pixmap pixmap;
public Texture texture;
public AStarPathFinder pathFinder;
public GameMap() {
HexMapGenerator hmg = new HexMapGenerator();
map = hmg.getDiamondSquare();
width = map.length;
height = map[0].length;
pixmap = new Pixmap(width,height,Pixmap.Format.RGBA8888);
for (int i=0; i<width;i++) {
for (int j=0;j<height;j++) {
pixmap.setColor(getColor(map[i][j]));
pixmap.drawPixel(i, j);
}
}
texture = new Texture(pixmap);
pixmap.dispose();
pathFinder = new AStarPathFinder(map, 100);
}
private Color getColor(int color) { // r g b
if (color == 0) return myColor(34 ,53 ,230);
else if (color == 1) return myColor(105 ,179 ,239);
else if (color == 2) return myColor(216 ,209 ,129);
else if (color == 3) return myColor(183 ,245 ,99);
else if (color == 4) return myColor(109 ,194 ,46);
else if (color == 5) return myColor(87 ,155 ,36);
else if (color == 6) return myColor(156 ,114 ,35);
else if (color == 7) return myColor(135 ,48 ,5);
else return new Color(1,1,1,1);
}
private static Color myColor(int r, int g, int b) {
return new Color(r/255f, g/255f, b/255f,1);
}
}
</pre>
<br />
I also changed MapRenderSystem.java to the VoidEntitySystem, and it looks like this:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.systems;
import com.artemis.systems.VoidEntitySystem;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.graphics.g2d.TextureAtlas.AtlasRegion;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.utils.MapTools;
public class MapRenderSystem extends VoidEntitySystem {
private SpriteBatch batch;
private TextureAtlas atlas;
private Array<atlasregion> textures;
private OrthographicCamera camera;
private GameMap gameMap;
public MapRenderSystem(OrthographicCamera camera, GameMap gameMap) {
this.camera = camera;
this.gameMap = gameMap;
}
@Override
protected void initialize() {
batch = new SpriteBatch();
// Load the map tiles into an Array
atlas = new TextureAtlas(Gdx.files.internal("textures/maptiles.atlas"),Gdx.files.internal("textures"));
textures = atlas.findRegions(MapTools.name);
}
@Override
protected boolean checkProcessing() {
return true;
}
protected void processSystem() {
TextureRegion reg;
int x, y;
// Get bottom left and top right coordinates of camera viewport and convert
// into grid coordinates for the map
int x0 = MathUtils.floor(camera.frustum.planePoints[0].x / (float)MapTools.col_multiple) - 1;
int y0 = MathUtils.floor(camera.frustum.planePoints[0].y / (float)MapTools.row_multiple) - 1;
int x1 = MathUtils.floor(camera.frustum.planePoints[2].x / (float)MapTools.col_multiple) + 2;
int y1 = MathUtils.floor(camera.frustum.planePoints[2].y / (float)MapTools.row_multiple) + 1;
// Restrict the grid coordinates to realistic values
if (x0 % 2 == 1) x0 -= 1;
if (x0 < 0) x0 = 0;
if (x1 > gameMap.width) x1 = gameMap.width;
if (y0 < 0) y0 = 0;
if (y1 > gameMap.height) y1 = gameMap.height;
// Loop over everything in the window to draw. Draw 2 columns at once
for (int row = y0; row < y1; row++) {
for (int col = x0; col < x1-1; col+=2) {
x = col*MapTools.col_multiple;
y = row*MapTools.row_multiple;
reg = textures.get(gameMap.map[col][row]);
batch.draw(reg, x, y, 0, 0, reg.getRegionWidth(), reg.getRegionHeight(), 1, 1, 0);
x += MapTools.col_multiple;
y += MapTools.row_multiple/2;
reg = textures.get(gameMap.map[col+1][row]);
batch.draw(reg, x, y, 0, 0, reg.getRegionWidth(), reg.getRegionHeight(), 1, 1, 0);
}
// Due to the map generation algorithm I use, there is guaranteed to be an odd number of columns.
// Since I drew 2 columns at once above, the far right one won't be touched. This bit is a little
// silly because it draws the far right column, whether it is in the frustum or not. Oh well...
if (x1 >= gameMap.width) {
int col = gameMap.width-1;
x = col*MapTools.col_multiple;
y = row*MapTools.row_multiple;
reg = textures.get(gameMap.map[col][row]);
batch.draw(reg, x, y, 0, 0, reg.getRegionWidth(), reg.getRegionHeight(), 1, 1, 0);
}
}
// This line can draw a small image of the whole map
//batch.draw(gameMap.texture,0,0);
}
@Override
protected void begin() {
batch.setProjectionMatrix(camera.combined);
batch.begin();
}
@Override
protected void end() {
batch.end();
}
}
</atlasregion></pre>
<br />
In my main screen class, GameXYZ.java, I simply added a field for the GameMap:<br />
<pre class="brush:java">public class GameXYZ implements Screen {
public static int WINDOW_WIDTH = 1300;
public static int WINDOW_HEIGHT = 720;
OrthographicCamera camera;
SpriteBatch batch;
World world;
Game game;
public static GameMap gameMap;
private SpriteRenderSystem spriteRenderSystem;
private HudRenderSystem hudRenderSystem;
private MapRenderSystem mapRenderSystem;
public GameXYZ(Game game) {
this.game = game;
batch = new SpriteBatch();
camera = new OrthographicCamera();
gameMap = new GameMap();
world = new World();
.
.
.
</pre>
<br />
Now whenever anything needs to see the GameMap, it can easily by checking GameXYZ.gameMap. With these fairly important updates, I was able to make my new pathfinding system. I make no guarantee that it is a smart way, not the way it will end up, but here goes.<br />
<br />
Here is the code I ended up with from Kevin's tutorial. I put it in a new package com.blogspot.javagamexyz.gamexyz.pathfinding:<br />
<br />
AStarPathFinder.java<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.pathfinding;
import java.util.ArrayList;
import java.util.Collections;
import com.badlogic.gdx.utils.Array;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.utils.MapTools;
/**
* A path finder implementation that uses the AStar heuristic based algorithm
* to determine a path.
*
* @author Kevin Glass
*/
public class AStarPathFinder {
/** The set of nodes that have been searched through */
private Array<Node> closed = new Array<Node>();
/** The set of nodes that we do not yet consider fully searched */
private SortedList open = new SortedList();
/** The map being searched */
private int[][] map;
/** The maximum depth of search we're willing to accept before giving up */
private int maxSearchDistance;
/** The complete set of nodes across the map */
private Node[][] nodes;
/**
* Create a path finder
*
* @param heuristic The heuristic used to determine the search order of the map
* @param map The map to be searched
* @param maxSearchDistance The maximum depth we'll search before giving up
* @param allowDiagMovement True if the search should try diaganol movement
*/
public AStarPathFinder(int[][] map, int maxSearchDistance) {
this.map = map;
this.maxSearchDistance = maxSearchDistance;
nodes = new Node[map.length][map[0].length];
for (int x=0;x<map.length;x++) {
for (int y=0;y<map[0].length;y++) {
nodes[x][y] = new Node(x,y);
}
}
}
/**
* @see PathFinder#findPath(Mover, int, int, int, int)
*/
public Path findPath(int sx, int sy, int tx, int ty) {
// easy first check, if the destination is blocked, we can't get there
// if (map.blocked(mover, tx, ty)) {
// return null;
// }
// initial state for A*. The closed group is empty. Only the starting
// tile is in the open list and it's cost is zero, i.e. we're already there
nodes[sx][sy].cost = 0;
nodes[sx][sy].depth = 0;
closed.clear();
open.clear();
open.add(nodes[sx][sy]);
nodes[tx][ty].parent = null;
// while we haven't found the goal and haven't exceeded our max search depth
int maxDepth = 0;
while ((maxDepth < maxSearchDistance) && (open.size() != 0)) {
// pull out the first node in our open list, this is determined to
// be the most likely to be the next step based on our heuristic
Node current = getFirstInOpen();
if (current == nodes[tx][ty]) {
break;
}
removeFromOpen(current);
addToClosed(current);
Array<Pair> neighbors = MapTools.getNeighbors(current.x, current.y);
// search through all the neighbours of the current node evaluating
// them as next steps
for (Pair n : neighbors) {
int xp = n.x;
int yp = n.y;
float nextStepCost = current.cost + getMovementCost(current.x,current.y,xp,yp);
Node neighbor = nodes[xp][yp];
if (nextStepCost < neighbor.cost) {
if (inOpenList(neighbor)) removeFromOpen(neighbor);
if (inClosedList(neighbor)) removeFromClosed(neighbor);
}
if (!inOpenList(neighbor) && !inClosedList(neighbor)) {
neighbor.cost = nextStepCost;
neighbor.heuristic = (float)MapTools.distance(xp,yp,tx,ty);
maxDepth = Math.max(maxDepth, neighbor.setParent(current));
addToOpen(neighbor);
}
}
}
// since we've got an empty open list or we've run out of search
// there was no path. Just return null
if (nodes[tx][ty].parent == null) {
return null;
}
// At this point we've definitely found a path so we can uses the parent
// references of the nodes to find out way from the target location back
// to the start recording the nodes on the way.
Path path = new Path();
Node target = nodes[tx][ty];
while (target != nodes[sx][sy]) {
path.prependStep(target.x, target.y);
target = target.parent;
}
path.prependStep(sx,sy);
// thats it, we have our path
return path;
}
/**
* Get the first element from the open list. This is the next
* one to be searched.
*
* @return The first element in the open list
*/
protected Node getFirstInOpen() {
return (Node) open.first();
}
/**
* Add a node to the open list
*
* @param node The node to be added to the open list
*/
protected void addToOpen(Node node) {
open.add(node);
}
/**
* Check if a node is in the open list
*
* @param node The node to check for
* @return True if the node given is in the open list
*/
protected boolean inOpenList(Node node) {
return open.contains(node);
}
/**
* Remove a node from the open list
*
* @param node The node to remove from the open list
*/
protected void removeFromOpen(Node node) {
open.remove(node);
}
/**
* Add a node to the closed list
*
* @param node The node to add to the closed list
*/
protected void addToClosed(Node node) {
closed.add(node);
}
/**
* Check if the node supplied is in the closed list
*
* @param node The node to search for
* @return True if the node specified is in the closed list
*/
protected boolean inClosedList(Node node) {
return closed.contains(node,false);
}
/**
* Remove a node from the closed list
*
* @param node The node to remove from the closed list
*/
protected void removeFromClosed(Node node) {
closed.removeValue(node,false);
}
/**
* Check if a given location is valid for the supplied mover
*
* @param mover The mover that would hold a given location
* @param sx The starting x coordinate
* @param sy The starting y coordinate
* @param x The x coordinate of the location to check
* @param y The y coordinate of the location to check
* @return True if the location is valid for the given mover
*/
protected boolean isValidLocation(int sx, int sy, int x, int y) {
boolean invalid = (x < 0) || (y < 0) || (x >= map.length) || (y >= map[0].length);
if ((!invalid) && ((sx != x) || (sy != y))) {
//invalid = map.blocked(mover, x, y);
}
return !invalid;
}
/**
* Get the cost to move through a given location
*
* @param mover The entity that is being moved
* @param sx The x coordinate of the tile whose cost is being determined
* @param sy The y coordiante of the tile whose cost is being determined
* @param tx The x coordinate of the target location
* @param ty The y coordinate of the target location
* @return The cost of movement through the given tile
*/
public float getMovementCost(int sx, int sy, int tx, int ty) {
return (float)map[tx][ty];
//return map.getCost(mover, sx, sy, tx, ty);
}
/**
* Get the heuristic cost for the given location. This determines in which
* order the locations are processed.
*
* @param mover The entity that is being moved
* @param x The x coordinate of the tile whose cost is being determined
* @param y The y coordiante of the tile whose cost is being determined
* @param tx The x coordinate of the target location
* @param ty The y coordinate of the target location
* @return The heuristic cost assigned to the tile
*/
public float getHeuristicCost(int x, int y, int tx, int ty) {
return MapTools.distance(x, y, tx, ty);
//return heuristic.getCost(map, mover, x, y, tx, ty);
}
/**
* A simple sorted list
*
* @author kevin
*/
private class SortedList {
/** The list of elements */
private ArrayList<Node> list = new ArrayList<Node>();
/**
* Retrieve the first element from the list
*
* @return The first element from the list
*/
public Object first() {
return list.get(0);
}
/**
* Empty the list
*/
public void clear() {
list.clear();
}
/**
* Add an element to the list - causes sorting
*
* @param o The element to add
*/
public void add(Node o) {
list.add(o);
Collections.sort(list);
}
/**
* Remove an element from the list
*
* @param o The element to remove
*/
public void remove(Object o) {
list.remove(o);
}
/**
* Get the number of elements in the list
*
* @return The number of element in the list
*/
public int size() {
return list.size();
}
/**
* Check if an element is in the list
*
* @param o The element to search for
* @return True if the element is in the list
*/
public boolean contains(Object o) {
return list.contains(o);
}
}
/**
* A single node in the search graph
*/
private class Node implements Comparable {
/** The x coordinate of the node */
private int x;
/** The y coordinate of the node */
private int y;
/** The path cost for this node */
private float cost;
/** The parent of this node, how we reached it in the search */
private Node parent;
/** The heuristic cost of this node */
private float heuristic;
/** The search depth of this node */
private int depth;
/**
* Create a new node
*
* @param x The x coordinate of the node
* @param y The y coordinate of the node
*/
public Node(int x, int y) {
this.x = x;
this.y = y;
}
/**
* Set the parent of this node
*
* @param parent The parent node which lead us to this node
* @return The depth we have no reached in searching
*/
public int setParent(Node parent) {
depth = parent.depth + 1;
this.parent = parent;
return depth;
}
/**
* @see Comparable#compareTo(Object)
*/
public int compareTo(Object other) {
Node o = (Node) other;
float f = heuristic + cost;
float of = o.heuristic + o.cost;
if (f < of) {
return -1;
} else if (f > of) {
return 1;
} else {
return 0;
}
}
/**
* @see Object#equals(Object)
*/
public boolean equals(Object other) {
if (other instanceof Node) {
Node o = (Node) other;
return (o.x == x) && (o.y == y);
}
return false;
}
}
}
</pre>
<br />
One thing to note is that Kevin used ArrayList to hold the list of nodes, whereas I changed it to the libgdx class Array. If you've built the <a href="http://code.google.com/p/libgdx/wiki/SimpleApp" target="_blank">SimpleApp</a> bucket drop game you may remember them saying this:<br />
<blockquote class="tr_bq">
The <tt>Array</tt> class is a libgdx utility class to be used instead of standard Java collections like <tt>ArrayList</tt>. The problem with the later is that they produce garbage in various ways. The <tt>Array</tt>
class tries to minimize garbage as much as possible. Libgdx offers
other garbage collector aware collections such as hashmaps or sets as
well. </blockquote>
To do that, I had to add a .equals() method to the private class Node.<br />
<br />
Note on lines <b>39-49</b> I load the map and initialize the node array in the constructor. Next, the method findPath() is the main piece of the puzzle. It starts commented out because I'm assuming my unit can move anywhere, but someday I'd like to include information about the mover. Lines <b>62-68</b> initialize everything (again, you can read the details in Kevin's article). Then we begin our search.<br />
<br />
Line <b>75</b> gets the current node we will work on. Line <b>83</b> gets an Array of coordinates of its neighbors using my getNeighbors() method. It loops over those neighbors, checks them out, updates their cost if it has found a shorter path to get there, etc... On line <b>89</b> it updates the cost of the path it's currently searching using getMovementCost(). If we look down at lines <b>221-224</b> we see I was just lazy and made each cell's cost equal to its value: deep ocean = 0, shallow water = 1, desert = 2, etc... This means that for now, paths will really like to go through deep ocean, and will really hate going through mountains. Clearly this will be something to update when the game becomes more involved.<br />
<br />
After searching as long as it can/needs to, if it never finds a path it returns null. Otherwise it returns our path!<br />
<br />
On line <b>228</b> in the getHeuristicCost() method, I got rid of the Heuristic classes Kevin wrote. As of now I can't imagine using another heuristic than the straight up distance method in MapTools, so I just use that. Everything else is pretty much just helper methods, I didn't change too much from Kevin. The code refers to a class called Path, which is a separate file which looks like this:<br />
<br />
Path.java<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.pathfinding;
import com.badlogic.gdx.utils.Array;
/**
* A path determined by some path finding algorithm. A series of steps from
* the starting location to the target location. This includes a step for the
* initial location.
*
* @author Kevin Glass
*/
public class Path {
/** The list of steps building up this path */
private Array<Step> steps = new Array<Step>();
/**
* Create an empty path
*/
public Path() {
}
/**
* Get the length of the path, i.e. the number of steps
*
* @return The number of steps in this path
*/
public int getLength() {
return steps.size;
}
/**
* Get the step at a given index in the path
*
* @param index The index of the step to retrieve. Note this should
* be >= 0 and < getLength();
* @return The step information, the position on the map.
*/
public Step getStep(int index) {
return (Step) steps.get(index);
}
/**
* Get the x coordinate for the step at the given index
*
* @param index The index of the step whose x coordinate should be retrieved
* @return The x coordinate at the step
*/
public int getX(int index) {
return getStep(index).x;
}
/**
* Get the y coordinate for the step at the given index
*
* @param index The index of the step whose y coordinate should be retrieved
* @return The y coordinate at the step
*/
public int getY(int index) {
return getStep(index).y;
}
/**
* Append a step to the path.
*
* @param x The x coordinate of the new step
* @param y The y coordinate of the new step
*/
public void appendStep(int x, int y) {
steps.add(new Step(x,y));
}
/**
* Prepend a step to the path.
*
* @param x The x coordinate of the new step
* @param y The y coordinate of the new step
*/
public void prependStep(int x, int y) {
steps.add(new Step(x, y));
}
/**
* Check if this path contains the given step
*
* @param x The x coordinate of the step to check for
* @param y The y coordinate of the step to check for
* @return True if the path contains the given step
*/
public boolean contains(int x, int y) {
return steps.contains(new Step(x,y),false);
}
/**
* A single step within the path
*
* @author Kevin Glass
*/
public class Step {
/** The x coordinate at the given step */
private int x;
/** The y coordinate at the given step */
private int y;
/**
* Create a new step
*
* @param x The x coordinate of the new step
* @param y The y coordinate of the new step
*/
public Step(int x, int y) {
this.x = x;
this.y = y;
}
/**
* Get the x coordinate of the new step
*
* @return The x coodindate of the new step
*/
public int getX() {
return x;
}
/**
* Get the y coordinate of the new step
*
* @return The y coodindate of the new step
*/
public int getY() {
return y;
}
/**
* @see Object#hashCode()
*/
public int hashCode() {
return x*y;
}
/**
* @see Object#equals(Object)
*/
public boolean equals(Object other) {
if (other instanceof Step) {
Step o = (Step) other;
return (o.x == x) && (o.y == y);
}
return false;
}
}
}
</pre>
<br />
The included private class Step makes me feel a little guilty because it's so similar to Node from AStarPathFinder, and also to my class Pair, and I don't want to have <i>3</i> classes where one will do, so maybe at some point I'll combine them into a single class.<br />
<br />
To integrate it all into our code, I created a Component called Movement which just stores a Path:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.components;
import com.artemis.Component;
import com.blogspot.javagamexyz.gamexyz.GameXYZ;
import com.blogspot.javagamexyz.gamexyz.pathfinding.Path;
public class Movement extends Component {
public Path path;
public Movement(int x0, int y0, int tx, int ty) {
path = GameXYZ.gameMap.pathFinder.findPath(x0, y0, tx, ty);
}
}
</pre>
<br />
Notice on line <b>12</b> where it gets the GameMap by referencing GameXYZ.gameMap, and then references its pathFinder to findPath(). If the pathfinder is unable to find a path, it will return Null. Clearly this will be extended/changed in the future to include stuff like what kind of unit is moving, how far can it move, how well does it move over mountains, ocean, etc.<br />
<br />
To test it out I wanted to make it so whenever you click a cell, it tries to come up with a path from my main dude to that cell. The place to do this seemed like PlayerInputSystem touchDown() (or touchUp()), but there's a little problem. Those listener methods can't actually see the Entity being processed, so they can't add a new Movement component to it.<br />
<br />
Instead, I created a boolean flag: <b>moving</b>, along with a Pair <b>moveTarget</b>. In the touchDown/Up() method I set the <b>moving</b> flag to be true, and set <b>moveTarget</b> to the cell that was clicked on. Then, in the process() method I check to see if <b>moving</b> is true, and if so, I add the Movement component to the Entity (and set <b>moving</b> to false, so I'm not adding this same component every cycle). Here's what it looks like:<br />
<br />
PlayerInputSystem.java<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.systems;
import com.artemis.Aspect;
import com.artemis.Entity;
import com.artemis.systems.EntityProcessingSystem;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.math.Vector3;
import com.blogspot.javagamexyz.gamexyz.EntityFactory;
import com.blogspot.javagamexyz.gamexyz.components.Movement;
import com.blogspot.javagamexyz.gamexyz.components.Player;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.utils.MapTools;
public class PlayerInputSystem extends EntityProcessingSystem implements InputProcessor {
private OrthographicCamera camera;
private Vector3 mouseVector;
private boolean moving;
private Pair moveTarget;
@SuppressWarnings("unchecked")
public PlayerInputSystem(OrthographicCamera camera) {
super(Aspect.getAspectForAll(Player.class));
this.camera=camera;
moving=false;
moveTarget = new Pair(0,0);
}
@Override
protected void initialize() {
Gdx.input.setInputProcessor(this);
}
@Override
protected void process(Entity e) {
mouseVector = new Vector3(Gdx.input.getX(),Gdx.input.getY(),0);
camera.unproject(mouseVector);
if (moving) {
moving = false;
Movement movement = new Movement(11,14,moveTarget.x,moveTarget.y);
e.addComponent(movement);
e.changedInWorld();
}
}
@Override
public boolean keyDown(int keycode) {
return false;
}
@Override
public boolean keyUp(int keycode) {
return false;
}
@Override
public boolean keyTyped(char character) {
return false;
}
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
return false;
}
@Override
public boolean touchUp(int screenX, int screenY, int pointer, int button) {
// Get the hex cell being clicked
Pair coords = MapTools.window2world(Gdx.input.getX(), Gdx.input.getY(), camera);
moving = true;
moveTarget = coords;
if (button == 1) camera.zoom = 1;
EntityFactory.createClick(world, coords.x, coords.y, 0.2f, 4f).addToWorld();
return false;
}
@Override
public boolean touchDragged(int screenX, int screenY, int pointer) {
Vector3 delta = new Vector3(-camera.zoom*Gdx.input.getDeltaX(), camera.zoom*Gdx.input.getDeltaY(),0);
camera.translate(delta);
return false;
}
@Override
public boolean mouseMoved(int screenX, int screenY) {
return false;
}
@Override
public boolean scrolled(int amount) {
if ((camera.zoom > 0.2f || amount == 1) && (camera.zoom < 8 || amount == -1)) camera.zoom += amount*0.1;
return false;
}
}
</pre>
<br />
Also in the PlayerInputSystem, note line <b>46</b>. Because I added a new Movement component, it overrides the old one, but this doesn't register until you call e.changedInWorld(). <br />
<br />
Now, this is cute... maybe it works, maybe it doesn't? How would I know? I click, and supposedly it makes a path? Well, let's visualize that path. I did a quick Google search and came up with this cute little feet.png<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhQ1sMvO5xIdP4erJI9WrRXDUBNOakgPwYt3uOp1f0xBDmvxznmzFWxgAs6QuqCtNgXaTMbhqYGBOuu_AZHiRdINJ3Th00gmndjNbonyZ9oM3LkYzq9UhSWrc9xqIbxo2x8d6brIhmXM0k/s1600/feet.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhQ1sMvO5xIdP4erJI9WrRXDUBNOakgPwYt3uOp1f0xBDmvxznmzFWxgAs6QuqCtNgXaTMbhqYGBOuu_AZHiRdINJ3Th00gmndjNbonyZ9oM3LkYzq9UhSWrc9xqIbxo2x8d6brIhmXM0k/s1600/feet.png" /></a></div>
I put it in its own new folder, "textures/misc". Then I made a new RenderingSystem just to draw the path, called PathRenderingSystem:<br />
<pre class="brush:java">package com.blogspot.javagamexyz.gamexyz.systems;
import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.EntitySystem;
import com.artemis.annotations.Mapper;
import com.artemis.utils.ImmutableBag;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.blogspot.javagamexyz.gamexyz.components.Movement;
import com.blogspot.javagamexyz.gamexyz.custom.FloatPair;
import com.blogspot.javagamexyz.gamexyz.utils.MapTools;
public class PathRenderingSystem extends EntitySystem {
@Mapper ComponentMapper<Movement> mm;
private OrthographicCamera camera;
private SpriteBatch batch;
private Texture feet;
@SuppressWarnings("unchecked")
public PathRenderingSystem(OrthographicCamera camera) {
super(Aspect.getAspectForAll(Movement.class));
this.camera = camera;
}
@Override
protected void initialize() {
batch = new SpriteBatch();
feet = new Texture(Gdx.files.internal("textures/misc/feet.png"));
}
@Override
protected boolean checkProcessing() {
// TODO Auto-generated method stub
return true;
}
@Override
protected void processEntities(ImmutableBag<Entity> entities) {
for (int i=0; i<entities.size(); i++) {
process(entities.get(i));
}
}
@Override
protected void begin() {
batch.setProjectionMatrix(camera.combined);
batch.begin();
}
private void process(Entity e) {
Movement move = mm.get(e);
if (move.path != null) {
for (int i=0; i<move.path.getLength(); i++) {
FloatPair coords = MapTools.world2window(move.path.getX(i), move.path.getY(i));
batch.draw(feet, coords.x-feet.getWidth()/2, coords.y-feet.getHeight()/2);
}
}
}
@Override
protected void end() {
batch.end();
}
}
</pre>
<br />
On line <b>26</b> I grab all entities with the "Movement" component. On line <b>33</b> I just go ahead and hardcode loading feet.png - every path cell will just be rendered with that image, so I don't have to get fancy.<br />
<br />
For processing, first on line <b>57</b> I make sure that the path isn't null. On lines <b>58-61</b> I loop over the path, getting all the steps, change those to coordinates for where to draw the feet in window space, then draw them. That's just enough to actually see the path it's finding.<br />
<br />
Pretty awesome! Next I plan on working on the user interface a little bit. For instance, I want to be able to click on a character to select them, then click on a map tile to move them. I also want to be able to scroll the camera without counting as a movement click. I'll probably also add some code to literally move the entities you click to their new location. This part will have me think a lot more about how I want the game to feel. I'll be shooting towards a <a href="http://kotaku.com/5973196/why-everybody-loves-final-fantasy-tactics?utm_source=gawker.com&utm_medium=recirculation&utm_campaign=recirculation" target="_blank">Final Fantasy Tactics</a>, <a href="http://www.wesnoth.org/" target="_blank">Battle for Wesnoth</a>, <a href="http://en.wikipedia.org/wiki/Fire_Emblem" target="_blank">Fire Emblem</a> -esque SRPG experience. Because this is written with libgdx it has the potential to be deployed to desktop, HTML5, or Android, which may be too much to think about right now. I'll probably start just focusing on desktop, but we'll see!<br />
<br />
<span style="color: #bf9000;"><b>You have gained 100 XP. Progress to level 3: 450/600</b></span>Unknownnoreply@blogger.com0tag:blogger.com,1999:blog-4125624026143571209.post-60240615536751672112013-03-08T17:51:00.000-08:002013-03-14T10:37:34.452-07:00Google Code RepositoryI have opened up a Google Code repository where you can go and checkout the source code as we work through it. Check it out at <a href="http://code.google.com/p/javagamexyz/">http://code.google.com/p/javagamexyz/.</a><br />
<br />
The code is licensed under the <a href="http://opensource.org/licenses/BSD-3-Clause" target="_blank">New BSD </a>(BSD 3-clause) which more or less says you may do whatever you want with the code, but I won't assume any liability for anything about it. The code is hosted using <a href="http://subversion.tigris.org/" target="_blank">Subversion</a>, for which you can download an <a href="http://www.eclipse.org/subversive/" target="_blank">eclipse plugin</a>.<br />
<br />
To check out a particular version of the code<br />
<ol>
<li>Install the plugin</li>
<li>In Eclipse say File->Import</li>
<li>Find the option "Project from SVN" and click it</li>
<li>Say "Create a new repository location"</li>
<li>Under URL, say "http://javagamexyz.googlecode.com/svn/tags/2013-03-08/GameXYZ"</li>
<ul>
<li>Or replace the date with whichever version you want</li>
</ul>
<li>Go with the Head Revision (I don't really know what this means...)</li>
<li>Check out as a project configured using the New Project Wizard</li>
<li>Give the project whatever name</li>
<li>Do the same for /GameXYZ-desktop</li>
<li>You still have to put the .jars in the buildpath and set project dependencies, but then it should work!</li>
</ol>
<br />
To check out the most recent<br />
Use this URL: "http://javagamexyz.googlecode.com/svn/trunk/GameXYZ"<br />
<br />
As time goes on, that most recent version may become outdated. When that happens, right click on the project, go to Team->Update.<br />
<br />
There are a few changes to this code from my last blog update that need to be mentioned:<br />
<ul>
<li>MapTools can calculate the "raw" distance between two cells, that is, how many cells they are apart from each other. This was confusing to do for a hex map, and probably isn't very optimal, but it works!</li>
<li>The resource "images" file structure is a little updated</li>
<li>I created my own sprite character animation that fits well into a grid cell (32x32 image) - it was fun to do, and I may post a tutorial on how I did it later (note - I suck as an artist, so even if you have no artistic talent you may be able to do something like these)</li>
<li>n, wmult, and hmult have been moved to from MidpointDisplacement to HexMapGenerator. I will probably someday move smoothness and all the thresholds there as well</li>
<li>The GameMap component now has a Texture which builds itself upon instantiation, holding an image of the overall map where each cell takes up a single pixel. This was done using Pixmap from libgdx. The code is pretty straightforward except for using Pixmap.Format.RBGA8888 - I'm not totally sure what this format means and why I had to use it. But when I used the other formats, it looked terrible. My getColor() method hardcodes which color to use for each terrain type. MapRenderSystem has a line commented out which draws this map at (0,0).</li>
<li>I added a FloatPair class, so I can have a Pair not just with ints. I wanted to redo Pair as a generic class (i.e. Pair<Class1,Class2>) but when I did that Java wouldn't let me make an array of Pair<Integer,Integer>, which I use to return a list coordinates of neighbors. It's supposedly possible to do it by using raw types, and not telling the compiler that they are supposed to be Integers, but that seems dirty and may not be available forever. So I just made a separate class for floats.</li>
<li>MapTools has a method which window2world() can figure out what hex cell you're clicking on, depending on where the cursor is in the window. This replaced the hardcode in PlayerInputSystem which did the same thing. I want to implement the reverse, world2window(), but haven't gotten around to it yet.</li>
</ul>
Here's a screenshot of its current incarnation:<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiws38Wfz5AEhSd2yTETIATea98b2mYPQFAL7l2uyVvF9yQKv4Wjt7xU3L1r-02IcCfQpSMSYUzU6lC1U0UTzCBRf1I7y30_tKSUT7ZkAUQcFxgmzZWCzWRAhGJe0ux9QuArc7PsIOospY/s1600/screenshot.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="368" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiws38Wfz5AEhSd2yTETIATea98b2mYPQFAL7l2uyVvF9yQKv4Wjt7xU3L1r-02IcCfQpSMSYUzU6lC1U0UTzCBRf1I7y30_tKSUT7ZkAUQcFxgmzZWCzWRAhGJe0ux9QuArc7PsIOospY/s640/screenshot.png" width="640" /></a></div>
<br />
<span style="color: #bf9000;"><b>You have gained 50 XP. Progress to Level 3: 350/600</b></span>Unknownnoreply@blogger.com1tag:blogger.com,1999:blog-4125624026143571209.post-78194653451924868742013-03-06T12:55:00.000-08:002013-03-06T12:55:31.512-08:00Terrain GenerationOkay, time to talk about the random terrain generation algorithm, so that you too can have some cool maps!<br />
<br />
A little research brought me to two common random terrain algorithms, <a href="https://www.google.com/search?q=perlin+noise&oe=utf-8&aq=t&rls=org.mozilla:en-US:official&client=firefox-a&um=1&ie=UTF-8&hl=en&tbm=isch&source=og&sa=N&tab=wi&ei=C583UaDAFqraygH6joCABw&biw=1366&bih=703&sei=Gp83Ua6hPMOYyAG12oHoBg#um=1&hl=en&safe=off&client=firefox-a&rls=org.mozilla:en-US%3Aofficial&tbm=isch&sa=1&q=perlin+noise+terrain&oq=perlin+noise+terrain&gs_l=img.3..0j0i24l2.1593.3214.0.3432.8.5.0.3.3.1.352.1136.0j2j1j2.5.0...0.0...1c.1.5.img.b1LGXPuoJbg&bav=on.2,or.r_gc.r_pw.r_qf.&bvm=bv.43287494,d.aWc&fp=db760845735d570a&biw=1366&bih=703" target="_blank">Perlin Noise</a> and <a href="https://www.google.com/search?q=perlin+noise&oe=utf-8&aq=t&rls=org.mozilla:en-US:official&client=firefox-a&um=1&ie=UTF-8&hl=en&tbm=isch&source=og&sa=N&tab=wi&ei=C583UaDAFqraygH6joCABw&biw=1366&bih=703&sei=Gp83Ua6hPMOYyAG12oHoBg#um=1&hl=en&safe=off&client=firefox-a&rls=org.mozilla:en-US%3Aofficial&tbm=isch&sa=1&q=diamond+square+terrain&oq=diamond+square+terrain&gs_l=img.3...22927.25068.2.25223.15.13.0.0.0.1.463.1771.5j3j1j0j2.11.0...0.0...1c.1.5.img.ib7wYevUc-I&bav=on.2,or.r_gc.r_pw.r_qf.&bvm=bv.43287494,d.aWc&fp=db760845735d570a&biw=1366&bih=703" target="_blank">Diamond-Square</a>. I liked the look of Diamond-Square a little more, and it seemed easier to implement than Perlin Noise anyway. My guiding light in this part was an article from <a href="http://gameprogrammer.com/">Gameprogrammer.com</a> on <a href="http://www.gameprogrammer.com/fractal.html" target="_blank">fractal terrain generation</a>. It breaks the algorithm down into tiny bitsized steps, and is awesome!<br />
<br />
It does, however, have some unfortunate limitations. First and foremost, it can only build maps which have dimenions 2^n+1. I don't like the idea of going from n=9 (513 cells) to n=10 (1025 cells) with no middle ground! I didn't like it one bit.<br />
<br />
Also, and perhaps even worse, while setting n to a larger number creates a larger map, it's not really that it builds a larger world... it just builds the same world at a finer and finer scale. To get an idea of what I mean, consider a few iterations of the diamond square algorithm from the gameprogrammer article:<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="http://www.gameprogrammer.com/fractal/dsap1.gif" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="http://www.gameprogrammer.com/fractal/dsap1.gif" /></a></div>
<div class="separator" style="clear: both; text-align: center;">
<a href="http://www.gameprogrammer.com/fractal/dsap2.gif" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="http://www.gameprogrammer.com/fractal/dsap2.gif" /></a></div>
<div class="separator" style="clear: both; text-align: center;">
<a href="http://www.gameprogrammer.com/fractal/dsap5.gif" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="http://www.gameprogrammer.com/fractal/dsap5.gif" /></a></div>
You don't really get any new features, you just get refinements on features already there. Thus, whereas a grid cell in the top image may cover 10 square miles, a grid cell in the bottom image may only by 10 feet by 10 feet. In game, then, you will have to walk over a LOT of tiles to cover much distance, which kind of sucks. There is room for finer and finer details, but ultimately your map is limited to the features the first few steps came up with. Especially when you are transforming it to a 2D map anyway, you will lost most of the fine details later iterations created, and you just get a HUGE boring map.<br />
<br />
This really limits the diversity of maps you can generate, and just won't do.<br />
<br />
To get around this, I modified the initialization step a bit. Whereas in the original algorithm you just initialize the four corners, either all to the same value (boring) or to some random value (a little less boring), I modified it to allow you to initialize an arbitrary sized grid.<br />
<table align="center" cellpadding="0" cellspacing="0" class="tr-caption-container" style="margin-left: auto; margin-right: auto; text-align: center;"><tbody>
<tr><td style="text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEha3qWdDlajhCda4SJC45lVR-O1xJLlbLQIJxDrfJSFtPh_w81de5_ps7BJLvIYSY1vtKol4RYGr9LmZWgt3HSs9DNMTgliZLlPCcwPUpInYX8qOi-cMYEPHB-E4nP-nSoUfEjZ_M-JA8Y/s1600/start_corners.png" imageanchor="1" style="margin-left: auto; margin-right: auto;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEha3qWdDlajhCda4SJC45lVR-O1xJLlbLQIJxDrfJSFtPh_w81de5_ps7BJLvIYSY1vtKol4RYGr9LmZWgt3HSs9DNMTgliZLlPCcwPUpInYX8qOi-cMYEPHB-E4nP-nSoUfEjZ_M-JA8Y/s1600/start_corners.png" /></a></td></tr>
<tr><td class="tr-caption" style="text-align: center;">The standard algorithm lets you initialize 4 corners, then works its way inward.</td></tr>
</tbody></table>
<br />
<table align="center" cellpadding="0" cellspacing="0" class="tr-caption-container" style="margin-left: auto; margin-right: auto; text-align: center;"><tbody>
<tr><td style="text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhMYGzKq6Ik-7tvRzsDInX1JBvjOWAfLyFIdJxR3foZlz4Nv3MuyZ52GOiADewFyz96udh2RudyqtZGjksf9VyiqKw35wiF3VIiIbIMChEHptqQSReQfxVuFqr3LENLzlMwnSkvUkjm5Jc/s1600/start_grid.png" imageanchor="1" style="margin-left: auto; margin-right: auto;"><img border="0" height="212" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhMYGzKq6Ik-7tvRzsDInX1JBvjOWAfLyFIdJxR3foZlz4Nv3MuyZ52GOiADewFyz96udh2RudyqtZGjksf9VyiqKw35wiF3VIiIbIMChEHptqQSReQfxVuFqr3LENLzlMwnSkvUkjm5Jc/s320/start_grid.png" width="320" /></a></td></tr>
<tr><td class="tr-caption" style="text-align: center;">Modified algorithm lets you initialize a grid, and works its way in through each of the regions, which all share borders and can see into their neighbors when appropriate.</td><td class="tr-caption" style="text-align: center;"><br /></td><td class="tr-caption" style="text-align: center;"><br /></td><td class="tr-caption" style="text-align: center;"><br /></td></tr>
</tbody></table>
<div class="separator" style="clear: both; text-align: center;">
</div>
<div class="separator" style="clear: both; text-align: center;">
</div>
<div class="separator" style="clear: both; text-align: center;">
</div>
Now there is a tradeoff here! If you initialize too many points in your gird, you end up with maps which don't have much coherent structure:<br />
<br />
<table align="center" cellpadding="0" cellspacing="0" class="tr-caption-container" style="margin-left: auto; margin-right: auto; text-align: center;"><tbody>
<tr><td style="text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhS60Pxs4HrrY79Rgy5y6RDwR6Bm2hPYD14_DDEX9ifuUrW4aJFZjuEDndwwlv23UlHMXkuE0jlEWGSNswOvZkTVc0uPcNTRL6NcLZUg3Ouo4l1Nc8tEfuq0K2sgDo2Q86-rg_RLhCvST4/s1600/big_grid_map.png" imageanchor="1" style="margin-left: auto; margin-right: auto;"><img border="0" height="240" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhS60Pxs4HrrY79Rgy5y6RDwR6Bm2hPYD14_DDEX9ifuUrW4aJFZjuEDndwwlv23UlHMXkuE0jlEWGSNswOvZkTVc0uPcNTRL6NcLZUg3Ouo4l1Nc8tEfuq0K2sgDo2Q86-rg_RLhCvST4/s320/big_grid_map.png" width="320" /></a></td></tr>
<tr><td class="tr-caption" style="text-align: center;">The tectonic forces that gave rise to this geography were a little drunk at the time...</td></tr>
</tbody></table>
If you have too few grid points, and rely on making decent size maps by increasing <i>n</i>, you get too boring of maps<br />
<table align="center" cellpadding="0" cellspacing="0" class="tr-caption-container" style="margin-left: auto; margin-right: auto; text-align: center;"><tbody>
<tr><td style="text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjU7m4Z3RBmgQx0FudCosQwcFUS0fZyt02cka6deJNe20PjB5gP6HZFJxz4XkKlohqDtuh-_D14BsMo_zJ4yxV1by0VYRZCqiLvxumPClrDsdgD20G-dUqeFZoZ1d_wFwv3EuyA89BOeiw/s1600/big_n_map.png" imageanchor="1" style="margin-left: auto; margin-right: auto;"><img border="0" height="203" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjU7m4Z3RBmgQx0FudCosQwcFUS0fZyt02cka6deJNe20PjB5gP6HZFJxz4XkKlohqDtuh-_D14BsMo_zJ4yxV1by0VYRZCqiLvxumPClrDsdgD20G-dUqeFZoZ1d_wFwv3EuyA89BOeiw/s320/big_n_map.png" width="320" /></a></td></tr>
<tr><td class="tr-caption" style="text-align: center;">I know it's more realistic... but WHYYYYY do I have to cross 12,000 tiles just to make it across the mountain range on the bottom right?</td></tr>
</tbody></table>
You need to tweak things to strike a balance you like. Many things about this algorithm are customizable.<br />
<table align="center" cellpadding="0" cellspacing="0" class="tr-caption-container" style="margin-left: auto; margin-right: auto; text-align: center;"><tbody>
<tr><td style="text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEigSggqNB7qKHnxDRQTf1Y0cAWw7Cdbi_GBoqAEgT6cHfNolN0VWh72sFS-KUEaIbzYUeuMKpy7EOrnDrv9MLLyIn4ZOJmgMN0X4ch0sdtM38-ZqG_t2AKHS2654TD9HqcBQOz6cwfv96U/s1600/just_right_map.png" imageanchor="1" style="margin-left: auto; margin-right: auto;"><img border="0" height="213" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEigSggqNB7qKHnxDRQTf1Y0cAWw7Cdbi_GBoqAEgT6cHfNolN0VWh72sFS-KUEaIbzYUeuMKpy7EOrnDrv9MLLyIn4ZOJmgMN0X4ch0sdtM38-ZqG_t2AKHS2654TD9HqcBQOz6cwfv96U/s320/just_right_map.png" width="320" /></a></td></tr>
<tr><td class="tr-caption" style="text-align: center;">Boy, doesn't <i>that</i> look fun! I can't wait to spend my money to support the people who made this game!</td></tr>
</tbody></table>
Here's the rough idea of how to use the Diamond Square (also known as midpoint displacement) algorithm to make our 2D map:<br />
<ul>
<li>Generate the fractal terrain (note, this is really a 3D terrain being created)</li>
<li>Normalize all the heights to lie between 0 and 1 (inclusive)</li>
<li>Have a set of threshold parameters such that all points below the DeepWaterThreshold become deep water, otherwise if they are between that threshold and the ShalowWaterThreshold, they become shallow water, etc...</li>
<li>Smile at your pretty map</li>
</ul>
It's not the best, there are lots of ways it could be improved. For instance, instead of having strict thresholds, perhaps the height generated by the algorithm sets the probability that certain terrains might be picked. That way, instead of going from solid grassland to solid darker green grassland to solid forest, there could be smoother transitions and more engaging maps. But this is a start. <br />
<br />
Without further adieu, here is my MidpointDisplacement.java (you could call it DiamondSquare.java):<br />
<pre class="brush:java">package com.gamexyz.utils;
import com.badlogic.gdx.math.MathUtils;
public class MidpointDisplacement {
public float deepWaterThreshold,
shallowWaterThreshold,
desertThreshold,
plainsThreshold,
grasslandThreshold,
forestThreshold,
hillsThreshold,
mountainsThreshold;
public int n;
public int wmult, hmult;
public float smoothness;
public MidpointDisplacement() {
// the thresholds which determine cutoffs for different terrain types
deepWaterThreshold = 0.5f;
shallowWaterThreshold = 0.55f;
desertThreshold = 0.58f;
plainsThreshold = 0.62f;
grasslandThreshold = 0.7f;
forestThreshold = 0.8f;
hillsThreshold = 0.88f;
mountainsThreshold = 0.95f;
// n partly controls the size of the map, but mostly controls the level of detail available
n = 7;
// wmult and hmult are the width and height multipliers. They set how separate regions there are
wmult=6;
hmult=4;
// Smoothness controls how smooth the resultant terain is. Higher = more smooth
smoothness = 2f;
}
public int[][] getMap() {
// get the dimensions of the map
int power = MyMath.pow(2,n);
int width = wmult*power + 1;
int height = hmult*power + 1;
// initialize arrays to hold values
float[][] map = new float[width][height];
int[][] returnMap = new int[width][height];
int step = power/2;
float sum;
int count;
// h determines the fineness of the scale it is working on. After every step, h
// is decreased by a factor of "smoothness"
float h = 1;
// Initialize the grid points
for (int i=0; i<width; i+=2*step) {
for (int j=0; j<height; j+=2*step) {
map[i][j] = MathUtils.random(2*h);
}
}
// Do the rest of the magic
while (step > 0) {
// Diamond step
for (int x = step; x < width; x+=2*step) {
for (int y = step; y < height; y+=2*step) {
sum = map[x-step][y-step] + //down-left
map[x-step][y+step] + //up-left
map[x+step][y-step] + //down-right
map[x+step][y+step]; //up-right
map[x][y] = sum/4 + MathUtils.random(-h,h);
}
}
// Square step
for (int x = 0; x < width; x+=step) {
for (int y = step*(1-(x/step)%2); y<height; y+=2*step) {
sum = 0;
count = 0;
if (x-step >= 0) {
sum+=map[x-step][y];
count++;
}
if (x+step < width) {
sum+=map[x+step][y];
count++;
}
if (y-step >= 0) {
sum+=map[x][y-step];
count++;
}
if (y+step < height) {
sum+=map[x][y+step];
count++;
}
if (count > 0) map[x][y] = sum/count + MathUtils.random(-h,h);
else map[x][y] = 0;
}
}
h /= smoothness;
step /= 2;
}
// Normalize the map
float max = Float.MIN_VALUE;
float min = Float.MAX_VALUE;
for (float[] row : map) {
for (float d : row) {
if (d > max) max = d;
if (d < min) min = d;
}
}
// Use the thresholds to fill in the return map
for(int row = 0; row < map.length; row++){
for(int col = 0; col < map[row].length; col++){
map[row][col] = (map[row][col]-min)/(max-min);
if (map[row][col] < deepWaterThreshold) returnMap[row][col] = 0;
else if (map[row][col] < shallowWaterThreshold) returnMap[row][col] = 1;
else if (map[row][col] < desertThreshold) returnMap[row][col] = 2;
else if (map[row][col] < plainsThreshold) returnMap[row][col] = 3;
else if (map[row][col] < grasslandThreshold) returnMap[row][col] = 4;
else if (map[row][col] < forestThreshold) returnMap[row][col] = 5;
else if (map[row][col] < hillsThreshold) returnMap[row][col] = 6;
else if (map[row][col] < mountainsThreshold) returnMap[row][col] = 7;
else returnMap[row][col] = 8;
}
}
return returnMap;
}
}
</pre>
int n controls the level of detail (and hence the size of your map). int wmult and int hmult kind of control how many (mostly) independent regions there are (and hence also control the size of your map). The thresholds all control cutoff points for the different terrain types. I don't want to explain how the actual algorithm itself works, check out the Gameprogrammer article if you are more curious.<br />
<br />
I also created a class HexMapGenerator.java which calls my MidpointDisplacement algorithm (and for now that's all it does, but I hope to expand to make cooler maps, maybe place towns or resources, who knows?)<br />
<pre class="brush:java">package com.gamexyz.utils;
public class HexMapGenerator {
public HexMapGenerator() {
}
public int[][] getDiamondSquare() {
MidpointDisplacement md = new MidpointDisplacement();
return md.getMap();
}
}
</pre>
<br />
I also updated my GameMap component to load a random map (which is remarkably fast) from the HexMapGenerator<br />
<pre class="brush:java">HexMapGenerator hmg = new HexMapGenerator();
map = hmg.getDiamondSquare();
width = map.length;
height = map[0].length;
</pre>
With that, you can now make some awesome, playable looking maps! We already know how to scroll around, zoom in and out, etc. Notice as you scroll out that FPS goes down, that's because we already implemented the frustum culling, but when we zoom out more and more tiles are in the frustum, so it runs slower. Play around and have some fun!<br />
<br />
<span style="color: #bf9000;"><b>You have gained 50 XP. Progress to Level 3: 300/600</b></span>Unknownnoreply@blogger.com5tag:blogger.com,1999:blog-4125624026143571209.post-38152625970872772522013-03-06T11:53:00.000-08:002013-03-06T11:53:08.465-08:00The Game Map Pt 2 (Level 2)This will be a pretty short update, but kind of fun. The goal is to be able to click-drag to move around the world, tell which cell is being clicked on, scroll to zoom in/out, and draw only the map tiles within our camera's view.<br />
<br />
I'm going to implement the first three pieces in PlayerInputSystem.java, and the last part in MapRenderSystem.java.<br />
<br />
First let's look at PlayerInputSystem.java<br />
<pre class="brush:java">package com.gamexyz.systems;
import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.annotations.Mapper;
import com.artemis.systems.EntityProcessingSystem;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.math.Vector3;
import com.gamexyz.components.Player;
import com.gamexyz.components.Position;
import com.gamexyz.utils.MapTools;
public class PlayerInputSystem extends EntityProcessingSystem implements InputProcessor {
@Mapper ComponentMapper<Position> pm;
private OrthographicCamera camera;
private Vector3 mouseVector;
@SuppressWarnings("unchecked")
public PlayerInputSystem(OrthographicCamera camera) {
super(Aspect.getAspectForAll(Player.class));
this.camera=camera;
}
@Override
protected void initialize() {
Gdx.input.setInputProcessor(this);
}
@Override
protected void process(Entity e) {
mouseVector = new Vector3(Gdx.input.getX(),Gdx.input.getY(),0);
camera.unproject(mouseVector);
}
@Override
public boolean keyDown(int keycode) {
return false;
}
@Override
public boolean keyUp(int keycode) {
return false;
}
@Override
public boolean keyTyped(char character) {
return false;
}
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
int x = (int)((mouseVector.x - 6f) / (float)MapTools.col_multiple);
int y = (int)((mouseVector.y - (float)MapTools.row_multiple*(x%2)/2) / (float)MapTools.row_multiple);
return false;
}
@Override
public boolean touchUp(int screenX, int screenY, int pointer, int button) {
return false;
}
@Override
public boolean touchDragged(int screenX, int screenY, int pointer) {
Vector3 delta = new Vector3(-camera.zoom*Gdx.input.getDeltaX(), camera.zoom*Gdx.input.getDeltaY(),0);
camera.translate(delta);
return false;
}
@Override
public boolean mouseMoved(int screenX, int screenY) {
return false;
}
@Override
public boolean scrolled(int amount) {
if ((camera.zoom > 0.2f || amount == 1) && (camera.zoom < 8 || amount == -1)) camera.zoom += amount*0.1;
return false;
}
}
</pre>
The touchDown() method computes which cell is being clicked, and stores the coordinates in x,y. It looks a little hideous because you have to be careful whether you are in an even or odd column. Remember, because it's a hex map, if you are in an odd column all the cells are drawn down a little lower.<br />
<br />
touchDragged() handles clicking and dragging. It's kind of awesome that libgdx just comes with built in methods for Gdx.input.getDeltaX() and Y. I multiply them both by camera.zoom, because when we are zoomed very far in or very far out, the distance the camera moves should change accordingly. camera.translate() just moves the x,y, and z coordinate of the camera, but we're not touching z, so that component is 0.<br />
<br />
scrolled() handles scrolling, and if you scroll up it zooms in (up to 0.2) whereas if you scroll down it scrolls out (up to 8). You can adjust those points, but be wary of what can happen if you get a negative zoom! Also, the fact that zoom changes by amount*0.1 sets how fine tuned you can adjust the zoom. If it were 0.01, you would have finer control.<br />
<br />
If you implement these straight away, you might notice something funny... I sure did! The HUD which displays FPS and so on stays at a fixed point on the MAP, not on the screen. As you zoom out, it gets smaller and smaller. As you zoom in it does the same. That is <i>silly</i>, so let's fix it!<br />
<br />
The problem in HudRenderSystem.java is that we run batch.setProjectionMatrix(camera.combined). This lets things move as you move your camera, exactly what we want to avoid. Get rid of this line. In fact, you don't really need anything to do with a camera, so this is what my new HudRenderSystem looks like (I know I left the camera as an argument, but I was just too lazy to change the main code where I initialize it).<br />
<pre class="brush:java">package com.gamexyz.systems;
import com.artemis.systems.VoidEntitySystem;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.Texture.TextureFilter;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.gamexyz.GameXYZ;
public class HudRenderSystem extends VoidEntitySystem {
private SpriteBatch batch;
private BitmapFont font;
public HudRenderSystem(OrthographicCamera camera) {
}
@Override
protected void initialize() {
batch = new SpriteBatch();
Texture fontTexture = new Texture(Gdx.files.internal("fonts/normal_0.png"));
fontTexture.setFilter(TextureFilter.Linear, TextureFilter.MipMapLinearLinear);
TextureRegion fontRegion = new TextureRegion(fontTexture);
font = new BitmapFont(Gdx.files.internal("fonts/normal.fnt"), fontRegion, false);
font.setUseIntegerPositions(false);
}
@Override
protected void begin() {
batch.begin();
}
@Override
protected void processSystem() {
batch.setColor(1, 1, 1, 1);
font.draw(batch, "FPS: " + Gdx.graphics.getFramesPerSecond(), 20, GameXYZ.WINDOW_HEIGHT - 20);
font.draw(batch, "Active entities: " + world.getEntityManager().getActiveEntityCount(), 20, GameXYZ.WINDOW_HEIGHT - 40);
font.draw(batch, "Total created: " + world.getEntityManager().getTotalCreated(), 20, GameXYZ.WINDOW_HEIGHT - 60);
font.draw(batch, "Total deleted: " + world.getEntityManager().getTotalDeleted(), 20, GameXYZ.WINDOW_HEIGHT - 80);
}
@Override
protected void end() {
batch.end();
}
}
</pre>
<br />
With that, our HUD should stay up where it belongs... silly<i> </i>HUD...<br />
<br />
The last bit we want right now is frustum culling: to only draw the tiles in our camera's view. Libgdx makes this pretty easy too, but to give you super clear idea of what a frustum is, here's <a href="http://en.wikipedia.org/wiki/Viewing_frustum" target="_blank">wikipedia</a>.<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="http://upload.wikimedia.org/wikipedia/commons/0/02/ViewFrustum.svg" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="163" src="http://upload.wikimedia.org/wikipedia/commons/0/02/ViewFrustum.svg" width="200" /></a></div>
What we're going to do is get the coordinates of the corners of the far plane, and use them to limit what we render. In MapRenderSystem, where we defined int x0, x1, y0, and y1, change the code to look like this:<br />
<pre class="brush:java"> // Get bottom left and top right coordinates of camera viewport and convert
// into grid coordinates for the map
int x0 = MathUtils.floor(camera.frustum.planePoints[0].x / (float)MapTools.col_multiple) - 1;
int y0 = MathUtils.floor(camera.frustum.planePoints[0].y / (float)MapTools.row_multiple) - 1;
int x1 = MathUtils.floor(camera.frustum.planePoints[2].x / (float)MapTools.col_multiple) + 2;
int y1 = MathUtils.floor(camera.frustum.planePoints[2].y / (float)MapTools.row_multiple) + 1;
// Restrict the grid coordinates to realistic values
if (x0 % 2 == 1) x0 -= 1;
if (x0 < 0) x0 = 0;
if (x1 > gameMap.width) x1 = gameMap.width;
if (y0 < 0) y0 = 0;
if (y1 > gameMap.height) y1 = gameMap.height;
</pre>
And <i>voila</i>! We probably don't notice a huge difference yet, but when we start playing with HUGE maps later on, this will mean a world of difference. It handles zooming in and out as well.<br />
<br />
This is almost starting to look like it could become a game. Almost...<br />
<br />
<span style="color: #bf9000;"><b>You have gained 50 XP. Progress to Level 3: 250/600</b></span>Unknownnoreply@blogger.com0tag:blogger.com,1999:blog-4125624026143571209.post-54188051015460030582013-03-06T11:08:00.000-08:002013-03-14T10:42:47.321-07:00The Game Map (Level 2)While what we have so far is kind of cute, it doesn't look like much of a game just yet. As with any decent top down, tile based, 2D RPG, we need some awesome looking maps. For right now, I don't have a perfect idea of what it should look like, but I've decided to expand myself a bit and make it a hex based grid, instead of square tiles.<br />
<br />
The first thing I wanted was a set of terrain tile sprites, which I threw together in Paint.NET. I based my tiles on a 46 x 39 pixel square, because I wanted my hexagons to be as close to <a href="http://en.wikipedia.org/wiki/Hexagon#Regular_hexagon" target="_blank">regular hexagons</a> as possible, which requires some rounding on a computer (pesky sqrt(3))!<br />
<br />
Anyway, I chose to make tiles for deep water, shallow water, beach/desert, plains, light forest, forest, hills, mountains, and tall mountain peaks. I named the files hex_0.png through hex_8.png to make them load into a single object with the ImagePacker. Because I actually drew these, I suppose I should mention something about a license. I like the creative commons attribution license, so use them however you like as long as you give me credit! As for my code, use it however you like too.<br />
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiiAvURWdhg9cvmheX0CDiEZuPwwgqVheXMMXulz6SELXWVTsssK4-FEkxQQxX7KXHjJKlUJcF_d88xcdTP_PXxxnk62Pris6M6G8-v8eY6rgHte4VcBg5onoVcqmhePz_uokiMCkTHoIM/s1600/hex_0.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiiAvURWdhg9cvmheX0CDiEZuPwwgqVheXMMXulz6SELXWVTsssK4-FEkxQQxX7KXHjJKlUJcF_d88xcdTP_PXxxnk62Pris6M6G8-v8eY6rgHte4VcBg5onoVcqmhePz_uokiMCkTHoIM/s1600/hex_0.png" /></a>
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiGcHh95fnvB9o9mjven_tHu_xTIBlZxZlxjOHg2h9jl4ThxiaVtw6V3kyfOWZUNExFY4jc_6zBMjsAh_DKnU9EQc3NU1vSjesWtsvJBXs3F5L5I9wVHbfOnGC3EzAXjtAHUQwXC-YeNmI/s1600/hex_1.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiGcHh95fnvB9o9mjven_tHu_xTIBlZxZlxjOHg2h9jl4ThxiaVtw6V3kyfOWZUNExFY4jc_6zBMjsAh_DKnU9EQc3NU1vSjesWtsvJBXs3F5L5I9wVHbfOnGC3EzAXjtAHUQwXC-YeNmI/s1600/hex_1.png" /></a>
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhDoHxvC4js3zqP4FCg_8rOw9vpXBDHoiDvCFABgyJJbbbbxIqyqhsW9wHvbYxqgQy6TAwET2DKTdgk-O-DulkaAiFvAm6mEthfjDgh_63faIFPPMlWEZna8yORY5UNy_jmazdOq-3HMGw/s1600/hex_2.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhDoHxvC4js3zqP4FCg_8rOw9vpXBDHoiDvCFABgyJJbbbbxIqyqhsW9wHvbYxqgQy6TAwET2DKTdgk-O-DulkaAiFvAm6mEthfjDgh_63faIFPPMlWEZna8yORY5UNy_jmazdOq-3HMGw/s1600/hex_2.png" /></a>
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhIubTgaHDED_s1HCGqGLI9TXGpQz-fcYW7LyXcgaw-QqTnpjo72tzHdeAwj0vct9wBO3zpFui9QrZdK_gI4zsIY-00NuWi3ZlQzmaJc750oaeXXWf-Nw8w8X74H7XeDR9sk5PpV8l2Ni4/s1600/hex_3.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhIubTgaHDED_s1HCGqGLI9TXGpQz-fcYW7LyXcgaw-QqTnpjo72tzHdeAwj0vct9wBO3zpFui9QrZdK_gI4zsIY-00NuWi3ZlQzmaJc750oaeXXWf-Nw8w8X74H7XeDR9sk5PpV8l2Ni4/s1600/hex_3.png" /></a>
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhxNi-BpNnryo2VORokxLWRba8c-78FeKDpFFWXoPIh7pSyb2sNLJbTolRF_c-MHp0ELT8cak17wtlSqqn7zmWE0JAoa0KjY5hS4ov1rVTc50AzS2TqMosqnTkHl1xCmq0TiOW7FXIhnrI/s1600/hex_4.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhxNi-BpNnryo2VORokxLWRba8c-78FeKDpFFWXoPIh7pSyb2sNLJbTolRF_c-MHp0ELT8cak17wtlSqqn7zmWE0JAoa0KjY5hS4ov1rVTc50AzS2TqMosqnTkHl1xCmq0TiOW7FXIhnrI/s1600/hex_4.png" /></a>
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgTW6363RfIRh8W5t-fluOmLmpAuygsYLxtgBcunknQN8aQjkHwJXr6SzuAv1o3MaY1EDIwgeTkxIeOIvk_sz84JCgB2BReSNULQDrGRm0pCibtSjDCbY_lTvSmIip6w5yifzCUIPPomb4/s1600/hex_5.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgTW6363RfIRh8W5t-fluOmLmpAuygsYLxtgBcunknQN8aQjkHwJXr6SzuAv1o3MaY1EDIwgeTkxIeOIvk_sz84JCgB2BReSNULQDrGRm0pCibtSjDCbY_lTvSmIip6w5yifzCUIPPomb4/s1600/hex_5.png" /></a>
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjRBJMWnAhEO44gy-uoueAb1L95PXcS24wwO07HQTQskJFfGFpJwCSlM2s-QuhYUPh6oOvSMvtT7LQ3Z1j8dPOV-spoS2eDDcaogos1aiipN648qDhGXF3ownIyQnRi1N9lvqK6bTpMO88/s1600/hex_6.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjRBJMWnAhEO44gy-uoueAb1L95PXcS24wwO07HQTQskJFfGFpJwCSlM2s-QuhYUPh6oOvSMvtT7LQ3Z1j8dPOV-spoS2eDDcaogos1aiipN648qDhGXF3ownIyQnRi1N9lvqK6bTpMO88/s1600/hex_6.png" /></a>
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgu9g0VfzTBw-Y0Ge2wNQhtSsqZc-N1I2P7OfI8zJwSrHMjhAQ8fcVsatjgDO4E6bfvsBHzdeXDzMjTVHnVOpwBGU7AzblZeP8BSO_e1abEWk0Qh01WLh7s8E2k2HBuaehpjpOkR4bHQCc/s1600/hex_7.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgu9g0VfzTBw-Y0Ge2wNQhtSsqZc-N1I2P7OfI8zJwSrHMjhAQ8fcVsatjgDO4E6bfvsBHzdeXDzMjTVHnVOpwBGU7AzblZeP8BSO_e1abEWk0Qh01WLh7s8E2k2HBuaehpjpOkR4bHQCc/s1600/hex_7.png" /></a>
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg6Yzb2TNUdcvgqNkTdMYBC2rfLdgA7RTj4rvL1C2M_J4wCSs-CI8qph8pYwcnKYFSw7b8lQr-APgFlp2M-X245-BTjZwuTIYeYtvTPEYR0KoDzFcViLcr_2tERKLE_s_RUOB6RSlWZJJM/s1600/hex_8.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg6Yzb2TNUdcvgqNkTdMYBC2rfLdgA7RTj4rvL1C2M_J4wCSs-CI8qph8pYwcnKYFSw7b8lQr-APgFlp2M-X245-BTjZwuTIYeYtvTPEYR0KoDzFcViLcr_2tERKLE_s_RUOB6RSlWZJJM/s1600/hex_8.png" /></a>
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjhzsHTVlpszAs2Nbbv8YrOcVY5SEnF5LGCYSgVxcF9C1axjNkIc9UGxjBdRmda6FSmF4rPUbd83uGLfG0TFrgTHqyq_y7B8ezmCQ-0ywqYKsCeh0xdpP9nTHasnWsPKARpYJhusxWzSWc/s1600/hex_template.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjhzsHTVlpszAs2Nbbv8YrOcVY5SEnF5LGCYSgVxcF9C1axjNkIc9UGxjBdRmda6FSmF4rPUbd83uGLfG0TFrgTHqyq_y7B8ezmCQ-0ywqYKsCeh0xdpP9nTHasnWsPKARpYJhusxWzSWc/s1600/hex_template.png" /></a>
<br />
<a href="http://creativecommons.org/licenses/by/3.0/" rel="license"><img alt="Creative Commons License" src="http://i.creativecommons.org/l/by/3.0/80x15.png" style="border-width: 0;" /></a><br />
This work is licensed under a <a href="http://creativecommons.org/licenses/by/3.0/" rel="license">Creative Commons Attribution 3.0 Unported License</a>.
<br />
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiQko1tUn4rxpRNwPvyWSRR47xVNWOeLxXsXBQJfIvbl3DWevKSBrjPJFF3hHb0uVrSKurEsJL5UqkFY-7bDqciS9gO_zPw5v8waUn7r8V0PbwVRKmP_w1_fJ6pcnDGI4zuAlDAh6Xha8s/s1600/hex_1.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"></a> <a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgkWefNbgtgnIXw38kXIM2syPjDsdkywfdd1HvRMyf0AJ-jcAUs1qEGIS79qNOBVS6_e8w4x18gRKLmGYinOvM2bRrTRF1jCO75RerDmmHpypcPoqPhmef-xOys8IwRiCfV5jd7EygCW5I/s1600/hex_2.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"></a> <a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjpBsi4ab5UKYfyLlxnc59NBbBiJqqpLwL7oNbq2Y_p7s25pl6sA2wskJ-t7cE0HjpdarYKtaFchHP1_rcVpZ4LgRPLfAbr-rxzrOhmUsWERxlhvZA4LfQik7kUxF9UY2ztOFhgM7w3N14/s1600/hex_3.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"></a> <a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh7rlFZ0SKkKpOU2q1U5qjYo7vI0k3lwEzamugToMlRr9i6lCbzIdvSVtv1T6gdzfUpeHy4eJEFkr3iQHx1V5U8ei1U3xYSiZEYAV7xCVWNhLlVkAeU2XsRSmJhfQ54D0HZ1h849uOL_vo/s1600/hex_4.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"></a> <a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEic0u3GUGN_apppvmjqV7UCP1NsKKv-cWbAVlLXDiNPcjfk1oBsTh4uB7w9mYt7qoaMF_l8qatG6K1FAaVo0-ls5okImRL_StPgwW2s9Y3qu7R3AonSIbl2mtdiGkonVHlyPheVSIOW5HI/s1600/hex_5.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"></a> <a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiYMUaiTEm7BU5F0cUGouRcEWS44pYx0zp0-w6xhvEgn8E79x49DuB07Shr1VMlgHCBYugJVcYXTDXYZhTCdGrABNZ7wq1Q8GPmQr35QrpCZ_Vapq0_WSgmPqniynZLVcECsrJD3Jl8CpY/s1600/hex_6.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"></a> <a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgCo8MZKJWYgPATTYW8lBSfVlD8p9yM7ocbrw-G2tt6gvb1wetW_AViEUPJx3Q1Yzk-FXAOaczLSalEtPotUxbIH0Gi-jyrSf_RwDC0eAhrYc5ZxR12APkO-kJGZd2rGft_7mSL1s5v-3c/s1600/hex_7.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"></a> <a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEinpSvzu2k4n_8_i_PIrxqXaCXZUYz0RL-goOmCdaoeaJbB1DZef4rCat6USPZroASQ-gBv85LUKwHYaBPXSezfa4CWGrX-BH-DWbyezj31h5BAHDXhYZhH1L8sc0WzSixs-A0CUGXZMEU/s1600/hex_8.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"></a> <a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiGueydeme3O07CLm7ncU84WGRKeUrQRQlMa0xPG06ZL8TinKMFtfuEbkBdgyIxpp2ngsNO0MD2Ps0y-tQQIWkRDcyzUCCsxnZ7vzxuPcKAU7YB2rUtcP9yNHIWZFklqk6uxswofY2UQyU/s1600/hex_template.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><br /></a><br />
I put these images in a folder textures/maptiles in GameXYZ-desktop. They were designed with partially transparent edges so they could overlap and show some natural looking grid lines. Here's an example of a random map I generated and rendered with these tiles (click for full size):<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjg_Q5k4JLIAWXtfOeTFwtYT6tPXriIZSO4x5B6vaKAZHtHj4mOgcFg2J31OiFQV3qtAhECJYYE5pb_QJBHcqVIIj3QCj3mr4hgV97ax9Tfq-DiW_HGcGr2Lw_QH2LyAj6YLhgqyM-jENA/s1600/Map_portion.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="352" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjg_Q5k4JLIAWXtfOeTFwtYT6tPXriIZSO4x5B6vaKAZHtHj4mOgcFg2J31OiFQV3qtAhECJYYE5pb_QJBHcqVIIj3QCj3mr4hgV97ax9Tfq-DiW_HGcGr2Lw_QH2LyAj6YLhgqyM-jENA/s640/Map_portion.png" width="640" /></a></div>
<br />
Actually, that's just a small portion of the whole, which looks like:<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh-r2RSJWKErw1O8ICntxIdmlEQ0pwNPCd0Zd3AOabTD9FIo0kq8KCW_tF8vefVyYX9nMOUnD44IVBOSv_XGBT77aw_T49PLajhh8v19DiRTWFEnoGaoxfitvlTRtIzFCmx22u6qAF_6yo/s1600/Map_whole.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="430" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh-r2RSJWKErw1O8ICntxIdmlEQ0pwNPCd0Zd3AOabTD9FIo0kq8KCW_tF8vefVyYX9nMOUnD44IVBOSv_XGBT77aw_T49PLajhh8v19DiRTWFEnoGaoxfitvlTRtIzFCmx22u6qAF_6yo/s640/Map_whole.png" width="640" /></a></div>
<br />
This is really just a first stab at the terrain generation, but I think it's okay for now. We'll talk about how to make it in a later terrain generation post. For now though let's focus on how we store and render it.<br />
<br />
The data for the map is stored in a 2D array of ints, where each int corresponds to a terrain type (0=deep water, 1=shallow water, etc...). Unlike a typical 2D tile map, which is just about perfectly represented by a 2D array, the hexmap needs a little tweaking. Whereas a typical square tile map might look like this:<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjp6INParAiONey4RDaQ7dArGTWmFX9fNsES-XoEaPzlSO60Zln75Vxxk0Lj04xExdxKFEZkGjGbfr13VqTpU7l0b43mJI0fBvFnIJXvEPL1sceYQfUYPXOlya56jXXEBgeDepyfB3jHZA/s1600/tile_map.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjp6INParAiONey4RDaQ7dArGTWmFX9fNsES-XoEaPzlSO60Zln75Vxxk0Lj04xExdxKFEZkGjGbfr13VqTpU7l0b43mJI0fBvFnIJXvEPL1sceYQfUYPXOlya56jXXEBgeDepyfB3jHZA/s1600/tile_map.png" /></a></div>
<br />
To turn it into a hexmap, we can shift every other column up by half a cell to look like this:<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj87hbkjZUEJNr2xthsYPN8WJdSBGbvWh2vX2ctTTA62MEUxTShrznL-R80Mp4m9lhwIlRIBYCUN48gymn-7CajOrwOkAleglHVi92uv-hp9lHLrkdCVkFbCXEL8IhQd8OX4CLaC15f990/s1600/hex_map.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj87hbkjZUEJNr2xthsYPN8WJdSBGbvWh2vX2ctTTA62MEUxTShrznL-R80Mp4m9lhwIlRIBYCUN48gymn-7CajOrwOkAleglHVi92uv-hp9lHLrkdCVkFbCXEL8IhQd8OX4CLaC15f990/s1600/hex_map.png" /></a></div>
<br />
<div class="separator" style="clear: both; text-align: center;">
</div>
Of course we won't want to render our tiles to look like squares, but as far as the 2D int array is concerned, this is how it will be rendered.<br />
<br />
Notice a frustrating problem with this though, whereas in the square array it's very easy to find a cells neighbors, it is less trivial in the hex map. Consider cell (1,1). It has a neighbor of (2,2), or (1+1, 1+1). However, (4,3) does NOT have a neighbor at (4+1,3+1)=(5,4). The problem comes with the upward shift of every other column. So when we are navigating the map, or getting cell neighbors, we'll have to be very careful how we do it.<br />
<br />
<span style="color: red;"><b>UPDATE:</b></span><br />
<span style="color: red;"><b>The following no longer reflects how I implemented it. Very shortly after writing this, I realized it stunk as I tried to implement pathfinding. The general concept is the same, but it is not a Component and Entity thing, it is just a stand alone class called GameMap.java which has a field declared in GameXYZ.java. To see how it is done more recently, check out the pathfinding post <a href="http://javagamexyz.blogspot.com/2013/03/a-star-pathfinding.html" target="_blank">here</a>.</b></span><br />
<span style="color: red;"><b> </b></span><br />
To actually implement this, I made a new Component called GameMap, and a new EntitySystem called MapRenderSystem. At the start of the game I'll create an entity with the GameMap component, which will store the 2D array, plus whatever other crap I can think of as necessary, and every cycle the MapRenderSystem will render it (much like SpriteRenderSystem). I'll specifically call MapRenderSystem first, so that sprites get rendered on top of it. I also create a few other helper classes. Here's what they look like:<br />
<pre class="brush:java">package com.gamexyz.components;
import com.artemis.Component;
public class GameMap extends Component {
public int[][] map;
public int width, height;
public GameMap() {
map = new int[][] {
{ 0, 1, 2, 3, 4, 5, 6, 7, 8 },
{ 0, 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0, 0 },
{ 1, 1, 1, 1, 1, 1, 1, 1, 1 },
{ 1, 1, 1, 1, 1, 1, 1, 1, 1 },
{ 2, 2, 2, 2, 2, 2, 2, 2, 2 },
{ 2, 2, 2, 2, 2, 2, 2, 2, 2 },
{ 3, 3, 3, 3, 3, 3, 3, 3, 3 },
{ 3, 3, 3, 3, 3, 3, 3, 3, 3 }
};
width = map.length;
height = map[0].length;
}
}
</pre>
Here we just have some crappy predefined map, plus info on the width and height. The RenderSystem looks like this:<br />
<pre class="brush:java">package com.gamexyz.systems;
import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.EntitySystem;
import com.artemis.annotations.Mapper;
import com.artemis.utils.ImmutableBag;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.graphics.g2d.TextureAtlas.AtlasRegion;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.utils.Array;
import com.gamexyz.components.GameMap;
import com.gamexyz.utils.MapTools;
public class MapRenderSystem extends EntitySystem {
@Mapper ComponentMapper<GameMap> gm;
private SpriteBatch batch;
private TextureAtlas atlas;
private Array<AtlasRegion> textures;
private OrthographicCamera camera;
@SuppressWarnings("unchecked")
public MapRenderSystem(OrthographicCamera camera) {
super(Aspect.getAspectForAll(GameMap.class));
this.camera = camera;
}
@Override
protected void initialize() {
batch = new SpriteBatch();
atlas = new TextureAtlas(Gdx.files.internal("textures/maptiles.atlas"),Gdx.files.internal("textures"));
textures = atlas.findRegions(MapTools.name);
}
@Override
protected boolean checkProcessing() {
return true;
}
@Override
protected void processEntities(ImmutableBag<Entity> entities) {
for (int i = 0; i < entities.size(); i++) process(entities.get(i));
}
private void process(Entity e) {
GameMap gameMap = gm.get(e);
TextureRegion reg;
int x, y;
int x0 = 0;
int x1 = gameMap.width;
int y0 = 0;
int y1 = gameMap.height;
// Loop over everything in the window to draw
// Because I am drawing a hexmap tile, I chose to
// do 2 columns at once. As such, I had to
// stop shy of the far right column, because
// col+1 would break for it. Thus we do that
// final column separately.
for (int row = y0; row < y1; row++) {
for (int col = x0; col < x1-1; col+=2) {
x = col*MapTools.col_multiple;
y = row*MapTools.row_multiple;
reg = textures.get(gameMap.map[col][row]);
batch.draw(reg, x, y, 0, 0, reg.getRegionWidth(), reg.getRegionHeight(), 1, 1, 0);
x += MapTools.col_multiple;
y += MapTools.row_multiple/2;
reg = textures.get(gameMap.map[col+1][row]);
batch.draw(reg, x, y, 0, 0, reg.getRegionWidth(), reg.getRegionHeight(), 1, 1, 0);
}
if (x1 >= gameMap.width) {
int col = gameMap.width-1;
x = col*MapTools.col_multiple;
y = row*MapTools.row_multiple;
reg = textures.get(gameMap.map[col][row]);
batch.draw(reg, x, y, 0, 0, reg.getRegionWidth(), reg.getRegionHeight(), 1, 1, 0);
}
}
}
@Override
protected void begin() {
batch.setProjectionMatrix(camera.combined);
batch.begin();
}
@Override
protected void end() {
batch.end();
}
}
</pre>
Here, when it's initialized, it stores the map textures in an Array called textures (lines 37-38). Because every 2nd column has to be shifted up by half a cell, I had a choice between rendering two columns at once, and manually putting the 2nd one up a bit, or rendering each column individually and checking to see if we were on an even or odd column every time. I chose the first option (lines 70-77). My random map generation method ends up with an odd number of columns overall, however, so I had to render the last one separately (lines 79-85).<br />
<br />
I call MapTools.col_multiple and MapTools.row_multiple to get the row and column offsets that each hex cell should be drawn at. Because they are hex cells, the images have to overlap a bit to line up properly, and those constants store that information. MapTools.name is just a string "hex", because I wanted to potentially have the flexibility to someday also do square tiles. The MapTools class went into com.gamexyz.utils<br />
<pre class="brush:java">package com.gamexyz.utils;
import com.gamexyz.custom.Pair;
public class MapTools {
public static final int col_multiple = 34;
public static final int row_multiple = 38;
public static final String name = "hex";
public static Pair[] getNeighbors(int x, int y, int n) {
Pair[] coordinates = new Pair[3*(n*n + n)];
int i = 0;
int min;
for (int row = y-n; row<y+n+1; row++) {
min = MyMath.min(2*(row-y+n), n, -2*(row-y-n)+1);
for (int col = x-min; col < x+min+1; col++) {
if (x==col && y==row) continue;
else if (x % 2 == 0) coordinates[i]=new Pair(col,2*y-row);
else coordinates[i] = new Pair(col,row);
i++;
}
}
return coordinates;
}
public static Pair[] getNeighbors(int x, int y) {
return getNeighbors(x,y,1);
}
}
</pre>
That getNeighbors method was a nightmare to create. I'm certainly not sure that it's the best way to go about it, but I've tested it and it works. It returns the neighbors as an array of Pairs, which I had to define in com.gamexyz.custom<br />
<pre class="brush:java">package com.gamexyz.custom;
public class Pair {
public Pair(int x, int y) {
this.x = x;
this.y = y;
}
public int x, y;
}
</pre>
<br />
Also, a few of these classes reference something called MyMath, which is just a collection of a few math methods I put together in com.gamexyz.utils. They're not the most general, but they get the job done quickly.<br />
<pre class="brush:java">package com.gamexyz.utils;
public class MyMath {
public static int min(int a, int b) {
if (a < b) return a;
return b;
}
public static int min(int a, int b, int c) {
if (min(a,b) < c) return min(a,b);
return c;
}
public static int pow(int a, int b) {
if (b > 1) return a*pow(a,b-1);
else return a;
}
}
</pre>
<br />
Remember to register the MapRenderSystem and create an Entity with the component for GameMap, and also remember to manually process MapRenderSystem. With that, you are now drawing a game map in hex tiles! These methods should be easy to customize if you want square tiles instead.<br />
<br />
There are a couple of crappy things going on here though. First, this code always renders the ENTIRE map... not just the portion it needs. That process is called Frustum Culling, and is actually super easy! For small maps, this isn't so important, but for larger maps it will make a difference. We'll talk about it in the next article.<br />
<br />
Also, it would be super nice to be able to click/drag and move the map, and maybe even use a scroll wheel to zoom in and out. These are also pretty easy and will be discussed next time.<br />
<br />
Also, you can design your own maps by hand, but that is PAINSTAKINGLY slow I think. I implemented the Diamond-Square fractal terrain generation algorithm, it's super fast, and I'll post that along with its inner workings coming soon.<br />
<br />
I will probably also start a Google Code repository soon so people can come and download the code in case something doesn't work so well because I forgot some minor thing I tweaked in another file.<br />
<br />
<span style="color: #bf9000;"><b>You have gained 100 XP. Progress to Level 3: 200/600</b></span>Unknownnoreply@blogger.com0tag:blogger.com,1999:blog-4125624026143571209.post-38755241583086206362013-02-09T18:40:00.000-08:002013-03-08T12:35:51.888-08:00Sprite Animation with libgdx and Artemis (Level 2)After finishing Spaceship warrior, I felt like there were a few more concepts I had to get down. The first piece I decided to tackle was implementing a SpriteAnimationSystem. There are a few useful tools in libgdx to accomplish this.<br />
<br />
First, when TextuerPacker2 creates a TextureAtlas, it creates a file mapping the file-name to the appropriate region. If you have a series of images named <i>imageName_0.png, imageName_1.png</i>, ..., <i>imageName_100.png</i> it interprets the <i>_x</i> as an index, and refers to them all by the name <i>imageName</i>. When there is no number, the atlas just assigns a default index of -1. But with the numbers, you get indices corresponding to each file.<br />
<br />
When you call<br />
<pre class="brush:java">atlas.findRegion("imageName");
</pre>
it returns only the last image of the series. But if you call<br />
<pre class="brush:java">atlas.findRegions("imageName");
</pre>
it returns an array of all of them, indexed appropriately.. This array can be passed into libgdx's Animation class to create an object that will cycle through all the images in the set. My idea was to create a new Component called SpriteAnimation which would hold this animation, and SpriteAnimationSystem which would update the Sprite component based on the status of SpriteAnimation.<br />
<br />
This way, the SpriteRenderSystem could be used to render everything with just a single static Sprite, and things with SpriteAnimation because all SpriteRenderSystem would ever call was the Sprite class.<br />
<br />
First, I did a Google search to find a sprite sheet I could use for the animation, and I came across a game in development called <a href="http://www.allacrost.org/" target="_blank">Hero of Allacrost</a>, staring the young knight Claudius. I found a <a href="http://www.allacrost.org/multimedia/artwork" target="_blank">spritesheet </a>for Claudius and lifted a few frames, naming them warrior_2 through warrior_6 (I started off of 0 just to see what would happen - it worked fine!)<br />
<br />
<img alt="" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAA8CAYAAAAHbrgUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAABKpJREFUWEfVmL1rVEEUxWNhY20laJFWbGyi+QNESKFWgggixKAWNom2QhBMIZJCIqmi7QpWgohlSrs0VilsbKxTP/d33TM5bzK7b97uKhgY3rw7H+fcc+/cN9mFhf/t7/7l8w3t2bWLqf0THwAE+NWd5ebbzmrq866xv0ZEAPLeScim59xJLJ051dDeP7kenjcHL5MCAsVOYx62uZJgU3mcK6FQACpyzJ8bgRI4Srjs8h6bVJobCTYCQJ7LY4A8FPJeJBgfR2Jtba2hDVWarJRir8x3QPrY3SZSWlcKA8A/BuvRRiTGR2vkQXgCkKSXKko4jSlReaf/dfC6BQJgc7Qvz6vzJEJgkqY+NmTUk768F3kjMRU48qQjx6Z7H/ebS1cfBCgy8uQdO+PYRASP377YiPdpPFdsdK7/hOLnl2Zt/U0A0Y9YDt/pOwFARUAkOpNuTDakmCnJiHGpWShCOZFwJaatDydJDD0OQsOnkjCk/vU95ioUTqLq6DlDpHP5Nrc/xOYkpYeAd3nMU+sy6ZMiVaFgMYlzsP8ukSDZnIQqIBvKzjrWaK2vHznXXYAigENwtYe3VpKHkJC3OmqAy85cX5s5UZcCN68sRRFhM/pswrsfJfcecI0zn3UogQ0F1BirYzCKYzP8YzMpkJ3lpARAqKK5rDs8PAwSgDOuOdUE2IxNJKlk393dTcD0/V1KsW4wGCQlRKIq+ZQoI7liE7GPgmO1PFfEPWU9TSfC8qhThABxz+lTavEAz6SGyZrGVXYVDs8JIzyRRLCWnH6+lWxRXIZq8ISEh0GklJR+IqwudKvgwHlfHx8/kpPmZ2OTwdl0e+9zc3t1q1XdsG9s7iW7Yiqpmc94Tkp2I1FHwAtL7p0npupFSQH28FZ1CljgrFWMBODgnoxeLbFrHylqytTFX8lEFusrtrV1HBZPLkImT0v50gccdunjg1f2CW0g4Pkhkk5MBLxG9CLgVygVIzYlLAU5W4nqYaLvZbwq/rif3dkTgAjgben46aodNeL4zt/qdwbf4+ebuAIQ0RF16ccR6FWCxb6kAqBKtloCfcAj+bQg914KKAFFwOe5AtmRrFE+5sQ9T4nHRVMhkfc5AR8vFaLqxBtRbN1kc3A/9/4VlDp8LfPy3ZtAYUFsWroJ5SQgIBKeqNX6j5noV6nWmV+5cLr1LgJOpK8CJQ7FQlOK94QQzSRCEHi0dK7VsOUKiJTmGsmZCMTpyAnk4Pk47/OQX8yDwM7d5dbTwwAhxtXmCR4KuLzqP398L+wCh+SnpzeC5DzlTwT8y+h9geW/ltic2XJA93vKri4nsgFeIuPjs6GP5Edu1X19qAQsYnwZsfFk/rwUiHgq3pLbf3iCAO+uBPPNNpMIrUroIHkp9n9I+v4j2sUwVPD4+8nwT3IhH7r2rhovluP8zqDEy0hUAXRNSnlAbPN8kDpSqPp34C5UGw8Cym4R8F9H/ahO82PEJC7N0ebZ1k+xkAEcu+dCrkDnD9E1CiwuLp4Azyuf1f1SrtTAdM6JIuTNPTcCnRv1noACKjS6cBRuzL33rV4AgVLByUhU79d7IgQKcjdm771n3wWT7oR99xo7/zeod3kI4V2cMgAAAABJRU5ErkJggg==" /><img alt="" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAA8CAYAAAAHbrgUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAABMJJREFUWEetmL+LFVcUx9fCxtoqoMW2YpNmE/8ACViolRACEjBLksJGTSssgluIWIhhq9X2CakCIim3TGdjtUWaNNbW434O8x2/c96duXf3zYPLnXvvued8z88587a2zvD7+dtLHeOP61eGcQY2p7+CQAQ/+/Fa9++re8Mza52dnmvjDQmQ9g5Ce5obWbaT7Vw41zHe3P8hNO8+PB0sIKHsM6Bjr517AyVMpXG2hFyBUIGDvoFtG0lJOJZws0t79mSlRUDARMyluTRGEK5guPYCcaLepBV2d3c7xhyNm2eIdve9BAuIBLPfa18EIOH/rR4IRNUVwRDGMr0so4ATCAWqQPyzej4SgvDu85E0b46TIeKlGQIlrDdjgONZYEVrIM4m3LWG6eFfR93V738JgZiRmTX7SlcBQeM/nzwMYGfSnIt9Toew0O7/993ug5ex5jl8ebLmmXPWsooACERr0JWCIph65CsDfJYbegZxR0FnIKpBNwlgBOJE4wB0MitAw9SfPobgbInTpt4WiDVk0r0Xb4M5tcBdwFo0zKW77paqK2Dw4eh1BA6zzEewOQgVKRhqP9/1++6aWT8g2Mevt28MGgJC2ijVEK59aPN9A1H3/63vdjpyF01gxhqG7HkqufYSLhrucBce7AGAwX4dQe/D4+Pjjh+MZAEBcX/rGUE8i5678BAInTcBgMlqtQoGWXOEHBwcDG7g2dcCyV14uCWqwacgwVS9uQKA0FNwclVLFW5ELz5yZwuA8PNgRntxUGplbjTLpmftNM5HVjCwk54InymKzW8hkGDD3NkirN0NslrOhtZqOCoqpYATGL2Ypmgm9qfjEA1fHL7r7tzbj+EMOHu4dzjsKzPkLug59zrBfe0L9GwWCIAz8SIkQOzJDcwlGgmEl0Y1BSEUYllAxagk3AMxgxCvpFQVQ5gdrQSAVFIDub//1S0eYALi59xXo4Jbe6vWAXjzgFbWvXYIUIwIqPzMfgZATBCoAlOVntsnFSMJKcWIfC0tcxCmTJjHkJqGURZIC7TMQlhr389ylaxZYNTL5RwWAE/Rks9z+rZ2QqP+zf0uIDCWmSXEAcg90KsjKvCZLsFKI7/kfZ0AuAWcVucAoEdklvYtn2HR4ynoYFASPlUlPRN4zg1pb8XZEBhdcv9LM8/7/JIqAQCEA2kNQKcLMwKgEM1DKfYY8ddxdM29K1osMAVwTZAE3rh8fi1NASAQ/v9BTfu586ZXcwSeddLJRZvIjz8Zut92vhkN9rIFZBnRWhxtBIDLawCy8AyQ9SZ+z4i7Vz9dG4aYC4RmaDjTvCgAZ5wEhKaAcJBLCg8XuH/1/Pj3uyPhgPz70c2wwpL+HwB42S29K/TfgGYB3DgC1RF5Xfc3XAkMe0sBCHPCbA4AZ7wZAcsM/eIAPBayBSg83j0h3PY28sLatx5AAJBfSN6mn+ZLuAVduKH0sSowbp303MK/SlN8H5SCz92wZC0YAjEF1wiYPsUTiKp2LQQhKEe3/ScY7snpupQFus97F4f/geVfhLOvtQPwPzRatJul2d7eXhPuIDzgSgVrYwA9g2rL7nSLZgEWmKqAS79wStYaCk5ut1IKLmXpNT6jL+NcipeK8hr6uaa0drfp/Ato/vh0DmtknwAAAABJRU5ErkJggg==" /><img alt="" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAA8CAYAAAAHbrgUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAABP9JREFUWEetmL1rHUcUxZUiTepUgaRQa9KkUaI/IBhUxK4CJhACiohduJHi1iACUWGMCiOjSkn7AqkCIaRUmc6NKxVu3LhWvdG5vN/47NXs7rx978Hydmbuvefcj7k7u1tbM34/fPFpp+vJ13fKNcPM6ioCFPCzB7vdf2f75V5j1la32qgBAN47Ceb4bzTZLrbz0Qedrt8f3w3Pu1e/lggAqnldktNcu/UGSRnF4xwJUiFQyEm+wWybSA1ckfCw473miNJGSMgIxvEcjwXkqcB7SNy4NxiFg4ODTteYjIenVLsD6l5gPgeppfdVAoC/WRxCYjIVUVgCIvREhoJjjULVWPf/Lp73QATeXV/ieXOdlIrHMxEBTGHUeBnOmOfSnJGYB+5ey/DFn5fd51/9GKAKo/411rzWNSdgK8Ieida8kxf2dBiOVLz9pzs4fBFj3Ucub8a6dwJLA0FGoVckVgWXDc9XeKv8Dl2WitAlGpB4+cvRLBI9IoXEjcdxf/NPEQqoe/c6QATOhSMi0EwCYVPojk//COPa854CjTXPuu5d3yPRREJCry5/i6rVPyRUbE6CJgW41gGv6XtdDG5+gQnYr5/u75VacBJsS5ECXAQkn20snRhvOve+3AmPVbFSkCHNyZjmaCKAEXrGLic96cuOdGVXc1Ntr4BfXV11+skIEQCAooIADQeC6EhfdiDRsg2DgAwsFotQds8BPj8/723PPIao9GVH9lr7QAn7MlyhiLIaTmy1ZY3ke9ZdR3ZIx1QEwjC5p5DwWq2We3nlTcrHLid7pIPiHiMR4F69FrYAVLEp3DkiGmved4LXBTZbmlDPs9yKfcwDKIOO6UylYAtjR8cX3bf7Jz0yWvN5dgYhlrzWMyHmieDgNpTi6cXfYcDBvQnhnVc1u6bmOWRkz+1XSSDA8x4SNKMaOHMDJCKigMsuDg4SkLAEPdTaQhwgT07ep8WLFSK+LhAIyJ7sQmYoDSXnVLm8t9NrJwB5QXSQ01jzicAte5amKoeyDRGkGWlcyWPvAVWrH68LeszgTvDnfjq5BhAE5GWudI2Zz7uHbgmZsadihNtD7h5AwAE85LV11596GenteQmnk0tEwKo45J0Au8gJYqPiVK8GwhgF50x11sMLCDiAG66to4/NodexAOfc5kaZw7jvgNx4ajL5VDxUgOX0StWLeQankdSKjCIVwSwXh1eL5K39N1IcpfoxTDOptV2aWCbgr/GDz4GRhXIiEglPwd5nHwZB/j0F6eg2B7folCKk9eYzgj8P/KQ01fVaWRUCD3c+6W3VHAEAs1wr0JhcJ6N+Ae6gWWaw5c5g1J19t1suPMwRkIzW+J+BM6jSM2wApQiZ++vnb0J2k96LVRj03D599H2vHrQm8ERuY0Eon2D0Op7BvSc4yZbXsEmGfhpyoNoTk48T/r1oEqBBIDz2h0l+ZNfIpLkGmGGRXsidDBGBkB7Nipj+JWepmk2gvBdiDALeDfkA5cc3yUlmE3UQ1c4Lpr905gcSpJLMbO9RrL6qeY6HjnBrI5uBKELyOgKYyW6MQzn/UQtpq/U+1So16XvhWkS66+OPewAyrjmvAc15H7D1tcC3tre3b3mXPOwRqXTJ9QgstQNk7J3B62Pq/L8SI0Wg1gVzIar5IMc21Fj6KwFmYZoMRv3bf+4DI0Tnc5AHfAHR/0SOhz7vzCfgNZA8XtsoBv4HUkB51qWSiJgAAAAASUVORK5CYII=" /><img alt="" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAA8CAYAAAAHbrgUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAABLFJREFUWEetWL9rFUEQjoWNtZWgRVqxsYnmDxAhhVoJIogQH2phk2grBMEUIikkkiraPsFKELFMaZcmVQobG+vU6/vG+zbfm8zd7r27B8veze3M982Pndt7S0sL/B5fv5wwXt26mscCZvqrABDA7x6spl+76/ka93zW32qlBgHovZKgjHOlyfplKxfOJYzPL26b5+nwbY4AQSHHwDrI6q1XrIRReuwjwVQAlOSwvsJs3ZIIHJHQsNN7yBilUUjACI3Tc3oMIKQCQ70niZl7rVGYTCZ4Vh2lXO2aewKTCIEhb7wPAQDemwAMwjBDz8iw4EiChSok5vK8CDgM5IqnZwAkWBNKI4dr7pggCimdHPQKvYGr1zC6//UgXbv5xOS/pxs24x5yPIeMRCTUi4HDULOnzbCl4s+PNNn4YPe4xox7XCsB6CqBPkWneWPIbNbK5w7QmWloDJjOonn3TSITySRmHtv1bGaBAjD9PbK1TMUgEh/fbCYOFhuLUlOA3gD51s6XTDTSrd5+UD48+GQDlYsZMgVhg1JwFCPWUN/pWlEXa0GVYYDj6b01UwYIPZWtZnICYK3qNtuv3Pvv3ljJnv+cvjdPYAxyGIGMJJgKRsDy36zBDB3owgb0cF9mMDOCxQQ/Pj5O+MEQI0ASWhO4hpyNhuuhCxtMXzUBej6dTs2AGgfY3t6eDZLQa40EdGGD5KsIMGwIWRO2HBX1Wtpq9hwNidFgpNROiYAZovc5jKf929otSWgq4CXlugb2AjutPAzcVy9rQsNNcPUYadCdoHVBx4pb0BdW1z1fQB60YKM7CzS2ubWf7q9vz70H8Ixy3RUsMKzHc08Icoyd/e98ViYBIxyRRwBlGjBLlZ8hTTu9CHAxmHuPFFwLMiIBXUZGbJUjAAI8eGgqtrdP06LFSiL6nDLou+ZVJgAlEPC1AACQIykaZo49AY1Kr24Y5T3IZc43nmnaqM9GxqbVkCj1o/zK1HNcLkp46esC95T73UMycjLqJBAdo0zGIopqgluNRecj6M6G7Z3QVzIU9UQkW8lIMeckp/WhJOh9QyQkYHuZ5zinYGc+GPQA9EybjUbInwu7WnEmoCToBWVtHioBv8bOjo0DXck375UxwCkrEeB5gb2hjWgrgeDsbiEHcyWF3uCrPDohgYhbV9x6Zxbo9z/IADwisHbl/Fz/1/bclfNaRvmEE7XeqGEFslqseHfA4LOVSzYiQEaAa4K1gwhAORNQECUDEi0EBoNnArsPVw0EM8DpuUYAzzjGyL+yz4YB4AuPxHQek4B5DOPfXt7JUSCJ188f5doI6mRwChIB+O0v/wFkYH3Z1L54apkZgeCfjizT94Ze134LlogYAQy8+WAUs3bOiICmpQTQ+RyAaLVqkDJGxbVxS8toBBp2c9+GvvfjnmRaviUHBcH6QDS0LvTzjSRG34Yw6Izn41vHO2Gw9zkC/MyOSDDvLFghNA4Bv72UBPuC7pbRCTDHIOL+cEgnWxfz/8QEBinIR6kDv899CqLOCGDIl5eXq/6YKuUp2gVndoc/SY/ifYmZVn/UEceKQBePvBX9yVgbVIUjg5aELytEp/q/4UHw/5XDbllTB/8AHbThooJlPBAAAAAASUVORK5CYII=" /><img alt="" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAA8CAYAAAAHbrgUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAABG9JREFUWEfVmDFrFUEUhWNhY20laJFWbGyi/gARUqiVIIIIMaiFTaKtEARTiKSQSKpo+wQrQcQypV0aqxQ2NtbWY77LnvHsZF52X3YTMDDM7sydOeeee+fu5M3N/a9/Dy6fT7Tn1y/mdiK+AAjw67vX0vfNpfzMu+aOjYgA5L2T0Jj60UksnDmVaB+e3gjP0+6rrIBAGadhx9ioJNhUHpdKKBSAihz2oxGogaOEyy7vGZNKo5CQ9ADIc3kMkIdC3osE84NJiIAy3wF5ZtzHRKoBroZheXk50XqFSBvRAyTpefeE05wSlfea9wbej0DDMsBM0vzM2L5NvNPTpFpFhZT+7MiulwAyykeOTbc/7aRLVx8G6M/JSvS8M848YyIiUvRHBYeEznUApF9f0/LK2wDimZ53np0A65pYDwIPAmpKMmJcaxaKvE4kZtK8YnyQxL7HQWi/VxKG1L9/hK1C4UrMTOLdy9Wk5kqQlB4C3plf2/iYidbW9SbA4t2d99FIHnrGHETVz8FJRmy0nrVa1zjQzcEXs4Hao9uLsTkg8tSOWoxLJWx9bTdqY3HrykL2HK+/Td6EB2zIHJsyJjCpIGDN02PPOux57ksiFtAk+97eXuKPzaQC854TToBn2bLOQtCLQwucjSaTSYIEoFZM0tbWVjSB+zN2rGWdSPdCF3tlMNI18mVl3HMnxDMFiXnICljr+xAIDz3mWcp/NTxKrkh4KFCKceYFLiX6luEA9+xVUskrl1vg7rWfBK0pjmKnENXkqiWcjqQ+TtNsbLwTHIMgsLq2ne4srbfI4J3G/URIbuyZlwrqGd/Y/qLxThJh6K3mmc53mXBu63vMTEALYF/G1cE9Ge245YrpqjX7dCoQYRD70nuNQ7AotQG6vn4wbEXR6k9A4H7WNSYgba441wiwZuZqqIIkWUt5a8p42HxeDjQkOhXImW83mXy14g7gFw0BERq8py9PDzZ9r+KtD457LTIlAUkOqBK2JOCOHOZ+gJcAItGHAN4ThhqBPgpkcJM4FyYRKQmKmBSoEVAImnDVRVBlq3w6q6W59NIJlCS4sB4K3pGWLQIAUftLArorqDZMUaLzBNQMAkwNcP8cqxSXxxIiRvJIwFoUGwm4RmDxwulqmKwCDiMgL2ult/R8yvtwAo8XziVaDUAKyKZiO4hAHMVy85IMJKYQGAyec2Hz3rWkVsYdcOa8H3L0StYhPZt/fnYzg4jEiyf3c2gqYRqsQL6W134NUU7Y1Tvbj6VAwkMHKEGZoyTrfwjvB7sPmAjw5WNz9foOiIDe6T0sg0iwOQXFN9SYA5b/K45GoGHf+resBPN3D9VYlTDqQC3ZiotGhEe5YGsGRSDXADYskqt6datUynEI6Ebj2V6CEXe10RXQjxY1AqoPAi+u5sMVcO8rMU5/1s62fqrFe0gxPloxmhLbAPDfid2O8fn5+d6/DR1FqtbnuVRqLO+7iLVOgy4t5AyEjluBXCMAqxWokyAQJBqgAzemkyLQqpazFKO/7WRarhO5FOMAAAAASUVORK5CYII=" /><br />
<br />
Each image is 32x64, and they line up to look like continuous walking.<br />
<br />
To understand my implementation, I want to first focus on the TextureRegion class. TextureRegions have a <i>reference</i> to a Texture, along with rectangle coordinates specifying which part of that Texture to use. These coordinates are stored in 2 different formats: <b>float u, u2</b> refer to the x coordinates as a percent of overall texture width. That is, if a Texture is 100 pixels wide, and the TextureRegion runs horizontally through pixel 35 and pixel 73, u would be 0.35f and u2 would be 0.73f. <b>float v, v2</b> do likewise for the vertical component.<br />
<br />
On the other hand, <b>int x, y, width, height</b> store the same information in raw pixel counts. In the example above, x would be 35 and width would be 73-35 = 38. <br />
<br />
The first major change I had to make was to SpriteRenderSystem and Sprite. In Spaceship Warrior, Sprites only had a <b>name</b>, but SpriteRenderSystem had a bag which assigned a TextureRegion to each entity. This wouldn't work anymore because the Sprite class itself had to hold its own TextureRegion so the AnimatedSpriteSystem could update it.<br />
<br />
Even worse, the Sprites would now only hold a <i>refenece </i>to the actual TextureRegion created from the actual TextureAtlas. That means when one Sprite updated "its" TextureRegion, it would actually mess with all of them.<br />
<br />
To get around this, I used the coordinate information talked about above, and each Sprite had it's own <b>int x, y, width, </b>and <b>height</b>. Instead of touching the TextureRegion itself, AnimatedSpriteSystem would instead change the Sprite's coordinate information, and the Sprite would update the TextureRegion right before it was drawn.<br />
<br />
Here is my implementation:<br />
<pre class="brush:java">public class Sprite extends Component {
public Sprite(String name) {
this.name = name;
}
public TextureRegion region;
public String name;
public float r,g,b,a,scaleX,scaleY,rotation;
public int x, y, width, height;
}
</pre>
<br />
<pre class="brush:java">package com.gamexyz.components;
public class SpriteAnimation extends Component {
public Animation animation;
public float stateTime;
public float frameDuration;
public int playMode;
public TextureRegion getFrame() {
return animation.getKeyFrame(stateTime);
}
}
</pre>
<br />
<pre class="brush:java">public class SpriteAnimationSystem extends EntityProcessingSystem {
@Mapper ComponentMapper<Sprite> sm;
@Mapper ComponentMapper<SpriteAnimation> sam;
@SuppressWarnings("unchecked")
public SpriteAnimationSystem() {
super(Aspect.getAspectForAll(Sprite.class, SpriteAnimation.class));
}
@Override
protected void process(Entity e) {
Sprite sprite = sm.get(e);
SpriteAnimation anim = sam.get(e);
anim.stateTime += world.getDelta();
TextureRegion region = anim.getFrame();
sprite.x = region.getRegionX();
sprite.y = region.getRegionY();
sprite.width = region.getRegionWidth();
sprite.height = region.getRegionHeight();
}
@Override
protected boolean checkProcessing() {
return true;
}
}
</pre>
<br />
<pre class="brush:java">public class SpriteRenderSystem extends EntitySystem {
@Mapper ComponentMapper<position> pm;
@Mapper ComponentMapper<sprite> sm;
@Mapper ComponentMapper<spriteanimation> sam;
private OrthographicCamera camera;
private SpriteBatch batch;
private TextureAtlas atlas;
private List<entity> sortedEntities;
@SuppressWarnings("unchecked")
public SpriteRenderSystem(OrthographicCamera camera) {
super(Aspect.getAspectForAll(Position.class, Sprite.class));
this.camera = camera;
}
@Override
protected void initialize() {
batch = new SpriteBatch();
atlas = new TextureAtlas(Gdx.files.internal("textures/pack.atlas"),Gdx.files.internal("textures"));
sortedEntities = new ArrayList<entity>();
}
@Override
protected boolean checkProcessing() {
return true;
}
@Override
protected void processEntities(ImmutableBag<entity> entities) {
for (Entity e : sortedEntities) {
process(e);
}
}
@Override
protected void begin() {
batch.setProjectionMatrix(camera.combined);
batch.begin();
}
protected void process(Entity e) {
if (pm.has(e)) {
Position position = pm.getSafe(e);
Sprite sprite = sm.get(e);
TextureRegion spriteRegion = sprite.region;
batch.setColor(sprite.r, sprite.g, sprite.b, sprite.a);
int width = spriteRegion.getRegionWidth();
int height = spriteRegion.getRegionHeight();
sprite.region.setRegion(sprite.x, sprite.y, width, height);
float posX = position.x - (spriteRegion.getRegionWidth() / 2 * sprite.scaleX);
float posY = position.y - (spriteRegion.getRegionHeight() / 2 * sprite.scaleX);
batch.draw(spriteRegion, posX, posY, 0, 0, spriteRegion.getRegionWidth(), spriteRegion.getRegionHeight(), sprite.scaleX, sprite.scaleY, sprite.rotation);
}
}
@Override
protected void end() {
batch.end();
}
@Override
protected void inserted(Entity e) {
Sprite sprite = sm.get(e);
sortedEntities.add(e);
TextureRegion reg = atlas.findRegion(sprite.name);
sprite.region = reg;
sprite.x = reg.getRegionX();
sprite.y = reg.getRegionY();
sprite.width = reg.getRegionWidth();
sprite.height = reg.getRegionHeight();
if (sam.has(e)) {
SpriteAnimation anim = sam.getSafe(e);
anim.animation = new Animation( anim.frameDuration, atlas.findRegions(sprite.name), anim.playMode);
}
Collections.sort(sortedEntities, new Comparator<entity>() {
@Override
public int compare(Entity e1, Entity e2) {
Sprite s1 = sm.get(e1);
Sprite s2 = sm.get(e2);
return s1.layer.compareTo(s2.layer);
}
});
}
@Override
protected void removed(Entity e) {
sortedEntities.remove(e);
}
}
</pre>
I completely abandoned the regionsByName bag, and even the regions HashMap which mapped "names" to regions, instead I rely entirely on atlas.getRegion(...). This is potentially slow, and if I ever need to create a lot of entities at once it may be helpful to recreate the HashMap - especially for the static sprites. For instance, if I created a particle explosion effect, I may do that down the line. But for now this seems fine.<br />
<br />
My least favorite part comes in lines 79-82. The SpriteRenderSystem doesn't necessarily require its entities have SpriteAnimation, so I had to carefully check before referencing it. The reason I didn't include this in a different system, which would have been preferable, is that this is where my TextureAtlas lives. Without having that to point to, I can't instantiate my Animation.<br />
<br />
From that, SpriteAnimationSystem updates the animated sprites x, y, etc, and it gets drawn properly to the screen. I created an EntityFactory that will make a character with the Claudius animation, and I added a bunch of them. Copying over my HudRenderingSystem from the previous project let me see that FPS did okay even with thousands of such entities being processed and rendered onscreen. Furthermore, I made them all start out of step by giving them a random stateTime. stateTime is the thing that Animation uses to calculate which frame to work with. For fun, I also added a few static Sprites to verify that they would alongside the animated ones.<br />
<br />
<b>int playMode</b> in SpriteAnimation refers to the playMode in libgdx's Animation class. I like to use Animation.LOOP_PINGPONG for the Claudius animation, which means that once it reaches the last frame, it turns around and loops through them in reverse, back and forth. This is particularly convenient for a walking animation. Unfortunately, PINGPONG has a quirk that the endpoint frames last for twice as long. This ruined our aesthetic in my opinion, and seems extremely silly! I downloaded the raw code from their github repository <a href="https://raw.github.com/libgdx/libgdx/master/gdx/src/com/badlogic/gdx/graphics/g2d/Animation.java" target="_blank">here</a> to see what was going on.<br />
<br />
Unsatisfied with the way they did things, I created my own Animation class by totally stealing theirs and changing one tiny piece of the code. I put it in a new package called com.gamexyz.custom, and here is what it looks like:<br />
<pre class="brush:java">/*******************************************************************************
* Copyright 2011 See AUTHORS file.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
******************************************************************************/
package com.gamexyz.custom;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.utils.Array;
/** * An Animation stores a list of {@link TextureRegion}s representing an animated sequence, e.g. for running or jumping. Each
* region of an Animation is called a key frame, multiple key frames make up the animation.
*
*
* @author mzechner */
public class Animation {
public static final int NORMAL = 0;
public static final int REVERSED = 1;
public static final int LOOP = 2;
public static final int LOOP_REVERSED = 3;
public static final int LOOP_PINGPONG = 4;
public static final int LOOP_RANDOM = 5;
final TextureRegion[] keyFrames;
public final float frameDuration;
public final float animationDuration;
private int playMode = NORMAL;
/** Constructor, storing the frame duration and key frames.
*
* @param frameDuration the time between frames in seconds.
* @param keyFrames the {@link TextureRegion}s representing the frames. */
public Animation (float frameDuration, Array keyFrames) {
this.frameDuration = frameDuration;
this.animationDuration = keyFrames.size * frameDuration;
this.keyFrames = new TextureRegion[keyFrames.size];
for (int i = 0, n = keyFrames.size; i < n; i++) {
this.keyFrames[i] = keyFrames.get(i);
}
this.playMode = NORMAL;
}
/** Constructor, storing the frame duration, key frames and play type.
*
* @param frameDuration the time between frames in seconds.
* @param keyFrames the {@link TextureRegion}s representing the frames.
* @param playType the type of animation play (NORMAL, REVERSED, LOOP, LOOP_REVERSED, LOOP_PINGPONG, LOOP_RANDOM) */
public Animation (float frameDuration, Array keyFrames, int playType) {
this.frameDuration = frameDuration;
this.animationDuration = keyFrames.size * frameDuration;
this.keyFrames = new TextureRegion[keyFrames.size];
for (int i = 0, n = keyFrames.size; i < n; i++) {
this.keyFrames[i] = keyFrames.get(i);
}
this.playMode = playType;
}
/** Constructor, storing the frame duration and key frames.
*
* @param frameDuration the time between frames in seconds.
* @param keyFrames the {@link TextureRegion}s representing the frames. */
public Animation (float frameDuration, TextureRegion... keyFrames) {
this.frameDuration = frameDuration;
this.animationDuration = keyFrames.length * frameDuration;
this.keyFrames = keyFrames;
this.playMode = NORMAL;
}
/** Returns a {@link TextureRegion} based on the so called state time. This is the amount of seconds an object has spent in the
* state this Animation instance represents, e.g. running, jumping and so on. The mode specifies whether the animation is
* looping or not.
*
* @param stateTime the time spent in the state represented by this animation.
* @param looping whether the animation is looping or not.
* @return the TextureRegion representing the frame of animation for the given state time. */
public TextureRegion getKeyFrame (float stateTime, boolean looping) {
// we set the play mode by overriding the previous mode based on looping
// parameter value
if (looping && (playMode == NORMAL || playMode == REVERSED)) {
if (playMode == NORMAL)
playMode = LOOP;
else
playMode = LOOP_REVERSED;
} else if (!looping && !(playMode == NORMAL || playMode == REVERSED)) {
if (playMode == LOOP_REVERSED)
playMode = REVERSED;
else
playMode = LOOP;
}
return getKeyFrame(stateTime);
}
/** Returns a {@link TextureRegion} based on the so called state time. This is the amount of seconds an object has spent in the
* state this Animation instance represents, e.g. running, jumping and so on using the mode specified by
* {@link #setPlayMode(int)} method.
*
* @param stateTime
* @return the TextureRegion representing the frame of animation for the given state time. */
public TextureRegion getKeyFrame (float stateTime) {
int frameNumber = getKeyFrameIndex (stateTime);
return keyFrames[frameNumber];
}
/** Returns the current frame number.
* @param stateTime
* @return current frame number */
public int getKeyFrameIndex (float stateTime) {
int frameNumber = (int)(stateTime / frameDuration);
if(keyFrames.length == 1)
return 0;
switch (playMode) {
case NORMAL:
frameNumber = Math.min(keyFrames.length - 1, frameNumber);
break;
case LOOP:
frameNumber = frameNumber % keyFrames.length;
break;
case LOOP_PINGPONG:
frameNumber = frameNumber % ((keyFrames.length * 2) - 2);
//if (frameNumber >= keyFrames.length)
frameNumber = keyFrames.length -1 - Math.abs(frameNumber - keyFrames.length + 1);//keyFrames.length - 2 - (frameNumber - keyFrames.length);
break;
case LOOP_RANDOM:
frameNumber = MathUtils.random(keyFrames.length - 1);
break;
case REVERSED:
frameNumber = Math.max(keyFrames.length - frameNumber - 1, 0);
break;
case LOOP_REVERSED:
frameNumber = frameNumber % keyFrames.length;
frameNumber = keyFrames.length - frameNumber - 1;
break;
default:
// play normal otherwise
frameNumber = Math.min(keyFrames.length - 1, frameNumber);
break;
}
return frameNumber;
}
/** Sets the animation play mode.
*
* @param playMode can be one of the following: Animation.NORMAL, Animation.REVERSED, Animation.LOOP, Animation.LOOP_REVERSED,
* Animation.LOOP_PINGPONG, Animation.LOOP_RANDOM */
public void setPlayMode (int playMode) {
this.playMode = playMode;
}
/** Whether the animation would be finished if played without looping (PlayMode Animation#NORMAL), given the state time.
* @param stateTime
* @return whether the animation is finished. */
public boolean isAnimationFinished (float stateTime) {
if(playMode != NORMAL && playMode != REVERSED) return false;
int frameNumber = (int)(stateTime / frameDuration);
return keyFrames.length - 1 < frameNumber;
}
}
</pre>
<br />
All that is different is that on PINGPONG mode, it doesn't linger extra long at the ends. I had to update all my <b>import</b> commands to import mine, instead of libgdx's.<br />
<br />
So there you go, we created an animation system using entities and components.<br />
<br />
To bring you up to speed, my project currently has the following structure<br />
<ul>
<li>GameXYZ</li>
<li>EntityFactory</li>
<li>Components</li>
<ul>
<li>Expires</li>
<li>Player</li>
<li>Position</li>
<li>Sprite </li>
<li>SpriteAnimation</li>
</ul>
<li>Systems</li>
<ul>
<li>ExpiringSystem</li>
<li>HudRenderSystem</li>
<li>SpriteAnimationSystem</li>
<li>SpriteRenderSystem</li>
</ul>
<li>Custom</li>
<ul>
<li>Animation</li>
</ul>
<li>Utils</li>
<ul>
<li>ImagePacker</li>
</ul>
</ul>
Then, of course, in the Desktop project I have a Launcher. I know I'm not using Expiring or Player yet, but they seem pretty important for down the line, and I probably won't change them a bit<br />
<br />
<b><span style="color: #bf9000;">You have gained 100 XP. Progress to Level 3: 100/600</span></b> Unknownnoreply@blogger.com5tag:blogger.com,1999:blog-4125624026143571209.post-85884991439528257692013-02-07T17:26:00.001-08:002013-02-08T18:17:13.357-08:00Spaceship Warrior Pt 6 (Level 1)This is the last post we will have to do in Spaceship Warrior - the end is in sight! And all things considered, nothing coming up is very hard given what we have learned. There are some surprises though.<br />
<br />
First, we're going to add the particle explosion effect. Notice in the demo that whenever your bullets hit an enemy ship, there's a small explosion with a shower of particles flying out in all directions. And if you shoot for a long enough time, you'll notice that most of the particles fade away really quickly, but <i>sometimes</i> there is a particle explosion that's extra bright and seems to last longer / travel farther out. How is all this handled?<br />
<br />
Well, that last part is a glitch, albeit a really cool one! But we'll get to it in due time. First let's look at where the particles are created. In CollisionSystem.java, when a collision is detected, we run this code:<br />
<pre class="brush:java">for (int i = 0; i < 50; i++) EntityFactory.createParticle(world, bp.x, bp.y).addToWorld();
</pre>
<br />
To run this we have to update our EntityFactory with createParticle()<br />
<pre class="brush:java"> public static Entity createParticle(World world, float x, float y) {
Entity e = world.createEntity();
Position position = new Position();
position.x = x;
position.y = y;
e.addComponent(position);
Sprite sprite = new Sprite();
sprite.name = "particle";
sprite.scaleX = sprite.scaleY = MathUtils.random(0.3f, 0.6f);
sprite.r = 1;
sprite.g = 216/255f;
sprite.b = 0;
sprite.a = 0.5f;
sprite.layer = Sprite.Layer.PARTICLES;
e.addComponent(sprite);
Velocity velocity = new Velocity(MathUtils.random(-400,400), MathUtils.random(-400,400));
e.addComponent(velocity);
Expires expires = new Expires();
expires.delay = 1;
e.addComponent(expires);
return e;
}
</pre>
<br />
As makes sense, the particles should all have a random velocity. But it's not perfect. If you pay close attention to the particle explosions (and it helps to decrease the velocity range from -100 to 100), you'll notice that they don't explode outward in a circle, but rather they explode into a kind of rectangle. It's not<b> </b><i>wrong</i> per se... but it certainly violates my sense of explosion aesthetics. The reason is that the velocity vector can point anywhere in the entire rectangle (-400,-400) to (400,400) In other words, the maximum velocity achievable if the particle is moving strictly left or right (or up or down) is 400. But the maximum velocity along the diagonals is sqrt(400^2 + 400^2) = 400sqrt(2) = 566. That's why particles on the diagonal go farther - they can go faster!<br />
<br />
Instead of generating random velocities from -400 to 400 in both dimensions, instead we can generate a random angle from 0 to 2*pi, and a random magnitude from 0 to 400, then use sin() and cos() to grab the components. It's a little more time consuming of an operation, but not terrible. If you really need to nickel and dime your performance, maybe go back to the rectangle, but in my code I'm going with this instead.<br />
<pre class="brush:java">
float radians = MathUtils.random(2*MathUtils.PI);
float magnitude = MathUtils.random(400f);
Velocity velocity = new Velocity(magnitude * MathUtils.cos(radians),magnitude * MathUtils.sin(radians));
e.addComponent(velocity);
</pre>
<br />
Note that MathUtils.sin() and MathUtils.cos() take radians for the angle (not degrees), and are precalculated at the beginning so you aren't running a full sin() or cos() method every time. It's just a lookup table optimized for games using libgdx, but if they drag you down too much, you can download the libgdx source code, decrease the precision of those functions, and recompile it into a .jar your own dang self (which is actually pretty easy to do if you look at their code).<br />
<br />
Our particles don't really fade away like the demo's yet, they just disperse until they vanish. We'll change that by introducing the ColorAnimation component and corresponding ColorAnimationSystem. The Component is really easy to understand, you can just steal it straight away, but well look at the System:<br />
<pre class="brush:java">public class ColorAnimationSystem extends EntityProcessingSystem {
@Mapper ComponentMapper<ColorAnimation> cam;
@Mapper ComponentMapper<Sprite> sm;
public ColorAnimationSystem() {
super(Aspect.getAspectForAll(ColorAnimation.class, Sprite.class));
}
@Override
protected void process(Entity e) {
ColorAnimation c = cam.get(e);
Sprite sprite = sm.get(e);
if(c.alphaAnimate) {
sprite.a += c.alphaSpeed * world.delta;
if(sprite.a > c.alphaMax || sprite.a < c.alphaMin) {
if(c.repeat) {
c.alphaSpeed = -c.alphaSpeed;
} else {
c.alphaAnimate = false;
}
}
}
}
}
</pre>
<br />
I'm starting with the demo's code, which didn't really complete the ColorAnimationSystem, but built the part they needed. You see they just update the sprites alpha level, or transparency, decreasing it steadily from 1 to 0. When it reaches the end, if repeat is set to true, it reverses direction and heads back to 1. Otherwise it sets alphaAnimate to false, which stops it from happening. I thought about replacing that with e.removeComponent(c), but that would cause problems if the alpha was done, but the others: r, g, and b, were not.<br />
<br />
Add this system to your world, and update EntityFactory to add a ColorAnimation to new particles like this:<br />
<pre class="brush:java">ColorAnimation colorAnimation = new ColorAnimation();
colorAnimation.alphaAnimate = true;
colorAnimation.alphaSpeed = -1f;
colorAnimation.alphaMin = 0f;
colorAnimation.alphaMax = 1f;
colorAnimation.repeat = false;
e.addComponent(colorAnimation);
</pre>
<br />
If we run it, we see particles fade away as expected. But note that you will sometimes <i>also </i>see an abnormally large particle explosion with extra bright particles! This is a neat effect, but where on Earth did it come from? The problem is in line 15.<br />
<br />
This allows the alpha to be decreased to something below zero. It turns out that if you do that, libgdx folds it back up to be between 0 and 1, and just below 0 maps to just below 1. That means that near the end of its life, your particles went from transparent to completely opaque! More opaque than they began! While I like the effect, it doesn't look so good in other cases. Imagine your ship obtained a cloaking device, and to represent that you created an alpha animation from 1 to 0 and back, on repeat. If you let it go <i>below</i> 0, it will fade out gently, suddenly pop into full brightness, then disappear and gradually fade back in. Then, when it's up to full alpha, there's a good chance it will overshoot alpha=1, and become almost transparent again! In this case, things aren't so good. I fixed it by changing it as follows:<br />
<pre class="brush:java"> protected void process(Entity e) {
ColorAnimation c = cam.get(e);
Sprite sprite = sm.get(e);
if(c.alphaAnimate) {
sprite.a += c.alphaSpeed * world.delta;
if(sprite.a > c.alphaMax) {
sprite.a = c.alphaMax;
if(c.repeat) {
c.alphaSpeed = -c.alphaSpeed;
} else {
c.alphaAnimate = false;
}
}
else if(sprite.a < c.alphaMin) {
sprite.a = c.alphaMin;
if(c.repeat) {
c.alphaSpeed = -c.alphaSpeed;
} else {
c.alphaAnimate = false;
}
}
}
if(c.redAnimate) {
sprite.r += c.redSpeed * world.delta;
if(sprite.r > c.redMax) {
sprite.r = c.redMax;
if(c.repeat) {
c.redSpeed = -c.redSpeed;
} else {
c.redAnimate = false;
}
}
else if(sprite.r < c.redMin) {
sprite.r = c.redMin;
if(c.repeat) {
c.redSpeed = -c.redSpeed;
} else {
c.redAnimate = false;
}
}
}
if(c.greenAnimate) {
sprite.g += c.greenSpeed * world.delta;
if(sprite.g > c.greenMax) {
sprite.g = c.greenMax;
if(c.repeat) {
c.greenSpeed = -c.greenSpeed;
} else {
c.greenAnimate = false;
}
}
else if(sprite.g < c.greenMin) {
sprite.g = c.greenMin;
if(c.repeat) {
c.greenSpeed = -c.greenSpeed;
} else {
c.greenAnimate = false;
}
}
}
if(c.blueAnimate) {
sprite.b += c.blueSpeed * world.delta;
if(sprite.b > c.blueMax) {
sprite.b = c.blueMax;
if(c.repeat) {
c.blueSpeed = -c.blueSpeed;
} else {
c.blueAnimate = false;
}
}
else if(sprite.b < c.blueMin) {
sprite.b = c.blueMin;
if(c.repeat) {
c.blueSpeed = -c.blueSpeed;
} else {
c.blueAnimate = false;
}
}
}
}
</pre>
<br />
Now we have particles that just gradually fade out. We lost the big, cool particle explosions, but I guess that's life.<br />
<br />
The demo also adds a tiny explosion graphic for every hit, and a big one when the ship dies. To do this, update CollisionSystem and EntityFactory to add the explosions:<br />
<pre class="brush:java"> public void handleCollision(Entity bullet, Entity ship) {
Health health = hm.get(ship);
Position bp = pm.get(bullet);
health.health -= 10;
EntityFactory.createExplosion(world, bp.x, bp.y, 0.1f).addToWorld();
for (int i = 0; i < 50; i++) EntityFactory.createParticle(world, bp.x, bp.y).addToWorld();
if (health.health <= 0) {
ship.deleteFromWorld();
EntityFactory.createExplosion(world, bp.x, bp.y, 0.5f).addToWorld();
}
bullet.deleteFromWorld();
}
</pre>
<br />
Update your EntityFactory like the Demo's, and grab ScaleAnimation and ScaleAnimationSystem from the demo, they work as prescribed, though they do force the x and y components be scaled identically. Add the ScaleAnimationSystem to your world, and you have yourself some bona fide explosions!<br />
<br />
To finish up our special effects, the only piece we're really missing are the background stars. They again work as prescribed, so they can be stolen straight from the demo. The only thing to remember is that you have to change the width and height variables manually to 1280 and 900, because our program doesn't reference some universal static field with that information. It probably should... if you want to update it, make sure you update the camera.setToOrtho, the enemy spawning system, and the Launcher. When I updated mine, I included two variables called <b>public static int WINDOW_HEIGHT</b> and <b>WINDOW_WIDTH</b> in GameXYZ.java. That way it was highly visible. I also updated them in the resize() method in GameXYZ.java.<br />
<br />
Anyway, to set the stars up you need to update EntityFactory, create an empty (placeholder) component ParallaxStar, and implement ParallaxStarRepeatingSystem. The actually create the stars, make a loop in GameXYZ.java which creates a bunch of them. Notice in EntityFactory that they also have a random velocity, but it's all in the negative y direction, and they have a random scale and random alpha. Once you've got your stars up and running, you are very near the completed product.<br />
<br />
The only bit remaining is to add in the HudRenderingSystem and HealthRenderingSystem. To make them work, you must copy the resources/fonts folder from the demo into your own project. Remember, Eclipse won't update the explorer automatically, you have to right click the project and refresh. These systems again work about as prescribed, with no surprises. HudRenderingSystem for some reason loads your texture-atlas - it doesn't actually <i>use</i> that information, so I got ride of all those lines in mine. Remember, rendering systems should be updated manually, so you need fields in GameXYZ.java to hold them, just like you did SpriteRenderSystem, and when you add them to the world you need to pass the "true" argument. You wouldn't want to inadvertently render in the middle of processing your other systems, and you'd hate to render the HUD or HEALTH systems beneath your main sprites. So deal with them manually, and under spriteRenderSystem.process(), include both of these.<br />
<br />
After that, you are officially pretty much done. Give it a whirl and be amazed - you have recreated Starship Warrior! Except for one, tiny little problem. The demo cruises along and hundreds of FPS... on my medium laptop. My program at this point only ran at 60. ... ... WTF?!?!<br />
<br />
Hunting this bugger down brought me back to Launcher.java. In the demo's launcher, they set vsynch to false, something I hadn't done. Making just that one little change made me skyrocket up into the hundreds right <a href="http://theredpeneditor.com/blog/?p=32" target="_blank">past</a> the demo even!<br />
<br />
You may have noticed that I was a lot less specific in this page. That's partly because I think it was all pretty easy by this point, but mostly because I'm TIRED of Spaceship Warrior. I want to move on to something COOLER! If you managed to make it here, congratulations! You just defeated the Spaceship Warrior and completed your quest.<br />
<br />
<span style="color: #bf9000;"><b>You have gained 150 XP. Progress to Level 2: 400/400</b></span><br />
<span style="color: #bf9000;"><b>DING! You have advanced to </b></span><span style="color: #bf9000;"><b><span style="color: #bf9000;"><b>L</b></span>evel 2, congratulations!</b></span><br />
<div style="text-align: center;">
<img alt="" height="320" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUEAAAG/CAIAAABJ9PIPAAAPPklEQVR4nO3dO5IjxxWG0d7E+ApYCroKWXQZMQ59BX05XILktDtL4DZoytbGZICgGuhGTXW97v2zzo3PlIZZWXkGT4Ivl99/lpTbS/kKJK2JYSk7hqWCXtfN2z+KYakghqXsGJayY1jKjmEpO4al7BiWsmNYyo5hKTuGpewYlrJjWMqOYSk7hqXsGJayY1jKjmEpO4al7BiWsmNYyo5hqbiVCBmWimNYyo5hKTuGpewYlrJjWMqOYSk7hqXsGJayY1jKjmEpO4al7BiWsmNYyo5hKTuGT9ff//mXby9fvr18+dc/fypfjNbH8Mn67W//evlyNfztr3/7e/l6tDqGT9VPv/z1Bvjly7evP1avRxvE8In6+vUN4Je//PJb/ZK0PoZP0+sPbwB7EB4nhk/Sj7++BeyVcKdqEf533by9kAaGX39Y+W7t/5+sbvYod3sFu0rd/ctgz6KbxfBW/XnQf/i66E/Y/tXmw7PfpYz//DDp2q+vtfusxxjeqDcfuix4KH5wsg2VB8PLGN//Ift8JvzTL1/XPjm//xvwXB9fM7xRd2f9kw/Fbz90XfYnzFrVgmfp9y+Dd3kfa+Yr7fv/2bxO8pSB4Y26d/iZ03N3Ojc/du8f4Wc/QN2/DN7pfaz3f8tsF8MMf667p3NzT/ydk52e/r1jPOvF9v3/a6PnBd9f22Z5Ls3w5/v0Q/FxX3t6R+V7Ju8eHnd9I3rJk2SPwG9jeMs+81D88IHNXg90H69tenn3fxkd9ID2wZsCDz37q+Txb4FTAb4wvHUzX9w+AD7m2D3+Q5888h/wPtZeG36qp9B/xvDG3T1r/fix7tHSccfu3WPd+3/0olf1VQH884XhHbo7WO9OVR3ga+/eB377FOD+ZXPz72MB/EcM79DTN4SqAX+wvP8/YX5436s3iZmvC04Rw3v04SerPQD//vNl1ic6u7/HtqbH9+dODPjC8F49vrX7Y8WbWFNNMy5f3idW3v1F++4xvFfvv8HbTchTxp0f1h5fCLR+vnBMDO/Wxx94Nnuj6INFNlvh1GobL/XAGN6xdw/FLc9cyTc6lvTwRnTLzayI4f169y3Cpq/c3q6z7VPTkm/FZMTwTr378KPvS80Aww/PaBo/WSiI4T16Arjp+etuOOqD64IY3rwP/qX2h4eRXs8Dm//su9/T/F4Mb9uzf2Gg8cu5zoY3+kmwsWN4u77zBm/Xt1XfOumExHc5ZsbwRj18dPnhU77Hjzd7vPhsafjdx3I99qplDG/RHMDX+n3N6O7hrsWrzfe/7LHkOcvzL5PW7/m2Mby6T75m+/TP4uzc3SNeueEPfiJvDuCpDwISPhpYFcPrWvT0uNUTxT6GP/qG+VPAE19H/26N3lDcIoZXtPzru+8fN6oYH/HDmos25AHb5x5ph3f7NoYXt/Kn2D74PceKczb9qyOH9P1fw/t8bd6cOyCGF7bFt/8++lnWg5/NLv9p+43a6DfiB36Y/W4Mb3HylsP7+NeVjzuR9xdytITFgMvfe+sUw0va9D9i8vSV3hGiKg3P+nX4wd5D3iOGl/Tm8G3zdasP32U9QFTpD1k+GB7tY9vDYnhJv+7BrOLrH7U/RvvnP/3Mr2bXx/CS/nj2u/2rstvz6sPeVl3zn1xVjxhe1OsPjf6lhXX98WB4pg9jBothKTuGpewYlrJjWMqOYSk7hqXihkG4MoaVGsPXGFZqDF9jWKkxfI1hpcbwNYaVGsPXGFZqDF9jWKkxfI1hpcbwNYaVGsPXGFZqDF9jWKkxfI1hpcbwNYaVGsPXGFZqDF9jWKkxfI1hpcbwNYaVGsPXGFZqDF9jWKkxfO3shl/WTfn6z1wh4JXHhuEtY7iwQoSv6x5IWx2bs5/CVjfjbDG8SWc/ha1uxtlieJPOfgpb3YyzxfAmnf0UtroZZ4vhTTr7KWx1M84Ww5t09lPY6macLYY36eynsNXNOFsMb9LZT2Grm3G2GN6ks5/CVjfjbDG8SWc/ha1uxtlieJPOfgpb3YyzxfAmnf0UtroZZ4vhTTr7KWx1M84Ww5t09lPY6macLYY36eynsNXNOFsMb9LZT2Grm3G2GN6ks5/CVjfjbDG8SWc/ha1uxtlieJPOfgpb3YyzxfAmnf0UrrwZxiwYhhk22cMwwyZ7GGbYZA/DDJvsYZhhkz0MM2yyh2GGTfYwzLDJHoYZNtnDMMMmexhm2GQPwwyb7GGYYZM9DDNssodhhk32MMywyR6GGTbZwzDDJnsYZthkD8MMm+xhuJHhNb+NaHKH4UYxbBYMw41i2CwYhhvFsFkwDDeKYbNgGG4Uw2bBMNwohs2CYbhRDJsFw3Cjxjb87eVLbtWbNzUMN4rhtlVv3tQw3CiG21a9eVPDcKMYblv15k0Nw41iuG3Vmzc1DDeK4bZVb97UMNwohttWvXlTw3CjGG5b9eZNDcONYrht1Zs3NQw3iuG2VW/e1DDcKIbbVr15U8NwoxhuW/XmTQ3DjWK4bdWbNzUMN4rhtlVv3tQw3CiG21a9eVPDcKMYblv15k0Nw41iuG3Vmzc1DDeK4bZVb97UMNwohttWvXlTw3CjGG5b9eZNDcONYrht1Zs3NQw3qrnhgRl8dzpfO8ONYrjtdL52hhvFcNvpfO0MN4rhttP52hluFMNtp/O1M9wohttO52tnuFEMt53O185woxhuO52vneFGMdx2Ol87w41iuO10vnaGG8Vw2+l87Qw3iuG20/naGW4Uw22n87UzPE57/xXQ+RzvPbteeyuEaw9h+QqiY3i/YXjuISxfQXQM7zcMzz2E5SuIjuH9huG5h7B8BdExvN8wPPcQlq8gOob3G4bnHsLyFUTH8H7D8NxDWL6C6BjebxieewjLVxDd3obPPAzPPYTlK4iO4f2G4bmHsHwF0TG83zA89xCWryA6hvcbhucewvIVRMfwfsPw3ENYvoLoGN5vGJ57CMtXEB3D+w3Dcw9h+QqiY3i/YXjuISxfQXQM7zcMzz2E5SuIjuH9huG5h7B8BdExvN8wPPcQlq8gOob3G4bnHsLyFUTH8H7D8NxDWL6C6BjebxieewjLVxAdw/sNw3MPYfkKomN4v2F47iEsX0F0DO83DM89hOUriI7h/YbhuYewfAXRMbzfMDz3EJavIDqG9xuG5x7C8hVEx/B+w/DcQ1i+gugYnphdEa4chnXbPoafD8MHHcLyFUTH8MQwfNAhLF9BdAxPDMMHHcLyFUTH8MQwfNAhLF9BdAxPDMMHHcLyFUTH8MQwfNAhLF9BdAxPDMMHHcLyFUTH8MQwfNAhLF9BdAxPDMMHHcLyFUTH8MQwfNAhLF9BdAxPDMMHHcLyFUTH8MQwfNAhLF9BdAxPDMMHHcLyFUTH8MQwfNAhLF9BdAxPDMMHHcLyFUTH8MQwfNAhLF9BdAxPDMMHHcLyFUTH8MQwfNAhLF9BdAxPDMMHHcLyFUTH8MQwfNAhLF9BdGMb7oxw5TCs2/YxzHB1vVYTF8MMl9drNXExzHB5vVYTF8MMl9drNXExzHB5vVYTF8MMl9drNXExzHB5vVYTF8MMl9drNXExzHB5vVYTF8MMl9drNXExzHB5vVYTF8MMl9drNXExzHB5vVYTF8MMl9drNXExzHB5vVYTF8MMl9drNXExzHB5vVYTF8MMl9drNXExzHB5vVYTF8MMl9drNXE1NzwwwpXDsG7bx3DmMKzb9jGcOQzrtn0MZw7Dum0fw5nDsG7bx3DmMKzb9jGcOQzrtn0MZw7Dum0fw5nDsG7bx3DmMKzb9jGcOQzrtn0MZw7Dum0fw5nDsG7bx3DmMLxxK49a5fYxnDkMbxzDOw3Dz4bhjWN4p2H42TC8cQzvNAw/G4Y3juGdhuFnw/DGMbzTMPxsGN44hncahp8NwxvH8LNZuTOnjeGjY5hhhpcfwvIVXBhmmOE1h7B8BReGGWZ4zSEsX8GFYYYZXnMIy1dwYZhhhtccwvIVXBhmmOE1h7B8BReGGWZ4zSEsX8GFYYYZXnMIy1dwYZhhhtccwvIVXBhmmOE1h7B8BReGGWZ4zSEsX8GFYYYZXnMIy1dwYZhhhtccwvIVXFYbXtnrimG4ZwwzzHB2DDPMcHYMM8xwdgwzzHB2DDPMcHYMM8xwdgwz3MWw2WkYZpjh7GGYYYazh2GGGc4ehhlmOHsYZpjh7GGYYYazh2GGGc4ehhlmOHsYZpjh7GGYYYazh2GGGc4ehhlmOHsYZnibWXmSVk60opVTfuZHM7wyhpcNw2M0wsUwvGwYHqMRLobhZcPwGI1wMQwvG4bHaISLYXjZMDxGI1wMw8uG4TEa4WIYXjYMj9EIF8PwsmF4jEa4GIaXDcNjNMLFMLxsGB6jES6G4WXD8BiNcDEMLxuGx2iEi2F42TA8RiNcDMPLhuExGuFiGF42DI/RCBfD8LJheIxGuBiGlw3DYzTCxTC8bBgeoxEuhuFlw/AYjXAxDC8bhsdohIvJNfz6+rpSwqp7T9EQtdjK//z7H2taabi2ynvP8BC12EqGa+49w0PUYisZrrn3DA9Ri61kuObeMzxELbaS4Zp7z/AQtdhKhmvuPcND1GIrGa659wwPUYutZLjm3jM8RC22kuGae8/wELXYSoZr7j3DQ9RiKxmuufcMD1GLrWS45t4zPEQttpLhmnvP8BC12EqGa+49w0PUYisZrrn3DA9Ri61kuObeMzxELbaS4Zp7z/AQtdhKhmvuPcND1GIrGa659wwPUYutZLjm3jM8RC22kmFpcSMYrs1fAaqNYYaVHcMMKzuGGVZ2DDOs7BhmWNkxzLCyY5hhZccww8qOYYaVHcMMKzuGGVZ2DDOs7BhmWNkxzLCyY5hhZccww8qOYYaVHcMMKzuGGVZ22xguh8SwThvD/yi/B9KaGGZY2THMsLJjmGFlxzDDyo5hhpUdwwwrO4YZVnYMM6zsGGZY2THMsLJjmGFlxzDDyo5hhpUdwwwrO4YZVnYMM6zsGGZY2THMsLJjmGFlxzDDym4Ew+WbKBX2h+HyB0OGpWUxLGXHsJQdw1J2DEvZMSxlx7CUHcNSdgxL2TEsZcewlB3DUnYMS9kxLGXHsJQdw1J2DEvZMSxlx7CUHcNSdi0Ml++ClBvDUnYMS9kxLGXHsJQdw1J2DEvZMSxlx7CUHcNSdgxL2TEsZcewlB3DUnYMS9kxLGXHsJQdw1J2DEvZMSxlx7CU3Uv5CiStiWEpO4al7P4HU6xmRDbO1AQAAAAASUVORK5CYII=" width="229" /></div>
As a Level 2 PC, you have mastered<br />
<ul>
<li>The art of setting up Artemis systems and components within the libgdx game loop</li>
<li>Basic use of the GroupManager, with a specific application to collision detection</li>
<li>The ability to handle multiple rendering systems, along with sprite manipulation animations</li>
</ul>
You have also started down the path of understanding how to read open source game code, but you are by no means a master. More of... a Novice's Apprentice.Unknownnoreply@blogger.com1tag:blogger.com,1999:blog-4125624026143571209.post-56440604060233266352013-02-03T22:14:00.002-08:002013-02-03T22:14:27.284-08:00Spaceship Warrior Pt 5 (Level 1)In this article we'll add collision detection and ship health. To do this, the demo has added 2 new components, and 1 new system:<br />
Components<br />
<ul>
<li>Health - Obviously to track how much health the ship has.</li>
<li>Bounds - This component defines the boundary which determines what constitutes a collision or not. In the demo, all the boundaries are just circles.</li>
</ul>
Systems<br />
<ul>
<li>CollisionSystem</li>
</ul>
The components are really simple, so let's start with them.<br />
<pre class="brush:java">package com.gamexyz.components;
import com.artemis.Component;
public class Bounds extends Component {
public Bounds(float radius) {
this.radius = radius;
}
public Bounds() {
this(0);
}
public float radius;
}
</pre>
<pre class="brush:java">package com.gamexyz.components;
import com.artemis.Component;
public class Health extends Component {
public Health(float health, float maxHealth) {
this.health = health;
this.maxHealth = maxHealth;
}
public Health(float health) {
this(health,health);
}
public Health() {
this(0,0);
}
public float health, maxHealth;
}
</pre>
<br />
The demo includes the maxHealth field so it can display the percent health left. We have to update our EntityFactory and EntitySpawningSystem to add bounds and health to our ships and bullets (probably no health to bullets though). Make up your own values or check the demo to see what they give used.<br />
<br />
The real magic comes in the CollisionSystem, take a look at mine here:<br />
<pre class="brush:java">package com.gamexyz.systems;
import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.EntitySystem;
import com.artemis.annotations.Mapper;
import com.artemis.managers.GroupManager;
import com.artemis.utils.Bag;
import com.artemis.utils.ImmutableBag;
import com.artemis.utils.Utils;
import com.gamexyz.Constants;
import com.gamexyz.components.Bounds;
import com.gamexyz.components.Health;
import com.gamexyz.components.Position;
public class CollisionSystem extends EntitySystem {
@Mapper ComponentMapper<Position> pm;
@Mapper ComponentMapper<Bounds> bm;
@Mapper ComponentMapper<Health> hm;
private Bag<CollisionPair> collisionPairs;
@SuppressWarnings("unchecked")
public CollisionSystem() {
super(Aspect.getAspectForAll(Position.class, Bounds.class));
}
@Override
public void initialize() {
collisionPairs = new Bag<CollisionPair>();
collisionPairs.add(new CollisionPair(Constants.Groups.PLAYER_BULLETS, Constants.Groups.ENEMY_SHIPS, new CollisionHandler() {
@Override
public void handleCollision(Entity bullet, Entity ship) {
Health health = hm.get(ship);
health.health -= 10;
bullet.deleteFromWorld();
if (health.health <= 0) {
ship.deleteFromWorld();
}
}
}));
}
@Override
protected void processEntities(ImmutableBag<Entity> entities) {
for(int i = 0; collisionPairs.size() > i; i++) {
collisionPairs.get(i).checkForCollisions();
}
}
@Override
protected boolean checkProcessing() {
return true;
}
private class CollisionPair {
private ImmutableBag<Entity> groupEntitiesA;
private ImmutableBag<Entity> groupEntitiesB;
private CollisionHandler handler;
public CollisionPair(String group1, String group2, CollisionHandler handler) {
groupEntitiesA = world.getManager(GroupManager.class).getEntities(group1);
groupEntitiesB = world.getManager(GroupManager.class).getEntities(group2);
this.handler = handler;
}
public void checkForCollisions() {
for(int a = 0; groupEntitiesA.size() > a; a++) {
for(int b = 0; groupEntitiesB.size() > b; b++) {
Entity entityA = groupEntitiesA.get(a);
Entity entityB = groupEntitiesB.get(b);
if(collisionExists(entityA, entityB)) {
handler.handleCollision(entityA, entityB);
}
}
}
}
private boolean collisionExists(Entity e1, Entity e2) {
Position p1 = pm.get(e1);
Position p2 = pm.get(e2);
Bounds b1 = bm.get(e1);
Bounds b2 = bm.get(e2);
return Utils.doCirclesCollide(p1.x, p1.y, b1.radius, p2.x, p2.y, b2.radius);
//return Utils.distance(p1.x, p1.y, p2.x, p2.y)-b1.radius < b2.radius;
}
}
private interface CollisionHandler {
void handleCollision(Entity a, Entity b);
}
}
</pre>
One thing you may notice is that it references the GroupManager that we skipped in the past. So now we'll have to update our EntityFactory to also include the group manager declarations. Also, in GameXYZ.java, you must add the GroupManager to the world.<br />
<pre class="brush:java">world.setManager(new GroupManager());
</pre>
<br />
But back to the CollisionSystem. In line 26 it purportedly processes entities with Position and Bounds, but that call ends up being ignored. Consequently, we need to assign our groups carefully to make sure we don't accidentally process something which does not have those components. Instead of relying on the System to hand it appropriate entities, it has its own private class called CollisionPair (see lines 60-93). The idea is that a CollisionPair is created that specifically processes collisions between two different GroupManagers, in our case PLAYER_BULLETS and ENEMY_SHIPS (line 33). This can be useful because we don't want to worry about enemy ships colliding with one another, nor the possibility of bullets colliding with one another. The processEntities() method, which comes with an ImmutableBag of entities to be processed, once again ignores the passed Bag and instead processes all the predefined CollisionPairs (lines 49-53). The CollisionPair consists of Bags containing the entities in each group of the pair, and to find collisions it iterates over each entity combination between the Bags to see if their bounds overlap (lines 71-81). If there is a collision, it runs the handleCollision() method which is defined for each CollisionPair in the constructor (lines 33-45). The demo uses line 91 to see if a collision happens, but I found Artemis also comes with a doCirclesCollide() method which does the same thing. I haven't tested (and probably never will) which method runs faster, but I'm guessing there is no major difference.<br />
<br />
So to bring our project up to speed for this, we had to create those Components and add them to our entities, we had to add the GroupManager to the world in GameXYZ.java and add entities to the appropriate GroupManagers in EntityFactory.java. Then, the CollisionSystem checks all pairs of things that CAN collide, and processes a method which you construct to deal with the collision. In this, we delete the bullet and damage the ship. Then, if the ship's health has run out, we delete it as well. Part of me would rather create a HealthSystem which just checks to see if anyone's health has gone below 0, because maybe in more advanced games there are multiple ways to lose health, and I wouldn't want to have to hardcode checking to see if it kills them every time. For us, for now, this is fine.<br />
<br />
It's still not pretty, we don't have any particle effects or explosions, but it's officially a game! Next we'll explore adding the special effects animations.<br />
<br />
<span style="color: #bf9000;"><b>You gained 50 XP. Progress to level 2: 250/400</b></span> Unknownnoreply@blogger.com5tag:blogger.com,1999:blog-4125624026143571209.post-18103577322259320792013-02-02T17:04:00.000-08:002013-02-02T17:04:04.922-08:00Spaceship Warrior Pt 4 (Level 1)As of this post, we have a game with a ship that can be controlled, and guns that can fire. We've even made the bullets disappear after a few seconds, so they don't keep using system resources. But no enemies. And somehow, a game with nothing more than the player's ship isn't all that fun.<br />
<br />
This post will cover how to add enemy ships, but first I think it's high time we improved our Sprite class. If you'll remember, the one we made was a lot simpler than the demo's class, mostly because I wanted to get SOMETHING on the screen somehow, so I reverted to my previous skills. But today we're diving into TexturePacker.<br />
<br />
Well, as of more recent versions of libgdx, I should say TexturePacker2, which is apparently faster in Java 1.7. The fist thing to look at is the ImagePacker.java class that comes with Spaceship Warrior. It's not very long, but most of the settings included in it aren't all that important. You can see the full list of settings <a href="http://code.google.com/p/libgdx/wiki/TexturePacker#Settings" target="_blank">here</a>, and notice that some of them are just set to the default, and others have been replaced (for instance <i>padding</i> has been replaced with <i>paddingX </i>and <i>paddingY</i>). This is the modified version that I made in com.gamexyz.utils:<br />
<pre class="brush:java">package com.gamexyz.utils;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.tools.imagepacker.TexturePacker2;
import com.badlogic.gdx.tools.imagepacker.TexturePacker2.Settings;
public class ImagePacker {
public static void run() {
Settings settings = new Settings();
settings.filterMin = Texture.TextureFilter.Linear;
settings.filterMag = Texture.TextureFilter.Linear;
settings.pot=false;
TexturePacker2.process(settings, "textures-original", "resources/textures", "pack");
}
}
</pre>
<br />
Setting pot=false is fine for us because we opted to use OgenGL2.0, so our images don't have to be loaded as powers of two. "textures-original" is the folder in GameXYZ-desktop which holds all the images, and this file will make a new folder in that project called resources, with a subfolder called textures, and files pack.atlas and pack.png.<br />
<br />
To run this, in our Launcher.java file, just include (somewhere in main(), before the new LwjglApplication() line.<br />
<pre class="brush:java">ImagePacker.run();
</pre>
<br />
And make sure to ctrl+shift+o to update your imports list. Now running the program will include some console lines about making your files, but over in the Eclipse explorer, they're nowhere to be seen! When "Eclipse" doesn't create files for your project, <i>even if it's your program IN Eclipse</i>, those files won't be automatically added to the project. To make them show, right click on the GameXYZ-desktop project and click "Refresh". They should show up now. Open up pack.png to see what it looks like.<br />
<br />
NOTE: If you ever release your game, you will not want to include this line of code, but rather just include the already created Texture atlas. For large games, it will add unnecessary launch time to recreate the atlas every time.<br />
<br />
Of course, we still aren't loading using them to get our images, we're still referencing the old folder (which is still there, thankfully). To access images from within a TextureAtlas, we just need these lines of code<br />
<pre class="brush:java">TextureAtlas atlas = new TextureAtlas(Gdx.files.internal("textures/pack.atlas"),Gdx.files.internal("textures"));
AtlasRegion region = atlas.findRegion("imagename")
</pre>
<br />
"imagename" should not include the ".png", but to access fighter.png we would just say findRegion("fighter"). Unfortunately, findRegion is slow, so we don't want to do it EVERY single time we want to load that image (which is quite often - for instance, every time we shoot a bullet we will need to point to that image). The demo's workaround is to create a HashMap which maps String->AtlasRegion. The HashMap is quicker to look up in, and so right after we load the TextureAtlas the first time, we'll make a HashMap to associate the imagename with with correct image. Even then, the HashMap isn't as quick as we'd like, and Artemis includes a much faster container called a Bag.<br />
<br />
So when we first load the program, we will read the Textures from the atlas and store them in a HashMap associating the name to the image. From there, whenever we create a new entity we will ask the HashMap which image it should get. Then we will store that image in a Bag, referenced by the entity's ID. That way, every time we draw entities to the screen we only have to look the images up in the quickest container.<br />
<br />
All of this will go into our SpriteRenderSystem, and to get started we can add the following code<br />
<pre class="brush:java">public class SpriteRenderSystem extends EntitySystem {
(...)
private TextureAtlas atlas;
private HashMap<String, AtlasRegion> regions;
private Bag<Atlasregion> regionsByEntity;
@Override
protected void initialize() {
batch = new SpriteBatch();
atlas = new TextureAtlas(Gdx.files.internal("textures/pack.atlas"),Gdx.files.internal("textures"));
regions = new HashMap<String, AtlasRegion>();
for (AtlasRegion r : atlas.getRegions()) {
regions.put(r.name, r);
}
regionsByEntity = new Bag<atlasregion>();
}
(...)
@Override
protected void inserted(Entity e) {
Sprite sprite = sm.get(e);
regionsByEntity.set(e.getId(), regions.get(sprite.name));
}
@Override
protected void removed(Entity e) {
regionsByEntity.set(e.getId(), null);
}
(...)
}
</pre>
Notice that we have included in the inserted() and removed() methods code which will keep our Bag up to date, but we need to update our Sprite component to have a String called name, which stores something like "fighter" or "bullet". For now, I'll let you add it however you want, but later I'll post my full Sprite class so we can be on the same page.<br />
<br />
Now that we have a Bag which maps entities to the correct AtlasRegion, we can update our process(Entity) method to draw from this.<br />
<pre class="brush:java"> protected void process(Entity e) {
if (pm.has(e)) {
Position position = pm.getSafe(e);
Sprite sprite = sm.get(e);
AtlasRegion spriteRegion = regionsByEntity.get(e.getId());
batch.setColor(sprite.r, sprite.g, sprite.b, sprite.a);
float posX = position.x - (spriteRegion.getRegionWidth() / 2 * sprite.scaleX);
float posY = position.y - (spriteRegion.getRegionHeight() / 2 * sprite.scaleX);
batch.draw(spriteRegion, posX, posY, 0, 0, spriteRegion.getRegionWidth(), spriteRegion.getRegionHeight(), sprite.scaleX, sprite.scaleY, sprite.rotation);
}
}
</pre>
Here lines 9-10 allow us to draw the image CENTERED at the position coordinates, whereas before the sprite was always drawn from the bottom left corner.<br />
<br />
Running this should basically give you the same result as before, except now our bullets are off center because we changed where the ship is drawn relative to it's position. Back in PlayerInputSystem we can change where we create the bullets, I like them in the x direction at pos.x-27 and pos.x+27, and in the y direction to both be pos.y+7.<br />
<br />
There is one more thing we need to do to bring our SpriteRenderSystem and Sprite classes up to snuff. In the Spaceship Warrior demo, the particle effects are drawn on top of everything, the little ships are always above you, you are always above the bigger ships, and everything is above the starfield. This was accomplished by introducing Layers, and drawing the bottom layer first and so on. You can see this really easily by looking at a 2D platformer, like this Indie game <a href="http://www.youtube.com/watch?v=B2XtgBCmJ_M" target="_blank">Hyperion</a>, which has a starfield in the back, a layer of trees in front of that, another layer of trees, and then the game world. They scroll these layers at different speeds to get an effect of depth, but it would be ruined if the stars got drawn last, because they would become the front layer!<br />
<br />
To manage layers, the Sprite component gets an enumeration of the different possible layers, and each sprite gets assigned to one. Then the SpriteRenderSystem maintains a SORTED list of entities, sorted based on which layer they are in. Thus the back layers get drawn first, and so on. The demo stored this sorted list as a List<Entity> called sortedEntities. Remember the processEntities() method which passed us our bag of entities? Well, now we'll ignore that passed Bag because the Bag class doesn't maintain any order, and thus can't be sorted.<br />
<br />
This is what my final Sprite and SpriteRenderSystem look like to take all this into account:<br />
<pre class="brush:java">package com.gamexyz.components;
import com.artemis.Component;
public class Sprite extends Component {
public enum Layer {
DEFAULT,
BACKGROUND,
ACTORS_1,
ACTORS_2,
ACTORS_3,
PARTICLES;
public int getLayerId() {
return ordinal();
}
}
public Sprite(String name, Layer layer) {
this.name = name;
this.layer = layer;
}
public Sprite(String name) {
this(name, Layer.DEFAULT);
}
public Sprite() {
this("default",Layer.DEFAULT);
}
public String name;
public float r = 1;
public float g = 1;
public float b = 1;
public float a = 1;
public float scaleX = 1;
public float scaleY = 1;
public float rotation;
public Layer layer = Layer.DEFAULT;
}
</pre>
<pre class="brush:java">package com.gamexyz.systems;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.EntitySystem;
import com.artemis.annotations.Mapper;
import com.artemis.utils.Bag;
import com.artemis.utils.ImmutableBag;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.graphics.g2d.TextureAtlas.AtlasRegion;
import com.gamexyz.components.Position;
import com.gamexyz.components.Sprite;
public class SpriteRenderSystem extends EntitySystem {
@Mapper ComponentMapper<Position> pm;
@Mapper ComponentMapper<Sprite> sm;
private OrthographicCamera camera;
private SpriteBatch batch;
private TextureAtlas atlas;
private HashMap<String, AtlasRegion> regions;
private Bag<AtlasRegion> regionsByEntity;
private List<Entity> sortedEntities;
@SuppressWarnings("unchecked")
public SpriteRenderSystem(OrthographicCamera camera) {
super(Aspect.getAspectForAll(Position.class, Sprite.class));
this.camera = camera;
}
@Override
protected void initialize() {
batch = new SpriteBatch();
atlas = new TextureAtlas(Gdx.files.internal("textures/pack.atlas"),Gdx.files.internal("textures"));
regions = new HashMap<String, AtlasRegion>();
for (AtlasRegion r : atlas.getRegions()) {
regions.put(r.name, r);
}
regionsByEntity = new Bag<AtlasRegion>();
sortedEntities = new ArrayList<Entity>();
}
@Override
protected boolean checkProcessing() {
return true;
}
@Override
protected void processEntities(ImmutableBag<Entity> entities) {
for (Entity e : sortedEntities) {
process(e);
}
}
@Override
protected void begin() {
batch.setProjectionMatrix(camera.combined);
batch.begin();
}
protected void process(Entity e) {
if (pm.has(e)) {
Position position = pm.getSafe(e);
Sprite sprite = sm.get(e);
AtlasRegion spriteRegion = regionsByEntity.get(e.getId());
batch.setColor(sprite.r, sprite.g, sprite.b, sprite.a);
float posX = position.x - (spriteRegion.getRegionWidth() / 2 * sprite.scaleX);
float posY = position.y - (spriteRegion.getRegionHeight() / 2 * sprite.scaleX);
batch.draw(spriteRegion, posX, posY, 0, 0, spriteRegion.getRegionWidth(), spriteRegion.getRegionHeight(), sprite.scaleX, sprite.scaleY, sprite.rotation);
}
}
@Override
protected void end() {
batch.end();
}
@Override
protected void inserted(Entity e) {
Sprite sprite = sm.get(e);
regionsByEntity.set(e.getId(), regions.get(sprite.name));
sortedEntities.add(e);
Collections.sort(sortedEntities, new Comparator<Entity>() {
@Override
public int compare(Entity e1, Entity e2) {
Sprite s1 = sm.get(e1);
Sprite s2 = sm.get(e2);
return s1.layer.compareTo(s2.layer);
}
});
}
@Override
protected void removed(Entity e) {
regionsByEntity.set(e.getId(), null);
sortedEntities.remove(e);
}
}
</pre>
The Sprite class doesn't have too many changes. It has the enum, and a new constructor which takes Sprite(String name, Layer layer). If those options aren't passed, I refer to a "default" in both cases. For a default sprite, I googled to find a small exclamation mark png<br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiRL8wxe8I-07iz1BH_c_8XUSpAq7fu5BYlyx7x5eIB76Ph9NrkIxgnc8cmrL6ggV-2mvIMbAjnS-g0iAxVDCUvs5tzIfg7jz8ohvXi8nJjdIF7WXKcbMOZduoZ7d8dzdyJlmx3yLq4B18/s1600/default.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiRL8wxe8I-07iz1BH_c_8XUSpAq7fu5BYlyx7x5eIB76Ph9NrkIxgnc8cmrL6ggV-2mvIMbAjnS-g0iAxVDCUvs5tzIfg7jz8ohvXi8nJjdIF7WXKcbMOZduoZ7d8dzdyJlmx3yLq4B18/s1600/default.png" /></a></div>
and stuck it in the textures-original folder.<br />
<br />
In SpriteRenderSystem, we added a List called sortedEntities and initialized it in the initialize() method. In the inserted() and removed() methods we added the entity to sortedEntities, and in inserted() also added a sorter which compared them based on layer. Entities in the same layer will be stacked in the order of their creation, from first on bottom to last on top.<br />
<br />
In processEntities(), as promised we ignore the ImmutableBag we are given, and stick with our sortedEntities List to make sure we draw in the correct order. I admit this was a lot of work to achieve no discernible change in our program, but I'm going to trust that it is stronger for it now, more expandable. Because of course that's the ultimate goal, borrow the code and methods learned here and apply them to an original game.<br />
<br />
Before we're done for the day, let's add some enemies, it turns out to not be too tough. First, I edited my EntityFactory to look a bit more like the Demo's (though not completely) and added a createEnemyShip() method:<br />
<pre class="brush:java">public class EntityFactory {
public static Entity createPlayer(World world, float x, float y) {
Entity e = world.createEntity();
Position position = new Position();
position.x = x;
position.y = y;
e.addComponent(position);
Sprite sprite = new Sprite("fighter",Sprite.Layer.ACTORS_3);
sprite.r = 93/255f;
sprite.g = 255/255f;
sprite.b = 129/255f;
e.addComponent(sprite);
Velocity velocity = new Velocity(0,0);
e.addComponent(velocity);
e.addComponent(new Player());
return e;
}
public static Entity createBullet(World world, float x, float y) {
Entity e = world.createEntity();
Position position = new Position();
position.x = x;
position.y = y;
e.addComponent(position);
Sprite sprite = new Sprite();
sprite.name = "bullet";
sprite.layer = Sprite.Layer.PARTICLES;
e.addComponent(sprite);
Velocity velocity = new Velocity(0, 800);
e.addComponent(velocity);
Expires expires = new Expires(2f);
e.addComponent(expires);
return e;
}
public static Entity createEnemyShip(World world, String name, Sprite.Layer layer, float x, float y, float vx, float vy) {
Entity e = world.createEntity();
Position position = new Position();
position.x = x;
position.y = y;
e.addComponent(position);
Sprite sprite = new Sprite();
sprite.name = name;
sprite.r = 255/255f;
sprite.g = 0/255f;
sprite.b = 142/255f;
sprite.layer = layer;
e.addComponent(sprite);
Velocity velocity = new Velocity(vx, vy);
e.addComponent(velocity);
return e;
}
}
</pre>
<br />
The lines setting Sprite.r, etc, provide a color filter. If you look, the textures have white borders, where in the demo game the ships are colored. That magic happens by setting these red, green, blue, and alpha filters. The enemy ship method has a lot of arguments used to decide which kind of ship to create. To actually create them, we'll make a new System called EntitySpawningTimerSystem, taken almost straight from the demo:<br />
<pre class="brush:java">package com.gamexyz.systems;
import com.artemis.systems.VoidEntitySystem;
import com.artemis.utils.Timer;
import com.badlogic.gdx.math.MathUtils;
import com.gamexyz.EntityFactory;
import com.gamexyz.components.Sprite;
public class EntitySpawningTimerSystem extends VoidEntitySystem {
private Timer timer1;
private Timer timer2;
private Timer timer3;
public EntitySpawningTimerSystem() {
timer1 = new Timer(2, true) {
@Override
public void execute() {
EntityFactory.createEnemyShip(world, "enemy1", Sprite.Layer.ACTORS_3, MathUtils.random(0, 1280), 900 + 50, 0, -40).addToWorld();
}
};
timer2 = new Timer(6, true) {
@Override
public void execute() {
EntityFactory.createEnemyShip(world, "enemy2", Sprite.Layer.ACTORS_2, MathUtils.random(0, 1280), 900 + 100, 0, -30).addToWorld();
}
};
timer3 = new Timer(12, true) {
@Override
public void execute() {
EntityFactory.createEnemyShip(world, "enemy3", Sprite.Layer.ACTORS_1, MathUtils.random(0, 1280), 900 + 200, 0, -20).addToWorld();
}
};
}
@Override
protected void processSystem() {
timer1.update(world.delta);
timer2.update(world.delta);
timer3.update(world.delta);
}
}
</pre>
There are a couple of differences I noticed. First, because we have separated ourselves into 2 projects, files in this project cannot see the variables we set in Launcher.java for width and height, so they had to be hardcoded. In general, that's a pretty awful idea. Perhaps they should be stored as static fields in GameXYZ.java? Or perhaps a constants file. Also, the demo's world seemed to range from [-width/2, +width/2] and likewise for height, so that (0,0) is right in the middle of the screen. I suspect that the difference lies in the camera, but I couldn't just trivially see what was different. As it stands, I actually like having (0,0) be the bottom left corner, and (width,height) be the top right, it just meant we had to tweak the code somewhat.<br />
<br />
Other than that, I changed the createEnemyShip arguments to match those in my EntityFactory (since we haven't implemented all the components yet, certain things didn't need to be there - someday they probably will). One neat thing is that the class extends VoidEntitySystem, which is a system that runs without processing any actual entities (i.e. there is no getAspectForAll(...)). Other than that I think the Timer class is pretty awesome, and hopefully I'll be able to play with it more another time.<br />
<br />
In summary, we revamped our SpriteRenderSystem to use a TextureAtlas instead of loading individual .PNGs, and implemented a set of Layers to control which entities are drawn on top of which other ones. We also added a new system which spawns lots of entities. Overall, I feel like at this point most of the quirks are out of the way. Coming into the project, the one that scared me was their rendering system. Of course we haven't touched the collision detection yet, I'm eager to see how that works out. We also haven't touched on the Groups - the demo had them but I don't really think it ever used them. It may be that we can totally ignore that facet, we'll see. I'm also excited to get around to the ScaleAnimationSystem, and add particles and stars. Fun times ahead!<br />
<br />
<span style="color: #bf9000;"><b>You have gained 50 XP. Progress to Level 2: 200/400</b></span>Unknownnoreply@blogger.com2tag:blogger.com,1999:blog-4125624026143571209.post-7263521466048423492013-02-01T13:15:00.002-08:002013-02-08T18:52:24.776-08:00Spaceship Warrior Pt 3 (Level 1)It's time to add controls to your spaceship, so that we can move it around and shoot bullets. We'll diverge a little bit from Spaceship Warrior and use the keyboard for motion input, instead of the mouse. Add the following Components and Systems to our code:<br />
Components<br />
<ul>
<li>Velocity - This will hold how fast we move, in both x and y directions</li>
<li>Player - This will mark which ship is controlled by the player, we wouldn't want ALL the ships to respond to our commands</li>
</ul>
Systems<br />
<ul>
<li>MovementSystem - This will process all entities with both position and velocity, and update their position accordingly</li>
<li>PlayerInputSystem - This will listen for user input. The demo uses mouse position, but I think arrow controls will be more fun</li>
</ul>
We will also follow the example of the demo and create an EntityFactory.java class which we will use to actually make our entities.<br />
<br />
Let's start with the new components:<br />
<pre class="brush:java">package com.gamexyz.components;
import com.artemis.Component;
public class Velocity extends Component {
public float vx, vy;
public Velocity(float vx, float vy) {
this.vx = vx;
this.vy = vy;
}
public Velocity() {
this(0,0);
}
}
</pre>
<pre class="brush:java">package com.gamexyz.components;
import com.artemis.Component;
public class Player extends Component {
}
</pre>
<br />
Notice that Velocity has x and y components, which will control how fast it moves laterally and vertically respectively. Negative velocities will move it either left or down.<br />
<br />
Notice that Player is totally empty. In this case, we don't actually need to store any data relevant only to Player entities, we just need to tag them with "Player" so that we can grab them specifically to handle user input.<br />
<br />
Let's look at the new systems now:<br />
<pre class="brush:java">public class MovementSystem extends EntityProcessingSystem {
@Mapper ComponentMapper<position> pm;
@Mapper ComponentMapper<velocity> vm;
@SuppressWarnings("unchecked")
public MovementSystem() {
super(Aspect.getAspectForAll(Position.class, Velocity.class));
}
@Override
protected void process(Entity e) {
Position position = pm.get(e);
Velocity velocity = vm.get(e);
position.x += velocity.vx*world.delta;
position.y += velocity.vy*world.delta;
}
}
</pre>
<pre class="brush:java">public class PlayerInputSystem extends EntityProcessingSystem implements InputProcessor {
@Mapper ComponentMapper<Velocity> vm;
private OrthographicCamera camera;
private Vector3 mouseVector;
private int ax, ay;
private int thruster = 400;
private float drag = 0.4f;
@SuppressWarnings("unchecked")
public PlayerInputSystem(OrthographicCamera camera) {
super(Aspect.getAspectForAll(Velocity.class, Player.class));
this.camera=camera;
}
@Override
protected void initialize() {
Gdx.input.setInputProcessor(this);
}
@Override
protected void process(Entity e) {
mouseVector = new Vector3(Gdx.input.getX(),Gdx.input.getY(),0);
camera.unproject(mouseVector);
Velocity vel = vm.get(e);
vel.vx += (ax - drag * vel.vx) * world.getDelta();
vel.vy += (ay - drag * vel.vy) * world.getDelta();
}
@Override
public boolean keyDown(int keycode) {
if (keycode == Input.Keys.UP) ay = thruster;
if (keycode == Input.Keys.DOWN) ay = -thruster;
if (keycode == Input.Keys.RIGHT) ax = thruster;
if (keycode == Input.Keys.LEFT) ax = -thruster;
return false;
}
@Override
public boolean keyUp(int keycode) {
if (keycode == Input.Keys.UP) ay = 0;
if (keycode == Input.Keys.DOWN) ay = 0;
if (keycode == Input.Keys.RIGHT) ax = 0;
if (keycode == Input.Keys.LEFT) ax = 0;
return false;
}
}
</pre>
<br />
MovementSystem at this point is pretty basic, it just updates position based on kinematics equations from physics. PlayerInputSystem is a little more advanced, so let's look at exactly what's happening.<br />
<br />
First, I want to note that the mouseVector and camera stuff are all extras, and things that would be really important if you were actually going to use the mouse for input. We're using the keyboard though, so our commands come in the keyDown() and keyUp() methods. There are other required input methods I didn't list in my code, but must be present because the class implements InputProcessor: keyTyped(), touchDown(), touchUp(), touchDragged(), mouseMoved(), and scrolled(). You can add System.out.println()'s to see when they are called if you want.<br />
<br />
On lines 7 - 9 I declare variables to hold the acceleration rate in the x and y directions, as well as a "thruster" which controls how strongly the ship will accelerate and "drag" coefficient. You can play with those values to find a combination you like. These are things that probably should have been included in their own "Thruster" component, so that different ships could have different acceleration abilities, but for now this will work.<br />
<br />
The keyDown() method listens for a key to be depressed, but only fires once. Here, when you press UP, it will set ay to the value of thruster, so that the ship can accelerate upward in the y direction. The keyUp() methods listens for keys to be released, and will reset accelerations to 0 accordingly.<br />
<br />
Then, in process(), I update velocity taking into account some drag. Drag lets me not worry about any "max_velocity", because drag naturally introduces a terminal velocity. It will also gradually slow the ship down, and even though we're in space and that shouldn't be a huge issue, I find it more aesthetically pleasing.<br />
<br />
Back in GameXYZ.java I add these new processing Systems to my World, and add a Player and Velocity component to my entity.<br />
<pre class="brush:java"> public GameXYZ(Game game) {
camera = new OrthographicCamera();
camera.setToOrtho(false, 1280,900);
this.game = game;
world = new World();
spriteRenderSystem = world.setSystem(new SpriteRenderSystem(camera),true);
world.setSystem(new PlayerInputSystem(camera));
world.setSystem(new MovementSystem());
world.initialize();
Entity e = world.createEntity();
e.addComponent(new Position(150,150));
e.addComponent(new Sprite());
e.addComponent(new Player());
e.addComponent(new Velocity(0,0));
e.addToWorld();
}
</pre>
<br />
Running the program lets us now control our ship using the arrow keys.<br />
<br />
Before we go on to add the ability to shoot, we should consider using an EntityFactory to manage the creation of our entities. If you look at the demo code, there won't be very many surprises, but to get us started I've made a simplified EntityFactory that just creates our player, the way we've been doing.<br />
<pre class="brush:java">package com.gamexyz;
import com.artemis.Entity;
import com.artemis.World;
import com.gamexyz.components.Player;
import com.gamexyz.components.Position;
import com.gamexyz.components.Sprite;
import com.gamexyz.components.Velocity;
public class EntityFactory {
public static Entity createPlayer(World world, float x, float y) {
Entity e = world.createEntity();
e.addComponent(new Position(x, y));
e.addComponent(new Sprite("textures-original/fighter.png"));
e.addComponent(new Velocity());
e.addComponent(new Player());
return e;
}
}
</pre>
<br />
To implement it, we just replace our code in GameXYZ which created our entity with<br />
<pre class="brush:java">EntityFactory.createPlayer(world, 150, 150).addToWorld();
</pre>
<br />
A quick run of the program reveals that this works just as well.<br />
<br />
When we go to shoot, we will need to create new entities for the bullets. To start with, we'll just give our bullets Position, Velocity, and Sprite. So in our EntityFactory, add a new method called createBullet()<br />
<pre class="brush:java"> public static Entity createBullet(World world, float x, float y) {
Entity e = world.createEntity();
e.addComponent(new Position(x, y));
e.addComponent(new Sprite("textures-original/bullet.png"));
e.addComponent(new Velocity(0,800));
return e;
}
</pre>
<br />
The velocity is all in the y direction, because our ship is always pointing that way. Back in PlayerInputSystem, we now need a new control that will create bullets for us when users press the space bar. Note: because the bullet should be shot out from our ship's position, we now need to include Position.class in our "getAspectForAll()" list, and we will also need an @Mapper for position.<br />
<br />
We want to put our "space bar" listener in either keyDown() or keyPressed(), but there is a problem. Neither of these methods can see our Position variable. So instead, we will create a new private boolean flag called "shoot", and if they press space, shoot gets set to true, and when they release, shoot gets set to false. So we'll use keyDown() and keyUp(), but not keyPressed(). This is because we don't want to continuously set shoot to true while they're holding space bar, it just needs to be set the first time. Here's the code we need:<br />
<pre class="brush:java">public class PlayerInputSystem extends EntityProcessingSystem implements InputProcessor {
@Mapper ComponentMapper<Velocity> vm;
@Mapper ComponentMapper<Position> pm;
private OrthographicCamera camera;
private Vector3 mouseVector;
private int ax, ay;
private final int thruster = 400;
private final float drag = 0.4f;
private boolean shoot = false;
@SuppressWarnings("unchecked")
public PlayerInputSystem(OrthographicCamera camera) {
super(Aspect.getAspectForAll(Velocity.class, Player.class, Position.class));
this.camera=camera;
}
@Override
protected void initialize() {
Gdx.input.setInputProcessor(this);
}
@Override
protected void process(Entity e) {
mouseVector = new Vector3(Gdx.input.getX(),Gdx.input.getY(),0);
camera.unproject(mouseVector);
Velocity vel = vm.get(e);
Position pos = pm.get(e);
vel.vx += (ax - drag * vel.vx) * world.getDelta();
vel.vy += (ay - drag * vel.vy) * world.getDelta();
if (shoot) {
EntityFactory.createBullet(world,pos.x+7,pos.y+40).addToWorld();
EntityFactory.createBullet(world,pos.x+60,pos.y+40).addToWorld();
}
}
@Override
public boolean keyDown(int keycode) {
if (keycode == Input.Keys.UP) ay = thruster;
if (keycode == Input.Keys.DOWN) ay = -thruster;
if (keycode == Input.Keys.RIGHT) ax = thruster;
if (keycode == Input.Keys.LEFT) ax = -thruster;
if (keycode == Input.Keys.SPACE) shoot = true;
return false;
}
@Override
public boolean keyUp(int keycode) {
if (keycode == Input.Keys.UP) ay = 0;
if (keycode == Input.Keys.DOWN) ay = 0;
if (keycode == Input.Keys.RIGHT) ax = 0;
if (keycode == Input.Keys.LEFT) ax = 0;
if (keycode == Input.Keys.SPACE) shoot = false;
return false;
}
}
</pre>
<br />
We can run it and shoot, which is pretty cool, but things still aren't perfect. For one thing, the continuous stream of bullets seems a little unreasonable. We can introduce a "timeToFire" variable and "fireRate" variable to control this as follows:<br />
<br />
Now we can run around blasting our imagined enemies to oblivion. Unfortunately, every bullet we have ever fired will keep going and going and going. Sooner or later, the computer will be trying to process so many bullets that it just can't keep looking smooth anymore. We will create a timer which will destroy old bullets. Following the Demo, we'll create a Component called Expires, and a System called ExpiringSystem.<br />
<pre class="brush:java">package com.gamexyz.components;
import com.artemis.Component;
public class Expires extends Component {
public float delay;
public Expires(float delay) {
this.delay = delay;
}
public Expires() {
this(0);
}
}
</pre>
<pre class="brush:java">package com.gamexyz.systems;
import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.annotations.Mapper;
import com.artemis.systems.DelayedEntityProcessingSystem;
import com.gamexyz.components.Expires;
public class ExpiringSystem extends DelayedEntityProcessingSystem {
@Mapper
ComponentMapper<expires> em;
public ExpiringSystem() {
super(Aspect.getAspectForAll(Expires.class));
}
@Override
protected void processDelta(Entity e, float accumulatedDelta) {
Expires expires = em.get(e);
expires.delay -= accumulatedDelta;
}
@Override
protected void processExpired(Entity e) {
e.deleteFromWorld();
}
@Override
protected float getRemainingDelay(Entity e) {
Expires expires = em.get(e);
return expires.delay;
}
}
</expires></pre>
The System is kind of cool, notice it extends DelayedEntityProcessingSystem, which is a whole EntitySystem designed to handle stuff like this, with processDelta(), and processExpired(). Adding this to our world will kill anything with the Exires component, so let's go back into the EntityFactory and give that to bullets.<br />
<pre class="brush:java"> public static Entity createBullet(World world, float x, float y) {
Entity e = world.createEntity();
e.addComponent(new Position(x, y));
e.addComponent(new Sprite("textures-original/bullet.png"));
e.addComponent(new Velocity(0,800));
e.addComponent(new Expires(1f));
return e;
}
</pre>
<br />
Uh-oh, we have a problem. The argument (1f) tells Expires that it should wait 1 second before expiring. That seems to work well, except it expires ALL the bullets, not just the ones that have existed for 1 second. Damn... it's never easy, huh?<br />
<br />
To make sure we're not insane, check the demo program. It's set to expire bullets after 5 seconds, watch and see every 5 seconds your whole bullet stream dies. When I do this, I see that sometimes they die, and sometimes they don't. The difference seems to be if any bullets of that 5 seconds have hit an enemy ship. To verify this to myself I went into their EntityFactory and under createEnemyShip() I set the bounds.radius to 0 (this is used in their collision detection) so that no bullets would hit ships. And sure enough, every 5 seconds all the bullets died like clockwork.<br />
<br />
Huh... a mystery for another day.<br />
<div style="background-color: #cccc00;">
UPDATE:
I read on the <a href="http://slick.javaunlimited.net/viewtopic.php?f=28&t=5477&sid=8d62b8dc2f286671ba328b45ad2a80f7">Artemis forum</a> that the whole Delay system isn't very good, so I just ignored the demo's class and made my very own ExpiringSystem
<br />
<pre class="brush:java">public class ExpiringSystem extends EntityProcessingSystem {
@Mapper ComponentMapper<Expires> em;
public ExpiringSystem() {
super(Aspect.getAspectForAll(Expires.class));
}
@Override
protected boolean checkProcessing() {
return true;
}
@Override
protected void process(Entity e) {
Expires exp = em.get(e);
exp.delay -= world.getDelta();
if (exp.delay <= 0) {
e.deleteFromWorld();
}
}
}
</pre>
This System seems to work well, though it doesn't really have many frills.</div>
<br />
One other comment I'd like to make: I'm trying to roughly reproduce the demo program, with a little variation here and there. But I feel that this PlayerInputSystem is becoming too clogged with things that should be separated. For instance, I think we should have separate components for Thruster or Accelerator, and Gun. It seems that too much data has creeped into our PlayerInputSystem. In future posts I may explore separating it out more. I worry, though, that it would end up required a bunch of different InputSystems to handle the different types of weapons (guns vs grenades vs swords vs whatever...), but maybe that's not such a bad thing? Or maybe it won't be important. We'll see!<br />
<br />
<span style="color: #bf9000;"><b>You gain 50 XP. Progress to level 2: 150/400</b></span>Unknownnoreply@blogger.com4tag:blogger.com,1999:blog-4125624026143571209.post-28024359926842947932013-01-30T12:54:00.000-08:002013-02-08T18:53:16.464-08:00Spaceship Warrior Pt 2 (Level 1)Quest Log:<br />
<ul>
<li><a href="http://javagamexyz.blogspot.com/2013/01/setting-up-your-work-environment-level-0.html">Setting Up Your Work Environment (Level 0)</a></li>
<li><span style="color: black;"><a href="http://javagamexyz.blogspot.com/2013/01/introducing-spaceship-warrior-level-1.html">Introducing Spaceship Warrior (Level 1)</a></span></li>
<li><span style="color: black;"><a href="http://javagamexyz.blogspot.com/2013/01/drawing-your-spaceship-warrior-level-1.html">Drawing your Spaceship Warrior (Level 1)</a></span></li>
</ul>
<br />
Sweat drips from your brow, the stifling heat threatening to derail your concentration. You have glanced through the code, but it is frustratingly unclear where to start. You decide to go to your roots. Start with the smallest pieces.<br />
<br />
<span style="color: #741b47;"><b>Set up a two Java Projects as we discussed before, GameXYZ and GameXYZ-desktop. In GameXYZ's buildpath, add<i> gdx.jar</i> and <i>artemis-xxxxxxx.jar</i>. Don't forget to hit the "Order and Export" tab in check gdx.jar. In GameXYZ-desktop's add <i>gdx-natives</i>, <i>gdx-backend-lwjgl</i>, and <i>gdx-backend-lwjgl-natives</i>. Also, make sure you hit the Build Path -> Projects tab and reference GameXYZ. Spaceship Warrior uses more .jar's than we have included, but we'll build up to them.</b></span><br />
<span style="color: #741b47;"><b><br /></b></span>
<span style="color: #741b47;"><b>In GameXYZ-desktop, create a Launcher.java class in new package <i>com.gamexyz</i>.</b></span><br />
<pre class="brush:java">package com.gamexyz;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;
public class Launcher extends Game {
public static final int FRAME_WIDTH = 1280;
public static final int FRAME_HEIGHT = 900;
@Override
public void create() {
setScreen(new GameXYZ(this));
}
public static void main(String[] args) {
LwjglApplicationConfiguration cfg = new LwjglApplicationConfiguration();
cfg.width=FRAME_WIDTH;
cfg.height=FRAME_HEIGHT;
cfg.useGL20=true;
cfg.title = "GameXYZ";
new LwjglApplication(new Launcher(), cfg);
}
}
</pre>
<br />
<span style="color: #741b47;"><b>In GameXYZ create a GameXYZ.java class in new package <i>com.gamexyz</i>.</b></span><br />
<pre class="brush:java">package com.gamexyz;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.GL10;
import com.badlogic.gdx.graphics.OrthographicCamera;
public class GameXYZ implements Screen {
OrthographicCamera camera;
public GameXYZ(Game game) {
camera = new OrthographicCamera();
camera.setToOrtho(false, 1280,900);
}
@Override
public void render (float delta) {
Gdx.gl.glClearColor(0,0,0.2f,1);
Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
camera.update();
}
}
</pre>
<div style="background-color: #aaaacc; color: red;">
Eclipse Tip: You don't have to put in your own <i>import</i> commands. Once you have typed the code without them, you can press ctrl+shift+o, and they will automatically get added. If there is any ambiguity, you have to select which package you meant to import from.
</div>
<br />
<b><span style="color: #741b47;"><br /></span></b>
<b><span style="color: #741b47;">Eclipse will now warn you that because class GameXYZ implements Screen, it must implement a series of methods. It can automatically add them, leaving them empty. I like to add reporters to them, so my list looks like this:</span></b><br />
<br />
<pre class="brush:java"> @Override
public void resize (int width, int height) {
System.out.println("Resize");
}
@Override
public void pause () {
System.out.println("Pause");
}
@Override
public void resume () {
System.out.println("Resume");
}
@Override
public void dispose () {
System.out.println("Dispose");
}
@Override
public void show() {
System.out.println("Show");
}
@Override
public void hide() {
System.out.println("Hide");
}</pre>
<br />
Cold dread washes over you as you realize just how different things seem. Now our Launcher extends something called Game. And our GameXYZ implements something called Screen. You check the Javadocs for them, and see that Game <i>is</i> an ApplicationListener, like what you used in Drop, but that it can delegate to screens, allowing multiple screens to be created. This strikes you as a powerful tool, because someday you plan on having splash screens, menus, inventory screens, and more. Perhaps this Game/Screen feature will be useful.<br />
<br />
You decide that it's time to start pursuing Artemis, you want to create a character for your Spaceship Warrior. You check the demo code and decide that you should start by making Components for Position and Sprite. In GameXYZ, you create a new package called com.gamexyz.components and add these two classes to it:<br />
<br />
<pre class="brush:java">package com.gamexyz.components;
import com.artemis.Component;
public class Position extends Component {
public Position(float x, float y) {
this.x = x;
this.y = y;
}
public Position() {
this(0,0);
}
public float x, y;
}
</pre>
<pre class="brush:java">package com.gamexyz.components;
import com.artemis.Component;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Texture;
public class Sprite extends Component {
public Sprite(String path) {
sprite = new Texture(Gdx.files.internal(path));
}
public Sprite() {
this("textures-original/fighter.png");
}
public Texture sprite;
public float r = 1;
public float g = 1;
public float b = 1;
public float a = 1;
public float scaleX = 1;
public float scaleY = 1;
public float rotation;
}
</pre>
Your Sprite class is a shadow of the target, but it should be enough to start with. You also chose to copy the entire "textures-original" folder from Spaceship Warrior, and put it in your GameXYZ-desktop package. A quick run of Launcher reveals that your code is not broken... yet. Clearly though, these components alone are not enough to draw your ship to the screen.<br />
<br />
<span style="color: #741b47;"><b>Artemis is based on Components and Systems. Components only hold data, but no logic. Systems grab all the entities that have the relevant components and process the logic of them. For instance, a SpriteRenderSystem may be used to draw entities that have both Position and Sprite classes.</b></span><br />
<span style="color: #741b47;"><b><br /></b></span>
<span style="color: #741b47;"><b>Create a new package <i>com.gamexyz.systems</i> and a new class SpriteRenderSystem.java.</b></span><br />
<pre class="brush:java">public class SpriteRenderSystem extends EntitySystem {
@Mapper ComponentMapper<Position> pm;
@Mapper ComponentMapper<Sprite> sm;
private OrthographicCamera camera;
private SpriteBatch batch;
@SuppressWarnings("unchecked")
public SpriteRenderSystem(OrthographicCamera camera) {
super(Aspect.getAspectForAll(Position.class, Sprite.class));
this.camera = camera;
}
@Override
protected void initialize() {
batch = new SpriteBatch();
}
@Override
protected boolean checkProcessing() {
return true;
}
@Override
protected void processEntities(ImmutableBag<Entity> entities) {
for (int i = 0; i < entities.size(); i++) {
process(entities.get(i));
}
}
@Override
protected void begin() {
batch.setProjectionMatrix(camera.combined);
batch.begin();
}
protected void process(Entity e) {
if (pm.has(e)) {
Position position = pm.getSafe(e);
Sprite sprite = sm.get(e);
batch.setColor(sprite.r, sprite.g, sprite.b, sprite.a);
float posx = position.x;
float posy = position.y;
batch.draw(sprite.sprite, posx, posy);
}
}
@Override
protected void end() {
batch.end();
}
}
</pre>
<br />
As with Sprite.java, you decided to start smaller, with more familiar tools. You notice that SpriteRenderingSystem extends EntitySystem, and you <a href="https://code.google.com/p/artemis-framework/source/browse/src/com/artemis/EntitySystem.java" target="_blank">look up its code in the Google code repository</a>. You notice that it has a process() method that looks like this:<br />
<pre class="brush:java">public final void process() {
if(checkProcessing()) {
begin();
processEntities(actives);
end();
}
}
</pre>
<br />
As such you learned that by switching checkProcessing() to false, you can disable the system. You also learned the order our child methods are called in.<br />
<br />
<span style="color: #741b47;"><b>Using @Mapper instead of getComponent speeds execution up, apparently. getAspectForAll() gets all the entities which have EVERY one of the argument Components. You can also use getAspectForOne to get entities that only have at least one of the listed Components. process() is where most of the magic happens, but begin() and end() call batch.begin() and batch.end(), which we know is important from the drop demo.</b></span><br />
<br />
You review the demo code to see how to start processing your new system. In GameScreen.java, you see they have defined a "World", and they registered their systems to this world. They also have a special object for SpriteRenderSystem, though not <i>all</i> of their systems got this same treatment. Also, when they add the SpriteRenderSystem, it gets a second argument of true. Reading more documentation you learn that this is to stop SpriteRenderSystem from running automatically, you must call it manually.<br />
<br />
You study the code that shows initializing the world, creating a new Entity, and adding Components to it. You also look down in the render() method, and see the use of world.setDelta(), world.process(), and spriteRenderSystem.process(). You include all these in your new GameXYZ.java:<br />
<pre class="brush:java">public class GameXYZ implements Screen {
private OrthographicCamera camera;
private Game game;
private World world;
private SpriteRenderSystem spriteRenderSystem;
public GameXYZ(Game game) {
camera = new OrthographicCamera();
camera.setToOrtho(false, 1280,900);
this.game = game;
world = new World();
spriteRenderSystem = world.setSystem(new SpriteRenderSystem(camera),true);
world.initialize();
Entity e = world.createEntity();
e.addComponent(new Position(150,150));
e.addComponent(new Sprite());
e.addToWorld();
}
@Override
public void render (float delta) {
Gdx.gl.glClearColor(0,0,0.2f,1);
Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
camera.update();
world.setDelta(delta);
world.process();
spriteRenderSystem.process();
}
(...)
}
</pre>
<br />
You expertly use ctrl+shift+o to clean up any needed imports, and congratulate yourself on a job well done. You have a ship.<br />
<br />
<span style="color: #bf9000;"><b>You gain 50 XP. Progress to Level 2: 100/400</b></span>Unknownnoreply@blogger.com4tag:blogger.com,1999:blog-4125624026143571209.post-19849264008616135482013-01-30T09:55:00.003-08:002013-02-08T18:16:17.085-08:00Spaceship Warrior Pt 1 (Level 1)Adrenaline rushes through your veins as the Drop Beast's blood drips from your bare hands. You have tasted victory, and now have a hunger which can never truly be satisfied. Back in town, you hear whispers about a new creature plaguing the outlying farms, each of it's 200 teeth the size of a broadsword, harder than diamond, and sharper than a sharp pair of scissors. The creature is known as <a href="http://code.google.com/p/spaceship-warrior/source/browse/" target="_blank">Spaceship Warrior</a>, and rumors say that it possesses the power of both libgdx AND Artemis. You let a slow smile creep into your face: fate has favored those farmers today. You're on the job now.<br />
<br />
You ask around for any information you can find, and discover that a magical scroll is required to summon the monster at will: <a href="http://javaforge.com/project/HGE" target="_blank">MercurialEclipse</a>. You've heard of it before. This scroll can summon forth anything, you will have to be very careful in its application.<br />
<br />
Once it has been properly installed within you Eclipse spellbook, begin the summoning ritual.<br />
<br />
<b><span style="color: #741b47;">On the Google code page, click the "Checkout" link. Where it says "Command-line access" copy the link, starting with <i>https://...</i> Back in Eclipse, click File->Import. In the list of options, find Mercurial->Clone Existing Mercurial Repository, and click Next. In the URL field, paste the checkout URL from Google and select finish. In the Project Explorer on the left side of Eclipse you should see a new project called "spaceship-warrior".</span></b><br />
<br />
Eager to test your mettle against its creations, you browse its source through a file-tree and find <i>com.gamadu.spaceshipwarrior.SpaceshipWarrior.java</i>, which resembles the launch file you created for Drop. You tell Eclipse to run it, but, alas. The selection cannot be launched. Confusion and rage twist your body as you let out a blood curling scream! <b><span style="color: #cc0000;">AAAAAAGGGGGGHGHGHGHGHHHHH!!!!!</span></b><br />
<br />
Once you calm down, you realize the problem. Somehow, Eclipse does not recognize this as a Java Project, and has no clue what to do with these files. You quickly form a plan.<br />
<br />
<b><span style="color: red;">WARNING: My solution is probably a pretty stupid workaround. There must be an easier way, and you may want to find a better solution.</span></b><br />
<br />
<b><span style="color: #741b47;">Create a new Java Project, called spaceship-warrior2, then copy the "lib", "resources", and "textures-original" folders into your new project, leaving "src" alone for a moment. Then, clicking into the "src" folder, you copy the "com" folder (which will come with all its subfolders) into the "src" folder of your new project. It instantly converts the file folder structure into a package structure like we expected. Unfortunately our new project is still riddled with errors.</span></b><br />
<b><span style="color: #741b47;"><br /></span></b>
<b><span style="color: #741b47;">Our problem is that we still haven't included the appropriate .jar files. Look in the "libs" folder and you will find a collection of "kryonet" jars and "libgdx" jars. I don't know what they "kryonet" jars do, but they aren't important to include. Add the libgdx ones + sources.</span></b><br />
<br />
The beast comes into focus, but there are still errors in the project. What haven't you done?!?!?! You recall that the creature also harnesses the power of Artemis, and though no Artemis jars were included in the download, you know exactly <a href="http://www.gamadu.com/artemis/download.html" target="_blank">where to find them</a>. You see there is only one jar, and its associated source. You quickly add them to the build path and watch all the errors fizzle away. You are eager to begin.<br />
<br />
Proceeding to launch <i>com.gamadu.spaceshipwarrior.SpaceshipWarrior.java</i>, the beast phases into existence, but before you can strike it, disappears again. Righteous anger flows through your veins. There were no errors! What could have gone wrong now?! You check the console, and find that textures\pack could not be found. But you personally have no trouble finding it within the "resources" folder.<br />
<br />
<b><span style="color: #741b47;">I'm not yet sure why this happens, but the <a href="http://slick.javaunlimited.net/viewtopic.php?f=28&t=5505&sid=0395f4c36c0202654f91ec7b8a2e6bfc" target="_blank">problem</a> is that the "resources" folder hasn't been added to the "classpath" yet. Go back to the Build Path under "Libraries" (where you added all the .jars) and click "Add Class Folder". Add the "resources" folder and run it again.</span></b><br />
<br />
A large window comes up, filled with stars and a ship which you control. You blast your enemies to smithereens. Victory at last.<br />
<br />
<b><span style="color: #bf9000;">You gain 50 XP. Progress to level 2: 50/400</span></b><br />
<br />
You take a few moments to browse through the code. Some things look similar, some look foreign. One thing is for sure, Spaceship Warrior will not be such an easy foe as Drop was.<br />
<br />
<br />Unknownnoreply@blogger.com1tag:blogger.com,1999:blog-4125624026143571209.post-84945849935829916872013-01-30T08:49:00.000-08:002013-01-30T12:55:58.526-08:00Setting Up Your Work Environment (Level 0)Quest Log:<br />
<ul>
<li><a href="http://javagamexyz.blogspot.com/2013/01/setting-up-your-work-environment-level-0.html">Setting Up Your Work Environment (Level 0)</a></li>
<li><span style="color: black;"><a href="http://javagamexyz.blogspot.com/2013/01/introducing-spaceship-warrior-level-1.html">Introducing Spaceship Warrior (Level 1)</a></span></li>
<li><span style="color: black;"><a href="http://javagamexyz.blogspot.com/2013/01/drawing-your-spaceship-warrior-level-1.html">Drawing your Spaceship Warrior (Level 1)</a></span></li>
</ul>
<br />
A shiver runs down your back as you stare at the Guild work board. The one job too terrifying for anyone else to touch returns your stare, daring you to accept. <b>Setting Up Your Work Environment (Level 0)</b>. A quick scan to the bottom reveals artifacts so legendary that their legends are known by... well... at least a lot of people. Your final quest will be to tame the wild <a href="http://code.google.com/p/libgdx/wiki/SimpleApp" target="_blank">Drop Beast</a>, but you must first procure and master these artifacts:<br />
<ul>
<li><a href="http://www.eclipse.org/downloads/packages/release/indigo/sr2" target="_blank">Eclipse Indigo Java EE IDE</a></li>
<li><a href="http://www.oracle.com/technetwork/java/javase/downloads/index.html" target="_blank">Java SE 1.6</a></li>
<li><a href="http://code.google.com/p/libgdx/downloads/detail?name=libgdx-0.9.7.zip&can=2&q=" target="_blank">libgdx 0.9.7</a></li>
</ul>
<span style="color: #444444;">The shiver keeps shivering.</span><br />
<br />
<span style="color: #444444;"><span style="color: #741b47;"><b>In general, for the future it's probably best to go with the most up-to-date versions of whatever you can find (except to stick with Java 1.6, instead of 1.7 which is still not as well supported by these libraries... I think). If my examples don't work for you, my first guess is that incompatible versions are at fault.</b></span></span><br />
<span style="color: #444444;"><br /></span>
<span style="color: black;"><span style="color: #444444;">You will be on your own to master these ancient tools, though a reliable source has indicated that libgdx installation instructions can be found deep within the Cavern of </span><a href="http://code.google.com/p/libgdx/wiki/ProjectSetup" target="_blank">http://code.google.com/p/libgdx/wiki/ProjectSetup</a><span style="color: #444444;">.</span></span><br />
<br />
<span style="color: #741b47;"><b>So that we are on the same page for the rest of the time, I have created these two projects in keeping with the libgdx instructions:</b></span><br />
<ol>
<li><span style="color: #741b47;"><b>GameXYZ - This is where we will actually put most of the code for the game</b></span></li>
<li><span style="color: #741b47;"><b>GameXYZ-desktop - This is where we will store the launcher</b></span></li>
</ol>
<span style="color: #741b47;"><b>I won't ever talk about the Android or HTML5 versions, but if you ever wanted to expand, this setup would make it easy to do so.</b></span><br />
<br />
<span style="color: #444444;">Once you have assembled your armaments, you must proceed to the lair of the <a href="http://code.google.com/p/libgdx/wiki/SimpleApp" target="_blank">Drop Beast</a>. It is up to you, and you alone, to conquer whatever perils lie within. If you do, the reward will be great. If you fail, then you should practice your Java skills more thoroughly before starting again.</span><br />
<br />
<span style="color: #bf9000;"><b>Ding! For your hard work and dedication in completing your first quest, you are awarded 200 XP, and have officially begun your life as an adventure programmer. Welcome to Level 1!</b></span><br />
<br />
<div class="separator" style="clear: both; text-align: center;">
<span style="color: #444444;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiirFz_ExgkXG9VtHmjOO4KcRck_9HfswOw2DWQ407kmZUo4nmaDbOtsK2azP7xPe0r13N6dEy-Bpz7blhThHAP2B3m6hidTOKPQMKEEtusabzfHbB2xTWLovsDgVs4vX-5pF2jHqd6r3o/s1600/Level1.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" height="320" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiirFz_ExgkXG9VtHmjOO4KcRck_9HfswOw2DWQ407kmZUo4nmaDbOtsK2azP7xPe0r13N6dEy-Bpz7blhThHAP2B3m6hidTOKPQMKEEtusabzfHbB2xTWLovsDgVs4vX-5pF2jHqd6r3o/s320/Level1.png" width="229" /></a></span></div>
<span style="color: #444444;"><br /></span>
<span style="color: #444444;">As a Level 1 PC, you have gained modest skills in the arts of</span><br />
<ul>
<li><span style="color: #444444;">Making a master project which is referenced by other (launcher) projects</span></li>
<li><span style="color: #444444;">Using SpriteBatch.draw() to render sprites to the screen</span></li>
<li><span style="color: #444444;">Using Gdx.input to query the keyboard and mouse for user input</span></li>
</ul>
<span style="color: black;"><span style="color: #444444;">Your next quest will bring you face to face with fear itself, as you battle ferocious monsters from the murky depths of... Spaceship Warrior!!!!! </span></span>Unknownnoreply@blogger.com0tag:blogger.com,1999:blog-4125624026143571209.post-33606111126865643982013-01-28T11:26:00.000-08:002013-01-30T11:11:32.083-08:00WelcomeWelcome to JaveGameXYZ! For a long time I have been eager to make a game of my own - the process has looked something like this:<br />
<ol>
<li>Dream up lots of awesome things I wish were in games, but aren't</li>
<li>Start vigorously designing code and basic 32 x 32 sprites</li>
<li>Try to do anything more complicated than moving a character around a tilemap, and get extremely bogged down in the code and my crappy framework</li>
<li>Research online get bogged down in incomprehensible code and demos that I can't get to work</li>
<li>Shelf the project</li>
<li>Go to step 1</li>
</ol>
Rinse and repeat through C, C++, Java, C++, and now Java again. My dream of designing the next groundbreaking Indie RPG now seems a little far-fetched. BUT, I've realized that fighting through <i>designing</i> a game can be at LEAST as fun as <i>playing</i> somebody else's game, and so I imagine I'll have <i>something </i>someday.<br />
<br />
Most recently I wanted to make a game for the Android, because I have a Droid and had gotten bored with all the free RPGs I could find. Since it was Java I figured it wouldn't be tooooooooooo bad... derp derp derp. Well thankfully I found the WONDERFUL library <a href="http://code.google.com/p/libgdx/" target="_blank"><b>libgdx</b></a>, which makes programming for Android as easy as 1, 2, 3. ...4. ...5 ...27...1,000,000,000, ...and on. It still isn't easy, and I'm sure it never will be.<br />
<br />
I also stumbled upon the Entity/Component system of doing things, and found another wonderful library, <a href="http://gamadu.com/artemis/" target="_blank"><b>Artemis</b></a>. This blog is my attempt to piece together a bare-bones RPG with libgdx and Artemis. I will start with the Artemis demo program, StarWarrior, but I don't really understand it all yet. My hope is that by working through it, and writing about it, it will make more sense to me and and, just perhaps, be useful to the fine women and men of the future who manage to stumble upon this blog.<br />
<br />
From there, I hope to be able to piece together the most generic tile based RPG ever, JaveGameXYZ! God help us. <br />
<div class="separator" style="clear: both; text-align: center;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh0Mr3vbCDBJabahj-VGvvjOf3bvO-sJEUO27otngrdvoTIUczlnfyLn3BX6cOU3pxiEJ_pjwpPRXTzjhRTkFKpWZcyG2agT5Y61_1_6hkbN3y9tDGNNcBDvnU7aM0sv6fI_2fm7km7njI/s1600/favicon.png" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh0Mr3vbCDBJabahj-VGvvjOf3bvO-sJEUO27otngrdvoTIUczlnfyLn3BX6cOU3pxiEJ_pjwpPRXTzjhRTkFKpWZcyG2agT5Y61_1_6hkbN3y9tDGNNcBDvnU7aM0sv6fI_2fm7km7njI/s1600/favicon.png" width="0px" height="0px"/></a></div>
<br />Unknownnoreply@blogger.com4