Two Cars

בהמשך לפוסט הזה, הפעם ניצור משחק.

בקצרה: libGDX היא ספרייה נוחה לשימוש עבור יצירת משחקי 2D. אפשר לראות בפוסט הנ״ל מדריך איך ליצור פרוייקט חדש ולהריץ אותו על פלטפורמות שונות.

ישנו משחק בשם Two Cars – צריך לשלוט בו זמנית על שתי מכוניות בלי להתנגש במכשולים. אני הולך ליצור גירסא שלו, עם שינוי קל. המשחק יראה בסופו של דבר ככה:

ezgif-2403319550

ניצור פרוייקט חדש בשם TwoCars. אין צורך ב- Box2D – לא נשתמש במנוע הפיזיקלי לצורך המשחק הזה.

נתחיל.

מבנה כללי

libGDX היא event-driven – כלומר האירועים השונים הם מה שמניעים אותנו. לדוגמא, בכל פעם שהמסך מתרפרש – תיקרא הפונקציה –

public void render(float delta)

 בה נכתוב מה אנחנו רוצים שיוצג על המסך. (מזכיר קצת את העבודה עם סקריפטים ב- Unity3D, שם העדכונים מגיעים לפונקציית update).

ניצור package חדש בשם helpers, וניצור בו שתי מחלקות – GameWorld, GameRenderer.

GameWorld – בה תהיה הלוגיקה של המשחק. היא תחזיק את ה- State של המשחק, המיקום בו נמצא כל אובייקט על המסך, וניקוד.

GameRenderer – אחראי לצייר על המסך את כל המידע שיש ב- GameWorld.

GameWorld:

public class GameWorld {
    public void update(float delta) {

    }
}

GameRenderer:

public class GameRenderer {
    private OrthographicCamera cam;
    private GameWorld gameWorld;

    public GameRenderer(GameWorld world){
        this.gameWorld = world;

        cam = new OrthographicCamera();
        cam.setToOrtho(true, GameConstants.SCREEN_WIDTH, GameConstants.SCREEN_HEIGHT);
    }

    public void render(){

    }
}

זה המבנה הבסיסי שיחזור על עצמו בכל משחק. בכל פריים חדש, נקרא ל- GameWorld כדי לעדכן את המצב במשחק, ואז נקרא ל- GameRenderer לעדכן את מה שמוצג.

ב- GameRenderer גם מאתחלים את המצלמה – היא אחראית על איך יראה כל מה שמוצג. נרחיב על כך בהמשך.

 מהלך המשחק

נתחיל מ- GameWorld.

אנחנו צריכים לתחזק את המיקום של שתי מכוניות ושל כל המכשולים שקיימים כרגע. כדי לשמור מיקום נשתמש ב- Rectangle. לכן נוסיף את המשתנים הבאים:

// game objects
private Rectangle leftCar;
private Rectangle rightCar;
private Array<Obstacle> obstacles = new Array<Obstacle>();

שימו לב לעשות import ל- Rectangle מהסוג הנכון – com.badlogic.gdx.math.Rectangle – ולא ל- Rectangle של Java.

נוסיף getters כדי שנוכל להכיר את המיקומים האלו ב- GameRenderer:

// getters
public Rectangle getLeftCar() {
    return leftCar;
}
public Rectangle getRightCar() {
    return rightCar;
}
public Array<Obstacle> getObstacles() {
    return obstacles;
}

נקבע את המיקום הראשוני של המכוניות:

public GameWorld(){
        // the car images we use are 230x122 px
        float ratio = 230f / 122f;

        float carHeight = GameConstants.SCREEN_HEIGHT / 8;
        float carWidth = carHeight * ratio;

        int screenOffset = 10;
        leftCar = new Rectangle(screenOffset, GameConstants.SCREEN_HEIGHT - carHeight - GameConstants.SCREEN_HEIGHT / 16,
                carWidth, carHeight);
        rightCar = new Rectangle(GameConstants.SCREEN_WIDTH - carWidth - screenOffset, GameConstants.SCREEN_HEIGHT / 16,
                carWidth, carHeight);
    }

