ZetCode

Java Sokoban

最后修改于 2023 年 1 月 10 日

在本部分的 Java 2D 游戏教程中,我们创建一个 Java Sokoban 游戏克隆。源代码和图片可以在作者的 Github Java-Sokoban-Game 仓库中找到。

Sokoban

Sokoban 是另一款经典的电脑游戏。它由今林博之 (Hiroyuki Imabayashi) 于 1980 年创建。Sokoban 在日语中的意思是仓库管理员。玩家在迷宫中推箱子。目标是将所有箱子放到指定位置。

用 Java 开发 Sokoban 游戏

我们使用光标键控制 sokoban 对象。我们也可以按 R 键重新开始关卡。当所有包裹都放在目标区域时,游戏结束。我们在窗口左上角绘制“已完成”字符串。

Board.java
package com.zetcode;

import java.awt.Color;
import java.awt.Graphics;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.util.ArrayList;
import javax.swing.JPanel;

public class Board extends JPanel {

    private final int OFFSET = 30;
    private final int SPACE = 20;
    private final int LEFT_COLLISION = 1;
    private final int RIGHT_COLLISION = 2;
    private final int TOP_COLLISION = 3;
    private final int BOTTOM_COLLISION = 4;

    private ArrayList<Wall> walls;
    private ArrayList<Baggage> baggs;
    private ArrayList<Area> areas;
    
    private Player soko;
    private int w = 0;
    private int h = 0;
    
    private boolean isCompleted = false;

    private String level
            = "    ######\n"
            + "    ##   #\n"
            + "    ##$  #\n"
            + "  ####  $##\n"
            + "  ##  $ $ #\n"
            + "#### # ## #   ######\n"
            + "##   # ## #####  ..#\n"
            + "## $  $          ..#\n"
            + "###### ### #@##  ..#\n"
            + "    ##     #########\n"
            + "    ########\n";

    public Board() {

        initBoard();
    }

    private void initBoard() {

        addKeyListener(new TAdapter());
        setFocusable(true);
        initWorld();
    }

    public int getBoardWidth() {
        return this.w;
    }

    public int getBoardHeight() {
        return this.h;
    }

