Przykładowa gra na Libgdx - bijatyka GdxCombat


#1

Wstęp

Omówię tutaj kod GdxCombat. Jest to bardzo uproszczona wersja Mortal Kombat zrobiona na framework’u Libgdx. Projekt pisany był na jednej z najnowszych wersji nightly.

Projekt wykorzystuje mój system scen więc najlepiej będzie jeżeli na początek przeczytasz ten artykuł.

Pełne źródła gry włącznie z assetami: https://github.com/gamedevpl/GdxCombat

Zasoby

Wszystkie zasoby pochodzą z tej strony http://www.mortalkombatwarehouse.com/mk1/. Obrazki trzeba było konwertować z gif na png. Pomocny tutaj okazał się program ImageMagick, który konwertuje wszystkie pliki na odpowiedni format oraz BulkRename do zmiany nazw.

Animacje jednej z postaci.

Następnie użyłem TexturePacker, aby spakować wszystkie tekstury w jedną dużą. Robi się to w celu optymalizacji i uproszczenia wczytywania. Obsługa tego programu sprowadza się do wybrania katalogu wejściowego i wyjściowego. Tak samo zrobiłem z teksturami drugiej postaci oraz z teksturami HUD’u. Przy dźwiękach trzeba było tylko zmienić ich nazwy.

Plik timer.fnt jest plikiem czcionki. Powstał on dla tej tekstury:

O ile istnieje narzędzie do zamiany czcionek TrueType na czcionki bitmapowe, to nie ma narzędzia, które wygeneruję plik czcionki z obrazka, dlatego timer.fnt napisałem ręczenie. Nie jest to nic trudnego podajemy kod ASCII znaku, jego pozycję na teksturze i rozmiar. Plik można otworzyć dowolnym edytorem tekstu.

Pobierz wszystkie zasoby (Wgrywamy do katalogu assets).
Jeśli chcesz: Pobierz zasoby przed pakowaniem TexturePacker

Klasy startujące

Desktop:

public class Main {
	public static void main(String[] args) {
		LwjglApplicationConfiguration cfg = new LwjglApplicationConfiguration();
		cfg.title = "GdxCombat";
		cfg.useGL20 = true;
		cfg.width = 480;
		cfg.height = 320;
		new LwjglApplication(new GdxCombat(), cfg);
	}
}

Android:

public class MainActivity extends AndroidApplication {
	@Override
	public void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		
		AndroidApplicationConfiguration cfg = new AndroidApplicationConfiguration();
		cfg.useGL20 = true;
		cfg.useAccelerometer = false;
		cfg.useCompass = false;
		cfg.useWakelock = true;
		
		initialize(new GdxCombat(), cfg);
	}
}

Wyłączamy nie potrzebny nam akcelerometr i kompas. Włączony Wakelock sprawi, że ekran nie będzie sam się wyłączał. Wymaga to dodania to permisji do pliku AndroidManifest. Jeżeli nie wiesz jak to zrobić zajrzyj tutaj. Co prawda gra nie jest przystosowana do ekranów dotykowych, ale bez problemu uruchomi się na telefonie.

Przechodzimy teraz do kodu głównego projektu.

Klasa Assets - Ładowanie zasobów

[code] public class Assets { private static TextureAtlas textures; public static BitmapFont timerFont;
public static Sound fight;
private static ArrayList<Sound> hitSounds = new ArrayList<Sound>();

public static void load() {
	textures = new TextureAtlas(Gdx.files.internal("gfx/textures.pack")); //1
	timerFont = new BitmapFont(Gdx.files.internal("timer.fnt"), textures.findRegion("timer")); //2
	fight = Gdx.audio.newSound(Gdx.files.internal("sounds/fight.mp3"));

	for (int i = 0; i < 7; i++) { //3
		hitSounds.add(Gdx.audio.newSound(Gdx.files.internal("sounds/hit" + i + ".mp3")));
	}
}

public static void dispose() { //4
	textures.dispose();
	timerFont.dispose();
	fight.dispose();

	for (int i = 0; i < 7; i++) {
		hitSounds.get(i).dispose();
	}
}

public static TextureRegion getTextureRegion(String name) { //5
	return textures.findRegion(name);
}

public static TextureAtlas getTextureAtlas() { //6
	return textures;
}

public static Sound getRandomHitSound() { //7
	return hitSounds.get(MathUtils.random(hitSounds.size() - 1));
}

}
[/code]

  1. Tworzymy tutaj nowy obiekt typu TextureAtlas. Jest to klasa, która umożliwia prosty dostęp do tekstur spakowanych przez TexturePacker. Jako argument podajemy lokalizacje wygenerowanego pliku .pack.
  2. Ładowanie czcionki, pierwszy argument to lokalizacją pliku .fnt a drugi do tekstura. Zwróć uwagę na metodę findRegion zwraca ona teksturę z atlasu. Argument to nazwa pliku bez rozszerzenia.
  3. Pętla ładująca 8 dźwięków uderzeń i dodająca je do listy. Dzięki temu, że nazwy plików to hit0, hit1, itd. możemy użyć licznika pętli do konstrukcji nazwy pliku.
  4. Porządki, usuwanie zasobów z pamięci.
  5. Publiczna statyczna metoda, którą możemy wywołać z dowolnego miejsca w grze i zwróci nam teksturę.
  6. Zwróci nam cały atlas.
  7. Zwraca losowy dźwięk trafienia. Oczywiście od rozmiaru tablicy odejmujemy jeden, ponieważ liczymy od 0.

Klasy pomocnicze

AnimationUtils [code] public class AnimationUtils { public static Array loadAnim(TextureAtlas atlas, String name, int framesNumber, boolean flip) { Array frames = new Array();
	for (int i = 0; i < framesNumber; i++) {
		TextureRegion region = new TextureRegion(atlas.findRegion(name + i));
		if (flip)
			region.flip(true, false);
		frames.add(region);
	}
	
	return frames;
}

public static Array<TextureRegion> loadAnim(TextureAtlas atlas, String name, int framesNumber) {
	return loadAnim(atlas, name, framesNumber, false);
}

}
[/code]
Funkcja loadAnim, która automatycznie utworzy tablice z klatkami animacji z danego atlasu. Argumenty tej funkcji to:

