在Swing应用程序中,游戏循环应该放在哪里?

7

我正在尝试使用Java制作一个简单的2D游戏。
据我所知,我的游戏应该由两个线程组成:"事件分派线程"(用于GUI操作)和"游戏线程"(用于游戏循环)。
我创建了一个概述,但无法找到游戏循环的位置。
简而言之,我正在尝试创建一个游戏循环而不会冻结我的UI线程。
如果您能提供关于我做错事情的任何信息,我将不胜感激。
这是我的游戏循环(您还可以提供创建更好游戏循环的提示):

while(true) {
    repaint();
    try {
        Thread.sleep(17);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

import java.awt.Color;
import java.awt.Graphics;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;

import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;

public class Main {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                createAndShowGUI();

            }

        });
    }
    private static void createAndShowGUI() {
        JFrame frame = new JFrame("Forge and Attack");
        frame.setExtendedState(JFrame.MAXIMIZED_BOTH);
        frame.setUndecorated(true);
        frame.setVisible(true);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setFocusable(true);
        frame.add(new MyPanel()); 
    }   
}
class MyPanel extends JPanel implements KeyListener, MouseListener {
    public MyPanel() {
        setBackground(Color.BLACK);
        setOpaque(true);
        addKeyListener(this);
        addMouseListener(new MouseAdapter(){
            public void mousePressed(MouseEvent e){
                
            }
        });
    }
    @Override
    public void paint(Graphics g) {
        
    }
}

@PaulSamsotha 1. 我知道我们必须使用线程,以便游戏不会占用CPU。我能否只使用thread.sleep()而不是swing定时器?2. paint和paintcomponent之间的区别是什么?3. 为什么?如果我不使用这个,我的窗口就会消失。谢谢你的帮助。 - Kayaba Akihiko
以下是我在上述问题上发布的一些帖子,可能对您有用,并展示了从简单到复杂的各种游戏循环类型,如while循环、定时器、固定和可变步骤:1)https://stackoverflow.com/a/65759229/1133011,2)https://dev59.com/XnPYa4cB1Zd3GeqPoOL3#17374179,3)https://dev59.com/s2vXa4cB1Zd3GeqPML7S#13740162,4)https://dev59.com/_mzXa4cB1Zd3GeqPTGdw#14001011,5)https://stackoverflow.com/a/13792012/1133011。 - David Kroukamp
paint() 是 Swing 系统用来绘制组件及其所有子组件的方法。您应该重写 paintComponent() 方法,以避免意外破坏子组件、背景等的绘制。 - NomadMaker
1个回答

10

我认为这是一个有趣的主题,值得探讨... 我已经回答了你提出的问题,并展示了一些更好或正确的做法,比如绘画、监听按键以及其他一些内容,如关注点分离和使整个游戏更具可重用性/扩展性。

1. 游戏循环应该放在哪里?

所以这不是一件轻松的事情,可能取决于每个人的编码风格,但我们真正想要实现的就是创建游戏循环并在适当的时间开始它。我相信代码本身就会说明问题,下面是一些代码,以最小化的方式(仍然产生有效的工作示例)展示了游戏循环可以被创建/放置和在代码中使用的地方。 代码经过详细注释,以便于理解:

import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.ArrayList;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.imageio.ImageIO;
import javax.swing.AbstractAction;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;

public class MyGame {

    private Scene scene;
    private Sprite player;
    private Thread gameLoop;
    private boolean isRunning;

    public MyGame() {
        createAndShowUI();
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(MyGame::new);
    }

    /**
     * Here we will create our swing UI as well as initialise and setup our
     * sprites, scene, and game loop and other buttons etc
     */
    private void createAndShowUI() {
        JFrame frame = new JFrame("MyGame");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        player = new Sprite(/*ImageIO.read(getClass().getResourceAsStream("...."))*/);

        this.scene = new Scene();
        this.scene.add(player);

        this.addKeyBindings();
        this.setupGameLoop();

        frame.add(scene);
        frame.pack();
        frame.setVisible(true);

        // after setting the frame visible we start the game loop, this could be done in a button or wherever you want
        this.isRunning = true;
        this.gameLoop.start();
    }

