我认为这是一个有趣的主题,值得探讨... 我已经回答了你提出的问题,并展示了一些更好或正确的做法,比如绘画、监听按键以及其他一些内容,如关注点分离和使整个游戏更具可重用性/扩展性。
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);
}
private void createAndShowUI() {
JFrame frame = new JFrame("MyGame");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
player = new Sprite();
this.scene = new Scene();
this.scene.add(player);
this.addKeyBindings();
this.setupGameLoop();
frame.add(scene);
frame.pack();
frame.setVisible(true);
this.isRunning = true;
this.gameLoop.start();
}
private void setupGameLoop() {
gameLoop = new Thread(() -> {
while (isRunning) {
this.scene.update();
this.scene.repaint();
try {
Thread.sleep(15);
} catch (InterruptedException ex) {
}
}
});
}
private void addKeyBindings() {
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() {
this.setIgnoreRepaint(true);
this.sprites = new ArrayList<>();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2d = (Graphics2D) g;
sprites.forEach((sprite) -> {
sprite.render(g2d);
});
}
@Override
public Dimension getPreferredSize() {
return new Dimension(500, 500);
}
public void add(Sprite go) {
this.sprites.add(go);
}
private void update() {
sprites.forEach((go) -> {
go.update();
});
}
}
public class Sprite {
private int x = 50, y = 50, speed = 5;
public boolean LEFT, RIGHT, UP, DOWN;
public Sprite() {
}
public void render(Graphics2D g2d) {
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(() -> {
final double GAME_HERTZ = 60.0;
final double TIME_BETWEEN_UPDATES = 1000000000 / GAME_HERTZ;
final double TARGET_FPS = 60;
final double TARGET_TIME_BETWEEN_RENDERS = 1000000000 / TARGET_FPS;
final int MAX_UPDATES_BEFORE_RENDER = 5;
double lastUpdateTime = System.nanoTime();
double lastRenderTime = System.nanoTime();
while (isRunning) {
double now = System.nanoTime();
int updateCount = 0;
while (now - lastUpdateTime > TIME_BETWEEN_UPDATES && updateCount < MAX_UPDATES_BEFORE_RENDER) {
MyGame.this.scene.update();
lastUpdateTime += TIME_BETWEEN_UPDATES;
updateCount++;
}
if (now - lastUpdateTime > TIME_BETWEEN_UPDATES) {
lastUpdateTime = now - TIME_BETWEEN_UPDATES;
}
float interpolation = Math.min(1.0f, (float) ((now - lastUpdateTime) / TIME_BETWEEN_UPDATES));
MyGame.this.scene.render(interpolation);
lastRenderTime = now;
while (now - lastRenderTime < TARGET_TIME_BETWEEN_RENDERS && now - lastUpdateTime < TIME_BETWEEN_UPDATES) {
Thread.yield();
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(() -> {
final int FRAMES_PER_SECOND = 60;
final long TIME_BETWEEN_UPDATES = 1000000000 / FRAMES_PER_SECOND;
int frameCount;
final int MAX_UPDATES_BETWEEN_RENDER = 1;
long lastUpdateTime = System.nanoTime();
long currTime = System.currentTimeMillis();
while (isRunning) {
long now = System.nanoTime();
long elapsedTime = System.currentTimeMillis() - currTime;
currTime += elapsedTime;
int updateCount = 0;
while (now - lastUpdateTime >= TIME_BETWEEN_UPDATES && updateCount < MAX_UPDATES_BETWEEN_RENDER) {
MyGame.this.scene.update(elapsedTime);
lastUpdateTime += TIME_BETWEEN_UPDATES;
updateCount++;
}
if (now - lastUpdateTime >= TIME_BETWEEN_UPDATES) {
lastUpdateTime = now - TIME_BETWEEN_UPDATES;
}
MyGame.this.scene.repaint();
long lastRenderTime = now;
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) {
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;
}
}
}
优点:
缺点:
paint()
是 Swing 系统用来绘制组件及其所有子组件的方法。您应该重写paintComponent()
方法,以避免意外破坏子组件、背景等的绘制。 - NomadMaker