• atlas - atlas, z którego chcemy wczytać tekstury
• name – nazwa animacji, jeżeli np. poszczególne klatki mają nazwę fight0, fight1, fight2 nazwa to fight
• framesNumber – liczba klatek
• flip – czy tekstura ma być odbita w poziomie

TexturePart
Klasa umożliwiająca wyrenderowanie fragmentu tekstury, nie ja jestem jej autorem więc nie będę jej opisywał.

GdxCombat – Główna klasa

[code] public class GdxCombat implements ApplicationListener { private OrthographicCamera camera; private SpriteBatch batch;
private AbstractScene activeScene;

private FPSLogger fpsLogger = new FPSLogger();

@Override
public void create() {
	Assets.load();
	
	camera = new OrthographicCamera(480, 320);
	camera.position.x = 240; //wyśrodkowanie kamery
	camera.position.y = 160;
	Touch.setCamera(camera);
	batch = new SpriteBatch();
	
	changeScene(new GameScene(new PitArena(), new LiuKang(true), new Sonya(false)));
}

@Override
public void dispose() {
	batch.dispose();
	activeScene.dispose();
	
	Assets.dispose();
}

@Override
public void render() {
	Gdx.gl.glClearColor(0, 0, 0, 1);
	Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
	
	update();
	
	batch.setProjectionMatrix(camera.combined);
	activeScene.render(batch);
}

public void update() {
	camera.update();
	activeScene.update();
	fpsLogger.log();
}

//... pause(), resume(), resize() są puste

public void changeScene(AbstractScene scene) {
//...
}

}
[/code]

Raczej nie ma nic niezwykłego w tej klasie. FPSLogger wypisuje w konsoli, co sekundę ilość FPS’ów. Zdziwić tutaj może Touch.setCamera(). Służy ona do obliczania poprawnych współrzędnych dotyku. Do obliczeń potrzebuje właśnie kamerę. Touch może także tą kamerę zwracać, z tego będziemy głównie korzystać.

Ta instrukcja stworzy i ustawi na aktywną scenę gry. Konstruktor GameScene przyjmuje poziom, oraz dwie postacie, które będą walczyć. Argument boolean określa czy obiekt ma reagować na naciskanie przycisków na klawiaturze. Ale także mówi, po której stronie znajduję się dana postać, gracz jest zawsze po lewej.

HealthBar – pasek życia

[code] public HealthBar(TextureRegion foreground, TextureRegion background, TextureRegion name, int x, int y, boolean flip) {
//...
if (flip) {
	mx4Flip = new Matrix4();
	mx4Flip.rotate(0, 0, 1, 180);
	mx4Flip.translate(2 * -x - 1, 2 * -y, 0);
}

}
[/code]
Mamy 2 paski dla dwóch postaci, jeden z nich musi być przekręcony. Warto tutaj zwrócić uwagę jak renderowany jest pasek przekręcony. Tworzymy macierz, którą przekształcamy i obracamy. Skąd to się bierze:

Renderowanie:

public void draw(SpriteBatch batch) {
	batch.draw(background, x - background.getRegionWidth() / 2, y - background.getRegionHeight() / 2); //1
	
	if (flip) { //2
		Matrix4 old = batch.getTransformMatrix().cpy(); 
		batch.setTransformMatrix(mx4Flip);
		foreground.draw(batch);
		batch.setTransformMatrix(old);
	} else {
		foreground.draw(batch);
	}
	
	if (flip) { //3
		batch.draw(name, x + background.getRegionWidth() / 2 - name.getRegionWidth() - 20, y - name.getRegionHeight() / 2 - 1);
	} else {
		batch.draw(name, x - background.getRegionWidth() / 2 + 20, y - name.getRegionHeight() / 2 - 1);
		
		}
	}
  1. Renderowanie tła paska, zawsze tak samo.
  2. Tutaj dzieją się ciekawe rzeczy, najpierw kopiujemy stary Matrix SpriteBatcha’a później ustawiamy naszą stworzoną macierz, renderujemy przedni pasek i z powrotem przywracamy starą macierz
  3. Renderowanie nazwy wojownika, 20 pikseli od początku paska, lub 20 pikseli od końca jeżeli pasek jest obrócony

Areny

W grze została zrobiona jedna arena, jednak bez problemu można zrobić nowe. Poziomy muszą rozszerzać klasę AbstractArena

public abstract class AbstractArena implements Disposable {
	public abstract void update();
	public abstract void render(SpriteBatch batch);
	public abstract void dispose();
	public abstract void initPhys(World world);
	public abstract int getGroundLevel();
}

Poza standardowymi metodami mamy też tutaj initPhys(), wtedy poziom musi stworzyć wszystkie ciała box2d. W tym wypadku są ta ścianki poziomu. Metoda getGroundLevel() zwraca poziom ‘podłogi’ będziemy tego używać później. Wszystkie metody są abstrakcyjne muszą, więc zostać zaimplementowane w klasie pochodnej.

ThiePit - Przykładowy poziom

public class PitArena extends AbstractArena {
	private Texture pit;
	
	public PitArena() {
		pit = new Texture(Gdx.files.internal("gfx/arenas/thepit.png"));
	}
	
	@Override
	public void update() {
	}
	
	@Override
	public void render(SpriteBatch batch) {
		batch.draw(pit, -442, 0);
	}
	
	@Override
	public void dispose() {
		pit.dispose();
	}
	
