ZeniTank Tutorial

bazald's adapation of DXTank (written by by Jon Voigt) to zenilib.


Index

  1. Get Started
  2. Add a Gamestate
  3. Add a Tank
  4. Add Bullets
  5. Add Explosions
  6. Add a Title Screen
  7. Polish

Instructions

Get Started

Set up the Development Environment, download zenilib, and learn how to build everything if you haven't already.

Add a Gamestate

  1. Open zenilib/jni/application/bootstrap.cpp.
  2. Start with the included Play_State:
    class Play_State : public Gamestate_Base {
      Play_State(const Play_State &);
      Play_State operator=(const Play_State &);
    
    public:
      Play_State() {
        set_pausable(true);
      }
    
    private:
      void on_push() {
        get_Window().set_mouse_state(Window::MOUSE_HIDDEN);
      }
    
      void on_pop() {
        get_Controllers().reset_vibration_all();
      }
    
      void on_cover() {
        get_Controllers().reset_vibration_all();
      }
    
      void on_controller_button(const SDL_ControllerButtonEvent &event) {
        if(event.button == SDL_CONTROLLER_BUTTON_BACK && event.state == SDL_PRESSED)
          get_Game().push_Popup_Menu_State();
      }
    };
    
    This will allow the game to be paused. When the game is not paused, the mouse will be hidden from view. Otherwise, defaults are inherited from Gamestate_Base.

    Note that Play_State is referenced by a line in Bootstrap::Gamestate_One_Initializer::operator()().
  3. Optionally, replace zenilib Application and Zenipex Library\nApplication in Bootstrap::Gamestate_One_Initializer::operator()() with the titles of your choosing. I recommend zenilib Tank Tutorial and zenilib:\nTank Tutorial respectively.