    /**
     * This method would actually create and setup the game loop The game loop
     * will always be encapsulated in a Thread, Timer or some sort of construct
     * that generates a separate thread in order to not block the UI
     */
    private void setupGameLoop() {
        // initialise the thread 
        gameLoop = new Thread(() -> {
            // while the game "is running" and the isRunning boolean is set to true, loop forever
            while (isRunning) {
                // here we do 2 very important things which might later be expanded to 3:
                // 1. We call Scene#update: this essentially will iterate all of our Sprites in our game and update their movments/position in the game via Sprite#update()
                this.scene.update();

                // TODO later on one might add a method like this.scene.checkCollisions in which you check if 2 sprites are interesecting and do something about it
                // 2. We then call JPanel#repaint() which will cause JPanel#paintComponent to be called and thus we will iterate all of our sprites
                // and invoke the Sprite#render method which will draw them to the screen
                this.scene.repaint();

                // here we throttle our game loop, because we are using a while loop this will execute as fast as it possible can, which might not be needed
                // so here we call Thread#slepp so we can give the CPU some time to breathe :)
                try {
                    Thread.sleep(15);
                } catch (InterruptedException ex) {
                }
            }
        });
    }

    private void addKeyBindings() {
        // here we would use KeyBindings (https://docs.oracle.com/javase/tutorial/uiswing/misc/keybinding.html) and add them to our Scene/JPanel
        // these would allow us to manipulate our Sprite objects using the keyboard below is 2 examples for using the A key to make our player/Sprite go left
        // or the D key to make the player/Sprite go to the right
        this.scene.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, false), "A pressed");
        this.scene.getActionMap().put("A pressed", new AbstractAction() {
            @Override
            public void actionPerformed(ActionEvent e) {
                player.LEFT = true;
            }
        });
        this.scene.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true), "A released");
        this.scene.getActionMap().put("A released", new AbstractAction() {
            @Override
            public void actionPerformed(ActionEvent e) {
                player.LEFT = false;
            }
        });
        this.scene.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_D, 0, false), "D pressed");
        this.scene.getActionMap().put("D pressed", new AbstractAction() {
            @Override
            public void actionPerformed(ActionEvent e) {
                player.RIGHT = true;
            }
        });
        this.scene.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_D, 0, true), "D released");
        this.scene.getActionMap().put("D released", new AbstractAction() {
            @Override
            public void actionPerformed(ActionEvent e) {
                player.RIGHT = false;
            }
        });
    }

    public class Scene extends JPanel {

        private final ArrayList<Sprite> sprites;

        public Scene() {
            // we are using a game loop to repaint, so probably dont want swing randomly doing it for us
            this.setIgnoreRepaint(true);
            this.sprites = new ArrayList<>();
        }

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2d = (Graphics2D) g;
            // this method gets called on Scene#repaint in our game loop and we then render each in our game
            sprites.forEach((sprite) -> {
                sprite.render(g2d);
            });
        }

        @Override
        public Dimension getPreferredSize() {
            // because no components are added to the JPanel, we will have a default sizxe of 0,0 so we instead force the JPanel to a size we want
            return new Dimension(500, 500);
        }

        public void add(Sprite go) {
            this.sprites.add(go);
        }

        private void update() {
            // this method gets called on Scene#update in our game loop and we then update the sprites movement and position our game
            sprites.forEach((go) -> {
                go.update();
            });
        }
    }

    public class Sprite {

        private int x = 50, y = 50, speed = 5;
        //private final BufferedImage image;

        public boolean LEFT, RIGHT, UP, DOWN;

        public Sprite(/*BufferedImage image*/) {
            //this.image = image;
        }

        public void render(Graphics2D g2d) {
            //g2d.drawImage(this.image, this.x, this.y, null);
            g2d.fillRect(this.x, this.y, 100, 100);
        }

        public void update() {
            if (LEFT) {
                this.x -= this.speed;
            }
            if (RIGHT) {
                this.x += this.speed;
            }
            if (UP) {
                this.y -= this.speed;
            }
            if (DOWN) {
                this.y += this.speed;
            }
        }
    }
}

2. 提高游戏循环效率的技巧

这个问题很主观,取决于你想要实现什么样的效果以及你满足问题所需的粒度是多少。因此,我们不应该只推荐一种类型的游戏循环,而是应该看看各种不同的循环方式:

首先,什么是游戏循环?*

游戏循环是整个游戏程序的总体流程控制。它是一个循环,因为游戏会不断地执行一系列动作,直到用户退出。游戏循环的每个迭代被称为一帧。大多数实时游戏每秒更新几次:30和60是最常见的间隔时间。如果一个游戏以60 FPS(每秒帧数)运行,这意味着游戏循环每秒完成60次迭代。

a. While循环

我们在上面的示例中已经看到了这种方式,它只是一个包含在Thread中的while循环,可能还有一个Thread#sleep调用来帮助节流CPU使用率。这和Swing Timer可能是最基本的两种。

gameLoop = new Thread(() -> {
    while (isRunning) {
        this.scene.update();
        this.scene.repaint();
        try {
            Thread.sleep(15);
        } catch (InterruptedException ex) {
        }
    }
});