	@Override
	public void initPhys(World world) {
		// Doł
		BodyDef groundBodyDef = new BodyDef();
		groundBodyDef.position.set(20, 6.5f);
		Body groundBody = world.createBody(groundBodyDef);
	
		PolygonShape groundBox = new PolygonShape();
		groundBox.setAsBox(70, 1.0f);
		groundBody.createFixture(groundBox, 0.0f);
		groundBox.dispose();
	
		// Lewa ścianka
		BodyDef leftBodyDef = new BodyDef();
		leftBodyDef.position.set(-45, 6.5f);
		Body leftBody = world.createBody(leftBodyDef);
	
		PolygonShape leftBox = new PolygonShape();
		leftBox.setAsBox(1.0f, 70f);
		leftBody.createFixture(leftBox, 0.0f);
		leftBox.dispose();
	
		// Prawa ścianka
		BodyDef rightBodyDef = new BodyDef();
		rightBodyDef.position.set(69, 6.5f);
		Body rightBody = world.createBody(rightBodyDef);
	
		PolygonShape rightBox = new PolygonShape();
		rightBox.setAsBox(1.0f, 70f);
		rightBody.createFixture(rightBox, 0.0f);
		rightBox.dispose();
	}
	@Override
	public int getGroundLevel() {
		return 76;
	}
}

Bardzo prosta klasa, ładuje i renderuje teksturę. Ciekawa jest za to funkcja initPhys(). Tworzymy 3 statyczne ciała(nie mogą się poruszać). Najciekawsze linijki to:

position.set(20, 6.5f); groundBox.setAsBox(70, 1.0f);
Pierwsza z nich ustala pozycje a druga rozmiar. Box2d używa swoich jednostek (metrów), dlatego musimy skalować z pikseli na metry. Dla tej gry wybrałem przelicznik 10 pikseli = 1 m.

GameScene

Zadaniem GameScene jest przygotowanie rozgrywki(wywołanie odpowiednich metod), stworzenie i obsługa HUD’u i przesuwanie kamery.

Zmienne klasy:

public class GameScene extends AbstractScene {
	private AbstractArena arena;
	private AbstractFighter player;
	private AbstractFighter enemy;
	
	private World world; //1
	
	private OrthographicCamera camera;
	
	private SpriteBatch hudBatch; //2
	
	private static int GAMETIME = 99; //3
	private int time;
	private long startTime;
	
	private boolean gameover = false; //4
	
	private Animation fightTextAnim; //5
	
	private Animation nameWinsAnim;
	private int nameWinAnimRenderX;
	
	private float stateTime; //6
	
	// Debug box2d //7
	private boolean box2dDebug = false;
	private Box2DDebugRenderer debugRenderer;
	private Matrix4 box2dRenderMatrix;
	//...
}
  1. Obiekt zarządzający wszystkimi obiektami box2d
  2. Osobny SpriteBatch do renderowania HUD’u
  3. Zmienne licznika czasu gry, GAMETIME to stała określająca długość czasu gry, time ile czasu zostało, a startTime to czas rozpoczęcia gry
  4. Czy gra jeszcze trwa
  5. Dwie animacje, jedna z nich wyświetlana jest przy rozpoczęciu gry(napis „Fight!”) a druga przy zakończeniu(np. napis „Sonya Wins!”). Zmienna int będzie obliczana przy końcu gry, aby napis był wyśrodkowany.
  6. Czas animacji, dzięki niemu obiekt Animation wie, którą klatkę animacji ma wyrenderować.
  7. Box2d może renderować kontur obiektów fizycznych, zmienne, które są do tego potrzebne.

Konstruktor:

public GameScene(AbstractArena arena, AbstractFighter player, AbstractFighter enemy) {
		this.arena = arena;
		this.player = player;
		this.enemy = enemy;
		
		world = new World(new Vector2(0, -20), true);
		
		camera = Touch.getCamera();
		
		Matrix4 hudMatrix = new Matrix4();
		hudMatrix.setToOrtho2D(0, 0, 480, 320);
		hudBatch = new SpriteBatch();
		hudBatch.setProjectionMatrix(hudMatrix);
		
		fightTextAnim = new Animation(0.03f, AnimationUtils.loadAnim(Assets.getTextureAtlas(), "fight", 21));
		startTime = System.currentTimeMillis();
		
		player.setPosition(30, 150 - player.getHeight());
		enemy.setPosition(350, 150 - enemy.getHeight());
		
		player.setGroundLevel(arena.getGroundLevel());
		enemy.setGroundLevel(arena.getGroundLevel());
		
		player.setOponent(enemy);
		enemy.setOponent(player);
		
		enemy.initAi(new RandomBot()); //2
		
		arena.initPhys(world);
		player.initPhys(world);
		enemy.initPhys(world);
		
		createHUD();
		
		debugRenderer = new Box2DDebugRenderer();
		box2dRenderMatrix = new Matrix4();
		
		Assets.fight.play();
	}

Wywołujemy metody, które przygotują rozgrywkę. Tworzymy osobny SpriteBatch to HUD’u ponieważ nasza kamera się przesuwa gdybyśmy HUD renderowali razem z innymi elementami gry zostałby on w miejscu. Potrzebujemy też osobny macierz.

Tworzenie HUD’u:

private HealthBar playerHealthBar;
private HealthBar enemyHealthBar;

private void createHUD() {
	playerHealthBar = new HealthBar(Assets.getTextureRegion("healthbar_full"), Assets.getTextureRegion("healthbar_bg"), Assets.getTextureRegion(player.getName()), 100, 300, false);
	enemyHealthBar = new HealthBar(Assets.getTextureRegion("healthbar_full"), Assets.getTextureRegion("healthbar_bg"), Assets.getTextureRegion(enemy.getName()), 375, 300, true);
}

Update:

@Override
	public void update() {
		stateTime += Gdx.graphics.getDeltaTime(); //1
	
		if (box2dDebug) { //2
			box2dRenderMatrix.set(camera.combined);
			box2dRenderMatrix.scale(10, 10, 0);
		}
	
		camera.position.x = player.getX() + 180; //3
	
		if (camera.position.x < -200)
			camera.position.x = -200;
		if (camera.position.x > 440)
			camera.position.x = 440;
	
		arena.update(); 
		player.update();
		enemy.update();
	
		if (!gameover) { //4
			playerHealthBar.setPercent(player.getHealth());
			enemyHealthBar.setPercent(enemy.getHealth());
	
			time = GAMETIME - (int) ((System.currentTimeMillis() - startTime) / 1000);
		}
	
		if (time == 0 && !gameover) { //5
			gameover = true;
	
			if (player.getHealth() == enemy.getHealth()) {
				createWinsText("draw");
				return;
			}
	
			if (player.getHealth() > enemy.getHealth())
				playerWins();
			else
				enemyWins();
		}
	
		if (player.getHealth() <= 0 && !gameover) { //6
			enemyWins();
		}
	
		if (enemy.getHealth() <= 0 && !gameover) {
			playerWins();
		}
	}
	
	private void enemyWins() { //7
		gameover = true;
		createWinsText(enemy.getName() + "wins");
		enemy.getWinsSound().play();
	}
	
	private void playerWins() {
		gameover = true;
		createWinsText(player.getName() + "wins");
		player.getWinsSound().play();
	}
	
	private void createWinsText(String animNane) { //8
		nameWinsAnim = new Animation(0.1f, AnimationUtils.loadAnim(Assets.getTextureAtlas(), animNane, 2));
		nameWinsAnim.setPlayMode(Animation.LOOP);
		nameWinAnimRenderX = (480 - nameWinsAnim.getKeyFrame(0).getRegionWidth()) / 2;
	}
}
  1. Aktualizujemy czas animacji dodając czas, jaki upłynął od ostatniej klatki
  2. Ustawiamy macierz kamery dla renderer’a box2d. Następnie skalujemy ją razy, 10 dlatego ponieważ wybraliśmy przelicznik 10 pikseli = 1m.
  3. Aktualizacja pozycji kamery oraz limity żeby nie przesuwała się za daleko.
  4. Jeżeli gra wciąż trwa(gameover == false) to aktualizujemy HUD i licznik czasu. Od aktualnego czasu odejmujemy czas rozpoczęcia i dzielimy wszystko, przez 1000 aby uzyskać wynik w sekundach.
  5. Jeżeli czas się skończył a gra jeszcze trwa. Jeżeli jest remis(obie postacie mają tyle samo punktów życia) wywołujemy funkcje, która stworzy migający napis „Draw”. Jeżeli gracz ma więcej życia to wygrywa.
  6. Jeżeli poziom życia gracza(albo przeciwnika) a gra nie została zakończona
  7. Przeciwnik wygrał, ustawiamy gameover na true, tworzymy migający tekst i odtwarzamy dźwięk wygranej, podobnie, gdy wygra gracz
  8. Ta funkcja tworzy migający tekst, który jest animacją z 2 klatek, ustawiamy, aby był zapętlany, i odliczamy gdzie trzeba go wyrenderować aby był na środku.

Zwróci pierwszą klatek animacji

Renderowanie:

@Override
	public void render(SpriteBatch batch) {
		batch.begin();
		
		arena.render(batch);
		player.render(batch);
		enemy.render(batch);
		
		world.step(1 / 60f, 6, 2); //1 
		
		batch.end();
		
		if (box2dDebug) {
			debugRenderer.render(world, box2dRenderMatrix);
		}
		
		hudBatch.begin(); // HUD
		
		if (!fightTextAnim.isAnimationFinished(stateTime)) //2
			hudBatch.draw(fightTextAnim.getKeyFrame(stateTime), 165, 220);
		if (gameover) //3
			hudBatch.draw(nameWinsAnim.getKeyFrame(stateTime), nameWinAnimRenderX, 240);
		playerHealthBar.draw(hudBatch);
		enemyHealthBar.draw(hudBatch);
		Assets.timerFont.draw(hudBatch, Integer.toString(time), 228, 325); //4
		
		hudBatch.end();
	
	}
  1. Aktualizujemy box2d, lepiej zrobić krok po wyrenderowaniu wszystkiego
  2. Renderujemy napis „Fight!” który wyświetla się na początku rundy fightTextAnim.isAnimationFinished(stateTime))
    Tak możemy sprawdzić czy animacja już się zakończyła
  3. Jeżeli gra się skończyła to renderujemy nasz stworzony migający tekst
  4. Renderowanie licznika czasu

Przekonwertuję nam int na string

Pozostałe funkcje:

[code]
@Override
public void dispose() {
arena.dispose();
player.dispose();
enemy.dispose();
world.dispose();
}

@Override
public boolean keyDown(int key) {
	if (!gameover)
		player.keyDown(key);

	if (key == Keys.F12)
		box2dDebug = !box2dDebug;

	return false;
}

@Override
public boolean keyUp(int key) {
	if (!gameover)
		player.keyUp(key);
	return false;
}[/code]

Oczywiście mamy dispose(), i mamy też keyDown() i keyUp(). Jeżeli gra wciąż trwa to przekazują informacje do gracza. Dodatkowo F12 może włączać i wyłączać tryb debugowania box2d.

AbstractFighter

