• Thứ Năm, 02/12/2004 09:14 (GMT+7)

    Lập trình game hành động 2D với MIDP 2.0


    Phiên bản MIDP 2.0 có nhiều ưu điểm hơn so với MIDP 1.0, giúp cho việc lập trình di động trở nên dễ dàng hơn rất nhiều. Và có lẽ điều mà các lập trình viên game mong đợi và hoan nghênh nhất đó là Game API mới trong MIDP 2.0 đơn giản hóa việc viết game 2D. API này nhỏ gọn, chỉ bao gồm năm lớp trong gói javax.microedition.lcdui.game. Năm lớp này cung cấp hai khả năng quan trọng:

    Lớp GameCanvas mới cho phép vẽ lên màn hình và đáp ứng lại dữ liệu nhập trong vòng lặp game, thay vì dựa vào các thread vẽ và nhập liệu của hệ thống.

    API quản lý layer mạnh và linh hoạt, cho phép xây dựng hiệu quả các cảnh game phức tạp.

    Xây dựng vòng lặp với GameCanvas

    GameCanvas là Canvas có thêm một số tính năng: cung cấp các phương thức để vẽ tức thời và kiểm tra trạng thái bàn phím thiết bị. Các phương thức mới này giúp tập hợp tất cả các chức năng của game trong một vòng lặp đơn, dưới sự điều khiển của một thread đơn. Để hiểu tại sao việc này lại hấp dẫn, hãy so sánh với cách mà bạn sẽ thực hiện một game mẫu bằng Canvas:

    public void MicroTankCanvas

       extends Canvas

       implements Runnable {

     public void run() {

       while (true) {

          // Update the game state.

        repaint();

         // Delay one time step.

      }

    }

    public void paint(Graphics g) {

      // Painting code goes here.

    }

    protected void keyPressed(int keyCode) {

      // Respond to key presses here.

      }

    }

    Đây không phải là một cách tốt. Phương thức run(), chạy trong một thread của ứng dụng, cập nhật game theo từng thời điểm. Các tác vụ chính sẽ cập nhật vị trí của trái banh hay tàu vũ trụ để làm cử động các nhân vật hay phương tiện. Qua mỗi vòng lặp, repaint() được gọi để cập nhật màn hình. Hệ thống gởi sự kiện nhấn phím cho phương thức keyPressed(), phương thức này sẽ cập nhật trạng thái của game một cách tương ứng.

    Vấn đề là các phương thức nằm trong các thread khác nhau. Khi vòng lặp hoạt họa chính trong run() gọi repaint(), không có cách nào để biết chính xác khi nào hệ thống sẽ gọi hàm paint(). Khi hệ thống gọi phương thức keyPressed(), không có cách nào để biết điều gì đang xảy ra với các phần khác của ứng dụng. Nếu đoạn mã trong keyPressed() đang cập nhật trạng thái của game cùng lúc với màn hình đang được vẽ trong paint(), thì màn hình sẽ kết thúc không như mong đợi. Nếu việc vẽ màn hình chậm hơn một bước thời gian trong run(), thì chuyển động của game có thể bị giật và không bình thường.

    GameCanvas cho phép bạn làm chủ được việc vẽ hình theo cách thông thường và cơ chế sự kiện nhấn phím để cho tất cả các logic của game có thể được chứa trong một vòng lặp đơn. Đầu tiên, GameCanvas cho phép bạn truy xuất trực tiếp đối tượng Graphics của nó sử dụng phương thức getGraphics(). Bất kỳ công việc vẽ nào trên đối tượng Graphics được trả về cũng sẽ được hoàn thành trong bộ đệm ngoài màn hình (offscreen). Sau đó bạn có thể chép bộ đệm vào màn hình sử dụng flushGraphics() mà không cần phải đợi cho đến khi màn hình được cập nhật. Cách tiếp cận này cho phép bạn điều khiển tốt hơn việc gọi repaint(). Phương thức repaint() trả về ngay lập tức và ứng dụng của bạn không cần phải bảo đảm chính xác khi nào hệ thống sẽ gọi paint() để cập nhật màn hình.

    GameCanvas cũng có một phương thức để lấy trạng thái hiện thời của bàn phím thiết bị, một kỹ thuật được gọi là thăm dò (polling). Thay vì chờ cho hệ thống gọi phương thức keyPressed(), bạn có thể xác định ngay lập tức các phím nào được nhấn bằng cách gọi phương thức getKeyStates() của GameCanvas.

    Một vòng lặp game mẫu sử dụng GameCanvas sẽ giống như thế này:

    public void MicroTankCanvas

      extends GameCanvas

      implements Runnable {

     public void run() {

      Graphics g = getGraphics();

      while (true) {

        // Update the game state.

      int keyState = getKeyStates();

       // Respond to key presses here.

      // Painting code goes here.

     flushGraphics();

      // Delay one time step.

      }

     }

    }

    Ví dụ sau thể hiện một vòng lặp game cơ bản. Nó sẽ hiển thị một chữ X quay vòng mà bạn có thể di chuyển xung quanh màn hình bằng cách sử dụng các phím mũi tên. Phương thức run() cực kỳ gọn và rõ ràng, nhờ GameCanvas:

    import javax.microedition.lcdui.*;

    import javax.microedition.lcdui.game.*;

    public class SimpleGameCanvas

       extends GameCanvas

       implements Runnable {

     private volatile boolean mTrucking;

     private long mFrameDelay;

     private int mX, mY;

     private int mState;

     public SimpleGameCanvas() {

       super(true);

       mX = getWidth() / 2;

       mY = getHeight() / 2;

       mState = 0;

       mFrameDelay = 20;

    }

    public void start() {

      mTrucking = true;

      Thread t = new Thread(this);

      t.start();

    }

    public void stop(){ mTrucking = false;}

    public void run(){

     Graphics g = getGraphics();

     while (mTrucking == true) {

      tick();

      input();

      render(g);

      try { Thread.sleep(mFrameDelay);}

      catch (InterruptedException ie){ stop();}

     }

    }

    private void tick() {

      mState = (mState + 1) % 20;

    }

    private void input() {

      int keyStates = getKeyStates();

      if ((keyStates & LEFT_PRESSED) != 0)

         mX = Math.max(0, mX - 1);

      if ((keyStates & RIGHT_PRESSED) != 0)

        mX = Math.min(getWidth(), mX + 1);

      if ((keyStates & UP_PRESSED) != 0)

        mY = Math.max(0, mY - 1);

      if ((keyStates & DOWN_PRESSED) != 0)

        mY = Math.min(getHeight(), mY + 1);

    }

    private void render(Graphics g) {

      g.setColor(0xffffff);

      g.fillRect(0, 0, getWidth(), getHeight());

      g.setColor(0x0000ff);

      g.drawLine(mX,mY,mX-10+mState,mY-10);

      g.drawLine(mX,mY,mX+10,mY-10+mState);

      g.drawLine(mX,mY,mX+10-mState,mY+10);

      g.drawLine(mX,mY,mX-10,mY+10-mState);

      flushGraphics();

     }

    }

    Sau đây là đoạn mã SimpleGameMIDlet.java sử dụng Canvas này.

    import javax.microedition.lcdui.*;

    import javax.microedition.midlet.MIDlet;

    public class SimpleGameMIDlet

    extends MIDlet

    implements CommandListener {

    private Display mDisplay;

    private SimpleGameCanvas mCanvas;

    private Command mExitCommand;

    public void startApp() {

      if (mCanvas == null) {

      mCanvas = new SimpleGameCanvas();

      mCanvas.start();

      mExitCommand = new Command("Exit", Command.EXIT, 0);

      mCanvas.addCommand(mExitCommand);

      mCanvas.setCommandListener(this);

    }

    mDisplay = Display.getDisplay(this);

    mDisplay.setCurrent(mCanvas);

    }

    public void pauseApp() {}

    public void destroyApp(boolean unconditional) { mCanvas.stop();}

    public void commandAction(Command c, Displayable s) {

    if (c.getCommandType() == Command.EXIT) {

    destroyApp(true);

    notifyDestroyed();

      }

     }

    }

    Hãy thử chạy SimpleGameMIDlet để xem nó hoạt động như thế nào.


     

    SimpleGameMIDlet Screen Shot

    Cảnh Game - Các lớp của củ hành

    Các game hành động 2D điển hình bao gồm một nền và nhiều nhân vật chuyển động. Mặc dù bạn có thể tự vẽ loại cảnh này, Game API cho phép bạn xây dựng các cảnh bằng cách sử dụng các layer. Bạn có thể tạo một layer là nền thành phố, và một layer khác là chiếc xe hơi. Đặt layer xe hơi lên trên hình nền sẽ tạo thành một cảnh hoàn chỉnh. Sử dụng chiếc xe như là một layer riêng biệt giúp việc thao tác được dễ dàng và độc lập với hình nền, và với bất kỳ layer nào khác trong cảnh game.
    Game API thực hiện việc hỗ trợ mềm dẻo cho các layer thông qua bốn lớp:

    Layer là một lớp cha abstract của tất cả các layer. Nó xác định các thuộc tính cơ bản của một layer, bao gồm vị trí, kích thước, và việc layer có được hiển thị hay không. Mỗi lớp con của Layer phải định nghĩa một phương thức paint() để hiển thị layer trên bề mặt vẽ của Graphics. Hai lớp con cụ thể là TiledLayer và Sprite, sẽ đáp ứng đầy đủ yêu cầu làm game 2D của bạn.

    TiledLayer có ích trong việc tạo các hình nền. Bạn có thể sử dụng một tập nhỏ các hình ảnh nguồn ghép lại với nhau để tạo thành các hình ảnh lớn một cách hiệu quả.

    Sprite là một lớp hoạt họa. Bạn cung cấp các frame nguồn và có toàn quyền điều khiển việc chuyển động. Sprite cũng cung cấp khả năng để lật ngược và quay các frame nguồn theo bội số của 90o.

    LayerManager là một lớp tiện dụng để theo dõi tất cả các layer trong cảnh game của bạn. Chỉ cần một lần gọi phương thức paint() của LayerManager cũng đủ để hiển thị tất cả các layer được chứa trong nó.

    Sử dụng TiledLayer

    Hình nguồn

     

    TiledLayer khá đơn giản để hiểu, mặc dù nó có một số tính chất không thể hiểu rõ ngay từ đầu. Ý tưởng căn bản là có một hình nguồn cung cấp một tập các tile (tạm dịch là viên gạch lót) có thể được sắp xếp để tạo thành một cảnh lớn. Ví dụ, hình nguồn có kích thước là 64 x 48 pixels.

    Hình này có thể được chia thành 12 tile, mỗi tile có kích thước 16 x 16 pixels. TiledLayer gán cho mỗi tile một con số, bắt đầu bằng số1 kể từ góc trái trên. Các tile trong hình nguồn được đánh số như sau:

    Tạo một TiledLayer trong chương trình cũng khá đơn giản. Bạn cần xác định số cột và số hàng, hình nguồn, và kích thước theo pixel của các tile trong hình nguồn. Đoạn chương trình sau biểu diễn cách nạp hình và tạo một TiledLayer.

    Image image = Image.createImage("/board.png");

     

                       Đánh số các file

    TiledLayer tiledLayer = new TiledLayer(10, 10, image, 16, 16);
    Trong ví dụ trên, ta đã tạo một TiledLayer mới có 10 cột và 10 hàng. Các tile tạo thành từ hình có kích thước vuông 16 pixels.

    Đến phần thú vị là tạo một cảnh sử dụng các tile này. Để gán một tile cho một cell (một ô), gọi hàm setCell(). Bạn cần phải cung cấp số cột và hàng của cell và số của tile. Ví dụ, bạn có thể gán tile số 5 cho cell thứ ba của hàng thứ hai bằng cách gọi hàm setCell(2, 1, 5). Nếu các tham số này bị sai, hãy chú ý là chỉ mục của tile bắt đầu từ 1, trong khi cột và hàng bắt đầu từ 0. Theo mặc định, tất cả các cell trong TiledLayer mới có giá trị tile là 0, có nghĩa là chúng rỗng.

    Đoạn chương trình sau biểu diễn một cách để phát sinh một TiledLayer, sử dụng một mảng số nguyên. Trong trò chơi thực tế, các TiledLayer có thể được định nghĩa từ các tập tin tài nguyên, cho phép định nghĩa nền game linh hoạt hơn và nâng cấp game với phiên bản mới.

    private TiledLayer createBoard() {

      Image image = null;

      try { image = Image.createImage("/board.png"); }

      catch (IOException ioe){return null;}

      TiledLayer tiledLayer = new TiledLayer(10, 10, image, 16, 16);

      int[] map = {

       1, 1, 1, 1, 11, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 9, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 1, 0, 0, 0, 0, 0,
       0, 0, 0, 7, 1, 0, 0, 0, 0, 0,
       1, 1, 1, 1, 6, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 7, 11, 0,
       0, 0, 0, 0, 0, 0, 7, 6, 0, 0,
       0, 0, 0, 0, 0, 7, 6, 0, 0, 0

    };

    for (int i = 0; i < map.length; i++) {

      int column = i % 10;

      int row = (i - column) / 10;

      tiledLayer.setCell(column, row, map[i]);

     }

       return tiledLayer;

    }

    Để hiển thị TiledLayer này lên màn hình, bạn cần phải chuyển đối tượng Graphics cho phương thức paint() của nó.

    TiledLayer cũng hỗ trợ các tile chuyển động (animated tile), làm cho việc di chuyển một tập các cell qua một chuỗi các tile được dễ dàng hơn. Để biết thêm chi tiết, xin xem tài liệu API của TiledLayer.

    Sử dụng Sprite cho chuyển động nhân vật

    Một layer cụ thể khác được cung cấp trong Game API là Sprite. Theo một cách nào đó, Sprite là khái niệm ngược lại của TiledLayer. Trong khi TiledLayer sử dụng một bảng pallete của các tile từ hình nguồn để tạo thành một cảnh lớn, thì Sprite lại sử dụng một chuỗi các khung hình nguồn để tạo thành chuyển động.

    Tất cả những gì bạn cần để tạo thành một Sprite là một hình nguồn và kích thước của mỗi frame (khung). Trong TiledLayer, hình nguồn được chia thành các tile có kích thước bằng nhau; còn trong Sprite, các hình con được gọi là các frame. Trong ví dụ sau, một hình nguồn tank.png được dùng để tạo thành một Sprite với kích thước frame là 32 x 32 pixels.

    private MicroTankSprite createTank(){

    Image image = null;

    try {image = Image.createImage("/tank.png");}

    catch (IOException ioe){return null;}

    return new MicroTankSprite(image, 32,32);

    }

    Mỗi frame của hình nguồn được đánh số, bắt đầu từ 0 và đếm lên (hãy nhớ là các số của tile bắt đầu từ 1). Sprite có một frame sequence (trình tự frame) xác định thứ tự hiển thị frame. Frame sequence mặc định cho một Sprite mới bắt đầu từ 0 và đếm lên qua các frame có sẵn.

    Để chuyển đến frame trước hay kế tiếp trong frame sequence, ta sử dụng các phương thức nextFrame() và prevFrame() của Sprite. Các phương thức này cuộn từ đầu cho đến cuối frame sequence. Nếu Sprite đang hiển thị frame cuối trong frame sequence của nó, có thể gọi phương thức nextFrame() để hiển thị frame đầu tiên trong frame sequence.

    Để xác định một frame sequence khác so với mặc định, hãy chuyển sequence, được biểu diễn dưới dạng một mảng số nguyên, cho setFrameSequence().

    Bạn có thể nhảy đến một điểm cụ thể trong frame sequence hiện tại bằng cách gọi phương thức setFrame(). Không có cách nào để nhảy đến một frame có số cụ thể. Bạn chỉ có thể nhảy đến một điểm nào đó trong frame sequence.

    Các thay đổi frame chỉ thấy được trong lần kế tiếp Sprite được hiển thị, sử dụng phương thức paint() kế thừa từ layer.

    Sprite cũng có thể biến đổi các frame nguồn. Frame có thể được quay theo cấp số nhân của 90o, lật ngược, hay kết hợp cả hai. Các hằng số trong lớp Sprite sẽ định nghĩa các khả năng biến đổi. Việc biến đổi hiện thời của Sprite có thể được thiết lập bằng cách chuyển một trong các hằng số này cho phương thức setTransform(). Ví dụ sau lật ngược frame hiện tại quanh trục đứng, và quay nó 90o:

    // Sprite sprite = ...
    sprite.setTransform(Sprite.TRANS_MIRROR_ROT90);

    Việc biến đổi được áp dụng sao cho pixel tham chiếu (reference pixel) của Sprite không di chuyển. Theo mặc định, pixel tham chiếu của Sprite được đặt tại vị trí (0, 0) trong không gian tọa độ, tại góc trái trên. Khi việc biến đổi được áp dụng, vị trí của pixel tham chiếu cũng biến đổi. Vị trí của Sprite được điều chỉnh để pixel tham chiếu vẫn ở cùng vị trí.

    Bạn có thể thay đổi vị trí của pixel tham chiếu bằng phương thức defineReferencePixel(). Đối với nhiều loại chuyển động, ta thường định nghĩa pixel tham chiếu là điểm giữa của Sprite.

    Cuối cùng, Sprite cung cấp một vài phương thức collidesWith() để phát hiện va chạm với các Sprite, TiledLayer, hay Images khác. Bạn có thể phát hiện va chạm bằng việc sử dụng các hình chữ nhật va chạm (nhanh nhưng không chính xác) hay ở mức pixel (chậm nhưng chính xác). Tính chất của các phương thức này cũng khá khó nắm bắt; hãy xem tài liệu API để biết thêm chi tiết.

    Tóm tắt

    Game API của MIDP 2.0 cung cấp một framework để đơn giản hóa việc phát triển các game hành động 2D. Đầu tiên, lớp GameCanvas cung cấp các phương thức vẽ và nhập liệu làm cho vòng lặp game chặt chẽ hơn. Kế tiếp, các layer giúp tạo nên các cảnh game phức tạp. TiledLayer tập hợp pallete các tile của ảnh nguồn để tạo thành một nền hay một cảnh lớn. Sprite thích hợp để tạo các nhân vật chuyển động và có thể phát hiện va chạm với các đối tượng khác trong game. LayerManager là chất keo để kết nối các layer lại với nhau... Với các cải tiến và mở rộng của Game API trong MIDP 2.0, các lập trình viên game có thể cho ra đời nhiều game xuất sắc.

    Lê Ngọc Quốc Khánh
    lnqkhanh@tma.com.vn

     

    TÀI LIÊU THAM KHẢO

     

     

    Sun - Creating 2D Action Games with the Game API - (http://developers.sun.com/techtopics/mobility/midp/articles/game)
    Sun - Game API Documentation
    Nokia - What's in MIDP 2.0: A Guide for Java™ Developers.

     

     

    ID: A0411_118