“文字地牢”小游戏通关 Rust 入门

“文字地牢”将会是一系列的文章,循序渐进、可直接上手实践的“博文式”教程,本篇我们将从 0 开始,用一个可玩的文字地牢小游戏,串起 Rust 入门的关键知识点与最佳实践。目前的每一节都进行了简单的讲解,并给出项目里的真实代码与动手步骤,确保你看完就能跑、改完就能玩。示例我上传到这里了,大家可以直接运行尝试,后续博主也会通过多篇文章对具体的内容进行讲解。

适合读者:

  • 想用“做一个小成品”快速上手 Rust 的同学
  • 需要把《Rust 程序设计语言》中的概念落地到真实项目的同学

阅读收获:

  • 从工具链、依赖管理到主循环、输入输出与数据建模的完整路径
  • Result/? 进行优雅错误处理,完成 JSON 存档/读档
  • 会跑会改的终端小游戏,可持续扩展为更完整的 roguelike

文章结构(可跳读):

    1. 从零到可玩(总览步骤)
  • 1-2. 工具链与 Cargo
  • 3-7. 主循环、IO、控制流、数据模型、所有权
  • 8-9. 错误处理与文件 IO
  • 10-12. 测试、扩展方向、调试技巧
    1. 总结与练习

0. 动手实践:从零到可玩

  1. 新建项目并运行“Hello, world!”
  • 命令:
cargo new dungeon_explorer
cd dungeon_explorer
cargo run
  • 你会看到:终端打印“Hello, world!”(在我们仓库里这一步已替换为游戏主循环)。
  1. 添加依赖与构建
  • 编辑 Cargo.toml 并添加 serde, serde_json, rand
  • 命令:cargo build
  1. 读取用户输入并打印反馈
  • src/io/mod.rs 中实现 read_input_line();主循环中打印地图与读取输入。
  1. 使用 match 分派命令
  • 识别 w/a/s/dsave/loadq,给出明确反馈。
  1. 引入结构体与枚举建模
  • 定义 PositionPlayerGameStateTileMap,实现渲染与移动。
  1. 错误处理与文件 IO
  • Result/? 传播错误;serde_json 完成存档/读档。
  1. 测试与扩展
  • 添加基本单元测试;扩展迭代器统计、随机生成、物品与敌人等。

1. 安装工具链与创建项目

概念:

  • rustup 管理工具链通道(stable/beta/nightly)与目标平台;cargo 负责构建、依赖与工作流;rustc 是编译器。
  • 组件:rustfmt(统一格式)、clippy(代码建议),在团队协作中尤为重要。

为什么:

  • 可重复、可升级的工具链管理是“代码能跑、到处能跑”的前提;统一的格式与建议减少风格分歧和隐藏 bug。

如何做(命令):

curl https://sh.rustup.rs -sSf | sh
rustup update
rustc --version
cargo --version
rustup component add rustfmt clippy

新建项目(本仓库已就绪):

cargo new dungeon_explorer

常见坑:

  • Mac 上安装后首次运行较慢,多为编译缓存未生成;可先 cargo check 预热。
  • 忘记安装 clippy 导致无法执行 cargo clippy;或 IDE 未启用 rust-analyzer 导致补全缺失。

小练习:

  • 运行 cargo fmtcargo clippy -- -D warnings,确保代码通过格式化与静态检查。

2. Cargo 与依赖管理

概念:

  • Cargo.toml 声明包与依赖,Cargo.lock 锁定版本;feature 用于按需启用功能(如 serde/derive)。
  • 常用命令:cargo run(编译并运行)、cargo build(构建二进制/库)、cargo check(快速类型检查)、cargo fmtcargo clippycargo test

为什么:

  • 精准声明依赖和 feature 能避免“臃肿/未使用/编译慢”;锁定版本保障团队与 CI 一致性。

如何做(项目用法):

