手把手教你用 Rust 打造复古太空射击游戏

  • King
  • 发布于 9小时前
  • 阅读 22

最近心血来潮,想做个小游戏练练手。想起小时候玩的那些红白机射击游戏,简单但上瘾。于是决定用现在比较火的技术栈——Rust+React+Tauri,复刻一个复古风格的太空射击游戏。说实话,一开始心里也没底。游戏开发看起来挺复杂的,但真正动手后发现,只要理清核心逻辑,一步步来,并没有

最近心血来潮,想做个小游戏练练手。想起小时候玩的那些红白机射击游戏,简单但上瘾。

于是决定用现在比较火的技术栈 —— Rust + React + Tauri,复刻一个复古风格的太空射击游戏。

说实话,一开始心里也没底。游戏开发看起来挺复杂的,但真正动手后发现,只要理清核心逻辑,一步步来,并没有想象中那么难。

这篇文章就记录一下整个开发过程和踩过的坑,希望能给想入门的同学一些参考。


先上效果图

游戏长这样:

  • 复古的 Game Boy 配色,带点霓虹效果
  • 经典的上下左右移动,空格射击
  • 有4种不同的敌人,还有道具系统
  • 一套代码能跑在电脑和手机上

整个项目做下来,最大的感受是:

Rust 写游戏逻辑真的很爽,类型安全加上高性能,基本不用担心运行时出错。


技术选型的心路历程

一开始纠结过要不要用游戏引擎,像 Unity 或者 Godot。但考虑到我想做的是个 2D 小游戏,用引擎有点"杀鸡用牛刀"的感觉。而且我想试试用 Web 技术栈做游戏,部署起来方便。

最后选了这个组合:

技术 干嘛用的 为什么选它
Rust 游戏核心逻辑 内存安全、性能强、编译时就能发现大部分 bug
Tauri 打包成桌面/手机应用 比 Electron 体积小很多,性能接近原生
React 做 UI 界面 熟悉,组件化开发方便
TypeScript 类型检查 和 Rust 搭配,前后端都有类型保障

架构大概是这么分的:

Rust 负责游戏逻辑运算,React 负责画面渲染和用户输入,两者通过 Tauri 的 IPC 通信。

这样 Rust 专心做它擅长的计算,React 专心做界面,各干各的,代码也清晰。


游戏的核心是怎么运转的

做游戏最关键的有三个东西:实体状态循环

实体就是游戏里所有能动的东西

玩家飞船、敌人、子弹、道具、爆炸效果,这些都是实体。用 Rust 的 struct 和 enum 定义很清晰:

// 玩家
pub struct Player {
    pub x: f64,
    pub y: f64,
    pub hp: i32,
    pub fire_level: i32,  // 火力等级,1-3级
    pub rapid_fire_timer: i32,  // 快速射击剩余时间
    pub shield_timer: i32,      // 护盾剩余时间
}

// 敌人类型
pub enum EnemyType {
    Scout,      // 小侦察机,速度快但脆皮
    Fighter,    // 战斗机,会发射子弹
    Bomber,     // 轰炸机,血厚
    Boss,       // Boss,出现频率低但威胁大
}

状态存了游戏的所有数据

每一帧游戏的状态都存在一个大的结构体里:

pub struct GameState {
    pub player: Player,
    pub score: i32,
    pub lives: i32,
    pub level: i32,
    pub enemies: Vec<Enemy>,
    pub bullets: Vec<Bullet>,
    pub powerups: Vec<PowerUp>,
    pub explosions: Vec<Explosion>,
    pub stars: Vec<Star>,  // 背景星星,营造太空感
    pub is_running: bool,
    pub is_paused: bool,
}

游戏循环是心脏

游戏能跑起来全靠一个循环,每 16.67 毫秒(60帧)执行一次:

pub fn update_game(state: &mut GameState, input: &GameInput) {
    // 1. 处理玩家输入
    handle_player_input(state, input);

    // 2. 更新所有东西的位置
    update_positions(state);

    // 3. 生成新的敌人和道具
    spawn_entities(state);

    // 4. 检测谁撞到了谁
    check_collisions(state);

    // 5. 清理已经销毁的东西
    cleanup_entities(state);

    // 6. 检查是不是过关了或者游戏结束了
    check_game_state(state);
}