    private void initWorld() {
        
        walls = new ArrayList<>();
        baggs = new ArrayList<>();
        areas = new ArrayList<>();

        int x = OFFSET;
        int y = OFFSET;

        Wall wall;
        Baggage b;
        Area a;

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

            char item = level.charAt(i);

            switch (item) {

                case '\n':
                    y += SPACE;

                    if (this.w < x) {
                        this.w = x;
                    }

                    x = OFFSET;
                    break;

                case '#':
                    wall = new Wall(x, y);
                    walls.add(wall);
                    x += SPACE;
                    break;

                case '$':
                    b = new Baggage(x, y);
                    baggs.add(b);
                    x += SPACE;
                    break;

                case '.':
                    a = new Area(x, y);
                    areas.add(a);
                    x += SPACE;
                    break;

                case '@':
                    soko = new Player(x, y);
                    x += SPACE;
                    break;

                case ' ':
                    x += SPACE;
                    break;

                default:
                    break;
            }

            h = y;
        }
    }

    private void buildWorld(Graphics g) {

        g.setColor(new Color(250, 240, 170));
        g.fillRect(0, 0, this.getWidth(), this.getHeight());

        ArrayList<Actor> world = new ArrayList<>();

        world.addAll(walls);
        world.addAll(areas);
        world.addAll(baggs);
        world.add(soko);

        for (int i = 0; i < world.size(); i++) {

            Actor item = world.get(i);

            if (item instanceof Player || item instanceof Baggage) {
                
                g.drawImage(item.getImage(), item.x() + 2, item.y() + 2, this);
            } else {
                
                g.drawImage(item.getImage(), item.x(), item.y(), this);
            }

            if (isCompleted) {
                
                g.setColor(new Color(0, 0, 0));
                g.drawString("Completed", 25, 20);
            }

        }
    }

    @Override
    public void paintComponent(Graphics g) {
        super.paintComponent(g);

        buildWorld(g);
    }

    private class TAdapter extends KeyAdapter {

        @Override
        public void keyPressed(KeyEvent e) {

            if (isCompleted) {
                return;
            }

            int key = e.getKeyCode();

            switch (key) {
                
                case KeyEvent.VK_LEFT:
                    
                    if (checkWallCollision(soko,
                            LEFT_COLLISION)) {
                        return;
                    }
                    
                    if (checkBagCollision(LEFT_COLLISION)) {
                        return;
                    }
                    
                    soko.move(-SPACE, 0);
                    
                    break;
                    
                case KeyEvent.VK_RIGHT:
                    
                    if (checkWallCollision(soko, RIGHT_COLLISION)) {
                        return;
                    }
                    
                    if (checkBagCollision(RIGHT_COLLISION)) {
                        return;
                    }
                    
                    soko.move(SPACE, 0);
                    
                    break;
                    
                case KeyEvent.VK_UP:
                    
                    if (checkWallCollision(soko, TOP_COLLISION)) {
                        return;
                    }
                    
                    if (checkBagCollision(TOP_COLLISION)) {
                        return;
                    }
                    
                    soko.move(0, -SPACE);
                    
                    break;
                    
                case KeyEvent.VK_DOWN:
                    
                    if (checkWallCollision(soko, BOTTOM_COLLISION)) {
                        return;
                    }
                    
                    if (checkBagCollision(BOTTOM_COLLISION)) {
                        return;
                    }
                    
                    soko.move(0, SPACE);
                    
                    break;
                    
                case KeyEvent.VK_R:
                    
                    restartLevel();
                    
                    break;
                    
                default:
                    break;
            }

            repaint();
        }
    }

    private boolean checkWallCollision(Actor actor, int type) {

        switch (type) {
            
            case LEFT_COLLISION:
                
                for (int i = 0; i < walls.size(); i++) {
                    
                    Wall wall = walls.get(i);
                    
                    if (actor.isLeftCollision(wall)) {
                        
                        return true;
                    }
                }
                
                return false;
                
            case RIGHT_COLLISION:
                
                for (int i = 0; i < walls.size(); i++) {
                    
                    Wall wall = walls.get(i);
                    
                    if (actor.isRightCollision(wall)) {
                        return true;
                    }
                }
                
                return false;
                
            case TOP_COLLISION:
                
                for (int i = 0; i < walls.size(); i++) {
                    
                    Wall wall = walls.get(i);
                    
                    if (actor.isTopCollision(wall)) {
                        
                        return true;
                    }
                }
                
                return false;
                
            case BOTTOM_COLLISION:
                
                for (int i = 0; i < walls.size(); i++) {
                    
                    Wall wall = walls.get(i);
                    
                    if (actor.isBottomCollision(wall)) {
                        
                        return true;
                    }
                }
                
                return false;
                
            default:
                break;
        }
        
        return false;
    }

    private boolean checkBagCollision(int type) {

        switch (type) {
            
            case LEFT_COLLISION:
                
                for (int i = 0; i < baggs.size(); i++) {

                    Baggage bag = baggs.get(i);

                    if (soko.isLeftCollision(bag)) {

                        for (int j = 0; j < baggs.size(); j++) {
                            
                            Baggage item = baggs.get(j);
                            
                            if (!bag.equals(item)) {
                                
                                if (bag.isLeftCollision(item)) {
                                    return true;
                                }
                            }
                            
                            if (checkWallCollision(bag, LEFT_COLLISION)) {
                                return true;
                            }
                        }
                        
                        bag.move(-SPACE, 0);
                        isCompleted();
                    }
                }
                
                return false;
                
            case RIGHT_COLLISION:
                
                for (int i = 0; i < baggs.size(); i++) {

                    Baggage bag = baggs.get(i);
                    
                    if (soko.isRightCollision(bag)) {
                        
                        for (int j = 0; j < baggs.size(); j++) {

                            Baggage item = baggs.get(j);
                            
                            if (!bag.equals(item)) {
                                
                                if (bag.isRightCollision(item)) {
                                    return true;
                                }
                            }
                            
                            if (checkWallCollision(bag, RIGHT_COLLISION)) {
                                return true;
                            }
                        }
                        
                        bag.move(SPACE, 0);
                        isCompleted();
                    }
                }
                return false;
                
            case TOP_COLLISION:
                
                for (int i = 0; i < baggs.size(); i++) {

                    Baggage bag = baggs.get(i);
                    
                    if (soko.isTopCollision(bag)) {
                        
                        for (int j = 0; j < baggs.size(); j++) {

                            Baggage item = baggs.get(j);

                            if (!bag.equals(item)) {
                                
                                if (bag.isTopCollision(item)) {
                                    return true;
                                }
                            }
                            
                            if (checkWallCollision(bag, TOP_COLLISION)) {
                                return true;
                            }
                        }
                        
                        bag.move(0, -SPACE);
                        isCompleted();
                    }
                }

                return false;
                
            case BOTTOM_COLLISION:
                
                for (int i = 0; i < baggs.size(); i++) {

                    Baggage bag = baggs.get(i);
                    
                    if (soko.isBottomCollision(bag)) {
                        
                        for (int j = 0; j < baggs.size(); j++) {

                            Baggage item = baggs.get(j);
                            
                            if (!bag.equals(item)) {
                                
                                if (bag.isBottomCollision(item)) {
                                    return true;
                                }
                            }
                            
                            if (checkWallCollision(bag,BOTTOM_COLLISION)) {
                                
                                return true;
                            }
                        }
                        
                        bag.move(0, SPACE);
                        isCompleted();
                    }
                }
                
                break;
                
            default:
                break;
        }

        return false;
    }

    public void isCompleted() {

        int nOfBags = baggs.size();
        int finishedBags = 0;

        for (int i = 0; i < nOfBags; i++) {
            
            Baggage bag = baggs.get(i);
            
            for (int j = 0; j < nOfBags; j++) {
                
                Area area =  areas.get(j);
                
                if (bag.x() == area.x() && bag.y() == area.y()) {
                    
                    finishedBags += 1;
                }
            }
        }

        if (finishedBags == nOfBags) {
            
            isCompleted = true;
            repaint();
        }
    }

    public void restartLevel() {

        areas.clear();
        baggs.clear();
        walls.clear();

        initWorld();

        if (isCompleted) {
            isCompleted = false;
        }
    }
}

