滑雪大冒险是经典的休闲跑酷类游戏,玩家操控滑雪者在雪坡上滑行,通过跳跃躲避障碍物并收集金币。本文用Java实现滑雪游戏的核心物理引擎,重点讲解抛物线跳跃运动学、障碍物检测与避让决策以及地形分段随机生成算法。
一、游戏机制与算法目标
滑雪游戏的核心规则:
– 滑雪者自动向右下方滑行,速度逐渐加快
– 点击空格触发跳跃,呈抛物线轨迹越过障碍
– 障碍物(树木、岩石)随机出现在滑行路径上
– 碰撞即游戏结束,连续躲避可累积连击分数
需要解决的三个算法问题:
1. 跳跃的抛物线轨迹如何由初速度和重力共同决定
2. 如何判断滑雪者与障碍物的矩形碰撞
3. 如何随机生成无限延续且难度递增的地形
二、核心算法原理
2.1 抛物线跳跃运动学
跳跃时给滑雪者一个向上的初速度 $v_{y0}$,水平速度保持 $v_x$。在重力 $g$ 作用下,任意时刻 $t$ 的位置为:
$$
x(t) = x_0 + v_x \cdot t \
y(t) = y_0 + v_{y0} \cdot t – \frac{1}{2} g \cdot t^2
$$
消去参数 $t$ 可得抛物线轨迹方程:
$$
y = y_0 + \frac{v_{y0}}{v_x}(x – x_0) – \frac{g}{2v_x^2}(x – x_0)^2
$$
代码中按帧离散更新即可,无需解析求解。
2.2 AABB轴对齐碰撞检测
滑雪者与障碍物均采用轴对齐包围盒(AABB)表示。两个矩形相交的条件:
$$
|x_1 – x_2| < \frac{w_1 + w_2}{2} \quad \text{且} \quad |y_1 – y_2| < \frac{h_1 + h_2}{2}
$$
其中 $(x, y)$ 为矩形中心坐标,$w, h$ 为宽高。
2.3 地形无限生成算法
采用滑动窗口 + 随机间隔策略:
– 维护一个地形段队列,每段包含障碍物类型和位置
– 当最左侧段移出屏幕时,在右侧生成新段
– 障碍物间距随分数增加而缩小,实现难度递增
– 使用种子随机数保证可复现性
三、完整Java实现
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* 滑雪大冒险游戏:实现抛物线跳跃物理、AABB碰撞检测与地形无限生成
*/
public class SkiAdventure extends JPanel implements ActionListener, KeyListener {
private static final int WIDTH = 900;
private static final int HEIGHT = 500;
private static final int GROUND_Y = 380; // 地面基准线
private static final double GRAVITY = 0.6;
private static final double BASE_SPEED = 5.0;
private static final double JUMP_VELOCITY = -12.0;
private static final int SKIER_WIDTH = 40;
private static final int SKIER_HEIGHT = 50;
// 滑雪者状态
private double skierX = 150;
private double skierY = GROUND_Y - SKIER_HEIGHT;
private double skierVx = BASE_SPEED;
private double skierVy = 0;
private boolean isJumping = false;
private boolean isGrounded = true;
private int jumpCount = 0; // 连跳计数(二段跳机制)
// 游戏世界
private double worldOffset = 0; // 世界滚动偏移量
private int score = 0;
private int combo = 0;
private boolean gameOver = false;
private Random random = new Random(42); // 固定种子便于调试
// 障碍物管理
private List<Obstacle> obstacles = new ArrayList<>();
private double nextObstacleX = 400; // 下一个障碍物的生成位置
private double minGap = 250; // 最小障碍物间距
private double maxGap = 500;
// 收集物(金币)
private List<Coin> coins = new ArrayList<>();
private Timer timer;
/**
* 障碍物类:树木和岩石
*/
static class Obstacle {
double x, y;
int width, height;
int type; // 0=树木, 1=岩石
boolean passed = false; // 是否已被滑雪者越过
Obstacle(double x, double y, int w, int h, int type) {
this.x = x; this.y = y;
this.width = w; this.height = h;
this.type = type;
}
/**
* AABB碰撞检测:判断与给定矩形是否相交
*/
boolean intersects(double rx, double ry, int rw, int rh) {
double halfW1 = width / 2.0, halfH1 = height / 2.0;
double halfW2 = rw / 2.0, halfH2 = rh / 2.0;
double cx1 = x + halfW1, cy1 = y + halfH1;
double cx2 = rx + halfW2, cy2 = ry + halfH2;
return Math.abs(cx1 - cx2) < (halfW1 + halfW2) &&
Math.abs(cy1 - cy2) < (halfH1 + halfH2);
}
}
static class Coin {
double x, y;
int radius = 12;
boolean collected = false;
Coin(double x, double y) { this.x = x; this.y = y; }
}
public SkiAdventure() {
setPreferredSize(new Dimension(WIDTH, HEIGHT));
setBackground(new Color(220, 240, 255));
setFocusable(true);
addKeyListener(this);
timer = new Timer(20, this); // 50 FPS
timer.start();
}
/**
* 随机生成障碍物,间距随分数递减以提升难度
*/
private void generateObstacle() {
// 难度曲线:分数越高,间距越小
double difficulty = Math.min(score / 5000.0, 0.6);
double actualMinGap = minGap * (1 - difficulty * 0.4);
double actualMaxGap = maxGap * (1 - difficulty * 0.3);
double gap = actualMinGap + random.nextDouble() * (actualMaxGap - actualMinGap);
nextObstacleX += gap;
int type = random.nextInt(2);
int w, h;
double y;
if (type == 0) { // 树木
w = 30; h = 60;
y = GROUND_Y - h;
} else { // 岩石
w = 40; h = 35;
y = GROUND_Y - h;
}
obstacles.add(new Obstacle(nextObstacleX, y, w, h, type));
// 在障碍物上方或间隙中生成金币
if (random.nextDouble() < 0.6) {
double coinX = nextObstacleX - gap / 2;
double coinY = GROUND_Y - 80 - random.nextInt(60);
coins.add(new Coin(coinX, coinY));
}
}
/**
* 核心游戏循环:物理更新、碰撞检测、世界生成
*/
@Override
public void actionPerformed(ActionEvent e) {
if (gameOver) return;
// 1. 速度递增(随着时间推移滑得更快)
skierVx = BASE_SPEED + score / 2000.0;
// 2. 重力与跳跃物理更新
if (!isGrounded) {
skierVy += GRAVITY;
}
skierY += skierVy;
// 3. 地面检测
if (skierY >= GROUND_Y - SKIER_HEIGHT) {
skierY = GROUND_Y - SKIER_HEIGHT;
skierVy = 0;
isGrounded = true;
isJumping = false;
jumpCount = 0;
}
// 4. 世界滚动:滑雪者相对屏幕位置固定,障碍物向左移动
worldOffset += skierVx;
score += (int)skierVx;
// 5. 生成新障碍物
while (nextObstacleX < worldOffset + WIDTH + 200) {
generateObstacle();
}
// 6. 碰撞检测:障碍物
for (Obstacle obs : obstacles) {
// 转换为屏幕坐标进行碰撞检测
double screenX = obs.x - worldOffset;
if (obs.intersects(skierX, skierY, SKIER_WIDTH, SKIER_HEIGHT)) {
gameOver = true;
combo = 0;
}
// 计分:成功越过障碍物
if (!obs.passed && obs.x < worldOffset + skierX) {
obs.passed = true;
combo++;
score += combo * 10;
}
}
// 7. 金币收集检测(圆形与矩形碰撞,简化为距离判断)
for (Coin coin : coins) {
if (coin.collected) continue;
double screenCoinX = coin.x - worldOffset;
double dx = (skierX + SKIER_WIDTH / 2.0) - screenCoinX;
double dy = (skierY + SKIER_HEIGHT / 2.0) - coin.y;
if (Math.hypot(dx, dy) < coin.radius + 20) {
coin.collected = true;
score += 50;
}
}
// 8. 清理屏幕外的对象
obstacles.removeIf(o -> o.x < worldOffset - 100);
coins.removeIf(c -> c.x < worldOffset - 100 || c.collected);
repaint();
}
/**
* 触发跳跃:支持二段跳机制
*/
private void jump() {
if (jumpCount < 2) { // 最多二段跳
skierVy = JUMP_VELOCITY - jumpCount * 2; // 二段跳稍弱
isGrounded = false;
isJumping = true;
jumpCount++;
}
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2d = (Graphics2D) g;
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 绘制雪山背景(多层视差滚动)
drawBackground(g2d);
// 绘制地面
g2d.setColor(new Color(240, 250, 255));
g2d.fillRect(0, GROUND_Y, WIDTH, HEIGHT - GROUND_Y);
g2d.setColor(new Color(200, 220, 240));
g2d.drawLine(0, GROUND_Y, WIDTH, GROUND_Y);
// 绘制障碍物
for (Obstacle obs : obstacles) {
int sx = (int)(obs.x - worldOffset);
if (sx < -100 || sx > WIDTH + 100) continue;
if (obs.type == 0) { // 树木
g2d.setColor(new Color(34, 85, 51));
g2d.fillRect(sx + obs.width / 4, obs.y, obs.width / 2, obs.height);
int[] tx = {sx, sx + obs.width / 2, sx + obs.width};
int[] ty = {obs.y + 20, obs.y - 20, obs.y + 20};
g2d.fillPolygon(tx, ty, 3);
} else { // 岩石
g2d.setColor(new Color(120, 120, 120));
g2d.fillOval(sx, obs.y, obs.width, obs.height);
}
}
// 绘制金币
g2d.setColor(new Color(255, 215, 0));
for (Coin coin : coins) {
int sx = (int)(coin.x - worldOffset);
if (sx < -50 || sx > WIDTH + 50) continue;
g2d.fillOval(sx - coin.radius, (int)coin.y - coin.radius,
coin.radius * 2, coin.radius * 2);
}
// 绘制滑雪者
drawSkier(g2d);
// 绘制UI
g2d.setColor(Color.BLACK);
g2d.setFont(new Font("Microsoft YaHei", Font.BOLD, 18));
g2d.drawString("Score: " + score, 20, 30);
g2d.drawString("Combo: x" + combo, 20, 55);
g2d.drawString("Speed: " + String.format("%.1f", skierVx), 20, 80);
if (gameOver) {
g2d.setColor(new Color(200, 0, 0, 180));
g2d.fillRect(0, 0, WIDTH, HEIGHT);
g2d.setColor(Color.WHITE);
g2d.setFont(new Font("Microsoft YaHei", Font.BOLD, 48));
g2d.drawString("GAME OVER", WIDTH / 2 - 140, HEIGHT / 2);
g2d.setFont(new Font("Microsoft YaHei", Font.PLAIN, 20));
g2d.drawString("Press R to Restart", WIDTH / 2 - 90, HEIGHT / 2 + 40);
}
}
private void drawBackground(Graphics2D g2d) {
// 远山(视差滚动,速度为0.1)
g2d.setColor(new Color(180, 200, 220));
int mountainOffset = (int)(worldOffset * 0.1) % 300;
for (int i = -1; i < 5; i++) {
int baseX = i * 300 - mountainOffset;
int[] mx = {baseX, baseX + 150, baseX + 300};
int[] my = {GROUND_Y - 50, GROUND_Y - 250, GROUND_Y - 50};
g2d.fillPolygon(mx, my, 3);
}
}
private void drawSkier(Graphics2D g2d) {
// 身体
g2d.setColor(new Color(50, 100, 180));
g2d.fillOval((int)skierX + 10, (int)skierY + 10, 20, 30);
// 头
g2d.setColor(new Color(255, 200, 150));
g2d.fillOval((int)skierX + 12, (int)skierY, 16, 16);
// 滑雪板
g2d.setColor(new Color(150, 50, 50));
int tilt = isJumping ? -10 : 0;
g2d.fillRoundRect((int)skierX, (int)skierY + SKIER_HEIGHT - 8 + tilt,
SKIER_WIDTH, 8, 4, 4);
// 跳跃轨迹预测线(辅助玩家判断)
if (isGrounded) {
g2d.setColor(new Color(255, 255, 255, 80));
double px = skierX + SKIER_WIDTH / 2.0;
double py = skierY;
double pvx = skierVx;
double pvy = JUMP_VELOCITY;
for (int i = 0; i < 20; i++) {
pvy += GRAVITY;
px += pvx;
py += pvy;
if (py > GROUND_Y) break;
g2d.fillOval((int)px - 1, (int)py - 1, 3, 3);
}
}
}
private void resetGame() {
skierX = 150;
skierY = GROUND_Y - SKIER_HEIGHT;
skierVx = BASE_SPEED;
skierVy = 0;
isJumping = false;
isGrounded = true;
jumpCount = 0;
worldOffset = 0;
score = 0;
combo = 0;
gameOver = false;
obstacles.clear();
coins.clear();
nextObstacleX = 400;
random = new Random(System.currentTimeMillis());
}
@Override public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_SPACE && !gameOver) {
jump();
}
if (e.getKeyCode() == KeyEvent.VK_R && gameOver) {
resetGame();
}
}
@Override public void keyReleased(KeyEvent e) {}
@Override public void keyTyped(KeyEvent e) {}
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
JFrame frame = new JFrame("滑雪大冒险 - 抛物线跳跃与避让决策");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.add(new SkiAdventure());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
});
}
}
四、关键算法详解
4.1 抛物线跳跃的离散模拟
代码中没有使用解析式直接计算落点,而是每帧按以下顺序更新:
skierVy += GRAVITY—— 垂直速度受重力加速skierY += skierVy—— 根据速度更新位置- 检测是否触及地面,触及则重置速度
这种离散模拟方式与经典的游戏物理引擎(如Box2D)一致,虽然存在微小的能量不守恒(因离散时间步进),但在帧率足够高时(50~60FPS)视觉效果流畅自然。
4.2 难度递增曲线
障碍物间距通过以下公式动态调整:
double difficulty = Math.min(score / 5000.0, 0.6);
double actualMinGap = minGap * (1 - difficulty * 0.4);
当分数达到5000时,间距缩小至原来的60%。这种线性难度曲线既保证了新手友好,又能给高手带来挑战。
4.3 视差滚动背景
远山使用 worldOffset * 0.1 计算偏移,近处地面使用 worldOffset * 1.0,形成了景深层次感。视差滚动是2D游戏中营造立体感的经典技术。
五、复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 单帧更新 | $O(N + M)$ | $N$ 为障碍物数,$M$ 为金币数 |
| AABB碰撞检测 | $O(1)$ | 单次矩形相交判断 |
| 障碍物生成 | $O(1)$ | 随机数计算与对象创建 |
| 屏幕外清理 | $O(N)$ | 遍历列表移除越界对象 |
由于每帧只处理屏幕附近的对象,实际运行中 $N$ 和 $M$ 被常数约束,整体性能稳定。
六、扩展思路
- 斜坡地形:将平坦地面改为正弦曲线或随机起伏,跳跃落点需要动态计算
- AI自动游玩:使用射线投射预测障碍物距离,自动决策最优跳跃时机
- 道具系统:引入磁铁(自动吸附金币)、护盾(抵消一次碰撞)等增益效果
- 网络同步:将世界状态编码为紧凑二进制,实现多人在线竞速
滑雪游戏的物理模型将运动学公式与离散游戏循环完美结合,是理解实时物理模拟的优秀实践场景。