Tiled

Tiled on kasutajasõbralik programm 2D kaartide loomiseks.

See võimaldab:

  • Hõlpsasti määrata/muuta kaartide suurusi.

  • Tõmmata alla kõikvõimalikke kujunduselemente.

  • Kõige peamine: selle abil saab väga lihtsasti hallata collisioneid.

Tilediga alustamine

1. Mine siia leheküljele ning tõmba endale Tiled alla.

2. Kui oled programmi käima tõmmanud, siis:

  • Vali New Map… ning seejärel saad valida kaardi ja tile’ide suuruseid. Enamik internetist leitavaid tilesete on küll mõeldud 32x32px suurustele tile’idele, aga nii tile’ide kui kaardi suurust on hiljem võimalik väga lihtsasti muuta.

Järgmiseks peaks avanema tühi ruudustik ja kaardil on üksainus layer.

3. Kõigepealt peame lisama oma projektile tileseti, mis näeb välja midagi sellist:

tileset

Tilesetid koosnevad tile’idest ning ongi sisuliselt need, millest me oma kaardi visuaalse poole ehitame.

Mõned lingid, kust saab tilesete alla laadida:

Samuti on võimalik kujundada ise oma tilesete programmidega nagu Blender, Inkscape või GIMP, aga see on üpriski aeganõudev tegevus ning internetis on väga lai valik erinevaid tasuta saadaval olevaid sete.

4. Kui oled midagi välja valinud ning alla laadinud, siis:

  • Mine File -> New -> New Tileset, ning lisa sinna oma tileset.

Sulle võiks vastu vaadata midagi sellist:

1

Nagu näed, siis minu tilesetis on olemas:

  • Tausta jaoks mõeldud ruudud.

  • Ruudud, millest saab ehitada erinevaid objekte (lombid, trepid, kastid jms).

Tilesete võid enda kaardile lisada mitu ning neid saab vabalt omavahel kombineerida.

Tiled ei erista neid ruute aga omavahel kuidagi ning seetõttu peame lisama oma kaardile juurde uue layeri.

  • Kui ehitaksime tausta valmis ja hakkaksime sama kihi peale objekte laduma, siis joonistataks taustal olevad ruudud lihtsalt üle.

5. Paremal tileset’ide menüü kohal on koht layerite haldamiseks. Vali sealt uus tile layer:

2
  • Esimesele layerile panemegi taustaks mõeldud tile’id (näiteks muru, vett, mulda jms).

Kui soovid suuremaid alasid katta mingit ühte sorti tile’iga, siis selleks on väga kasulik bucket fill tööriist, mis täidab kõik kaardil veel täitmata alad:

3

6. Seejärel vajuta äsjaloodud layeri peale ning saad taustale hakata lisama objekte.

  • Objektide üksteise otsa ladumiseks korda seda sammu nii mitu korda kui vaja.

7. Kui oled ära märgistanud kõik soovitud objektid, siis salvesta kaart enda projekti assets kausta.

  • Samuti lisa sinna kõik tilesetid, mida kaardi loomisel kasutasid.

  • Kaart peaks sulle salvestuma .tmx failina.

Arvatavasti on Sul juba olemas mingisugune GameScreen (võiks implementida Screen interface’i) klass, mis haldab Sinu mängus toimuvat.

8. Klassi konstruktorisse on vaja nüüd lisada järgnevad väljad:

private final TiledDemo tiledDemo;
public static OrthographicCamera camera;
private final FitViewport viewport;
private final TmxMapLoader mapLoader;
private final TiledMap map;
private final OrthogonalTiledMapRenderer renderer;

kus

this.camera = new OrthographicCamera();
this.viewport = new FitViewport(800,400, camera);
this.mapLoader = new TmxMapLoader();
this.map = mapLoader.load("demomap.tmx");
this.renderer = new OrthogonalTiledMapRenderer(map);
this.camera.position.set(viewport.getWorldwidth() / 2, viewport.getWorldHeight() / 2, 0);
  • Kaamera: saab liigutada, pöörata, zoomida, fikseerida mängijale jne. Positsioneerime selle enda kaardi keskele.

On olemas ka PerspectiveCamera(), kuid erinevalt perperspektiivkaamerast, ei võta ortograafiline kaamera perspektiivi arvesse, mis tähendab, et objektid kuvatakse nende tegelikus suuruses, olenemata nende kaugusest kaamerast.

  • ViewPort: aitab skaleerida mängu ekraani suuruse järgi.