[package]
name = "dungeon_explorer"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rand = "0.8"
  • serde + derive 让结构体/枚举支持序列化。
  • serde_json 用于 JSON 存档。
  • rand 后续用于随机地图。

常见坑:

  • 未启用 serdederive feature,导致 Serialize/Deserialize 无法派生。
  • 复制代码时混入制表与空格导致 Cargo.toml 解析失败(请保持缩进一致)。

小练习:

  • 新增依赖 ron = "0.8",为后续可选的 RON 存档做准备,并执行 cargo check 验证。

3. 程序入口与模块组织

概念:

  • mod 将子模块纳入当前 crate;use 将路径引入作用域,减少长路径污染。
  • fn main() -> Result<()> 使入口函数能以 ? 传播可恢复错误,避免 unwrap 导致崩溃。

为什么:

  • 清晰的模块边界与公共 API 能让游戏状态、地图、IO 职责分离,易于扩展(敌人/物品/AI)。

项目实现(src/main.rs):

mod game;
mod map;
mod io;
mod errors;

use errors::Result;
use game::{Game, GameState, Player, Position};

fn main() -> Result<()> {
    let map = map::Map::new(10, 10);
    let player = Player { position: Position { x: 1, y: 1 }, hp: 10 };
    let state = GameState { map, player };
    let mut game = Game::new(state);

    println!("欢迎来到文字地牢!使用 w/a/s/d 移动,save 保存,load 读取,q 退出。");

    loop {
        println!("{}", game.render());
        let input = io::read_input_line()?;
        let cmd = input.trim().to_lowercase();
        match cmd.as_str() {
            "w" | "a" | "s" | "d" => game.handle_move(cmd.chars().next().unwrap()),
            "save" => { io::save_state("save.json", &game.state)?; println!("已保存到 save.json"); }
            "load" => match io::load_state("save.json") {
                Ok(loaded) => { game.state = loaded; println!("已读取存档"); }
                Err(e) => println!("读取失败: {}", e),
            },
            "q" => { println!("退出游戏,再见!"); break; }
            _ => println!("未知命令:{}", cmd),
        }
    }

    Ok(())
}

运行效果示例(部分):

##########
#........#
#.@......#
#........#
#........#
#........#
#........#
#........#
#........#
##########
HP: 10 Pos:(1, 1)
> 

设计要点:

  • Game 的渲染与状态更新封装在 game 模块,io 仅负责人机交互与存档/读档,map 负责数据与不变式,彼此通过清晰方法通信。
  • 对输入先标准化(trim + to_lowercase)再 match,降低分支复杂度。

小练习:

  • main 中将 cmd.chars().next()unwrap() 改为安全分支:空输入时跳过当前循环并提示。

4. 读取输入与基础 IO

概念:

  • 标准输入输出:stdin/stdoutprint! 不换行常用于提示符,需手动 flush 及时显示。
  • 文本处理:String(可变、拥有所有权)与 &str(切片视图)的区分;trim() 去除尾随换行与空白。

项目实现(src/io/mod.rs):

use std::io::{self, Write};
use serde_json;
use crate::errors::Result;
use crate::game::GameState;

pub fn read_input_line() -> Result<String> {
    print!("> ");
    io::stdout().flush().ok();
    let mut buf = String::new();
    io::stdin().read_line(&mut buf)?;
    Ok(buf)
}

pub fn save_state(path: &str, state: &GameState) -> Result<()> {
    let s = serde_json::to_string_pretty(state)?;
    std::fs::write(path, s)?;
    Ok(())
}

pub fn load_state(path: &str) -> Result<GameState> {
    let s = std::fs::read_to_string(path)?;
    let state: GameState = serde_json::from_str(&s)?;
    Ok(state)
}

健壮性建议:

  • read_line 返回 0 可能表示 EOF(如管道输入结束),应在调用端处理为空输入的分支。
  • Windows 与 Unix 换行差异由 trim() 统一处理,避免显式判断 \r\n