I doszliśmy do największej klasy z tego projektu. Podstawa każdego wojownika. Klasa zawiera sporo getterów i setterów, nie będę ich tutaj zamieszał, bo nie ma to sensu. Zmienne klasy: [code] public abstract class AbstractFighter { public enum State { IDLE, LEFT, RIGHT, JUMP, DUCK, PUNCH, HIT, KICK, END, BLOCK }
private AbstractFighter oponent; // przeciwnik

private static final int movementForceAmount = 4; // predkosc poruszania sie

protected TextureRegion texture; // jedna klatka z głownej animacji dla obliczeń box2d
protected Sound winsSound; // dzwiek odtwarzany przy wygraniu

// Animacje
protected Animation currentAnim; // aktualna animacja

protected Animation stanceAnim; // idle
protected Animation walkAnim; // chodzenie
protected Animation jumpAnim; // skok w gore
protected Animation jumpFAnim; // skok na boki
protected Animation duckAnim; // kucanie
protected Animation punchAnim; // piesci
protected Animation kickAnim; // kopniak
protected Animation hitAnim; // gdy uderzony
protected Animation blockAnim; // blok
protected Animation winAnim; // przy wygraniu
protected Animation loseAnim; // przy przegraniu

private float stateTime; // czas aktualnej animacji
private boolean blockedAnim; // zmienna używane przez niektóre animacje

private int groundLevel; // poziom podłogi
private boolean player = false; // czy ten obiekt jest sterowany przez gracza

private BotInterface bot;

protected float x, y;
protected Body body;

private int health = 100; // życie

// Klawisze
private static final int KEY_JUMP = 0;
private static final int KEY_DUCK = 1;
private static final int KEY_LEFT = 2;
private static final int KEY_RIGHT = 3;
private static final int KEY_PUNCH = 4;
private static final int KEY_KICK = 5;
private static final int KEY_BLOCK = 6;
private boolean[] keys = { false, false, false, false, false, false, false };
private State state = State.IDLE;

long startTime; //dla ciosów
//...

}
[/code]

Zmiennych jest dużo. Mamy tutaj typ wyliczeniowy do aktualnego stanu postaci, przeciwnika, animacje(musi je stworzyć klasa pochodna), pozycje, licznik punktów życie. Jest też tablica keys, która przechowuję, jakie klawisze są wciśnięte. I mamy tez zmienną bot, która będzie sterować przeciwnikiem.
W klasie jest jedna metoda abstrakcyjna: public abstract String getName();
Zwraca ona nazwę postaci.

Konstruktor:

public AbstractFighter(boolean player) { this.player = player; }
Tylko ustawia zmienną player.

Obsługa klawiatury:

public void keyDown(int key) {
		if (player) {
			switch (key) {
			case Keys.UP:
				keys[KEY_JUMP] = true;
				break;
			case Keys.DOWN:
				keys[KEY_DUCK] = true;
				break;
			case Keys.LEFT:
				keys[KEY_LEFT] = true;
				break;
			case Keys.RIGHT:
				keys[KEY_RIGHT] = true;
				break;
			case Keys.A:
				keys[KEY_PUNCH] = true;
				break;
			case Keys.S:
				keys[KEY_KICK] = true;
				break;
			case Keys.Q:
				keys[KEY_BLOCK] = true;
				break;
			default:
				break;
	
			}
		}
	}
	
	public void keyUp(int key) {
		if (player) {
			switch (key) {
			case Keys.UP:
				keys[KEY_JUMP] = false;
				break;
			case Keys.DOWN:
				keys[KEY_DUCK] = false;
				break;
			case Keys.LEFT:
				keys[KEY_LEFT] = false;
				break;
			case Keys.RIGHT:
				keys[KEY_RIGHT] = false;
				break;
			case Keys.A:
				keys[KEY_PUNCH] = false;
				break;
			case Keys.S:
				keys[KEY_KICK] = false;
				break;
			case Keys.Q:
				keys[KEY_BLOCK] = false;
				break;
			default:
				break;
	
			}
		}
	}

Te funkcje wywoływane są przez GameScene.

Fizyka

	public void initPhys(World world) {
		BodyDef bodyDef = new BodyDef();
		bodyDef.type = BodyType.DynamicBody;
		bodyDef.position.set((x + texture.getRegionWidth()) / 10f, (y + texture.getRegionHeight()) / 10f); // samo x i y oznaczalo by dolny lewy rog sprite'a
	
		body = world.createBody(bodyDef);
		body.setFixedRotation(true);
	
		PolygonShape shape = new PolygonShape();
		shape.setAsBox(76 / 2 / 10f, 136 / 2 / 10f);
	
		FixtureDef fixtureDef = new FixtureDef();
		fixtureDef.shape = shape;
		fixtureDef.density = 1f;
		fixtureDef.friction = 100f; // w ogóle nie bedzie sie slizgac
		fixtureDef.restitution = 0f;
	
		body.createFixture(fixtureDef);
	
		shape.dispose();
	}

Oczywiście wszystko dzielimy przez 10 żeby zmienić jednostki.

Opis funkcji mówi nam, że argumenty to jest połowa wysokości i szerokości dlatego dodatkowo dzielimy przez 2.

Mniejsze funkcje:

	public void switchAnimation(Animation newAnim) {
		currentAnim = newAnim; //zmienia aktywną animacje
		stateTime = 0; //resetuje licznik
	}
	
	public void initAi(BotInterface bot) { //ustawia bota ai
		this.bot = bot;
	}
	
	private boolean isOnGround() { //czy postać jest na poziomie terenu
		return y < groundLevel;
	}
	
	public boolean isOponentInRange() { 
		int distance = (int) Math.abs(x - oponent.getX());
	
		return distance < 86;
	}

Zwraca czy przeciwnik w zasięgu. Jest to wykrywane na podstawie dystansu, dlatego może być nie dokładne.

	private void isOponentDead() {
		if (oponent.getHealth() <= 0) {
			state = State.END;
			switchAnimation(winAnim);
		}
	}

Czy przeciwnik nie żyje, jest to sprawdzane zaraz po zadaniu ciosu.