ViewPorte on mitmeid, mina kasutan FitViewport().

FitViewport(worldWidth, worldHeight, camera) tagab, et kuvatav sisu mahub hästi vaatesse, olenemata ekraani suuruse või aspect ratio muutustest.

  • MapLoader: laeb kaardi mängu.

  • MapRenderer: renderdab kaardi.

9. Update meetodisse lisa camera.update() ning renderer.setView(camera).

Kui tahad kaardil ka ringi liikuda, aga ringi liikumise loogikat Sul veel pole, siis tee mingi lihtne input handling meetod, mis reageerib näiteks ekraanipuudutusele.

Näiteks võid teha midagi sellist, et liikuda kaameraga mööda x ja y telge:

public void handleinput(float delta) {
   if (Gdx.input.isTouched()) {
      this.camera.position.x += 100 * delta;
      this.camera.position.y += 50 * delta;
   }
}

Screen interface’i implementeerimisel tuleb kaasa käputäis erinevaid @Override annotatsiooniga meetodeid.

10. Otsi üles render() meetod ja lisa sinna järgnevad väljad, kui sul neist mõnda veel ei ole:

@Override
 public void render(float delta) {
     update(delta);
     Gdx.gl.glClearColor(1, 1, 1, 1);
     Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

     this.renderer.render();

     this.game.batch.setProjectionMatrix(camera.combined);
     this.camera.update();
 }

11. Pildil on kasutatud muutujat game, mille peaksid konstruktorisse lisama kujul:

  • this.game = game

kus game on teie mängu põhiklass. Lisa see argumendina GameScreen klassi konstruktorisse.

Mängu põhiklass peaks extendima LibGDX sisseehitatud klassi Game.

12. Kirjelda klassis muutuja:

  • Game game

13. Juurde tekkinud @Override annotatsiooniga create() meetodisse lisa rida:

  • game = this;

Põhiklass võiks välja näha midagi sellist:

public class Demo extends Game {
   public SpriteBatch batch;
   public static final float PPM = 120;


   @Override
   public void create () {
      this.batch = new SpriteBatch();
      setScreen(new GameScreen(this));
   }

   @Override
   public void render () {
      super.render();
   }

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

Kui proovid oma mängu tööle panna, siis peaks Sulle kuvatama sinu kaart.

Suure tõenäosusega pead muutma viewportis worldWidth ja worldHeight väärtusi nagu minul juhtus:

8

Collisionid

14. Lisame enda kaardile veel ühe layeri, seekord aga collisionite jaoks:

9

15. Ülevalt menüüst saad valida sobiva kujundi ning selle abil hakata tõmbama jooni ümber kõikide objektide, millega sa tahad, et maailmas olevad mängijad, vaenlased jms kokku põrkaksid.

  • Vastasel juhul saab nendest lihtsalt üle kõndida nagu tavalisest taustaks mõeldud ruutudest.

10
11

Pro tip: Vaikimisi saavad mängijad, vastased, kaamera jms vabalt kaardilt välja liikuda. Selle vältimiseks on kõige lihtsam viis teha piisavalt suur kaart ning piirata selle sees mängimiseks mõeldud ala object layeri objektidega.

LibGDX-is on vägagi mõistlik kasutada Box2D füüsikaliidest, mille abil saab hallata:

  • Collisioneid

  • Mängija liikumist

  • Gravitatsiooni (nt platformerites) jne.

Selle jaoks tuleb build.gradle faili vajalik dependency lisada. (Sõber GPT aitab)

16. Järgmiseks täiendame veel oma GameScreen klassi konstruktorit:

private final World world;
private final Box2DDebugRenderer b2dr;
this.world = new World(new Vector2(0, 0), true);
this.b2dr = new Box2DDebugRenderer();
  • World defineeribki meile Box2D maailma, milles eksisteerib füüsika.

    World muutujas on Vector 2 gravitatsiooni määramiseks ning doSleep selleks, et säästa aega nende objektide pealt, mis on staatilised.

Selle maailma sees on kehad (body) ja objektid (fixture). Fixture kinnitatakse keha külge, et anda talle kuju, võime teiste kehadega kokku põrgata jne.