Add a Tank

  1. Download (right-click & save-as) Tank facing East to zenilib/assets/textures/, creating the directory textures/ as needed.
  2. Open zenilib/assets/config/textures.xml.
  3. Add an entry
      <tank>
        <filepath>textures/tank.png</filepath>
        <tile>0</tile>
      </tank>
    
    and save textures.xml.

    As described in textures.xml, this tells zenilib to give you access to zenilib/assets/textures/tank.png via the alias tank. Additionally, setting tile to 0 tells zenilib not to allow the texture to be tiled. (If you don't know whether you will need tiling or not, it is safer to use 1.)
  4. Before the definition of class Play_State, define class Game_Object:

    A Game_Object in our 2D world clear needs a position and some sort of size. Additionally, we will give our Game_Objects a velocity so that we can move them easily.
    class Game_Object {
    public:
      Game_Object(const Point2f &position_,
                  const Vector2f &size_,
                  const float &theta_ = 0.0f,
                  const float &speed_ = 0.0f)
      : m_position(position_),
        m_size(size_),
        m_theta(theta_),
        m_speed(speed_)
      {
      }
    
      // If you might delete base class pointers, you need a virtual destructor.
      virtual ~Game_Object() {}
    
    private:
      Point2f m_position; // Upper left corner
      Vector2f m_size; // (width, height)
      float m_theta;
    
      float m_speed;
    };
    
  5. After Game_Object, before Play_State, define class Tank:

    Our Tank is a simple extension of Game_Object.
    class Tank : public Game_Object {
    public:
      Tank(const Point2f &position_,
           const Vector2f &size_,
           const float &theta_)
      : Game_Object(position_, size_, theta_)
      {
      }
    };
    
    Instances of Tank can now be defined in Play_State. But what can we do with them?
  6. Let's make it possible to render Tanks on the screen. And let's do it in a way that makes it unlikely that we will have to duplicate work for other children of Game_Object.

    We're going to put most of the effort into Game_Object, and then just add a little bit of extra information in Tank.
    // in class Game_Object {...}
    public:
      virtual void render() const = 0; // pure virtual function call
    protected:
      void render(const String &texture, const Color &filter = Color()) const {
        // Use a helper defined in Zeni/EZ2D.h
        render_image(
          texture, // which texture to use
          m_position, // upper-left corner
          m_position + m_size, // lower-right corner
          m_theta, // rotation in radians
          1.0f, // scaling factor
          m_position + 0.5f * m_size, // point to rotate & scale about
          false, // whether or not to horizontally flip the texture
          filter); // what Color to "paint" the texture
      }
    
    // ...
    
    // in class Tank {...}
    public:
      void render() const {
        Game_Object::render("tank");
      }
    
  7. Now let's add a Tank to our Play_State and actually display it on the screen:
    class Play_State : public Gamestate_Base {
      Play_State(const Play_State &);
      Play_State operator=(const Play_State &);
    
    public:
      Play_State()
        : m_tank(Point2f(0.0f, 0.0f), Vector2f(64.0f, 64.0f), Global::pi * 1.5f)
      {
        set_pausable(true);
      }
    
    private:
      void on_push() {
        get_Window().set_mouse_state(Window::MOUSE_HIDDEN);
      }
    
      void render() {
        get_Video().set_2d();
    
        m_tank.render();
      }
    
      Tank m_tank;
    };
    
  8. Now let's add some keyboard controls:

    To do that, we need to override one of the input handling callback functions in our Gamestate.
    class Play_State : public Gamestate_Base {
      Play_State(const Play_State &);
      Play_State operator=(const Play_State &);
    
    public:
      Play_State()
        : m_tank(Point2f(0.0f, 0.0f), Vector2f(64.0f, 64.0f), Global::pi * 1.5f),
        m_forward(false),
        m_turn_left(false),
        m_backward(false),
        m_turn_right(false)
      {
        set_pausable(true);
      }
    
    private:
      void on_push() {
        get_Window().set_mouse_state(Window::MOUSE_HIDDEN);
      }
    
      void on_key(const SDL_KeyboardEvent &event) {
        switch(event.keysym.sym) {
          case SDLK_w:
            m_forward = event.type == SDL_KEYDOWN;
            break;
    
          case SDLK_a:
            m_turn_left = event.type == SDL_KEYDOWN;
            break;
    
          case SDLK_s:
            m_backward = event.type == SDL_KEYDOWN;
            break;
    
          case SDLK_d:
            m_turn_right = event.type == SDL_KEYDOWN;
            break;
    
          default:
            Gamestate_Base::on_key(event); // Let Gamestate_Base handle it
            break;
        }
      }
    
      void render() {
        get_Video().set_2d();
    
        m_tank.render();
      }
    
      Tank m_tank;
      bool m_forward;
      bool m_turn_left;
      bool m_backward;
      bool m_turn_right;
    };
    
  9. You may have noticed that the Tank still isn't moving. We need to do something in perform_logic().

    What we're going to do is move the Tank by an amount depending on how much time has passed.
    // in class Game_Object {...}
    public:
      void turn_left(const float &theta_) {
        m_theta += theta_;
      }
    
      void move_forward(const float &move_) {
        // Performance consideration: calculate and store a forward Vector2f when turning and avoid cosine & sine here?
        m_position.x += move_ * cos(m_theta);
        m_position.y += move_ * -sin(m_theta);
      }
    
    // in the initializer list for Play_State::Play_State()
        , m_time_passed(0.0f)
    
    // in Play_State::on_push() {...}
        m_chrono.start();
    
    // in Play_State::on_pop() {...}
        m_chrono.stop();
    
    // in class Play_State {...}
    private:
      Chronometer<Time> m_chrono;
      float m_time_passed;
    
      void perform_logic() {
        const float time_passed = m_chrono.seconds();
        const float time_step = time_passed - m_time_passed;
        m_time_passed = time_passed;
    
        // without a multiplier, this will rotate a full turn after ~6.28s
        m_tank.turn_left((m_turn_left - m_turn_right) * time_step);
        // without the '100.0f', it would move at ~1px/s
        m_tank.move_forward((m_forward - m_backward) * time_step * 100.0f);
      }
    

Add Bullets

  1. Download (right-click & save-as)    --> Cannonball <--    to zenilib/assets/textures/.
  2. Open zenilib/assets/config/textures.xml.
  3. Add an entry
      <bullet>
        <filepath>textures/bullet.png</filepath>
        <tile>0</tile>
      </bullet>
    
    and save textures.xml.
  4. After class Game_Object and before class Tank, define class Bullet:
    class Bullet : public Game_Object {
    public:
      Bullet(const Point2f &position_,
             const Vector2f &size_,
             const float &theta_)
      : Game_Object(position_, size_, theta_)  {
      }
    
      void render() const {
        Game_Object::render("bullet");
      }
    };
    
    See how easy this was because of our base class, shared with Tank? It is still a bit verbose, but we basically 'find-replace-all'ed Tank for Bullet and tank for bullet.
  5. Now, to fire bullets, a bit of work is required:
    // in class Game_Object {...}
    public:
      const Point2f & get_position() const {return m_position;}
      const Vector2f & get_size() const {return m_size;}
      const float & get_theta() const {return m_theta;}
    
      const float get_radius() const {
        return 0.5f * m_size.magnitude();
      }
    
    // in class Tank {...}
    public:
      Bullet * fire() const {
        const float radius = 1.2f * get_radius();
        const Vector2f bullet_size(8.0f, 8.0f);
    
        const Point2f position(get_position() +                                         // the Tank's upper-left coordinate
                               0.5f * (get_size() - bullet_size) +                      // shift to center of the Tank
                               radius * Vector2f(cos(get_theta()), -sin(get_theta()))); // then shift in front of the Tank
    
        return new Bullet(position, bullet_size, get_theta());
      }
    
    // in the initializer list for Play_State::Play_State()
        , m_fire(false)
    
    // in the switch statement in Play_State::on_key(...) {...}
          case SDLK_SPACE:
            m_fire = event.type == SDL_KEYDOWN;
            break;
    
    // in Play_State::perform_logic() {...}
        if(m_fire) {
          m_fire = false;
          m_bullets.push_back(m_tank.fire());
        }
    
    // in class Play_State {...}
    private:
      bool m_fire;
      list<Bullet *> m_bullets;
    public:
      ~Play_State() {
        for(list<Bullet *>::iterator it = m_bullets.begin(); it != m_bullets.end(); ++it)
          delete *it;
      }
    
  6. And to render them, even more work is required:
    // in Play_State::render() {...}
        for(list<Bullet *>::const_iterator it = m_bullets.begin(); it != m_bullets.end(); ++it)
          (*it)->render();
    
  7. And to finally move them, still more work is required:
    // in Play_State::perform_logic() {...}
        for(list<Bullet *>::iterator it = m_bullets.begin(); it != m_bullets.end(); ++it)
          (*it)->move_forward(time_step * 200.0f);
    
  8. One last step: We have to get rid of bullets that go off screen to keep good performance.
    // in Play_State::perform_logic() {...}
        for(list<Bullet *>::iterator it = m_bullets.begin(); it != m_bullets.end();) {
          const Point2f &p = (*it)->get_position();
    
          if(p.x < -10.0f || p.x > 10.0f + get_Window().get_width() ||
             p.y < -10.0f || p.y > 10.0f + get_Window().get_height())
          {
            delete *it;
            it = m_bullets.erase(it);
          }
          else
            ++it;
        }
    
    Note: this may be the wrong time to remove bullets in your game. Things happening off-screen can be important!

Add Explosions

  1. Download (right-click & save-as) Explosion with text: BOOM to zenilib/assets/textures/.
  2. Open zenilib/assets/config/textures.xml.
  3. Add an entry
      <boom>
        <filepath>textures/boom.png</filepath>
        <tile>0</tile>
      </boom>
    
    and save textures.xml.
  4. // in class Game_Object {...}
    public:
      bool collide(const Game_Object &rhs) const {
        const Vector2f dist_vec = m_position - rhs.m_position +
                                  0.5f * (m_size - rhs.m_size);
        const float dist2 = dist_vec * dist_vec;
        const float radius_sum = get_radius() + rhs.get_radius();
    
        return dist2 < radius_sum * radius_sum;
      }
    
    // in the initializer list for Tank::Tank(...)
        , m_exploded(false)
    
    // in class Tank {...}
    private:
      bool m_exploded;
    public:
      bool has_exploded() {return m_exploded;}
    
      void collide(const list<Bullet *> &bullets) {
        if(!m_exploded)
          for(list<Bullet *>::const_iterator it = bullets.begin(); it != bullets.end(); ++it)
            if((*it)->collide(*this)) {
              m_exploded = true;
              break;
            }
      }
    
    // in Tank::render() {...}
        if(m_exploded)
          render_image("boom",
                       get_position(),
                       get_position() + get_size());
    
    // in Play_State::perform_logic() {...}
        m_tank.collide(m_bullets);
    // and change m_tank.*(...) to
        if(!m_tank.has_exploded()) {
          m_tank.turn_left((m_turn_left - m_turn_right) * time_step);
          m_tank.move_forward((m_forward - m_backward) * time_step * 100.0f);
    
          if(m_fire) {
            m_fire = false;
            m_bullets.push_back(m_tank.fire());
          }
        }
    
  5. Now add a Tank for you to destroy:
    // in class Play_State {...}
    private:
      Tank m_enemy;
    
    // in the initializer list for Play_State::Play_State()
        , m_enemy(Point2f(400.0f, 400.0f), Vector2f(64.0f, 64.0f), Global::pi * 0.75f)
    
    // in Play_State::perform_logic() {...}
        m_enemy.collide(m_bullets);
    
    // in Play_State::render() {...}
        m_enemy.render();
    
  6. Now add controls for the enemy.
    // in Play_State {...}
    private:
      bool m_enemy_forward;
      bool m_enemy_turn_left;
      bool m_enemy_backward;
      bool m_enemy_turn_right;
      bool m_enemy_fire;
    
    // in the initializer list for Play_State::Play_State() {...}
        , m_enemy_forward(false)
        , m_enemy_turn_left(false)
        , m_enemy_backward(false)
        , m_enemy_turn_right(false)
        , m_enemy_fire(false)
    
    // in the switch statement in Play_State::on_key(...) {...}
          case SDLK_UP:
            m_enemy_forward = event.type == SDL_KEYDOWN;
            break;
    
          case SDLK_LEFT:
            m_enemy_turn_left = event.type == SDL_KEYDOWN;
            break;
    
          case SDLK_DOWN:
            m_enemy_backward = event.type == SDL_KEYDOWN;
            break;
    
          case SDLK_RIGHT:
            m_enemy_turn_right = event.type == SDL_KEYDOWN;
            break;
    
          case SDLK_RETURN:
            m_enemy_fire = event.type == SDL_KEYDOWN;
            break;
    
    // in Play_State::perform_logic(...) {...}
        if(!m_enemy.has_exploded()) {
          m_enemy.turn_left((m_enemy_turn_left - m_enemy_turn_right) * time_step);
          m_enemy.move_forward((m_enemy_forward - m_enemy_backward) * time_step * 100.0f);
    
          if(m_enemy_fire) {
            m_enemy_fire = false;
            m_bullets.push_back(m_enemy.fire());
          }
        }
    
    Note that it would probably be better to move the block just added in perform_logic into the class itself, as a generic step function dependent on the time_step value obtained in perform_logic. That way, a bit of duplicate code could be removed from perform_logic.

Customize the Title Screen

  1. Download (right-click & save-as) Text: ZENITANK to zenilib/assets/textures/.
  2. Open zenilib/assets/config/textures.xml.
  3. Add an entry
      <logo>
        <filepath>textures/logo.png</filepath>
        <tile>0</tile>
      </logo>
    
    and save textures.xml.
  4. Between class Play_State and class Gamestate_One, define class Title_State_Custom:
    class Title_State_Custom : public Title_State<Play_State, Instructions_State> {
    public:
      Title_State_Custom()
        : Title_State<Play_State, Instructions_State>("")
      {
        m_widgets.unlend_Widget(title);
      }
    
      void render() {
        Title_State<Play_State, Instructions_State>::render();
    
        render_image("logo", Point2f(200.0f, 25.0f), Point2f(600.0f, 225.0f));
      }
    };
    
  5. Change the transition in Bootstrap::Gamestate_One_Initializer::operator()() to be to Title_State_Custom rather than Title_State<Play_State, Instructions_State>.

Polish

  1. Change the background color to brown for the duration of the game:
    // in class Play_State {...}
    private:
      Color m_prev_clear_color;
    
    // in the initializer list for Play_State::Play_State() {...}
        , m_prev_clear_color(get_Video().get_clear_Color())
    
    // in Play_State::on_push() {...}
        get_Video().set_clear_Color(Color(1.0f, 0.26f, 0.13f, 0.0f));
    
    // in Play_State::on_pop() {...}
        get_Video().set_clear_Color(m_prev_clear_color);
    
  2. Return to the Title_State after a set amount of time after an explosion:
    // in Play_State {...}
    private:
      Chronometer<Time> m_explosion;
    
    // in Play_State::perform_logic() {...}
        if(m_tank.has_exploded() || m_enemy.has_exploded())
          if(m_explosion.is_running()) {
            if(m_explosion.seconds() > 3.0f)
              get_Game().pop_state();
          }
          else
            m_explosion.start();
    
  3. Make the Play_State screen-resolution independent:

    // at the very beginning of Play_State::render() {...}
        get_Video().set_2d(make_pair(Point2f(0.0f, 0.0f), Point2f(854.0f, 480.0f)), true);
    
    /** The following is optional because we aren't using the mouse in Play_State **/
    
    // in Play_State {...}
    private:
      Projector2D m_projector;
    
    // in Play_State::perform_logic()
        m_projector = Projector2D(make_pair(Point2f(0.0f, 0.0f), Point2f(854.0f, 480.0f)), get_Video().get_viewport());
    
    // finally, note that the Projector classes can do conversions between 
    // screen space and your virtual screen size (e.g. 854x480) for you (and your Widgets)
    

    Note that it might be nice to support different aspect ratios rather than strictly forcing a fixed width and height.

  4. Make bullet rendering more efficient:

    // TODO: Demo vr.apply_Texture(...)/vr.unapply_Texture(...) and manual quad rendering
    

    It is important to note that Zeni::render_image(...) is a relatively slow render path. Bullets don't need much of the included functionality (rotation, color filters, ...) and resetting the Material for each bullet has a performance penalty associated with it.