Tartalomjegyzék

RPG játék

Térkép felépítése

Tiled map editor segítségével:

  1. térkép létrehozása
  2. tileset betöltése
  3. tile layer-ek létrehozása: Egyszerű képi információkat tartalmaznak.
  4. object layer-ek létrehozása: A felhasználó számára közvetlenül nem látható objektumokat tartalmaznak, melyeknek saját tulajdonságai lehetnek. Az objektumokat a játékprogram fogja feldolgozni (pl. spawner-ek, portálok, effektek, savepoint-ok létrehozása céljából).
  5. térkép exportálása
    1. az exportált .tmx fájlt később lehet szerkeszteni Tiled-ban
    2. az exportált .json fájlt a Phaser közvetlenül be tudja tölteni

Térkép betöltése, játékos mozgatása

  1. asset-ek betöltése
  2. tilemap létrehozása
  3. tileset betöltése
  4. layer-ek létrehozása, ütközések beállítása
  5. térkép metaadatok kinyerése (spawner objektumok)
  6. játékos követése a kamerával

Spawner osztály

Hozzunk létre egy új fájlt a js/classes mappában, spawner.js néven, a következő tartalommal:

class Spawner {
    constructor(config, clock, spawnLocations, addObject, deleteObject) {
        this.id = config.id;
        this.spawnInterval = config.spawnInterval;
        this.limit = config.limit;
        this.objectType = config.objectType;
        this.clock = clock;
        this.spawnLocations = spawnLocations;
        this.addObject = addObject;
        this.deleteObject = deleteObject;

        this.objectsCreated = [];

        this.objectId = 1;
    }
}

Ez az osztály fogja szabályozni a spawner-ek működését. Az osztály konstruktora a következő adatokat fogadja:

Az objectsCreated tömb a spawner által létrehozott aktív objektumokat tartalmazza, az objectId pedig az első létrehozandó objektum egyedi azonosítóját.

Az osztályon belül hozzunk létre egy start metódust, mely az objektumok spawn-olását fogja időzíteni:

start() {
        this.interval = this.clock.addEvent({
            delay: this.spawnInterval,
            loop: true,
            callback: () => {
                if (this.objectsCreated.length < this.limit) {
                    this.spawnObject();
                }
            }
        });
    }

A konstruktorban, az adattagok inicializálását követően hívjuk is meg a létrehozott metódust: this.start();

A spawnObject metódus az objectType adattagnak megfelelő objektumot fog létrehozni (egyelőre a Spawner osztály csak ládák létrehozását támogatja):

spawnObject() {
        if (this.objectType === 'CHEST') {
            this.spawnChest();
        }
    }

A spawnChest metódus kisorsolja, hogy a spawner melyik lokációra spawnolja a ládát, majd létrehozza a láda adatait. Minden láda egyedi azonosítót kap, mely a spawner id-ból, és egy generált objektum azonosítóból áll. Ezen kívül minden ládához tartozik egy coins érték. A létrehozott ládát a Spawner osztály objectsCreated tömbjében eltároljuk, majd meghívjuk a konstruktorban kapott addObject callback függvényt (ez a GameScene osztályban lesz később implementálva, és a láda tényleges megjelenítéséért fog felelni).

spawnChest() {
        const location = this.pickRandomLocation();
        const newChest = {
            id: `${this.id}-${this.getNewObjectId()}`,
            spawnerId: this.id,
            x: location[0],
            y: location[1],
            coins: Phaser.Math.RND.between(10, 20)
        };
        this.objectsCreated.push(newChest);
        this.addObject(newChest);
    }

A lehetséges lokációk közül a pickRandomLocation metódus fog választani. Amennyiben a létrehozott objektumok között már található olyan, ami a kisorsolt lokáción található (Array.prototype.some() függvény működése), akkor újabb pozíció sorsolódik rekurzív módon, addig, amíg egy szabad helyet találunk:

pickRandomLocation() {
        const location = Phaser.Math.RND.pick(this.spawnLocations);
        const invalidLocation = this.objectsCreated.some((obj) => obj.x === location[0] && obj.y === location[1]);
        return invalidLocation ? this.pickRandomLocation() : location;
    }

A getNewObjectId metódus eggyel növeli az objectId-t, majd visszaadja az értéket, így egyedi azonosítót generál:

getNewObjectId() {
        return ++this.objectId;
    }

Ezen kívül szükségünk lesz a későbbiekben a spawnolt objektumok törlésére (pl. ha a játékos kinyitotta a ládát), ehhez is létrehozunk egy metódust, mely az objektum azonosítója alapján törli a megfelelő objektumot.

removeObject(id) {
        this.objectsCreated = this.objectsCreated.filter(obj => obj.id !== id);
    }

Ahhoz, hogy a Spawner osztályt ténylegesen használni tudjuk, hivatkozzunk rá az index.html fájlban, még a scene-k betöltése előtt:

<script src="js/classes/spawner.js"></script>

Ládák spawn-olása

A ládák spawnolása a Spawner osztályban most már megtörténik, viszont itt még csak a ládákhoz tartozó adatok (id, pozíció, tárolt coin-ok száma) kerülnek létrehozásra. A ládák tényleges megjelenítését a GameScene fogja végezni.

A scenes/game.scene.js fájlban szeretnénk tárolni a létrehozott spawnereket, ezért az init metódusban hozzunk létre számukra egy új objektumot:

this.spawners = {};

Ezen kívül egy setupSpawners metódust is létre fogunk hozni, mely a láda lokációk alapján hozza létre a spawner-eket (Object.keys() függvény működése):

gameScene.setupSpawners = function () {
  Object.keys(this.chestLocations).forEach(id => {
    const config = {
      id: `chest-${id}`,
      spawnInterval: 3000,
      limit: 3,
      objectType: 'CHEST'
    };
    const spawner = new Spawner(
      config,
      this.time,
      this.chestLocations[id],
      (chest) => this.addChest(chest),
      (id) => this.deleteChest(id)
    );
    this.spawners[spawner.id] = spawner;
  });
};

A setupSpawner metódust hívjuk meg a create metódusban, közvetlenül a player spawn-olása után! Emellett létre kell hoznunk egy Physics Group-ot is, melyben a láda sprite-okat fogjuk tárolni:

this.setupSpawners();

this.chests = this.physics.add.group();

A spawner-ek létrehozásakor hivatkoztunk az addChest és deleteChest metódusokra is, hozzuk létre ezeket is:

gameScene.addChest = function (chestData) {
  console.log('Spawning', chestData);
  this.chests[chestData.id] = chestData;
  const chest = this.add.sprite(chestData.x * this.scale, chestData.y * this.scale, 'items', 0);
  chest.config = {
    coins: chestData.coins,
    id: chestData.id
  };
  chest.setScale(this.scale);
  this.chests.add(chest, true);
};

gameScene.deleteChest = function (chestId) {
  delete this.chests[chestId];
};

A chest sprite létrehozásakor egy config objektumot rendeltünk a sprite-hoz, mely a láda azonosítóját és a benne tárolt coinok számát tárolja.

Akkor dolgoztunk jól, ha 3 másodperc elteltével a konzolon látjuk, hogy különböző id-kkal új ládák kerültek létrehozásra, és a pályán ezek meg is jelentek.

Ládák begyűjtése

Következő lépésként, amikor a láda és a játékos átfedésbe kerül, akkor szeretnénk a ládában tárolt pontokat jóváírni, a ládát eltüntetni, majd helyette új ládát spawnolni. Ennek vizsgálatához adjuk hozzá a következő sort a create metódus megfelelő részéhez:

this.physics.add.overlap(this.player, this.chests, (player, chest) => this.collectChest(player, chest));

A collectChest metódust a következőképpen implementáljuk. Ha a hivatkozott láda ténylegesen létezik, akkor a hozzá tartozó adatokat (ezt a Spawner osztály tárolja) és sprite-ot is töröljük!

gameScene.collectChest = function (player, chest) {
  this.score += chest.config.coins;
  uiScene.updateScore(this.score);

  if (this.chests[chest.config.id]) {
    this.spawners[this.chests[chest.config.id].spawnerId].removeObject(chest.config.id);
    chest.destroy();
  }
};

A láda begyűjtése után a coin-ok jóváíródnak, a láda eltűnik, és helyette új láda spawn-olódik egy véletlenszerűen kiválasztott pozíción (utóbbi a konzolon ellenőrizhető).