Trafienie

	public void hit(int damage) {
		if (state == State.JUMP || state == State.DUCK) { //1
			return;
		}
	
		Assets.getRandomHitSound().play(); //2
	
		if (bot != null) //3
			bot.beingHit(keys, this);
	
		if (state == State.BLOCK) { //4
			damage /= 2;
		}
	
		health -= damage; //5
	
		if (health > 0) { //6
			state = State.HIT;
			switchAnimation(hitAnim);
		} else {
			state = State.END;
			switchAnimation(loseAnim);
		}
	}
  1. Jeżeli postać aktualnie skacze albo kuca nie robimy nic
  2. Odtwarzamy losowy dźwięk trafienia
  3. Jeżeli bot jest ustawiony to informujemy go o trafieniu
  4. Jeżeli postać aktualnie broni się to zmieszamy damage
  5. Odejmujemy wartość obrażeń od poziomu życia
  6. Jeżeli postać ma jeszcze punkty życia to ustawiamy animacje trafienia, jeżeli nie to animacje gdy się przegra.

Render

public void render(SpriteBatch batch) {
		if (!player && (state == State.PUNCH || state == State.KICK))
			batch.draw(currentAnim.getKeyFrame(stateTime), x - 30, y);
		else
			batch.draw(currentAnim.getKeyFrame(stateTime), x, y);
	}

Render jest trochę dziwny, w większości przypadków wykona się druga instrukcja. Ale jeżeli postać nie jest graczem(jest po prawej stronie) i aktualnie uderza lub kopie. To renderujemy z przesunięciem o 30 pikseli. Wynika to z tego, że tekstury po obróceniu są przesunięte właśnie o 30 pikseli

Update

public void update() {
		stateTime += Gdx.graphics.getDeltaTime(); //1
	
		switch (state) { //2
		case IDLE:
			stateIdle();
			break;
		case RIGHT:
			stateRight();
			break;
		case LEFT:
			stateLeft();
			break;
		case JUMP:
			stateJump();
			break;
		case DUCK:
			stateDuck();
			break;
		case PUNCH:
			statePunch();
			break;
		case KICK:
			stateKick();
			break;
		case HIT:
			stateHit();
			break;
		case BLOCK:
			stateBlock();
			break;
		default:
			break;
	
		}
	
		if (bot != null) //3
			bot.update(keys, this, oponent);
	
		x = body.getPosition().x * 10 - texture.getRegionWidth() / 2; //4
		y = body.getPosition().y * 10 - texture.getRegionHeight() / 2;
	
	}
  1. Aktualizujemy licznik czasu animacji
  2. Zależnie od stanu, w jakim znajduje się postać wywołujemy odpowiednią funckje.
  3. Aktualizujemy bota
  4. Aktualizujemy zmienne do pozycji na podstawie pozycji ciała oczywiście musi pamiętać o pomnożeniu przez 10.

Idle:

private void stateIdle() {
	
		if (keys[KEY_LEFT]) { //1
			state = State.LEFT;
			switchAnimation(walkAnim);
			if (player)
				walkAnim.setPlayMode(Animation.LOOP_REVERSED);
			else
				walkAnim.setPlayMode(Animation.LOOP);
	
			return;
		}
	
		if (keys[KEY_RIGHT]) { //2
			state = State.RIGHT;
			switchAnimation(walkAnim);
			if (player)
				walkAnim.setPlayMode(Animation.LOOP);
			else
				walkAnim.setPlayMode(Animation.LOOP_REVERSED);
	
			return;
		}
	
		if (keys[KEY_DUCK]) { //3
			state = State.DUCK;
			switchAnimation(duckAnim);
			duckAnim.setPlayMode(Animation.NORMAL);
			return;
		}
	
		if (keys[KEY_BLOCK]) { //4
			state = State.BLOCK;
			switchAnimation(blockAnim);
			blockAnim.setPlayMode(Animation.NORMAL);
			return;
		}
	
		if (keys[KEY_PUNCH]) { //5
			state = State.PUNCH;
			switchAnimation(punchAnim);
			startTime = System.currentTimeMillis();
			return;
		}
	
		if (keys[KEY_JUMP] && isOnGround()) { //6
			state = State.JUMP;
			switchAnimation(jumpAnim);
			jumpAnim.setPlayMode(Animation.NORMAL);
			body.setLinearVelocity(0, 0);
			body.applyLinearImpulse(new Vector2(0, 2300), body.getWorldCenter(), true);
	
			return;
		}
	
		if (keys[KEY_KICK]) { //7
			state = State.KICK;
			kickAnim.setPlayMode(Animation.NORMAL);
			switchAnimation(kickAnim);
		}
}
  1. Jeżeli naciśnięta strzałka w lewo, zmieniamy stan postaci i animacje. Zależnie, z której strony znajduję się postać zmieniamy tryb odtwarzania, albo normalne zapętlenie albo zapętlanie w drugą stronę.
  2. Podobnie robimy dla strzałki w prawo.
  3. Kucanie, tutaj jedyne, co musimy zrobić to ustawić tryb odtwarzania na normalny, raz się odtworzy i koniec
  4. Postępujemy tak samo jak przy kucaniu
  5. Tutaj dodatkowo ustawiamy czas rozpoczęcia na aktualny systemowy będziemy tego później potrzebować. Zwróć uwagę, że nie zmieniamy trybu odtwarzania jest on zawszę ustawiony na zapętlanie
  6. Skok, dodatkowo sprawdzamy czy postać jest na ziemi. Jest to skok tylko w górę, dlatego najpierw zerujemy prędkość ciała.
  7. Kopnięcie, podobnie jak kucanie i blok.

Poruszanie w lewo:

private void stateLeft() {
		if (keys[KEY_LEFT]) {
			body.setLinearVelocity(-1 * movementForceAmount, body.getLinearVelocity().y);
		} else {
			state = State.IDLE;
			switchAnimation(stanceAnim);
		}
	
		if (keys[KEY_JUMP] && isOnGround()) {
			state = State.JUMP;
			switchAnimation(jumpFAnim);
			if (player)
				jumpFAnim.setPlayMode(Animation.LOOP_REVERSED);
			else
				jumpFAnim.setPlayMode(Animation.LOOP);
	
			body.applyLinearImpulse(new Vector2(0, 2300), body.getWorldCenter(), true);
			return;
		}
	}