אם שמתם לב, אני משתמש במהלך הקוד ב- constants אותם הגדרתי סטטית במחלקה GameConstants. ניצור package חדש בשם consts וניצור שם את המחלקה:

public class GameConstants {
    static public int SCREEN_WIDTH = 800;
    static public int SCREEN_HEIGHT = 480;

    static public Color MAIN_COLOR = new Color(22f/255f, 65f/255f, 119f/255f, 1);
    static public long INITIAL_SPAWN_AFTER_TIME = 15000000;

}

הגדרתי את גודל המסך כ- 800 על 480, כי זה גודל שנוח לעבוד איתו – ההתייחסות שלנו בכל מה שקשור למיקומים וגדלים היא רק במספרים האלו. בפועל, libGDX ידאג להציג את המשחק בצורה פרופורציונלית למסך הפיזי בו המשחק רץ.

ניצור package נוסף בשם objects. ניצור שם enum בשם Direction:

public enum Direction {
    LEFT, RIGHT, UP, DOWN
}

נוכל להשתמש בו כדי לדעת איפה ממוקמים המכוניות/מכשולים ולאיזה כיוון הם זזים.

וניצור מחלקה בשם Obstacle:

public class Obstacle {
    // Is needed to know in which direction to move the obstacle
    private Direction direction;
    // to check if collided
    private Rectangle rectangle;
    // getter - to draw the obstacle
    public Rectangle getRectangle() {
        return rectangle;
    }

    public Obstacle(Direction direction) {
        this.direction = direction;
        int size = GameConstants.SCREEN_HEIGHT / 8;
        // decide randomly if in which path to put the new obstacle
        boolean up = false;
        if (MathUtils.random(0, 30) < 15) {
            up = true;
        }
        // set position
        if (direction == Direction.LEFT) {
            this.rectangle = new Rectangle(GameConstants.SCREEN_WIDTH, GameConstants.SCREEN_HEIGHT / 2
                    + size / 2, size, size);
        } else {
            this.rectangle = new Rectangle(0, size / 2, size, size);
        }

        if (!up) {
            this.rectangle.setY(this.rectangle.getY() + GameConstants.SCREEN_HEIGHT / 4);
        }
    }

    // returns true if the obstacle is over Screen's edge, so we can remove it
    public boolean move(float pixels){
        if (direction == Direction.LEFT) {
            rectangle.setX(rectangle.getX() - pixels);
            if (rectangle.getX() + rectangle.getWidth() < 0) {                 return true;             }         } else {             rectangle.setX(rectangle.getX() + pixels);             if (rectangle.getX() > GameConstants.SCREEN_WIDTH) {
                return true;
            }
        }

        return false;
    }
}

המחלקה הזו קובעת רנדומלית איפה ימוקם המכשול. בעזרת הפונקציה move ניתן להזיז את המכשול – ולקבל חיווי האם אנחנו כבר מחוץ לגבולות המסך. לא כתבתי את זה קודם, כי כבר נגיע לזה – אנחנו לא מזיזים את המכוניות (רק למעלה/מטה). המכשולים הם אלו שזזים ללכיוון המכוניות.

נחזור ל- GameWorld. נוסיף type ומופע שלו:

public enum GameState {PLAY, GAME_OVER}
private GameState state = GameState.PLAY;

פונקציה ליצירת מכשולים:

private void spawnObstacles(){
    obstacles.add(new Obstacle(Direction.LEFT));
    obstacles.add(new Obstacle(Direction.RIGHT));

    lastObstacleSpawnTime = TimeUtils.nanoTime();
}

 והעיקר – פונקציית update:

public void update(float delta) {
        if (state != GameState.GAME_OVER) {
            float move = 5;
            for (Obstacle o : obstacles) {
                if (o.move(move)) {
                    // obstacle reach screen edge
                    obstacles.removeValue(o, false);
                    score++;
                    if (score % 10 == 0) {
                        // Harder!
                        spawnAfterTime -= 50000;
                        // Harder!
                        move += 0.1f;
                    }
                } else {
                    // we don't want to go to hard on the user.
                    // so Game_Over will be only if the close EDGE of obstacle is in car's rect
                    if (leftCar.contains(o.getRectangle().getX(), o.getRectangle().getY()) ||
                            rightCar.contains(o.getRectangle().getX() + o.getRectangle().getWidth(), o.getRectangle().getY())){
                        state = GameState.GAME_OVER;
                        // notify Screen that the game overed
                        gameCyclelistener.gameOver(score);
                    }
                }
            }
            // Check if it's time for new obstacles
            // We divide the nanoTimes in 100 - otherwise it's too much for long
            if ((TimeUtils.nanoTime() - lastObstacleSpawnTime) / 100 > spawnAfterTime) {
                spawnObstacles();
            }
        }
    }

בכל איטרציה – כל פעם שצריך לצייר מחדש על המסך – הפונקציה הזו תיקרא. קורים בה שני דברים חשובים:

1. מזיזים את כל המכשולים הקיימים במספר פיקסלים, ובודקים אם יש התנגשות בין אחת המכוניות לבין המכשולים, אם כן- מעדכנים את ה- listener שהמשחק נגמר (מייד נוסיף את ההגדרה שלו). כמו כן, מכשולים שיצאו מגבולות המסך ניתנים להסרה, ומעלים ניקוד.

2. כל x זמן יוצרים מכשולים חדשים.

ניצור package בשם api, וניצור בו interface בשם GameCycleListener:

public interface GameCycleListener {
    public void gamePause();
    public void gameOver(int score);
}

נוסיף לקונסטרקטור רישום של ה- listener הזה:

    public GameWorld(GameCycleListener gameCyclelistener){
        this.gameCyclelistener = gameCyclelistener;
        // the car images we use are 230x122 px
        float ratio = 230f / 122f;
        float carHeight = GameConstants.SCREEN_HEIGHT / 8;
        float carWidth = carHeight * ratio;

        int screenOffset = 10;
        leftCar = new Rectangle(screenOffset, GameConstants.SCREEN_HEIGHT - carHeight - GameConstants.SCREEN_HEIGHT / 16,
                carWidth, carHeight);
        rightCar = new Rectangle(GameConstants.SCREEN_WIDTH - carWidth - screenOffset, GameConstants.SCREEN_HEIGHT / 16,
                carWidth, carHeight);
    }

נעבור עכשיו ל- GameRenderer, כדי לצייר את כל זה על המסך.

ישנם מספר כלים בהם ניתן להשתמש ב- libGDX כדי לכתוב ל- buffer של התצוגה. אני משתמש כאן ב- ShapeRenderer כדי לצייר את הקווים שברקע, וב- SpriteBatch כדי לצייר תמונות וטקסט.

נעדכן את הקונסטרקטור:

    public GameRenderer(GameWorld world){
        this.gameWorld = world;

        cam = new OrthographicCamera();
        cam.setToOrtho(true, GameConstants.SCREEN_WIDTH, GameConstants.SCREEN_HEIGHT);

        batcher = new SpriteBatch();
        batcher.setProjectionMatrix(cam.combined);

        shapeRenderer = new ShapeRenderer();
        shapeRenderer.setProjectionMatrix(cam.combined);

        // create once the rects to the lines of bg, to save calculations time
        int lineWidth = 6;
        lines = new Array<Rectangle>();
        lines.add(new Rectangle(0, GameConstants.SCREEN_HEIGHT / 2 - lineWidth / 2, GameConstants.SCREEN_WIDTH, lineWidth));
        lines.add(new Rectangle(0, GameConstants.SCREEN_HEIGHT / 4 - lineWidth / 4, GameConstants.SCREEN_WIDTH, lineWidth/2));
        lines.add(new Rectangle(0, 3 * GameConstants.SCREEN_HEIGHT / 4 - lineWidth / 4, GameConstants.SCREEN_WIDTH, lineWidth/2));
    }