这个循环就是游戏的心跳,每一帧都走一遍这些步骤,游戏就动起来了。


几个关键功能的实现

碰撞检测

射击游戏最重要的是打中敌人。碰撞检测用的是最简单的 AABB(轴对齐边界框),就是判断两个矩形有没有重叠:

pub fn check_collision(a: &BoundingBox, b: &BoundingBox) -> bool {
    a.x < b.x + b.width
        && a.x + a.width > b.x
        && a.y < b.y + b.height
        && a.y + a.height > b.y
}

虽然简单,但对于这种 2D 游戏完全够用了。每一帧检查玩家子弹和敌人的碰撞、敌人子弹和玩家的碰撞、玩家和敌人的碰撞,还有玩家和道具的碰撞。

关卡难度曲线

游戏不能太简单也不能太难,难度要慢慢上去。我设计的是每 10 关一个循环,每过一轮敌人速度变快,生成率变高:

pub fn get_level_config(level: i32) -> LevelConfig {
    let cycle = (level - 1) / 10;  // 第几轮
    let sub_level = (level - 1) % 10 + 1;  // 当前轮的第几关

    LevelConfig {
        enemy_spawn_rate: 0.02 + cycle as f64 * 0.005,
        enemy_speed_multiplier: 1.0 + cycle as f64 * 0.2,
        target_score: 1000 * sub_level + cycle * 5000,
    }
}

这样玩家一开始能轻松上手,玩到后面就有挑战性了。

道具系统

道具让游戏更有策略性。我设计了4种道具:

  • 心形:回血
  • 闪电:10秒快速射击
  • 盾牌:10秒无敌
  • 火力:提升火力等级

火力等级会影响发射子弹的数量:

match player.fire_level {
    1 => spawn_bullet(state, player.x, player.y - 10.0, true),
    2 => {
        spawn_bullet(state, player.x - 5.0, player.y - 10.0, true);
        spawn_bullet(state, player.x + 5.0, player.y - 10.0, true);
    }
    3 => {
        spawn_bullet(state, player.x, player.y - 10.0, true);
        spawn_bullet(state, player.x - 8.0, player.y - 8.0, true);
        spawn_bullet(state, player.x + 8.0, player.y - 8.0, true);
    }
    _ => {}
}

1级单发,2级双发,3级扇形三发。吃到火力道具升级,被击中降级,这样玩家会更有动力去躲子弹、吃道具。


前端渲染部分

React 组件

前端用 React 主要是处理 Canvas 渲染和用户输入:

export function GameCanvas() {
    const canvasRef = useRef<HTMLCanvasElement>(null);
    const gameStateRef = useRef<GameState | null>(null);
    const inputRef = useRef({ left: false, right: false, up: false, down: false, fire: false });

    const gameLoop = useCallback(async () => {
        if (!gameStateRef.current) return;

        // 调用 Rust 更新游戏状态
        const newState = await invoke<GameState>('update_game', {
            state: gameStateRef.current,
            input: inputRef.current,
        });
        gameStateRef.current = newState;

        // 渲染画面
        render(canvasRef.current!, newState);

        requestAnimationFrame(gameLoop);
    }, []);

    // 键盘事件
    useEffect(() => {
        const handleKeyDown = (e: KeyboardEvent) => {
            if (e.key === 'ArrowLeft' || e.key === 'a') inputRef.current.left = true;
            if (e.key === 'ArrowRight' || e.key === 'd') inputRef.current.right = true;
            if (e.key === ' ' || e.key === 'z') inputRef.current.fire = true;
        };

        window.addEventListener('keydown', handleKeyDown);
        return () => window.removeEventListener('keydown', handleKeyDown);
    }, []);

    // 启动游戏
    useEffect(() => {
        initGame().then(state => {
            gameStateRef.current = state;
            gameLoop();
        });
    }, [gameLoop]);

    return <canvas ref={canvasRef} width={640} height={480} />;
}