动手验证:

  • 输入 save 后生成 save.json;输入 load 恢复状态;输入 q 退出。

5. 控制流与模式匹配

概念先导:

  • 控制流:loop 用于主循环,搭配 break 退出;for 适合迭代场景。
  • match:穷尽匹配、模式组合、解构,配合枚举可表达明确状态机。

在游戏中的应用:

  • loop 驱动帧循环;用 match 分派命令(移动、存档、读档、退出)。

常见坑:

  • 直接 unwrap() 处理空输入会崩溃;建议改为 if let Some(c) = cmd.chars().next()
  • 分支遗漏导致“未知命令”无反馈,最好统一落到 _ 分支给出帮助提示。

小练习:

  • 定义 enum Command { Move(char), Save, Load, Quit, Help, Unknown(String) },实现 parse_command(&str) 并在 mainmatch 该枚举。

6. 数据模型:结构体与枚举

概念先导:

  • 结构体 struct:组合相关字段,可实现方法与 trait;适合实体建模(玩家、地图)。
  • 枚举 enum:表达有限状态并可携带数据,适合地图瓦片、命令解析、错误类型。
  • 常用派生:Debug/Clone/Copy/PartialEq/Serialize/Deserialize,便于调试、比较与(反)序列化。

在游戏中的应用(src/game/mod.rssrc/map/mod.rs):

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct Position { pub x: usize, pub y: usize }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Player { pub position: Position, pub hp: i32 }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameState { pub map: Map, pub player: Player }

地图与瓦片:

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum Tile { Wall, Floor }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Map { pub width: usize, pub height: usize, pub tiles: Vec<Tile> }

impl Map {
    pub fn new(width: usize, height: usize) -> Self { /* 边框设为墙 */ }
    pub fn get_tile(&self, x: usize, y: usize) -> Tile { /* 下标计算 */ }
    pub fn set_tile(&mut self, x: usize, y: usize, tile: Tile) { /* 可变借用修改 */ }
}

设计取舍:

  • Position 采用 usize 与网格一致;如需负偏移或相对坐标可引入 i32 辅助。
  • Map.tiles 使用扁平 Vec<Tile> 而非二维 Vec<Vec<Tile>>,以降低内存碎片并简化序列化;通过方法隐藏索引换算保持不变式。

小练习:

  • Tile 扩展 Item(Kind)Trap(Damage) 变体;在 render() 中以不同字符呈现。

7. 所有权与借用

  • &T(共享借用)可并发读取;&mut T(独占借用)可修改但同一时刻唯一。
  • Rust 在编译期以借用规则保障数据一致性与线程安全。

项目签名:

pub fn render(&self) -> String { /* 读取状态并拼接字符串 */ }
pub fn handle_move(&mut self, dir: char) { /* 修改玩家坐标 */ }

常见坑:

  • 同时持有对 MapPlayer 的可变借用会引发借用冲突;建议以更粗粒度方法实现移动逻辑,或拆分不可变/可变借用的生命周期。
  • 返回借用的引用必须不超过拥有者的生命周期,避免将局部数据的引用存入全局状态。

小练习:

  • handle_move 内避免多重可变借用:先计算目标位置,再一次性更新玩家坐标。

8. 错误处理:ResultOption?

概念:

  • 可恢复错误用 Result 表达并向上传递;不可恢复错误用 panic!(仅在不可恢复的不变量被破坏时)。
  • ? 操作符简化传播并保持控制流清晰。

项目实现(src/errors.rs):

pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Debug)]
pub enum AppError { Io(std::io::Error), Serde(serde_json::Error) }
impl From<std::io::Error> for AppError { /* ... */ }
impl From<serde_json::Error> for AppError { /* ... */ }

友好提示:

  • 在读档时区分“文件不存在”和“格式错误”,对用户给出“是否先保存一次?”或“检查文件内容是否有效 JSON”的建议。