Najpierw sprawdzamy czy lewa strzałka jest jeszcze naciśnieta, jeżeli jest tu ustawiamy prędkość ciała, jeżeni nie to ustawiamy stan idle. Prędkość musi być ujemna, bo postać ma poruszać się w lewo.
Tutaj też sprawdzamy czy został naciśniety klawisz od skoku, jeżeli tak to wykonujemy skok bez zerowania prędkości. Spowoduje to skok w kierunku poruszania się. Ma on swoją osobną animacje jumpF

Poruszanie w prawo:

	private void stateRight() {
		if (keys[KEY_RIGHT]) {
			body.setLinearVelocity(movementForceAmount, body.getLinearVelocity().y);
		} else {
			state = State.IDLE;
			switchAnimation(stanceAnim);
		}
	
		if (keys[KEY_JUMP] && isOnGround()) {
			state = State.JUMP;
			switchAnimation(jumpFAnim);
			if (player)
				jumpFAnim.setPlayMode(Animation.LOOP);
			else
				jumpFAnim.setPlayMode(Animation.LOOP_REVERSED);
			body.applyLinearImpulse(new Vector2(0, 2300), body.getWorldCenter(), true);
			return;
		}
	}

Podobnie dla poruszania się w prawo, tutaj prędkość już nie jest na minusie.

Kucanie:

[code]
private void stateDuck() {

	if (blockedAnim && duckAnim.isAnimationFinished(stateTime)) { //3
		state = State.IDLE;
		switchAnimation(stanceAnim);
		blockedAnim = false;
		return;

	}

	if (blockedAnim) //2
		return;

	if (!keys[KEY_DUCK]) { //1

		switchAnimation(duckAnim);
		duckAnim.setPlayMode(Animation.REVERSED);
		blockedAnim = true;
		return;
	}

}[/code]

Kucanie już jest troszkę bardziej skomplikowane. Po naciśnięciu klawisza animacja normalnie się odtworzy. Nic nie będzie się działa póki nie puścimy klawisza. Gdy puścimy klawisz raz wykona się sekcja 1, tryb odtwarzania zostanie ustawiony na odwrócony(co będzie wyglądać jakby postać wstawała), zostanie też ustawiona zmienna blockedaAnim na true. Teraz dopóki animacja się nie zakończy kod będzie się wykonywał tylko do sekcji 2. Jak animacje się zakończy wykona się sekcja 3.

Blok:

private void stateBlock() {
		if (blockedAnim && blockAnim.isAnimationFinished(stateTime)) {
			state = State.IDLE;
			switchAnimation(stanceAnim);
			blockedAnim = false;
			return;
		}
	
		if (blockedAnim)
			return;
	
		if (!keys[KEY_BLOCK]) {
			switchAnimation(blockAnim);
			blockAnim.setPlayMode(Animation.REVERSED);
			blockedAnim = true;
			return;
		}
	}

Praktycznie to samo, co kucanie

Pięści:

private void statePunch() {
		if (!keys[KEY_PUNCH]) {
			state = State.IDLE;
			switchAnimation(stanceAnim);
			return;
		}
	
		if (System.currentTimeMillis() - startTime > 400) {
			startTime = System.currentTimeMillis();
	
			if (isOponentInRange()) {
				oponent.hit(5);
				isOponentDead();
			}
		}
	}

Postać będzie uderzać póki nie puścimy klawisza. Co 0,4 sekundy, jeżeli przeciwnik jest w zasięgu będziemy zadać 5 punktów obrażeń a potem sprawdzimy czy przeciwnik nie żyje.

Kopniak:

private void stateKick() {
		if (currentAnim.isAnimationFinished(stateTime) && !blockedAnim) { //1
			blockedAnim = true;
			currentAnim.setPlayMode(Animation.REVERSED);
			switchAnimation(currentAnim);
			
			if (isOponentInRange()) {
				oponent.hit(10);
				isOponentDead();
			}
			return;
	
		}
	
		if (currentAnim.isAnimationFinished(stateTime) && blockedAnim) { //2
			blockedAnim = false;
			state = State.IDLE;
			switchAnimation(stanceAnim);
		}
	}

Najpierw wykona się sekcja pierwsza, gdy animacja się zakończy. Zmienimy jej tryb odtwarzania na odwrócony i puścimy ją jeszcze raz, jeżeli przeciwnik będzie w zasięgu zadamy mu obrażenia. Gdy animacja się zakończy wykona się sekcja druga.

Skok:

private void stateJump() {
		if (isOnGround() && body.getLinearVelocity().y == 0) {
			state = State.IDLE;
			switchAnimation(stanceAnim);
		}
	}

Przy skoku sprawdzamy tylko czy się skończył. Aby to zrobić sprawdzamy czy postać jest na ziemi i czy jej prędkość w pionie wynosi 0.

Trafienie:

private void stateHit() {
		if (currentAnim.isAnimationFinished(stateTime)) {
			state = State.IDLE;
			switchAnimation(stanceAnim);
		}
	}

Sprawdzamy tylko czy animacja się zakończyła.

To tyle w tej klasie.

LiuKang – przykładowa postać

[code] public class LiuKang extends AbstractFighter { TextureAtlas atlas;
public LiuKang(boolean player) {
	super(player);
	atlas = new TextureAtlas(Gdx.files.internal("gfx/fighters/liukang.pack"));
	winsSound = Gdx.audio.newSound(Gdx.files.internal("sounds/liukangwins.mp3"));

	texture = new TextureRegion(atlas.findRegion("walk0"));

	if (player) {
		stanceAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "stance", 8));
		walkAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "walk", 8));
		jumpAnim = new Animation(0.3f, AnimationUtils.loadAnim(atlas, "jump", 1));
		jumpFAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "jumpf", 7));
		duckAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "duck", 3));
		punchAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "punch", 7));
		kickAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "kick", 5));
		hitAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "hit", 3));
		winAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "win", 15));
		loseAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "lose", 8));
		blockAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "block", 3));
	} else {
		stanceAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "stance", 8, true));
		walkAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "walk", 8, true));
		jumpAnim = new Animation(0.3f, AnimationUtils.loadAnim(atlas, "jump", 1, true));
		jumpFAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "jumpf", 7, true));
		duckAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "duck", 3, true));
		punchAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "punch", 7, true));
		kickAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "kick", 5, true));
		hitAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "hit", 3, true));
		winAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "win", 15, true));
		loseAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "lose", 8, true));
		blockAnim = new Animation(0.1f, AnimationUtils.loadAnim(atlas, "block", 3, true));

	}

	stanceAnim.setPlayMode(Animation.LOOP);
	jumpFAnim.setPlayMode(Animation.LOOP);
	punchAnim.setPlayMode(Animation.LOOP);

	switchAnimation(stanceAnim);
}