  • Box2DDebugRenderer renderdab kehasid ja objekte.

17. Mängu kehade ja objektide lisamiseks tee uus klass, näiteks Box2DWorldGenerator, kuhu anna kaasa GameScreen klassi.

Mina teen seda oma GameScreeni konstruktoris:

BodyDef bdef = new BodyDef();
PolygonShape shape = new PolygonShape();
FixtureDef fdef = new FixtureDef();
Body body;

for (MapObject object : map.getLayers().get(2).getObjects().getByType(RectangleMapObject.class)) {
    Rectangle rectangle = ((RectangleMapObject) object).getRectangle();

    bdef.type = BodyDef.BodyType.StaticBody;
    bdef.position.set((rectangle.getX() + rectangle.getWidth() / 2) / Demo.PPM, (rectangle.getY() + rectangle.getHeight() / 2) / Demo.PPM);

    body = world.createBody(bdef);

    shape.setAsBox(rectangle.getWidth() / 2 / Demo.PPM, rectangle.getHeight() / 2 / Demo.PPM);
    fdef.shape = shape;
    body.createFixture(fdef);
}
  • BodyDefi kasutatakse Box2D-s keha omaduste määratlemiseks.

    See sisaldab atribuute nagu keha tüüp (staatiline, kineetiline, dünaamiline), asend, kaal, kiirus jne.

  • PolygonShape on Box2D klass, mida kasutatakse keha külge kinnitatud objekti kuju määratlemiseks.

  • FixtureDefi kasutatakse objekti omaduste (nt kuju, tiheduse, hõõrdumise jne) määratlemiseks. See “kinnitatakse” keha külge.

For loopi abil hakkame looma kehasid objektidest, mis me varem Tiled abil enda kaardile lõime.

Nagu näed, siis mina saan enda objektid teisest layerist: loendamist alustatakse 0st ja minu kaardis on kokku 3 layerit, millest objektide layer on viimane.

Iga teises layeris oleva RectangleMapObject jaoks otsib see ristküliku, mis määrab objekti piirid.

(Hiljem saad lisada teise samasuguse loopi ka teiste kujundite jaoks, näiteks ringikujuliste kehade loomiseks.)

  • bdef.type = BodyDef.BodyType.StaticBody: määrab keha tüübiks StaticBody, mis ei liigu ega reageeri jõududele.

Seejärel määrame keha asukoha ristküliku keskele ja shape saab enda kujuks pool ristküliku laiusest ja pikkusest. Määrame objekti kujuks shape’i.

Lõpuks loome keha, kasutades enne valmis tehtud fdefi.

20. Lisaks peame render() meetodisse lisama read:

this.b2dr.render(world, camera.combined);
this.b2dr.setDrawBodies(true);

this.b2dr.setDrawBodies(true);:

  1. Renderdab meie objektidele ümber jooned, et saaksime paremini vajadusel debug-ida.

  2. Kui sulgudesse panna false, saame jooned peita.

Mängu jooksutamisel võiksid nüüd ruudukujuliste objektide ümber olla sellised jooned:

16

Arvatavasti on sul olemas juba mingi klass mängija jaoks.

18. Mängija klassi võiksid nüüd teha järgnevad muudatused:

  • Uued muutujad b2body (klass Body).