הקווים אותם נצייר (שהם בעצם מלבנים דקים), לא צריך לחשב מחדש את המיקום שלהם בכל פריים. נשמור את המיקום שלהם פעם אחת במערך ונרוץ עליו בכל איטרציה כדי לצייר אותם.

והפונקציה העיקרית, render:

  public void render(){
        // clear
        Gdx.gl.glClearColor(GameConstants.MAIN_COLOR.r,
                GameConstants.MAIN_COLOR.g, GameConstants.MAIN_COLOR.b, GameConstants.MAIN_COLOR.a);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        // create bg lines
        shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
        shapeRenderer.setColor(1, 1, 1, 1);
        for (Rectangle line : lines) {
            shapeRenderer.rect(line.getX(), line.getY(), line.getWidth(), line.getHeight());
        }
        shapeRenderer.end();

        // game objects
        batcher.begin();
        batcher.enableBlending();

        // obstacles
        for (Obstacle o : gameWorld.getObstacles()) {
            batcher.draw(AssetLoader.obstacle, o.getRectangle().getX(), o.getRectangle().getY(),
                    o.getRectangle().getWidth(), o.getRectangle().getHeight());
        }

        // left Car
        Rectangle left = gameWorld.getLeftCar();
        batcher.draw(AssetLoader.leftCar, left.getX(), left.getY(), left.getWidth(), left.getHeight());
        // right Car
        Rectangle right = gameWorld.getRightCar();
        batcher.draw(AssetLoader.rightCar, right.getX(), right.getY(), right.getWidth(), right.getHeight());

        // score
        // Draw shadow first
        AssetLoader.shadow.draw(batcher, "" + gameWorld.getScore(), 100, 25);
        // Draw text
        AssetLoader.font.draw(batcher, "" + gameWorld.getScore(), 99, 24);
        batcher.end();

    }

שתי השורות הראשונות הן קריאות ישירות ל- openGL כדי לנקות את מה שהיה מוצג קודם על המסך. תמיד פונקצייה שמציירת למסך  תתחיל בהן. הצבע שהמסך ״ינוקה״ איתו הוא זה שהגדרנו ב- GameConstants.

את התמונות אותן אני מצייר למסך (של המכוניות והמכשולים) אני טוען בתחילת המשחק. כבר נגיע לזה.

יותר יעיל ליצור תמונה של הרקע עם הקווים ולצייר אותה עם ה- SpriteBatch, במקום להשתמש בכל פעם ב- ShapreRenderer. אני לא עושה את זה משתי סיבות: 1. אני מתכנת ולא גרפיקאי. 2. זה נותן יותר דינמיות, אם רוצים לשנות צבע לכל אורך האפליקציה לא צריך ליצור מחדש את כל תמונות הרקע.

כדי לצייר את הניקוד אני משתמש בשני פונטים, כדי לקבל את האפקט של shaddow. אני מצייר את הניקוד פעמיים אחד על השני, בהפרש של פיקסל.

חשוב לא לשכוח:

   public void dispose(){
        shapeRenderer.dispose();
        batcher.dispose();
    }

אמנם זה java, ויש GC, אבל עדיין כל מה שהוא low level צריך תשומת לב מיוחדת.

את כל ה- resources בהם אנחנו משתמשים במהלך המשחק – תמונות, פונטים, קבצי קול – נטען אותם במחלקה בשם AssetLoader, אותה נוסיף ב- package שיצרנו קודם, helpers:

public class AssetLoader {
    // game textures
    public static Texture leftCar;
    public static Texture rightCar;
    public static Texture obstacle;
    // game over textures
    public static Texture restart;
    public static Texture back;
    // menu textures
    public static Texture play;
    // fonts
    public static BitmapFont font, shadow, title, klee;
    // preferences
    public static Preferences prefs;
    public static void load() {
        leftCar = new Texture(Gdx.files.internal("gfx/left.png"));
        rightCar = new Texture(Gdx.files.internal("gfx/right.png"));
        obstacle = new Texture(Gdx.files.internal("gfx/obstacle.png"));

        restart = new Texture(Gdx.files.internal("gfx/reload.png"));
        back = new Texture(Gdx.files.internal("gfx/back.png"));
        play = new Texture(Gdx.files.internal("gfx/play.png"));

        float fontSize = .60f;
        font = new BitmapFont(Gdx.files.internal("fonts/text.fnt"));
        font.getData().setScale(fontSize, -fontSize);
        shadow = new BitmapFont(Gdx.files.internal("fonts/shadow.fnt"));
        shadow.getData().setScale(fontSize, -fontSize);

        klee = new BitmapFont(Gdx.files.internal("fonts/klee.fnt"));
        klee.getData().setScale(fontSize, -fontSize);

        title = new BitmapFont(Gdx.files.internal("fonts/title.fnt"));
        title.getData().setScale(1.5f, -1.5f);
    }

    public static void dispose() {
        leftCar.dispose();
        rightCar.dispose();

        play.dispose();
        back.dispose();
        restart.dispose();

        font.dispose();
        shadow.dispose();
        title.dispose();
        klee.dispose();
    }
}

נקרא לפונקציה הסטטית load בזמן שהאפליקציה עולה, ול- dispose כשהאפליקציה יורדת.

Control

אבל איך שולטים על המכוניות?

בשביל זה ניצור מחלקה שמממשת interface בשם InputProcessor (נוסיף אותה גם ב- helpers):

public class InputHandler implements InputProcessor {
    private GameWorld world;
    private Camera camera;
    Vector3 point;

    public InputHandler(GameWorld world, Camera cam) {
        this.world = world;
        this.camera = cam;

        this.point = new Vector3();
    }

    @Override
    public boolean keyTyped(char character) {
        if (world.getState() != GameWorld.GameState.GAME_OVER) {
            if (character == 'z') {
                this.world.touched(Direction.LEFT);
                return true;
            } else if (character == 'a') {
                this.world.touched(Direction.RIGHT);
                return true;
            }
        }
        return false;
    }

    @Override
    public boolean touchDown(int screenX, int screenY, int pointer, int button) {
        point.set(screenX, screenY, 0); // Translate to world coordinates.
        point = camera.unproject(point);

        if (world.getState() != GameWorld.GameState.GAME_OVER) {
            if (point.y < GameConstants.SCREEN_HEIGHT / 2) {
                this.world.touched(Direction.RIGHT);
            } else {
                this.world.touched(Direction.LEFT);
            }
        } else {

        }

        return true;
    }

...

}

מכל ה- events שיש ב- InputProcessor מעניינים אותנו רק שניים: touchDown, ו- keyTyped.

keyTyped הוא בשביל לשחק בדסקטופ בעזרת המקשים a ו- z.

touchDown הוא בשביל touch של מובייל, וגם לזהות לחיצות עכבר.

בקונסטרקטור נשמור reference אל ה- GameWorld כדי שנוכל לעדכן אותו במידת הצורך שהתרחש event. בנוסף, נשמור reference אל המצלמה איתה אנחנו עובדים. זה חשוב, כדי לתרגם את ה- x/y שקיבלנו למערכת הצירים של העולם שאנחנו עובדים איתו. כלומר, מה שמתקבל בפונקצייה touchDown זה ביחס למסך הפיזי – חייבים להמיר את זה לפני השימוש.