Szörnyek spawn-olása

A szörnyek spawn-olása a ládákkal analóg módon fog történni. Először a Spawner osztályt kell módosítanunk, a spawnObject metódus egy else if ággal egészül ki:

spawnObject() {
        if (this.objectType === 'CHEST') {
            this.spawnChest();
        } else if (this.objectType === 'MONSTER') {
            this.spawnMonster();
        }
    }

Ezen kívül az új spawnMonster metódust is implementálnunk kell. A szörnyek a ládák tulajdonságai mellett frame (melyik szörny legyen megjelenítve a spritesheet-ről), health (életerő), attack (sebzés) értékekkel is rendelkeznek.

spawnMonster() {
        const location = this.pickRandomLocation();
        const newMonster = {
            id: `${this.id}-${this.getNewObjectId()}`,
            spawnerId: this.id,
            x: location[0],
            y: location[1],
            coins: Phaser.Math.RND.between(10, 20),
            frame: Phaser.Math.RND.between(0, 19),
            health: Phaser.Math.RND.between(3, 5),
            attack: 1
        };
        this.objectsCreated.push(newMonster);
        this.addObject(newMonster);
    }

A spawnerek példányosítását és a spawnolt szörnyek tényleges megjelenítését a GameScene fogja végezni. Itt a szörnyeket egy új Physics Group-ban fogjuk tárolni, melyet a create metódusban hozunk létre:

this.monsters = this.physics.add.group();

A szörnyek megjelenítéséhez ezentúl a BootScene-en be kell töltenünk a hozzájuk tartozó spritesheet-et is:

this.load.spritesheet('monsters', 'assets/monsters.png', {
    frameWidth: 32,
    frameHeight: 32
  });

Ezután a GameScene-n létre kell hoznunk a spawnereket, a setupSpawners metódushoz a következő sorokat kell hozzáadni (a láda spawnerek létrehozásával analóg módon):

Object.keys(this.monsterLocations).forEach(id => {
    const config = {
      id: `monster-${id}`,
      spawnInterval: 3000,
      limit: 3,
      objectType: 'MONSTER'
    };
    const spawner = new Spawner(
      config,
      this.time,
      this.monsterLocations[id],
      (monster) => this.addMonster(monster),
      (id) => this.deleteMonster(id)
    );
    this.spawners[spawner.id] = spawner;
  });

Szintén létre kell hoznunk az itt hivatkozott addMonster és deleteMonster metódusokat:

gameScene.addMonster = function (monsterData) {
  this.monsters[monsterData.id] = monsterData;

  const monster = this.add.sprite(monsterData.x * this.scale, monsterData.y * this.scale, 'monsters', monsterData.frame);
  monster.config = {
    id: monsterData.id,
    coins: monsterData.coins,
    health: monsterData.health,
    attack: monsterData.attack
  };
  monster.setScale(this.scale);
  this.monsters.add(monster, true);
};

gameScene.deleteMonster = function (monsterId) {
  delete this.monsters[monsterId];
};

Ha ezután a pályán mozogva a ládák mellett szörnyekkel is találkozunk, jól dolgoztunk:

Harc a szörnyekkel

Minden szörny rendelkezik életerővel (health), sebzéssel (attack), és coin-okkal (coins). Ezeket az adatokat a játékos szörnyekkel történő harca során használjuk fel.

Fegyver létrehozása, animálása

Kezdésként a játékos kezében szeretnénk megjeleníteni egy kardot, amit mindig a játékossal együtt mozgatunk. Ehhez a játékos spawn-olásán módosítani fogunk. A Player sprite mellé egy Weapon sprite-ot is létrehozunk, majd ezt a kettőt egy konténerben kapcsoljuk össze (Container osztály dokumentációja).

A spawnPlayer metódust változtassuk meg:

gameScene.spawnPlayer = function () {
  const location = Phaser.Math.RND.pick(this.playerLocations);
  this.player = this.add.image(0, 0, 'characters', 0);
  this.physics.add.existing(this.player);
  this.player.setScale(this.scale);
  this.player.body.setCollideWorldBounds(true);

  this.weapon = this.add.image(this.player.x - 32, this.player.y, 'items', 4);
  this.weapon.flipX = true;
  this.physics.add.existing(this.weapon);
  this.weapon.body.setCollideWorldBounds(true);

  this.playerContainer = this.add.container(location[0] * this.scale, location[1] * this.scale, [this.player, this.weapon]);
  this.playerContainer.setSize(32 * this.scale, 32 * this.scale);
  this.physics.world.enable(this.playerContainer);
  this.playerContainer.body.setCollideWorldBounds(true);
};