优点:

  • 易于实现
  • 所有更新和渲染、重绘都在与EDT(Event Dispatch Thread)分离的另一个线程中完成

缺点:

  • 无法保证在各种PC上获得相同的帧率,因此游戏的移动可能在不同的计算机上看起来更好/更差或更快/更慢,这取决于硬件。

b. Swing定时器

类似于while循环,可以使用Swing定时器,在其中定期触发一个动作事件。因为它是定期触发的,所以我们可以简单地使用if语句检查游戏是否正在运行,然后调用必要的方法。

gameLoop = new Timer(15, (ActionEvent e) -> {
    if (isRunning) {
        MyGame.this.scene.update();
        MyGame.this.scene.repaint();
    }
});

优点:

  • 易于实现

缺点:

  • 在EDT(事件分发线程)上运行,这不是必要的或者我们所希望的,因为我们没有更新任何Swing组件,而只是简单地对其进行绘画
  • 无法保证在各种计算机上具有相同的帧率,因此游戏的移动可能会在不同的计算机上看起来更好/更差或更慢/更快,这取决于硬件

c. 固定时间步长*

这是一个更复杂的游戏循环(但比变量时间步长循环简单)。它的原理是我们想要达到特定的FPS,即每秒30或60帧,因此我们只需确保我们每秒调用一次我们的更新和渲染方法。 更新方法不接受“流逝的时间”,因为它们假设每个更新都是针对一个固定的时间段。计算可以使用position += distancePerUpdate。示例包括在渲染期间进行的插值。

gameLoop = new Thread(() -> {
    //This value would probably be stored elsewhere.
    final double GAME_HERTZ = 60.0;
    //Calculate how many ns each frame should take for our target game hertz.
    final double TIME_BETWEEN_UPDATES = 1000000000 / GAME_HERTZ;
    //If we are able to get as high as this FPS, don't render again.
    final double TARGET_FPS = 60;
    final double TARGET_TIME_BETWEEN_RENDERS = 1000000000 / TARGET_FPS;
    //At the very most we will update the game this many times before a new render.
    //If you're worried about visual hitches more than perfect timing, set this to 1.
    final int MAX_UPDATES_BEFORE_RENDER = 5;
    //We will need the last update time.
    double lastUpdateTime = System.nanoTime();
    //Store the last time we rendered.
    double lastRenderTime = System.nanoTime();

    while (isRunning) {
        double now = System.nanoTime();
        int updateCount = 0;

        //Do as many game updates as we need to, potentially playing catchup.
        while (now - lastUpdateTime > TIME_BETWEEN_UPDATES && updateCount < MAX_UPDATES_BEFORE_RENDER) {
            MyGame.this.scene.update();
            lastUpdateTime += TIME_BETWEEN_UPDATES;
            updateCount++;
        }

        //If for some reason an update takes forever, we don't want to do an insane number of catchups.
        //If you were doing some sort of game that needed to keep EXACT time, you would get rid of this.
        if (now - lastUpdateTime > TIME_BETWEEN_UPDATES) {
            lastUpdateTime = now - TIME_BETWEEN_UPDATES;
        }

        //Render. To do so, we need to calculate interpolation for a smooth render.
        float interpolation = Math.min(1.0f, (float) ((now - lastUpdateTime) / TIME_BETWEEN_UPDATES));
        MyGame.this.scene.render(interpolation);
        lastRenderTime = now;

        //Yield until it has been at least the target time between renders. This saves the CPU from hogging.
        while (now - lastRenderTime < TARGET_TIME_BETWEEN_RENDERS && now - lastUpdateTime < TIME_BETWEEN_UPDATES) {
            //allow the threading system to play threads that are waiting to run.
            Thread.yield();

            //This stops the app from consuming all your CPU. It makes this slightly less accurate, but is worth it.
            //You can remove this line and it will still work (better), your CPU just climbs on certain OSes.
            //FYI on some OS's this can cause pretty bad stuttering. Scroll down and have a look at different peoples' solutions to this.
            //On my OS it does not unpuase the game if i take this away
            try {
                Thread.sleep(1);
            } catch (Exception e) {
            }

            now = System.nanoTime();
        }
    }
});

这个循环需要其他更改才能进行插值:
场景:
public class Scene extends JPanel {

    private float interpolation;

    @Override
    protected void paintComponent(Graphics g) {
        ...
        sprites.forEach((sprite) -> {
            sprite.render(g2d, this.interpolation);
        });
    }

    public void render(float interpolation) {
        this.interpolation = interpolation;
        this.repaint();
    }
}

Sprite:

public class Sprite {