עכשיו צריך לחזור ל- GameWorld ולהוסיף פונקצית touched:

    public void touched(Direction direction) {
        if (direction == Direction.LEFT) {
            if (leftCarPosition == Direction.UP) {
                leftCar.setY(leftCar.getY() + pathHeight);
                leftCarPosition = Direction.DOWN;
            } else {
                leftCar.setY(leftCar.getY() - pathHeight);
                leftCarPosition = Direction.UP;
            }
        } else {
            if (rightCarPosition == Direction.UP) {
                rightCar.setY(rightCar.getY() + pathHeight);
                rightCarPosition = Direction.DOWN;
            } else {
                rightCar.setY(rightCar.getY() - pathHeight);
                rightCarPosition = Direction.UP;
            }
        }
    }

Screens

עד כאן הכל טוב – יצרנו את הלוגיקה של המשחק, ראינו איך לצייר אותו למסך, לטעון תמונות, לקבל input מבחוץ. אבל איך משתמשים בכל זה?

כשיצרנו את הפרוייקט, ב- core module היתה רק מחלקה אחת, שנקראת TwoCars. זו ה- entry point של כל המשחק.

נשנה את הקוד שייראה כך:

public class TwoCars extends Game {

	@Override
	public void create () {
		AssetLoader.load();
		setScreen(new MenuScreen(this));
	}

	@Override
	public void dispose () {
		AssetLoader.dispose();
	}
}

יש כאן מספר שינויים. דבר ראשון, במקום לרשת מ- ApplicationAdapter, אנחנו יורשים מ- Game.

Game הוא בעצם הרחבה של ApplicationAdapter, שנותן לנו בנוסף להשתמש ב- SetScreen (כבר נגיע אליו).

בנוסף, בהתחלה (create) אנחנו טוענים את ה- resources, ובסוף (dispose) משחררים אותם.

במשחק שלנו ישנם שלושה מסכים:

מסך פתיחה – MenuScreen.

מסך משחק – GameScreen.

מסך סיום – GameOverScreen.

כל אחד משלושת המסכים האלו מממש interface שנקרא Screen. לכל מסך יש מחזור חיים משלו, עם events כמו show, pause, resume והכי חשוב – render.

את מסכי הפתיחה והסיום אין סיבה לפרק לעוד מחלקות, כי הם די בסיסיים. רק בשביל מסך המשחק נעזר במחלקות GameWorld ו- GameRenderer.

ניצור package חדש בשם screens. עם 3 מחלקות למסכים הנ״ל.

MenuScreen:

public class MenuScreen implements Screen {
    private Game game;

    private OrthographicCamera cam;
    private SpriteBatch batcher;

    private Rectangle startButton;

    private float titleWidth;

    public MenuScreen(Game game) {
        this.game = game;

        cam = new OrthographicCamera();
        cam.setToOrtho(true, GameConstants.SCREEN_WIDTH, GameConstants.SCREEN_HEIGHT);

        batcher = new SpriteBatch();
        batcher.setProjectionMatrix(cam.combined);

        int buttonSize = 100;

        startButton = new Rectangle((GameConstants.SCREEN_WIDTH - buttonSize) / 2,
                GameConstants.SCREEN_HEIGHT / 2, buttonSize, buttonSize);

        GlyphLayout layout = new GlyphLayout();
        layout.setText(AssetLoader.title, "TWO CARS");
        titleWidth = layout.width;
    }

    @Override
    public void render(float delta) {
        Gdx.gl.glClearColor(GameConstants.MAIN_COLOR.r,
                GameConstants.MAIN_COLOR.g, GameConstants.MAIN_COLOR.b, GameConstants.MAIN_COLOR.a);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        batcher.begin();
        batcher.enableBlending();
        // title
        AssetLoader.title.draw(batcher, "TWO CARS", (GameConstants.SCREEN_WIDTH - titleWidth) / 2 - 1, 40);

        batcher.draw(AssetLoader.play, startButton.getX(), startButton.getY(), startButton.getWidth(), startButton.getHeight());
        batcher.end();

        if(Gdx.input.isTouched()) {
            Vector3 touchPos = new Vector3();
            touchPos.set(Gdx.input.getX(), Gdx.input.getY(), 0);
            touchPos = cam.unproject(touchPos);

            if (startButton.contains(touchPos.x, touchPos.y)){
                game.setScreen(new GameScreen(game));
            }
        }
    }