游戏被简化了。它只提供了最基本的功能。因此代码更容易理解。游戏只有一个关卡。

private final int OFFSET = 30;
private final int SPACE = 20;
private final int LEFT_COLLISION = 1;
private final int RIGHT_COLLISION = 2;
private final int TOP_COLLISION = 3;
private final int BOTTOM_COLLISION = 4;

墙的图像尺寸是 20x20 像素。这反映了 SPACE 常量。OFFSET 是窗口边框和游戏世界之间的距离。有四种类型的碰撞。每种碰撞都由一个数字常量表示。

private ArrayList<Wall> walls;
private ArrayList<Baggage> baggs;
private ArrayList<Area> areas;

墙、包裹和区域是特殊的容器,它们容纳了游戏中的所有墙、包裹和区域。

private String level =
          "    ######\n"
        + "    ##   #\n"
        + "    ##$  #\n"
        + "  ####  $##\n"
        + "  ##  $ $ #\n"
        + "#### # ## #   ######\n"
        + "##   # ## #####  ..#\n"
        + "## $  $          ..#\n"
        + "###### ### #@##  ..#\n"
        + "    ##     #########\n"
        + "    ########\n";

这是游戏关卡。除了空格,还有五个字符。井号(#)代表墙。美元符号($)代表要移动的箱子。点(.)字符代表我们必须移动箱子的位置。at 字符(@)是 sokoban。最后,换行符(\n)开始新的一行世界。

private void initWorld() {
    
    walls = new ArrayList<>();
    baggs = new ArrayList<>();
    areas = new ArrayList<>();

    int x = OFFSET;
    int y = OFFSET;
...

initWorld() 方法初始化游戏世界。它遍历关卡字符串并填充上述列表。

case '$':
    b = new Baggage(x, y);
    baggs.add(b);
    x += SPACE;
    break;

对于美元字符,我们创建一个 Baggage 对象。该对象被附加到 baggs 列表中。x 变量相应地增加。

private void buildWorld(Graphics g) {
...

buildWorld() 方法在窗口上绘制游戏世界。

ArrayList<Actor> world = new ArrayList<>();

world.addAll(walls);
world.addAll(areas);
world.addAll(baggs);
world.add(soko);

我们创建一个世界列表,其中包含游戏的所有对象。

for (int i = 0; i < world.size(); i++) {

    Actor item = world.get(i);

    if (item instanceof Player || item instanceof Baggage) {
        
        g.drawImage(item.getImage(), item.x() + 2, item.y() + 2, this);
    } else {
        
        g.drawImage(item.getImage(), item.x(), item.y(), this);
    }
...
}

我们遍历世界容器并绘制对象。玩家和包裹的图像稍微小一些。我们将 2px 加到它们的坐标上以使它们居中。

if (isCompleted) {
    
    g.setColor(new Color(0, 0, 0));
    g.drawString("Completed", 25, 20);
}

如果关卡完成,我们在窗口的左上角绘制“已完成”。

case KeyEvent.VK_LEFT:
    
    if (checkWallCollision(soko,
            LEFT_COLLISION)) {
        return;
    }
    
    if (checkBagCollision(LEFT_COLLISION)) {
        return;
    }
    
    soko.move(-SPACE, 0);
    
    break;

keyPressed() 方法中,我们检查按下了什么键。我们使用光标键控制 sokoban 对象。如果我们按下左光标键,我们会检查 sokoban 是否与墙壁或包裹发生碰撞。如果没有,我们将 sokoban 向左移动。

case KeyEvent.VK_R:
    
    restartLevel();
    
    break;

如果我们按下 R 键,我们会重新开始关卡。

case LEFT_COLLISION:
    
    for (int i = 0; i < walls.size(); i++) {
        
        Wall wall = walls.get(i);
        
        if (actor.isLeftCollision(wall)) {
            
            return true;
        }
    }
    
    return false;

创建了 checkWallCollision() 方法以确保 sokoban 或包裹不会穿过墙壁。有四种类型的碰撞。上面的几行检查左侧碰撞。

private boolean checkBagCollision(int type) {
...
}

checkBagCollision() 涉及的内容更多一些。包裹可能与墙壁、sokoban 对象或其他包裹发生碰撞。只有当包裹与 sokoban 碰撞并且不与其他包裹或墙壁碰撞时,才能移动包裹。当包裹被移动时,就该通过调用 isCompleted() 方法来检查关卡是否完成。

for (int i = 0; i < nOfBags; i++) {
    
    Baggage bag = baggs.get(i);
    
    for (int j = 0; j < nOfBags; j++) {
        
        Area area =  areas.get(j);
        
        if (bag.x() == area.x() && bag.y() == area.y()) {
            
            finishedBags += 1;
        }
    }
}

isCompleted() 方法检查关卡是否完成。我们获取包裹的数量。我们比较所有包裹和目标区域的 x 和 y 坐标。

if (finishedBags == nOfBags) {
    
    isCompleted = true;
    repaint();
}

finishedBags 变量等于游戏中包裹的数量时,游戏就结束了。

private void restartLevel() {

    areas.clear();
    baggs.clear();
    walls.clear();

    initWorld();

    if (isCompleted) {
        isCompleted = false;
    }
}

如果我们犯了错误,我们可以重新开始关卡。我们从列表中删除所有对象并重新初始化世界。isCompleted 变量设置为 false。

Actor.java
package com.zetcode;

import java.awt.Image;

public class Actor {

    private final int SPACE = 20;

    private int x;
    private int y;
    private Image image;

    public Actor(int x, int y) {
        
        this.x = x;
        this.y = y;
    }

    public Image getImage() {
        return image;
    }

    public void setImage(Image img) {
        image = img;
    }

    public int x() {
        
        return x;
    }

    public int y() {
        
        return y;
    }

    public void setX(int x) {
        
        this.x = x;
    }

    public void setY(int y) {
        
        this.y = y;
    }

    public boolean isLeftCollision(Actor actor) {
        
        return x() - SPACE == actor.x() && y() == actor.y();
    }

    public boolean isRightCollision(Actor actor) {
        
        return x() + SPACE == actor.x() && y() == actor.y();
    }

    public boolean isTopCollision(Actor actor) {
        
        return y() - SPACE == actor.y() && x() == actor.x();
    }

    public boolean isBottomCollision(Actor actor) {
        
        return y() + SPACE == actor.y() && x() == actor.x();
    }
}

这是 Actor 类。该类是 Sokoban 游戏中其他 Actor 的基类。它封装了 Sokoban 游戏中对象的基本功能。

public boolean isLeftCollision(Actor actor) {
    
    return x() - SPACE == actor.x() && y() == actor.y();
}

此方法检查 Actor 是否与左侧的另一个 Actor(墙壁、包裹、sokoban)发生碰撞。

Wall.java
package com.zetcode;

import java.awt.Image;
import javax.swing.ImageIcon;

public class Wall extends Actor {

    private Image image;

    public Wall(int x, int y) {
        super(x, y);
        
        initWall();
    }
    
    private void initWall() {
        
        ImageIcon iicon = new ImageIcon("src/resources/wall.png");
        image = iicon.getImage();
        setImage(image);
    }
}

这是 Wall 类。它继承自 Actor 类。在构造时,它从资源加载墙壁图像。

Player.java
package com.zetcode;

import java.awt.Image;
import javax.swing.ImageIcon;

public class Player extends Actor {

    public Player(int x, int y) {
        super(x, y);

        initPlayer();
    }

    private void initPlayer() {

        ImageIcon iicon = new ImageIcon("src/resources/sokoban.png");
        Image image = iicon.getImage();
        setImage(image);
    }

    public void move(int x, int y) {

        int dx = x() + x;
        int dy = y() + y;
        
        setX(dx);
        setY(dy);
    }
}

这是 Player 类。

public void move(int x, int y) {

    int dx = x() + x;
    int dy = y() + y;
    
    setX(dx);
    setY(dy);
}

move() 方法将对象移动到世界中。

Baggage.java
package com.zetcode;

import java.awt.Image;
import javax.swing.ImageIcon;

public class Baggage extends Actor {

    public Baggage(int x, int y) {
        super(x, y);
        
        initBaggage();
    }
    
    private void initBaggage() {
        
        ImageIcon iicon = new ImageIcon("src/resources/baggage.png");
        Image image = iicon.getImage();
        setImage(image);
    }

    public void move(int x, int y) {
        
        int dx = x() + x;
        int dy = y() + y;
        
        setX(dx);
        setY(dy);
    }
}

这是 Baggage 对象的类。这个对象是可移动的,所以它也有 move() 方法。

Area.java
package com.zetcode;

import java.awt.Image;
import javax.swing.ImageIcon;

public class Area extends Actor {

    public Area(int x, int y) {
        super(x, y);
        
        initArea();
    }
    
    private void initArea() {

        ImageIcon iicon = new ImageIcon("src/resources/area.png");
        Image image = iicon.getImage();
        setImage(image);
    }
}

这是 Area 类。它是我们尝试放置包裹的对象。

Sokoban.java
package com.zetcode;

import java.awt.EventQueue;
import javax.swing.JFrame;

public class Sokoban extends JFrame {

    private final int OFFSET = 30;

    public Sokoban() {

        initUI();
    }

    private void initUI() {
        
        Board board = new Board();
        add(board);

        setTitle("Sokoban");
        
        setSize(board.getBoardWidth() + OFFSET,
                board.getBoardHeight() + 2 * OFFSET);
        
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        setLocationRelativeTo(null);
    }

    public static void main(String[] args) {
        
        EventQueue.invokeLater(() -> {
            
            Sokoban game = new Sokoban();
            game.setVisible(true);
        });
    }
}

这是主类。

Sokoban
图:Sokoban

这就是 Sokoban 游戏。