渲染函数

function render(canvas: HTMLCanvasElement, state: GameState) {
    const ctx = canvas.getContext('2d')!;

    // 清空画布
    ctx.fillStyle = '#0f380f';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // 绘制背景星星
    state.stars.forEach(star => {
        ctx.fillStyle = `rgba(155, 188, 15, ${star.brightness})`;
        ctx.fillRect(star.x, star.y, 2, 2);
    });

    // 绘制玩家
    drawSprite(ctx, state.player.x, state.player.y, PLAYER_SPRITE);

    // 绘制敌人
    state.enemies.forEach(enemy => {
        const sprite = getEnemySprite(enemy.enemy_type);
        drawSprite(ctx, enemy.x, enemy.y, sprite);
    });

    // 绘制子弹
    ctx.fillStyle = '#9bbc0f';
    state.bullets.forEach(bullet => {
        ctx.fillRect(bullet.x, bullet.y, 3, 8);
    });

    // 绘制UI(分数、生命值)
    drawUI(ctx, state);
}

音效系统

用 Web Audio API 做了几个简单的 8-bit 音效:

// 射击音效
playShoot() {
    const osc = this.ctx.createOscillator();
    const gain = this.ctx.createGain();

    osc.type = 'square';
    osc.frequency.setValueAtTime(880, this.ctx.currentTime);
    osc.frequency.exponentialRampToValueAtTime(110, this.ctx.currentTime + 0.1);

    gain.gain.setValueAtTime(0.3, this.ctx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.1);

    osc.connect(gain);
    gain.connect(this.ctx.destination);

    osc.start();
    osc.stop(this.ctx.currentTime + 0.1);
}

射击是高频率衰减的方波,爆炸是噪声,道具是上升音阶。虽然简单,但挺有复古游戏的味道。


开发过程中踩过的坑

1. 性能问题

一开始每帧都重新创建一堆对象,结果帧率掉到 30 帧。后来用了对象池,预分配好重复使用,性能立马就上来了。

2. 碰撞检测精度

最开始用圆形碰撞,结果敌人明明看着没碰到玩家,玩家却掉血了。改成矩形碰撞后准确多了。

3. 移动端适配

手机屏幕小,按钮要做得足够大。还加了触摸控制,左右滑动移动,点击射击。一开始忽略了横竖屏切换,后来加了个监听事件,自动调整布局。


一些优化心得

代码组织

把不同功能分开,结构清晰很多:

src-tauri/src/game/
├── entities/      # 游戏实体
├── state/         # 状态管理  
├── collision.rs   # 碰撞检测
├── config.rs      # 关卡配置
└── types.rs       # 类型定义

游戏平衡

  • 难度曲线:用 sigmoid 函数让难度平滑上升,不要突然变难
  • 正反馈:道具系统让玩家有成长感,每过一关都有奖励
  • 风险回报:Boss 敌人危险但得分高,鼓励玩家挑战

性能优化

  • 对象池:避免频繁创建销毁对象
  • 空间分割:敌人多的时候用四叉树加速碰撞检测
  • 脏矩形渲染:只重绘变化的区域

总结

整个项目做下来,收获还是挺大的:

  1. Rust 真的很适合写游戏逻辑,类型安全让我少踩了很多坑
  2. 游戏开发其实没那么神秘,核心就是实体+状态+循环
  3. Tauri 做跨平台应用很方便,一套代码多端运行
  4. 音效对游戏体验很重要,哪怕是很简单的 8-bit 音效

最重要的是:完成比完美更重要。先做出能玩的版本,再慢慢优化。如果一开始就想着要做得多完美,可能永远都开不了头。

💡 学习建议:看完文章后,不妨自己动手试试。从最简单的开始,先让一个小方块能在屏幕上移动,慢慢加上射击、敌人、碰撞检测...一步步来,你会发现游戏开发其实挺有趣的。


如果这篇文章对你有帮助,欢迎点赞、在看、转发! 🚀

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
King
King
0x56af...a0dd
擅长Rust/Solidity/FunC/Move开发