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 imageName_0.png, imageName_1.png, ..., imageName_100.png it interprets the _x as an index, and refers to them all by the name imageName. 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.
When you call
atlas.findRegion("imageName");it returns only the last image of the series. But if you call
atlas.findRegions("imageName");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.
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.
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 Hero of Allacrost, staring the young knight Claudius. I found a spritesheet 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!)
Each image is 32x64, and they line up to look like continuous walking.
To understand my implementation, I want to first focus on the TextureRegion class. TextureRegions have a reference to a Texture, along with rectangle coordinates specifying which part of that Texture to use. These coordinates are stored in 2 different formats: float u, u2 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. float v, v2 do likewise for the vertical component.
On the other hand, int x, y, width, height store the same information in raw pixel counts. In the example above, x would be 35 and width would be 73-35 = 38.
The first major change I had to make was to SpriteRenderSystem and Sprite. In Spaceship Warrior, Sprites only had a name, 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.
Even worse, the Sprites would now only hold a refenece 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.
To get around this, I used the coordinate information talked about above, and each Sprite had it's own int x, y, width, and height. 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.
Here is my implementation:
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; }
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); } }
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; } }
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); } }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.
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.
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.
int playMode 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 here to see what was going on.
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:
/******************************************************************************* * 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; } }
All that is different is that on PINGPONG mode, it doesn't linger extra long at the ends. I had to update all my import commands to import mine, instead of libgdx's.
So there you go, we created an animation system using entities and components.
To bring you up to speed, my project currently has the following structure
- GameXYZ
- EntityFactory
- Components
- Expires
- Player
- Position
- Sprite
- SpriteAnimation
- Systems
- ExpiringSystem
- HudRenderSystem
- SpriteAnimationSystem
- SpriteRenderSystem
- Custom
- Animation
- Utils
- ImagePacker
You have gained 100 XP. Progress to Level 3: 100/600