每日算法 — 使用java实现滑雪大冒险:抛物线跳跃物理与障碍物避让决策

滑雪大冒险是经典的休闲跑酷类游戏,玩家操控滑雪者在雪坡上滑行,通过跳跃躲避障碍物并收集金币。本文用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 抛物线跳跃的离散模拟

代码中没有使用解析式直接计算落点,而是每帧按以下顺序更新:

  1. skierVy += GRAVITY —— 垂直速度受重力加速
  2. skierY += skierVy —— 根据速度更新位置
  3. 检测是否触及地面,触及则重置速度

这种离散模拟方式与经典的游戏物理引擎(如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$ 被常数约束,整体性能稳定。

六、扩展思路

  1. 斜坡地形:将平坦地面改为正弦曲线或随机起伏,跳跃落点需要动态计算
  2. AI自动游玩:使用射线投射预测障碍物距离,自动决策最优跳跃时机
  3. 道具系统:引入磁铁(自动吸附金币)、护盾(抵消一次碰撞)等增益效果
  4. 网络同步:将世界状态编码为紧凑二进制,实现多人在线竞速

滑雪游戏的物理模型将运动学公式与离散游戏循环完美结合,是理解实时物理模拟的优秀实践场景。