A kódban látható, hogy abszolút pozíciója csak a konténernek van, a játékos és a fegyver koordinátái a konténer pozíciójához képest vannak megadva.

A kamerának a továbbiakban nem a player sprite-ot, hanem a playerContainer-t kell követnie, ezt módosítsuk:

this.cameras.main.startFollow(this.playerContainer);

Módosítanunk kell továbbá az updatePlayer metódust is, a megfelelő irányú erőket ezután magán a konténeren helyezzük el, a játékos helyett. Amikor jobbra vagy balra mozdulunk, akkor a fegyvert is ennek megfelelően fordítjuk el, illetve helyezzük át.

gameScene.updatePlayer = function () {
  this.playerContainer.body.setVelocity(0);

  if (this.cursors.left.isDown) {
    this.player.flipX = false;
    this.playerContainer.body.setVelocityX(-this.playerSpeed);
    this.weapon.flipX = true;
    this.weapon.setPosition(this.player.x - 32, this.player.y);
  } else if (this.cursors.right.isDown) {
    this.player.flipX = true;
    this.playerContainer.body.setVelocityX(this.playerSpeed);
    this.weapon.flipX = false;
    this.weapon.setPosition(this.player.x + 32, this.player.y);
  }

  if (this.cursors.up.isDown) {
    this.playerContainer.body.setVelocityY(-this.playerSpeed);
  } else if (this.cursors.down.isDown) {
    this.playerContainer.body.setVelocityY(this.playerSpeed);
  }
};

Módosítsuk továbbá a játékos és a blocked layer közötti collider-t is. Itt szintén a konténer és a blocked layer közötti ütközést kell vizsgálni:

this.physics.add.collider(this.playerContainer, this.blockedLayer);

Harc megvalósítása

A harc implementációjának első lépéseként adattagokat fogunk felvenni. Egyrészt tárolnunk kell azt, hogy a játékos éppen harcol-e, ezt adjuk hozzá az init metódushoz:

this.playerAttacking = false;

Másrészt tárolnunk kell a játékos életerejét (health), illetve azt, hogy az aktuális támadás során már megütötte-e a támadott szörnyet (swordHit). Ezt a spawnPlayer metódusban vezetjük be, a player sprite létrehozása után:

this.player.config = {
    swordHit: false,
    health: 7
  };

Következő lépésként egy tween animációt fogunk létrehozni, mely a SPACE billentyű lenyomása esetén a kardot 360°-ban megforgatja. Ehhez az updatePlayer metódust egészítsük ki a következő sorokkal:

if (Phaser.Input.Keyboard.JustDown(this.cursors.space) && !this.playerAttacking) {
    this.playerAttacking = true;

    this.tweens.add({
      targets: this.weapon,
      duration: 150,
      angle: this.weapon.flipX ? -360 : 360,
      paused: false,
      onComplete: () => {
        this.playerAttacking = false;
        this.player.config.swordHit = false;
      }
    });
  }

Ha jól dolgoztunk, a SPACE lenyomása után a játékos kardja 360°-ban körbefordul.

A Phaser.Input.Keyboard.JustDown() metódus a SPACE billentyű lenyomása után egyszer fog true értéket visszaadni, majd ezután mindig false-t (ezzel azt jelzi, hogy éppen most lett-e lenyomva a billentyű, vagy már hosszabb ideje nyomva van). A billentyű újbóli lenyomásakor ugyanez fog történni, így a SPACE egyszeri lenyomásakor egyetlen alkalommal forgatja meg a kardot a játék. Az animáció végeztével a playerAttacking és swordHit tulajdonságok értéke false-ra áll vissza, így újabb támadás indítható a SPACE újbóli lenyomásával.

A szörnyekkel történő harc tényleges megvalósításához helyezzünk el egy vizsgálatot a create metódusban, ami a játékos fegyverének és egy szörnynek az átfedését vizsgálja.

