
Java游戏编程实践:实现角色移动和碰撞检测
角色移动和碰撞检测是许多二维游戏的基础:玩家按下方向键后,角色改变位置;当角色遇到墙壁或障碍物时,移动被限制。本教程使用 Java Swing 编写一个可运行的二维示例,展示键盘输入、时间步更新、矩形碰撞和窗口绘制,并说明实现中的边界条件。
示例适合学习、原型验证,以及对象数量较少的俯视角或平台类游戏。它使用轴对齐矩形作为碰撞形状,不依赖第三方库。Swing 负责输入和绘制;如果项目需要大量物理对象、复杂关卡编辑或跨平台游戏运行环境,应考虑使用专门的游戏框架或物理引擎。
一、先确定碰撞规则
本例把角色和障碍物都视为不旋转的矩形。角色的状态由左上角坐标 x、y 和宽高表示。两个矩形在水平、垂直方向都存在实际重叠时,才算发生碰撞。边缘刚好接触不算重叠,因此角色可以停在墙边,不会因为“贴着墙”而被判定为卡在障碍物里。
对于两个矩形 A、B,严格重叠条件可以写成:
A.left < B.right
&& A.right > B.left
&& A.top < B.bottom
&& A.bottom > B.top
这类检测通常称为 AABB(Axis-Aligned Bounding Box,轴对齐包围盒)检测。它计算简单,适合角色、墙体、箱子等矩形碰撞区域。角色外观即使是圆形或不规则图片,也可以先用矩形近似;近似会影响手感和精度,但不影响本例的基本实现方式。
二、完整可运行示例
将下面代码保存为 MovementCollisionDemo.java,使用 JDK 编译并运行:
javac MovementCollisionDemo.java
java MovementCollisionDemo
代码只依赖标准 Java API。窗口打开后,使用方向键或 WASD 移动蓝色角色。角色会被窗口边界和灰色障碍物挡住;斜向移动经过归一化处理,不会比水平或垂直移动更快。
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
public class MovementCollisionDemo {
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
JFrame frame = new JFrame("角色移动与碰撞检测");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setContentPane(new GamePanel());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
});
}
private static final class GamePanel extends JPanel {
private static final int WORLD_WIDTH = 800;
private static final int WORLD_HEIGHT = 600;
private static final double PLAYER_SIZE = 32.0;
private static final double PLAYER_SPEED = 220.0;
private final List<Rectangle2D.Double> walls = new ArrayList<>();
private final EnumSet<Direction> pressed =
EnumSet.noneOf(Direction.class);
private double playerX = 60.0;
private double playerY = 60.0;
private long previousNanos;
private final Timer timer;
GamePanel() {
setPreferredSize(new Dimension(WORLD_WIDTH, WORLD_HEIGHT));
setBackground(new Color(245, 247, 250));
setFocusable(true);
// 障碍物采用世界坐标,坐标原点在窗口左上角。
walls.add(new Rectangle2D.Double(180, 100, 40, 260));
walls.add(new Rectangle2D.Double(320, 200, 260, 40));
walls.add(new Rectangle2D.Double(500, 340, 50, 180));
installKeyBindings();
previousNanos = System.nanoTime();
timer = new Timer(16, event -> tick());
timer.start();
}
private void installKeyBindings() {
bind(Direction.UP, "pressed UP", true);
bind(Direction.UP, "released UP", false);
bind(Direction.DOWN, "pressed DOWN", true);
bind(Direction.DOWN, "released DOWN", false);
bind(Direction.LEFT, "pressed LEFT", true);
bind(Direction.LEFT, "released LEFT", false);
bind(Direction.RIGHT, "pressed RIGHT", true);
bind(Direction.RIGHT, "released RIGHT", false);
bind(Direction.UP, "pressed W", true);
bind(Direction.UP, "released W", false);
bind(Direction.DOWN, "pressed S", true);
bind(Direction.DOWN, "released S", false);
bind(Direction.LEFT, "pressed A", true);
bind(Direction.LEFT, "released A", false);
bind(Direction.RIGHT, "pressed D", true);
bind(Direction.RIGHT, "released D", false);
// 窗口失去焦点时,清除按键状态,避免角色持续移动。
addHierarchyListener(event -> {
if ((event.getChangeFlags()
& HierarchyEvent.SHOWING_CHANGED) != 0
&& !isShowing()) {
pressed.clear();
}
});
}
private void bind(Direction direction, String keyStroke,
boolean isPressed) {
String actionName = keyStroke;
getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
.put(KeyStroke.getKeyStroke(keyStroke), actionName);
getActionMap().put(actionName, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
if (isPressed) {
pressed.add(direction);
} else {
pressed.remove(direction);
}
}
});
}
private void tick() {
long now = System.nanoTime();
double dt = (now - previousNanos) / 1_000_000_000.0;
previousNanos = now;
// 窗口被系统暂停、拖动等情况可能产生很大的时间差。
// 丢弃过长间隔,避免恢复后角色瞬间移动很远。
dt = Math.min(dt, 0.05);
double dx = 0.0;
double dy = 0.0;
if (pressed.contains(Direction.LEFT)) {
dx -= 1.0;
}
if (pressed.contains(Direction.RIGHT)) {
dx += 1.0;
}
if (pressed.contains(Direction.UP)) {
dy -= 1.0;
}
if (pressed.contains(Direction.DOWN)) {
dy += 1.0;
}
// 对角输入的长度为 sqrt(2),归一化后速度与单轴移动一致。
double length = Math.sqrt(dx * dx + dy * dy);
if (length > 0.0) {
dx = dx / length * PLAYER_SPEED * dt;
dy = dy / length * PLAYER_SPEED * dt;
// 分轴处理:先水平、后垂直。被一面墙挡住时,
// 另一轴仍可移动,从而实现沿墙滑动。
moveX(dx);
moveY(dy);
}
repaint();
}
private void moveX(double amount) {
if (amount == 0.0) {
return;
}
double oldX = playerX;
double nextX = clamp(
oldX + amount, 0.0, WORLD_WIDTH - PLAYER_SIZE);
for (Rectangle2D.Double wall : walls) {
if (!overlapsOnY(playerY, wall)) {
continue;
}
double oldRight = oldX + PLAYER_SIZE;
double nextRight = nextX + PLAYER_SIZE;
if (amount > 0.0
&& oldRight <= wall.x
&& nextRight > wall.x) {
nextX = Math.min(nextX, wall.x - PLAYER_SIZE);
} else if (amount < 0.0
&& oldX >= wall.x + wall.width
&& nextX < wall.x + wall.width) {
nextX = Math.max(nextX, wall.x + wall.width);
}
}
playerX = clamp(nextX, 0.0, WORLD_WIDTH - PLAYER_SIZE);
}
private void moveY(double amount) {
if (amount == 0.0) {
return;
}
double oldY = playerY;
double nextY = clamp(
oldY + amount, 0.0, WORLD_HEIGHT - PLAYER_SIZE);
for (Rectangle2D.Double wall : walls) {
if (!overlapsOnX(playerX, wall)) {
continue;
}
double oldBottom = oldY + PLAYER_SIZE;
double nextBottom = nextY + PLAYER_SIZE;
if (amount > 0.0
&& oldBottom <= wall.y
&& nextBottom > wall.y) {
nextY = Math.min(nextY, wall.y - PLAYER_SIZE);
} else if (amount < 0.0
&& oldY >= wall.y + wall.height
&& nextY < wall.y + wall.height) {
nextY = Math.max(nextY, wall.y + wall.height);
}
}
playerY = clamp(nextY, 0.0, WORLD_HEIGHT - PLAYER_SIZE);
}
private boolean overlapsOnX(double x, Rectangle2D.Double wall) {
return x < wall.x + wall.width
&& x + PLAYER_SIZE > wall.x;
}
private boolean overlapsOnY(double y, Rectangle2D.Double wall) {
return y < wall.y + wall.height
&& y + PLAYER_SIZE > wall.y;
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
Graphics2D g = (Graphics2D) graphics.create();
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(new Color(205, 211, 220));
for (Rectangle2D.Double wall : walls) {
g.fill(wall);
}
g.setColor(new Color(45, 116, 210));
g.fill(new Rectangle2D.Double(
playerX, playerY, PLAYER_SIZE, PLAYER_SIZE));
g.setColor(new Color(55, 62, 72));
g.drawString("方向键 / WASD 移动", 16, 24);
} finally {
g.dispose();
}
}
}
private enum Direction {
UP, DOWN, LEFT, RIGHT
}
}
三、代码的关键部分
1. 使用按键状态,而不是只处理一次按键事件
游戏中的移动通常需要在按键保持期间连续发生。代码使用 Swing 的输入映射,在按下时把方向加入 pressed 集合,在释放时移除。每次更新时读取集合,得到当前的移动方向。
相比在按键事件里直接把角色坐标加一个固定值,这种方式更容易控制速度:按键事件只负责记录输入,游戏更新负责根据时间推进位置。它也能同时处理两个方向键。不过,应用失去焦点时,操作系统或窗口系统不一定会把对应的释放事件交还给程序,因此示例在组件不再显示时清空按键集合,避免角色持续移动。
示例用 WHEN_IN_FOCUSED_WINDOW 输入映射,使窗口内的组件获得焦点时可以接收按键。若要支持菜单、文本框或多个可操作面板,应明确焦点策略,避免游戏输入和界面输入互相抢占。
2. 用时间差计算位移
移动速度以“世界坐标单位/秒”表示。若角色速度是每秒 220 个坐标单位,某次更新间隔为 dt 秒,则该次位移为 220 × dt。这比每次更新固定移动若干像素更能适应不同的机器性能和定时器节奏。
Swing 的 Timer 适合这个小型演示,但它不是硬实时定时器。系统负载、窗口拖动和事件队列繁忙都可能改变回调间隔。本例通过 System.nanoTime() 测量实际间隔,并把单次 dt 限制在 0.05 秒以内。这个上限是示例的稳定性取舍,不是 Java 或游戏开发规定的通用参数,也不意味着丢弃时间在所有游戏中都正确。需要可靠物理模拟或更精确同步时,通常使用固定时间步循环,并将更新与渲染分离。
3. 处理斜向速度
同时按下右键和上键时,原始方向向量是 (1, -1),长度为根号二。如果直接把两个分量分别乘以速度,斜向合速度会比单轴速度大约高 41%。代码先计算向量长度,再除以该长度,使任何非零输入的长度为 1,然后乘以速度和时间差。
如果游戏希望斜向移动更快、或角色具有加速度和惯性,可以改用不同的运动模型;关键是明确设计规则,而不是让对角线速度因计算疏漏而意外改变。
4. 分轴移动与滑墙
示例先调用 moveX,再调用 moveY。每一轴独立检查障碍物,只在当前位移会穿过障碍物边缘时,把坐标限制在障碍物外侧。角色斜着撞上墙时,撞墙方向的位移会被截断,另一轴仍可移动,因此表现为沿墙滑动。
分轴处理简单直观,但轴顺序会影响角色在某些角落处的移动结果:先处理水平再处理垂直,和反过来并不总是完全相同。对于这个示例的矩形、俯视角移动已经够用;若需要精确的斜面响应、旋转碰撞体、反弹或复杂平台运动,应采用更完整的碰撞解析或物理引擎。
四、碰撞与边界的适用范围
世界边界和窗口边界
代码中的 WORLD_WIDTH、WORLD_HEIGHT 是游戏世界大小,窗口的首选尺寸也设置为相同数值。坐标限制使用角色的宽高,而不是只限制角色左上角:横坐标范围是从 0 到 WORLD_WIDTH - PLAYER_SIZE,纵坐标同理。这样角色的整个矩形都留在世界范围内。
当窗口允许缩放时,世界尺寸不应简单等同于当前组件像素尺寸。可以采用固定世界坐标系并按窗口尺寸缩放绘制,或者在窗口尺寸变化时更新边界;两种方案都需要确保绘制坐标、输入映射和碰撞坐标采用一致的转换。
障碍物与初始状态
本例的碰撞解析假设角色开始移动时没有与障碍物重叠,且障碍物是静止的。当前初始化坐标满足这个假设。若关卡数据可能把角色放在墙内,需要在加载关卡时验证出生点,或单独实现脱离重叠的处理逻辑。移动碰撞本身不会自动解决初始穿透问题。
障碍物的坐标、宽高应是有效的有限数值,宽高应为正数。若这些值来自文件、编辑器或网络输入,应在进入游戏状态前验证;无效的浮点值(例如 NaN)会破坏比较逻辑。示例中的参数是写在源码里的常量,并未实现通用的关卡数据验证器。
高速移动与穿透
本例在单次水平或垂直更新中,根据移动前后的位置判断是否跨过障碍物边缘,并把角色限制到障碍物边缘,不依赖逐像素采样。因此对于静止的轴对齐矩形、初始不重叠且只按轴移动的角色,即便单次位移较大,也能处理穿过一侧边界的情况。
这不等于任意碰撞都不会穿透。多个快速移动对象、移动障碍物、旋转对象、角色瞬间传送,以及具有复杂形状的碰撞体,都需要不同的连续碰撞检测或时间步策略。仅仅把移动循环调得更快不能替代正确的碰撞模型;按小步长离散检测也存在步长大于薄障碍物时漏检的可能。
五、常见错误及排查方法
- 边界只检查左上角:角色右侧或底部仍可能越界。限制坐标时要减去角色宽高。
- 把每帧位移写成固定值:帧率变化会直接改变移动速度。用实际时间差换算位移,或使用固定时间步。
- 斜向移动过快:检查方向向量是否归一化,而不是分别给两个轴乘完整速度。
- 只在角色中心检测碰撞:中心点未进入墙体时,角色边缘可能已经穿墙。矩形角色应检查完整碰撞盒。
- 使用非严格重叠判断却没有统一边界规则:边缘接触可能被误判为穿透,产生抖动。先明确边缘接触是否属于碰撞,并在所有检测中保持一致。
- 更新位置后才检查,随后把角色整体重置到上一个位置:这种处理在斜向撞墙时可能同时取消两个方向的移动,手感生硬。分轴解析是简单的替代方案,但不是唯一方案。
- 按键状态没有在焦点变化时清理:角色可能在按键释放后继续移动。检查焦点处理和输入映射是否与窗口结构匹配。
- 在绘制代码中改变游戏状态:重绘次数可能与更新次数不同,导致移动速度和状态不可预测。绘制函数应读取状态并绘图,状态变化放在更新阶段。
六、性能与参数边界
本例每次更新都会遍历所有墙体,时间复杂度随障碍物数量线性增长。几个到几十个矩形通常足以应付原型;地图对象很多时,可以按网格、空间哈希或四叉树筛选附近候选物体。是否需要优化应通过实际测量判断,而不是仅凭对象数量猜测。
PLAYER_SPEED 是坐标单位每秒,PLAYER_SIZE 是角色碰撞盒的尺寸,墙体数据使用相同坐标单位。它们的数值没有跨游戏通用的安全范围。实际可用范围取决于世界尺寸、关卡比例和更新策略。若把这些参数改为外部输入,应验证它们是有限数值、尺寸大于零,并避免不合理的超大值造成溢出、性能问题或错误的关卡数据。
Java 的 double 能表示较宽范围的数值,但并不意味着任意数值都适合作为游戏坐标。对于示例这样的窗口游戏,几百或几千单位的世界坐标通常容易管理;大型世界可使用分区坐标或摄像机局部坐标。具体选择是工程设计问题,不应把某个数字描述为 Java 强制的上限。
七、安全用途的边界
这段碰撞代码用于本地游戏表现,不是安全校验机制。如果游戏有联机或排行榜,客户端提交的角色位置、速度和碰撞结果都可能被修改,不能据此认定玩家遵守了移动限制。需要防作弊或保护游戏状态时,关键规则应由可信服务器验证,例如服务器根据输入和权威状态计算移动,并校验速度、边界和碰撞结果。客户端的碰撞检测仍可用于及时反馈,但不能代替服务器端判定。
同样,碰撞检测也不等于文件、网络或用户输入的安全过滤。加载关卡文件时仍需限制数据规模、校验坐标和尺寸,并根据应用的威胁模型处理不可信输入。本示例没有网络通信和外部数据加载,因此不包含这些场景的防护逻辑。
八、如何扩展
可以先把硬编码墙体改为读取关卡数据,并保留统一的世界坐标;再增加敌人、可推动箱子或动画。每类对象都应明确自己的碰撞盒和移动规则。对象数量增长后,再把输入、世界更新、碰撞检测和绘制拆分为不同类,避免一个面板类承担所有职责。
完成扩展时,可以重点测试四类情况:角色贴着边界移动、从四个方向撞击墙体、斜向撞角、按住按键后切换窗口焦点。测试中还应覆盖障碍物边缘恰好接触、角色出生点与墙重叠、窗口尺寸改变等边界状态。把规则和假设写清楚,比单纯增加碰撞代码更能避免后续出现难以复现的移动问题。
总结来说,这个示例展示了一个清晰的起点:用按键状态表达输入,用时间差驱动移动,用完整矩形判断空间关系,并在水平、垂直轴上分别解析碰撞。它适合小型二维原型,但不是通用物理引擎;遇到动态碰撞体、复杂形状或联机权威判定时,应按需求升级模型,而不是把示例的边界误认为游戏开发的全部边界。