    ...
}

בקונסטרקטור של המחלקה נקבע את המיקום של כפתור Start. בכל פעם שהמסך יתרענן הפונקציה render תיקרא, שם נצייר את הכפתור.

מכיוון שמדובר סה״כ על מסך פשוט, במקום ליצור מחלקה שמממשת InputProcessor, פשוט נבדוק את Gdx.input.isTouched – זה מספיק עבור event פשוט של touch. אם הלחיצה אכן היתה בתוך ה- Rectangle של הכפתור – נעבור למסך הבא.

 וזה הקוד של GameScreen:

public class GameScreen implements Screen, GameCycleListener {
    private GameWorld world;
    private GameRenderer renderer;
    private Game game;

    public GameScreen(Game game) {
        this.game = game;

        world = new GameWorld(this); // initialize world
        renderer = new GameRenderer(world); // initialize renderer

        Gdx.input.setInputProcessor(new InputHandler(world, renderer.getCam()));
    }

    @Override
    public void render(float delta) {
        world.update(delta);
        renderer.render();
    }

    ...

    // gameCycle methods
    @Override
    public void gameOver(int score) {
        game.setScreen(new GameOverScreen(game, score));
    }

    @Override
    public void gamePause() {

    }
}

הרעיון דומה למה שראינו ב- MenuScreen, ההבדל הוא שאנחנו לא מרנדרים במקום, אלא מעבירים את זה לאובייקטים שאחראיים על כך. כנ״ל לגבי ה- input, אנחנו נרשמים בהתחלה לאובייקט שיצרנו, כך שכל עדכוני ה- input יגיעו לשם.

GameScreen גם מממש interface שיצרנו – GameCycleListener. כך שכאשר המשחק מסתיים נעבור למסך GameOver.

והקוד של GameOverScreen:

public class GameOverScreen implements Screen {
    private Game game;
    private int score;

    private OrthographicCamera cam;
    private SpriteBatch batcher;

    private Rectangle backButton;
    private Rectangle restartButton;

    private float gameOverTextWidth;
    private float scoreWidth;
    private float bestScoreWidth;
    private boolean newHighScore = false;

    public GameOverScreen(Game game, int score) {
        this.game = game;
        this.score = score;

        int currentHighScore = AssetLoader.getHighScore();
        if (score > currentHighScore) {
            AssetLoader.setHighScore(score);
            newHighScore = true;
        }

        cam = new OrthographicCamera();
        cam.setToOrtho(true, GameConstants.SCREEN_WIDTH, GameConstants.SCREEN_HEIGHT);

        batcher = new SpriteBatch();
        batcher.setProjectionMatrix(cam.combined);

        int buttonSize = 100;
        int offset = 10;

        backButton = new Rectangle(GameConstants.SCREEN_WIDTH / 2 - (buttonSize + offset),
                3 * GameConstants.SCREEN_HEIGHT / 4, buttonSize, buttonSize);
        restartButton = new Rectangle(GameConstants.SCREEN_WIDTH / 2 + offset,
                3 * GameConstants.SCREEN_HEIGHT / 4, buttonSize, buttonSize);

        GlyphLayout layout = new GlyphLayout();
        layout.setText(AssetLoader.title, "GAME OVER");
        gameOverTextWidth = layout.width;

        layout.setText(AssetLoader.klee, "YOUR SCORE: " + score);
        scoreWidth = layout.width;

        layout.setText(AssetLoader.klee, "HighScore: " + AssetLoader.getHighScore());
        bestScoreWidth = layout.width;
    }