    public void render(Graphics2D g2d, float interpolation) {
        g2d.fillRect((int) (this.x + interpolation), (int) (this.y + interpolation), 100, 100);
    }

}

优点:

  • 在各种电脑/硬件上都能预测到确定的FPS
  • 计算代码更清晰

缺点:

  • 未与监视器垂直同步(会导致图形抖动,除非你进行插值) - 此示例进行了插值处理
  • 有限的最大帧率(除非进行插值)- 此示例进行了插值处理

d. 可变时间步长*

通常在实现物理系统或需要记录经过时间的情况下使用,例如动画。物理/动画更新将传递“自上次更新以来经过的时间”参数,因此具有帧速率依赖性。这可能意味着进行如下计算:position += distancePerSecond * timeElapsed

gameLoop = new Thread(() -> {
    // how many frames should be drawn in a second
    final int FRAMES_PER_SECOND = 60;
    // calculate how many nano seconds each frame should take for our target frames per second.
    final long TIME_BETWEEN_UPDATES = 1000000000 / FRAMES_PER_SECOND;
    // track number of frames
    int frameCount;
    // if you're worried about visual hitches more than perfect timing, set this to 1. else 5 should be okay
    final int MAX_UPDATES_BETWEEN_RENDER = 1;

    // we will need the last update time.
    long lastUpdateTime = System.nanoTime();
    // store the time we started this will be used for updating map and charcter animations
    long currTime = System.currentTimeMillis();

    while (isRunning) {
        long now = System.nanoTime();
        long elapsedTime = System.currentTimeMillis() - currTime;
        currTime += elapsedTime;

        int updateCount = 0;
        // do as many game updates as we need to, potentially playing catchup.
        while (now - lastUpdateTime >= TIME_BETWEEN_UPDATES && updateCount < MAX_UPDATES_BETWEEN_RENDER) {
            MyGame.this.scene.update(elapsedTime);//Update the entity movements and collision checks etc (all has to do with updating the games status i.e  call move() on Enitites)
            lastUpdateTime += TIME_BETWEEN_UPDATES;
            updateCount++;
        }

        // if for some reason an update takes forever, we don't want to do an insane number of catchups.
        // if you were doing some sort of game that needed to keep EXACT time, you would get rid of this.
        if (now - lastUpdateTime >= TIME_BETWEEN_UPDATES) {
            lastUpdateTime = now - TIME_BETWEEN_UPDATES;
        }

        MyGame.this.scene.repaint(); // draw call for rendering sprites etc

        long lastRenderTime = now;

        //Yield until it has been at least the target time between renders. This saves the CPU from hogging.
        while (now - lastRenderTime < TIME_BETWEEN_UPDATES && now - lastUpdateTime < TIME_BETWEEN_UPDATES) {
            Thread.yield();
            now = System.nanoTime();
        }
    }
});
< p >场景:

public class Scene extends JPanel {

    private void update(long elapsedTime) {
        // this method gets called on Scene#update in our game loop and we then update the sprites movement and position our game
        sprites.forEach((go) -> {
            go.update(elapsedTime);
        });
    }
}

精灵:

public class Sprite {

    private float speed = 0.5f;
    
    public void update(long elapsedTime) {
        if (LEFT) {
            this.x -= this.speed * elapsedTime;
        }
        if (RIGHT) {
            this.x += this.speed * elapsedTime;
        }
        if (UP) {
            this.y -= this.speed * elapsedTime;
        }
        if (DOWN) {
            this.y += this.speed * elapsedTime;
        }
    }
}

优点:

  • 流畅
  • 更容易编写代码

缺点:

  • 非确定性,在非常小或非常大的步骤中难以预测

我使用图像而不是缓冲图像,或者实现keylistener而不是getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_A,0,false),"A pressed")都无所谓,对吗? - Kayaba Akihiko
1
问题1:我会参考https://dev59.com/A2865IYBdhLWcg3wLbir#20950381并建议使用BufferedImage,但如果您愿意,您也可以使用它,但我很少看到它被使用。问题2:同样,我会参考https://stackoverflow.com/a/23486873/1133011,你可以使用两者,但因为你只监听特定的键而且不想有焦点问题,所以KeyBindings是最好的选择。 - David Kroukamp
我注意到如果没有按键,gameLoop将无法正确更新屏幕。如果您想在释放按键后保持盒子以恒定速度移动(就像汽车或船在加速后一样),则屏幕将立即开始颤抖。观察时间越长,情况就会变得越糟。您可以通过简单地删除D释放操作来尝试此操作。有什么建议可以避免这种颤动吗? - Fable
额外观察: 如果您在释放D键后按住任何(!)其他键,则框将再次平稳移动。 - Fable

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接