小练习:

  • AppError 新增 NotFound 变体,或在 load_state 中匹配 io::ErrorKind::NotFound 并输出定制消息。

9. 文件 IO 与序列化

概念:

  • 文本(JSON/RON/TOML)可读易调试;二进制(bincode)体积小、速度快。
  • 版本兼容:在存档结构加入 version 字段,或使用带默认值的可选字段。

项目实现:

  • serde_json::to_string_pretty 生成可读 JSON,std::fs::write/read_to_string 完成写读。

小练习:

  • GameState 增加 version: u32 字段,调整序列化后进行一次存档/读档回归测试。

10. 测试:验证核心逻辑

概念:

  • 单元测试置于模块末尾 #[cfg(test)];集成测试置于 tests/ 目录。
  • 行为导向:将关键逻辑(边框为墙、撞墙不移动)写成用例,避免回归。

示例:

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn map_border_is_wall() { /* 检查边框都是墙 */ }

    #[test]
    fn move_blocked_by_wall() { /* 检查撞墙不移动 */ }
}

命令:

cargo test

检查清单:

  • 渲染字符串包含正确宽高与玩家位置
  • 撞墙保持坐标不变;地板移动坐标正确
  • 存档后文件存在且可读;读档后状态等价

11. 逐步扩展

  • 随机地图:用 rand 生成通道/房间。
  • 物品与敌人:ItemEnemy,引入行为 trait,如 Actor
  • HUD 与统计:迭代器统计地板/墙数量并显示。

示例迭代器统计:

let floor_count = (0..game.state.map.height)
    .flat_map(|y| (0..game.state.map.width).map(move |x| (x, y)))
    .map(|(x,y)| game.state.map.get_tile(x,y))
    .filter(|t| matches!(t, Tile::Floor))
    .count();

如何落地:

  • 将地图生成抽象为 MapGenerator trait,实现不同风格(隧道/房间/混合)的生成器;在 main 通过参数选择。
  • 定义 Actor 行为(tick(&mut self, &Map)),让 Enemy 通过最短路或贪心靠近玩家。

12. 开发命令与调试技巧

  • 常用:cargo runcargo checkcargo fmtcargo clippycargo test
  • 调试:dbg!(&value)println!
  • 日志:log + env_loggertracing(进阶)。

建议:

  • RUST_LOG=infoenv_logger 结合,区分用户输出与开发日志。
  • cargo watch -x check -x test -x run(需安装)获得快速反馈循环。

13. 总结与练习

你已完成一个可运行终端小游戏的搭建与拆解,涵盖:

  • 安装与工具链、Cargo 项目结构与依赖管理
  • 主循环、输入输出、控制流与模式匹配
  • 数据模型(结构体/枚举)、所有权与借用
  • 错误处理与文件 IO(存档/读档)
  • 测试与扩展方向

练习清单:

  1. 将命令解析抽象为 parse_command(&str) -> Command 枚举,为未知命令输出帮助列表。
  2. render() 中显示更多 HUD:步数、地板/墙统计、玩家坐标。
  3. 引入 Actor trait 与 Enemy,实现简易 AI(朝玩家移动但不穿墙)。
  4. 将存档格式改为 rontoml,并为存档结构添加 version 字段以做兼容。
  5. handle_move 编写更完整的测试覆盖:四方向、边界、墙阻挡、连续移动。

结语与下一步

如果你完成了上面的练习,此时已经具备将《文字地牢》教程中的模式(读取输入、解析、匹配、循环、错误处理)迁移到更大项目中的能力。

下一步建议:

  • 将本项目的命令解析独立为 Commandparse_command,为后续加入物品/敌人/AI 打好基础。
  • 引入日志与更结构化的错误,补充边界测试与属性测试,持续把“可玩 demo”进化成“可维护的小项目”。

后续博主也会针对每个章节中的内容进行讲解,敬请期待。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