  • World Konstruktor võiks olla midagi sellist (lisaks olemasolevale loogikale):

public Player(World world) {
    this.world = world;
    definePlayer();
}

19. Uus meetod definePlayer().

Teeme midagi sarnast, mis enne objektide genereerimisega:

private void definePlayer() {
 BodyDef bdef = new BodyDef();
 bdef.position.set(32 / Demo.PPM, 32 / Demo.PPM);
 bdef.type = BodyDef.BodyType.DynamicBody;
 b2body = world.createBody(bdef);
 FixtureDef fdef = new FixtureDef();
 CircleShape shape = new CircleShape();
 shape.setRadius(10 / Demo.PPM);

 fdef.shape = shape;
 b2body.createFixture(fdef);
}

Näed, et igal pool on arvulised väärtused jagatud muutujaga PPM.

PPM ehk pixels per meter on konstant, mille võiksid defineerida oma mängu põhiklassis (väärtus võiks olla nt 100).

Kuna Box2D engine kasutab meetreid, siis peame skaleerima oma mängu kasutama väiksemaid suurusi.

Näiteks oleks meie b2body raadius ilma PPMita 10 meetrit.

20. Nüüd navigeeri uuesti GameScreen klassi ja jaga seal ka läbi PPMiga järgnevad väärtused:

this.viewport = new FitViewport(Gdx.graphics.getWidth() / Demo.PPM,
Gdx.graphics.getHeight()/ Demo.PPM, camera);

this.renderer = new OrthogonalTiledMapRenderer(map, 1 / Demo.PPM);

this.bdef.position.set((rectangle.getX() + rectangle.getWidth() / 2) /
Demo.PPM, (rectangle.getY() + rectangle.getHeight() / 2) / Demo.PPM);

this.shape.setAsBox(rectangle.getWidth() / 2 / Demo.PPM,
this.rectangle.getHeight() / 2 / Demo.PPM);

Kui sul seda veel pole, siis võiksid luua mängijale liikumisloogika.

Kui sul see juba on, siis võiksid seda muuta nii, et liigutad b2body, rakendades sellele applyLinearImpulse meetodit.

21. Nüüd peame lisama update() meetodisse:

this.player.b2body.setLinearVelocity(Vector2.Zero);

sest vastasel juhul hõljub mängija keha mööda maailma ringi ning ta reageerib kasutaja sisendile vaevumärgatavalt.

22. Samuti lisame rea:

this.world.step(1/60f, 6, 2);

See määrab kui kiiresti Su mängus igasuguseid kokkupõrkeid jms handlitakse. Selle väärtusi pole otseselt muuta vaja.

23. Viimaseks lisame kaks rida, mis fikseerivad kaamera mängija peale:

this.camera.position.x = player.b2body.getPosition().x;
this.camera.position.y = player.b2body.getPosition().y;

Nüüd jooksuta jälle oma mängu ning:

  • Peaksid kaardil nägema ringi, millega saad ringi liikuda.

  • Kaamera peaks olema fikseeritud ringile.

  • Ring peaks collide'ima objektidega, mille enne genereerisime.

20

Fog of War

Fog of War on kontseptsioon, millega kirjeldatakse kaardil varjatud alasid. Seda kasutatakse sageli reaalaja strateegiamängudes, kuid ka käigupõhistes mängudes.

../_images/civilization.png

Paljud 4X (eXplore, eXpand, eXploit, eXterminate) mängud kaasaarvatud “Civilization” kasutab “Fog of war” efekti.

"Fog of war" lisab mängule:

  • Ajendi mängukaarti avastada, et suurendada oma piiratud teadmisi selle kohta.

  • Realistlikkust: Simuleeritakse lahinguvälja, kus maastiku omadused ja vaenlaste asukohad selguvad alles luure käigus.

  • Põnevust: Tundmatud piirkonnad avanevad järk-järgult, samal ajal kui vaenlaste positsioon ja väesuurus on vaid ajutised teadmised, mis võivad muutuda, kui mängija nähtavusalast lahkub.

../_images/chess.png

"Dark chess" mängus näeb mängija vaid mänguruute, kuhu on lubatud oma malend viia.

Fog of War top-down tüüpi kaardil

Kaardi piiride loomiseks ja hiljem külastatud kohtade tähistamiseks on vaja teada tileide mõõtmeid (pikkust ja laiust) ning nende koguarvu. Selle teabe leiame TMX-faili algusest.

../_images/fow.png

Need mõõtmed saab salvestada kas eraldi muutujatena:

  • int mapWidth

  • int mapHeight

  • int tileWidth

  • int tileHeight

Või loome nende jaoks enumi klass:

public enum MapConfig {
   MAP_WIDTH(20),
   MAP_HEIGHT(15),
   TILE_WIDTH(16),
   TILE_HEIGHT(16);

   private final int value;

   MapConfig(int value) {
       this.value = value;
   }

   public int getValue() {
       return value;
   }
}

Nüüd saab näiteks Player klassis defineerida kaardi piirid. Selleks loome meetodi defineMapBounds ning kutsume seda meetodit välja Player klassi konstruktoris. Piiride määramiseks kasutame ChainShape klassi, mis moodustab suletud kontuuri. Selleks kasutame ChainShape liidese meetodit createLoop, mis ühendab tipud suletud ahelaks.