@Override
public void dispose() {
	super.dispose();
	atlas.dispose();
	winsSound.dispose();
}

@Override
public String getName() {
	return "liukang";
}

}
[/code]

Stworzenie postaci jest proste, w konstruktorze ładujemy atlas z teksturami i dźwięk wygranej.
Tworzymy jedną teksturę, która jest używana w obliczeniach. Później mamy sekcje, która wczytuje animacje. Jeżeli postać jest po lewej stronie ładujemy animacje odwrócone. Ustawiamy tryb odtwarzania dla niektórych animacji. Oczywiście mamy też dipose() i getName() zwraca nazwę te postaci.

Ai – sztuczna inteligencja

Sztuczna inteligencja w tym projekcie jest bardzo prosta większość działań bota jest wybierana losowo. Mamy interfejs, który musi implementować bot: [code] public interface BotInterface { public void update(boolean[] keys, AbstractFighter controlled, AbstractFighter oponent); public void beingHit(boolean[] keys, AbstractFighter controlled); } [/code] Funkcja update() wywoływana jest przy aktualizacji postaci, a beingHit() przy trafieniu.

RandomBot – przykład AI

[code] public class RandomBot implements BotInterface { // Klawisze private static final int KEY_JUMP = 0; private static final int KEY_DUCK = 1; private static final int KEY_LEFT = 2; private static final int KEY_RIGHT = 3; private static final int KEY_PUNCH = 4; private static final int KEY_KICK = 5; private static final int KEY_BLOCK = 6;
@Override
public void update(boolean[] keys, AbstractFighter controlled, AbstractFighter oponent) {
	if (controlled.getState() == AbstractFighter.State.JUMP) { //1
		keys[KEY_JUMP] = false;
		keys[KEY_RIGHT] = false;
		keys[KEY_LEFT] = false;		

}

	if (controlled.getState() == AbstractFighter.State.BLOCK)
		keys[KEY_BLOCK] = false;

	if (controlled.getState() == AbstractFighter.State.KICK)
		keys[KEY_KICK] = false;

	if (controlled.getState() == AbstractFighter.State.DUCK && MathUtils.random(100) == 10) //2
		keys[KEY_DUCK] = false;

	if (controlled.getState() == AbstractFighter.State.PUNCH) { 
		if (controlled.isCurrentAnimFinished() && MathUtils.random(10) == 10) {
			keys[KEY_PUNCH] = false;
		}
	}

	if (!controlled.isOponentInRange()) { //3
		keys[KEY_LEFT] = true;
	} else {
		keys[KEY_LEFT] = false;
	}

	if (controlled.isOponentInRange()) { //4
		if (oponent.getState() == AbstractFighter.State.BLOCK || oponent.getState() == AbstractFighter.State.DUCK)
			return;

		switch (MathUtils.random(10)) { //5
		case 2:
			keys[KEY_PUNCH] = true;
			break;
		case 5:
			keys[KEY_KICK] = true;
			break;
		default:
			break;

		}
	}
}

@Override
public void beingHit(boolean[] keys, AbstractFighter controlled) {
	switch (MathUtils.random(7)) {
	case 2:
		keys[KEY_BLOCK] = true;
		break;
	case 3:
		keys[KEY_JUMP] = true;
		break;
	case 4:
		keys[KEY_JUMP] = true;
		keys[KEY_RIGHT] = true;
		break;
	case 5:
		keys[KEY_DUCK] = true;
		break;
	default:
		break;

	}
}

}
[/code]

Kontrola opiera się na zmienianiu tablicy keys, dlatego potrzebujemy jeszcze raz stałych do klawiszy. Zacznę od funkcji beingHit() bo jest prostsza, jak już wspomniałem wszystko opiera się na losowości, bot losuje jakąś liczbę i zależnie od tego co wylosował podejmie jakieś działanie albo nie zrobi nic.

Teraz update(), jeżeli zmieniliśmy coś w tablicy keys później musimy to cofnąć, tym głównie zajmuje się ta funkcja:

  1. Gdy postać skoczyła musi zmienić na false wszystkie klawisze, które mogły przy tym być zmienione.
  2. Podobnie jak wyżej, ale dodatkowo losujemy żeby od razu nie wstawać
  3. Jeżeli przeciwnik nie jest w zasięgu to zbliżamy się do niego.
  4. Jeżeli przeciwnik aktualnie kuca lub blokuję to nie robimy nic.
  5. Podejmujemy losowo jakieś działanie.

Zakończenie

To na razie tyle, projekt jest bardzo prosty i na pewno można go rozwijać, zaimportować więcej postaci, poziomów, zrobić menu, więcej ciosów itp.

#2

@Kotcrab link do http://www.kotcrab.pl/libgdx-05-system-scen/ jest martwy(404), czy ten tekst jest jeszcze gdzieś dostępny?


#3

Tutaj w ‘surowej’ postaci: http://dl.kotcrab.com/blog_old/Libgdx_05_System_Scen.html. Nie mam nic przeciwko żeby to skopiować tutaj. Reszta starych poradników też tam jest jak by była potrzebna.