this.physics.add.overlap(this.weapon, this.monsters, (weapon, monster) => this.enemyOverlap(weapon, monster));

Ezután adjunk egy kezdeti implementációt az enemyOverlap metódusnak. Ez azt vizsgálja, hogy a játékos támadásban van-e, illetve még nem ütötte-e meg a szörnyet. Ha a feltétel teljesül, akkor rögzítjük, hogy az adott támadás során a játékos megütötte a szörnyet, és a későbbiekben változtatni fogjuk a szörny, valamint a játékos életerejét is (de egyelőre csak egy üzenetet íratunk ki a konzolra).

gameScene.enemyOverlap = function (weapon, monster) {
  if (this.playerAttacking && !this.player.config.swordHit) {
    this.player.config.swordHit = true;
    console.log('MONSTER HIT!');
  }
};

Akkor dolgoztunk jól, ha a szörny támadásakor SPACE billentyű egyszeri lenyomásakor egyszer jelenik meg a konzolon a MONSTER HIT! üzenet (ennek vizsgálatakor a spawnerek logja zavaró lehet, így azt törölhetjük a kódunkból).

A metódusunkat tovább bővítjük, minden ütéskor 1-et levonunk a szörny életerejéből, majd megvizsgáljuk, hogy az 0-ra csökkent-e. Amennyiben igen, a szörnyhöz tartozó coin-okat jóváírjuk a játékosnál (és frissítjük a kijelzést), majd a szörny adatait eltávolítjuk a spawner-ből, és végül magát a sprite-ot is megszüntetjük.

gameScene.enemyOverlap = function (weapon, monster) {
  if (this.playerAttacking && !this.player.config.swordHit) {
    this.player.config.swordHit = true;
    
    if (this.monsters[monster.config.id]) {
      monster.config.health -= 1;

      if (monster.config.health <= 0) {
        this.score += monster.config.coins;
        uiScene.updateScore(this.score);
        this.spawners[this.monsters[monster.config.id].spawnerId].removeObject(monster.config.id);
        monster.destroy();
      }
    }
  }
};

Ha jól dolgoztunk, azt kell látnunk a konzolon, hogy minden ütéssel 1-et csökken az ellenség életereje, majd ha elérte a 0-t, a szörny eltűnik és a játékosnál a pontok jóváíródnak.

Most tovább bővítjük az implementációt, amennyiben a szörny nem halt meg, a játékos életerejét is csökkentjük a szörny attack tulajdonságának értékével. Emellett azt is megvizsgáljuk, hogy a játékos életereje 0-ra csökkent-e. Amennyiben igen, a játékot újraindítjuk.

gameScene.enemyOverlap = function (weapon, monster) {
  if (this.playerAttacking && !this.player.config.swordHit) {
    this.player.config.swordHit = true;

    if (this.monsters[monster.config.id]) {
      monster.config.health -= 1;

      console.log('monster health', monster.config.health);

      if (monster.config.health <= 0) {
        this.score += monster.config.coins;
        uiScene.updateScore(this.score);
        this.spawners[this.monsters[monster.config.id].spawnerId].removeObject(monster.config.id);
        monster.destroy();
      } else {
        this.player.config.health -= monster.config.attack;
        console.log('player health', this.player.config.health);
        if (this.player.config.health <= 0) {
          this.scene.restart();
        }
      }
    }
  }
};

A konzolon ekkor a szörny és a játékos életerejének csökkenését is látnunk kell.

A játékos életerejét a felhasználó számára is láthatóvá szeretnénk tenni, ennek érdekében a UiScene-t is módosítjuk. A create metódust új szöveg létrehozásával egészítjük ki:

this.healthText = this.add.text(690, 8, 'Health: 0', { fontSize: '16px', fill: '#fff' });

Ezen kívül biztosítunk egy metódust az életerő értékének megváltoztatására:

uiScene.updateHealth = function (newHealth) {
  this.healthText.setText(`Health: ${newHealth}`);
};

A GameScene-en az update metódusban meg is hívjuk az új életerő kijelzését biztosító metódust:

uiScene.updateHealth(this.player.config.health);

A kódból minden console.log utasítást kitörölhetünk, ezek csak a fejlesztés során voltak segítségünkre.

Továbbfejlesztési lehetőségek