    @Override
    public void render(float delta) {
        Gdx.gl.glClearColor(GameConstants.MAIN_COLOR.r,
                GameConstants.MAIN_COLOR.g, GameConstants.MAIN_COLOR.b, GameConstants.MAIN_COLOR.a);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        batcher.begin();
        batcher.enableBlending();
        batcher.draw(AssetLoader.back, backButton.getX(), backButton.getY(), backButton.getWidth(), backButton.getHeight());
        batcher.draw(AssetLoader.restart, restartButton.getX(), restartButton.getY(), restartButton.getWidth(), restartButton.getHeight());

        // title
        AssetLoader.title.draw(batcher, "GAME OVER", (GameConstants.SCREEN_WIDTH - gameOverTextWidth) / 2 - 1, 24);

        // score
        AssetLoader.klee.draw(batcher, "YOUR SCORE: " + score, (GameConstants.SCREEN_WIDTH - scoreWidth) / 2, GameConstants.SCREEN_HEIGHT / 4);

        if (AssetLoader.getHighScore() > 0) {
            AssetLoader.klee.draw(batcher, "HighScore: " + AssetLoader.getHighScore() , (GameConstants.SCREEN_WIDTH - bestScoreWidth) / 2, GameConstants.SCREEN_HEIGHT / 2);
        }

        if (newHighScore) {

        }

        batcher.end();

        if(Gdx.input.isTouched()) {
            Vector3 touchPos = new Vector3();
            touchPos.set(Gdx.input.getX(), Gdx.input.getY(), 0);
            cam.unproject(touchPos);

            if (backButton.contains(touchPos.x, touchPos.y)){
                game.setScreen(new MenuScreen(game));
            } else if (restartButton.contains(touchPos.x, touchPos.y)) {
                game.setScreen(new GameScreen(game));
            }
        }
    }
}

סיכום

בפוסט הזה הצגתי משחק פשוט, שניתן ליצור רבים כמותו בלי יותר מדי מאמץ. מכיוון שזה פוסט ראשון בנושא לא נכנסתי לפרטים הקטנים של כל דבר, אני מקווה להרחיב בפוסטים אחרים.

נושאים שלא כתבתי עליהם כאן (וכדאי להכיר):

1. גדלי מסך – איך להתאים את המשחק לרזולוציות ויחסי מסך שונים.

2. פיזיקה – אפשר היה לממש את ההתנגשויות של המכוניות במכשולים בעזרת המנוע הפיזיקלי של libGDX – לתת תכונות אמיתיות למכוניות ולמכשולים, וכך לזהות את ההתנגשויות. לא עשיתי את זה כי זה די מורכב בשביל משחק ראשון, וגם לא ממש חייבים את זה במשחק הזה.

3. יצירת פונטים – השתמשתי כאן במספר פונטים שהכנתי. אפשר להכין כאלו בעזרת כלי ש- libGDX מספקת, בשם Hiero.

4. UI – בשביל ליצור כפתורים, ושאר widgets, יש דרך יותר טובה מאשר ליצור תמונה וכו׳ כמו שעשיתי כאן – Scene2D.

את הקוד של המשחק אפשר למצוא כאן.

 

מודעות פרסומת

להשאיר תגובה

הזינו את פרטיכם בטופס, או לחצו על אחד מהאייקונים כדי להשתמש בחשבון קיים:

הלוגו של WordPress.com

אתה מגיב באמצעות חשבון WordPress.com שלך. לצאת מהמערכת / לשנות )

תמונת Twitter

אתה מגיב באמצעות חשבון Twitter שלך. לצאת מהמערכת / לשנות )

תמונת Facebook

אתה מגיב באמצעות חשבון Facebook שלך. לצאת מהמערכת / לשנות )

תמונת גוגל פלוס

אתה מגיב באמצעות חשבון Google+ שלך. לצאת מהמערכת / לשנות )

מתחבר ל-%s