 private void defineMapBounds() {
    BodyDef bdef = new BodyDef();
    bdef.type = BodyDef.BodyType.StaticBody;

    ChainShape shape = new ChainShape();
    float rightDown = MapConfig.MAP_HEIGHT.getValue() * MapConfig.TILE_HEIGHT.getValue() / MyGame.PPM;
    float rightUp = MapConfig.MAP_WIDTH.getValue() * MapConfig.TILE_WIDTH.getValue() / MyGame.PPM;
    shape.createLoop(new float[]{
        0, 0,
        rightUp, 0,
        rightUp, rightDown,
        0, rightDown
    });

    FixtureDef fdef = new FixtureDef();
    fdef.shape = shape;

    world.createBody(bdef).createFixture(fdef);
    shape.dispose();
}

Lisame Player klassi ka meetodid mängija x- ja y-koordinaatide saamiseks, et neid kasutada põhimänguklassis (mis pärib Screen liidest), “Fog of War”’i efekti tekitamiseks.

public float getX() {
    return b2body.getPosition().x;
}
public float getY() {
    return b2body.getPosition().y;
}

Lisame põhimänguklassi järgmised muutujad ning ka mapWidth, mapHeight, tileWidth ja tileHeight muutujaid, kui ei loonud enum klassi.

 private static final int VISION_RADIUS = 1;  // määratlemiseks, kui mitut ``tile``\ ‘i mängija näeb igas suunas.
 private boolean[][] visibilityMap;  // näitamaks mis x- ja y-koordinaadi ``tile`` \’idel oleme käinud.
 //  int mapWidth;
 //  int mapHeight;
 //  int tileWidth = 16;
 //  int tileHeight = 16;
int mapWidth = MapConfig.MAP_WIDTH.getValue();
int mapHeight = MapConfig.MAP_HEIGHT.getValue();
int tileWidth = MapConfig.TILE_WIDTH.getValue();
int tileHeight = MapConfig.TILE_HEIGHT.getValue();

Põhimängukonstruktoris initsialiseerime visibilityMap muutuja vastavalt kaardi mõõtmetele ning ka kaardimõõtmete muutujad kui me ei võta neid enumi klassist.

public Maze(MyGame game) {
    //this.mapWidth = map.getProperties().get("width", Integer.class);  // mul 20
    //this.mapHeight = map.getProperties().get("height", Integer.class); // mul 15
    this.visibilityMap = new boolean[mapWidth][mapHeight];
}

renderFogOfWar meetodis kasutame ShapeRenderer klassi, et joonistada mustad ristkülikud tile‘idele, kus mängija pole veel käinud.

private void renderFogOfWar() {
    ShapeRenderer shapeRenderer = new ShapeRenderer();
    shapeRenderer.setProjectionMatrix(camera.combined);
    shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);

    for (int x = 0; x < mapWidth; x++) {
        for (int y = 0; y < mapHeight; y++) {
            if (!visibilityMap[x][y]) {
                shapeRenderer.setColor(0.0f, 0.0f, 0.0f, 0.1f);
                shapeRenderer.rect(x * tileWidth / game.getPPM(), y * tileHeight / game.getPPM(), tileWidth / game.getPPM(), tileHeight / game.getPPM());
            }
        }
    }
    shapeRenderer.end();
    shapeRenderer.dispose();
}

updateFogOfWar meetodis uuendame tile‘ide nähtavust mängija hetkese x- ja y-koordinaati järgi nii kaugele, kui on määratud VISION_RADIUS põhjal.

private void updateFogOfWar(float playerX, float playerY) {
    int tileX = (int) (playerX * game.getPPM() / tileWidth);
    int tileY = (int) (playerY * game.getPPM() / tileHeight);

    for (int x = tileX - VISION_RADIUS; x <= tileX + VISION_RADIUS; x++) {
        for (int y = tileY - VISION_RADIUS; y <= tileY + VISION_RADIUS; y++) {
            if (x >= 0 && x < visibilityMap.length && y >= 0 && y < visibilityMap[0].length) {
                double distance = Math.sqrt((tileX - x) * (tileX - x) + (tileY - y) * (tileY - y));
                if (distance <= VISION_RADIUS) {
                    visibilityMap[x][y] = true;
                }
            }
        }
    }
}

render funktsiooni on nüüd vaja lisada nii updateFogOfWar kui ka renderFogOfWar meetodi välja kutsumine. Kuna SpriteBatch ja ShapeRenderer ei saa korraga aktiivsed olla (SpriteBatch joonistab tekstruure ja ShapeRenderer kujundeid), peame renderFogOfWar meetodi välja kutsuma peale batch.end toimingut.

@Override
public void render(float delta) {
    updateFogOfWar(player.getX(), player.getY());
    // vahepeal on batch.begin() ja batch.end()
    renderFogOfWar();
}
../_images/fow.gif