在实践中学习

上一期学习笔记:从零开始的 C# 学习笔记 ①: Microsoft Learn | CircleCrop Blog

这一次的学习笔记是完成代码后隔了一段时间才写的,看起来会更像一次回顾。

代码概览

在实践中学习。枯燥地学习、记录知识点有些乏味的话,不妨试着上手写一个小程序,从实践中学得新知,增长经验。

这次,笔者尝试写一个控制台贪吃蛇小游戏,用来快速了解 C# 的基本语法和功能实现。

成果

完成代码CSharp-Learning-Tests/Program.cs
编译程序C-Sharp-Learning-Test.exe | CircleCrop WebDrive

项目概述

核心结构

变量声明

public const int snakeFullHealth = 1;
public static int snakeHealth = snakeFullHealth;
public static int snakeStartLength = 10;
public const int windowWidth = 20;
public const int windowHeight = 16;
// 便于修改围墙大小
public static int time = 0;
public static int score = 0;
public static char[,] map = new char[windowHeight, windowWidth];
// 0=blank, 1=snakebody, 2=snakehead, 3=apple, 4=wall
public static string? message;
public static Boolean gameOver = false;
public static string direction = "right";
public static Timer? timer;
public static List<(int x, int y)> snakeLocation = new List<(int x, int y)>();
// 蛇的位置,蛇头在最后一行
public static (int x, int y) appleLocation;
// 苹果坐标

private static int _snakeSpeed = 500; //ms
public static int snakeSpeed {
    get {
        return _snakeSpeed;
    }
    set {
        if (value >= 60) {
            _snakeSpeed = value;
        }
    }
}

snakeSpeed 使用了 C# 的属性,保证速度不会过高(刷新间隔 <=60ms

初始化

internal static void InitializingGameWindow() {
    Console.OutputEncoding = Encoding.UTF8; 
    // 设定控制台编码以便输出 Emoji
    message = "游戏开始!";
    for (int i = 0; i < snakeStartLength; i++) {
        snakeLocation.Add((0, i));
    } // 开始将蛇放在左上角
    RandomApple(); //初始化苹果
    UpdateOnMap(); //刷新地图
    RenderFrame(); //渲染到控制台
    message = "";
}

internal static void RandomApple() {
    Random random = new Random();
    appleLocation = (random.Next(0, windowHeight), random.Next(0, windowWidth));
    foreach (var x in snakeLocation) {
        if (appleLocation == x) {
            RandomApple();
        }
    } // 如果和蛇重叠就重新生成
}

按键监听

这部分还未掌握,是由 GPT 给出的。

if (Console.KeyAvailable) {
    var key = Console.ReadKey(intercept: true).Key;
    switch (key) {
        case ConsoleKey.UpArrow:
        case ConsoleKey.W:
            ControlDirection("up");
            break;
        case ConsoleKey.DownArrow:
        case ConsoleKey.S:
            ControlDirection("down");
            break;
        case ConsoleKey.LeftArrow:
        case ConsoleKey.A:
            ControlDirection("left");
            break;
        case ConsoleKey.RightArrow:
        case ConsoleKey.D:
            ControlDirection("right");
            break;
    }
}

public static void ControlDirection(string newDirection) {
    if ((newDirection == "up" && direction != "down") ||
        (newDirection == "down" && direction != "up") ||
        (newDirection == "left" && direction != "right") ||
        (newDirection == "right" && direction != "left")) {
        direction = newDirection;
    } // 防止头转 180°
}

逻辑处理

地图刷新和逻辑处理写在一起了。

public static void UpdateMap() {
    // 控制蛇的方向:每次根据当前方向更新蛇头的位置,并将其插入 List 的开头位置;
    // 移除 List 的最后一个元素,以模拟前进的效果。
    var lastSnakeHead = snakeLocation[snakeLocation.Count - 1];
    switch (direction) {
        case "up":
            snakeLocation.Add((lastSnakeHead.x - 1, lastSnakeHead.y));
            break;
        case "down":
            snakeLocation.Add((lastSnakeHead.x + 1, lastSnakeHead.y));
            break;
        case "left":
            snakeLocation.Add((lastSnakeHead.x, lastSnakeHead.y - 1));
            break;
        case "right":
            snakeLocation.Add((lastSnakeHead.x, lastSnakeHead.y + 1));
            break;
    }
    var newSnakeHead = snakeLocation[snakeLocation.Count - 1];
    // 苹果判定
    if (newSnakeHead == appleLocation) {
        score += 1;
        snakeSpeed -= 20;
        RandomApple();
    } else {
        snakeLocation.RemoveAt(0); //移除最后一个
    }
    UpdateOnMap();
    /*foreach (var snake in snakeLocation) {
        Console.WriteLine(snake.ToString());
    } 调试用*/
    // 位置更新完成,下面开始判定是否撞墙/自己
    var positionSet = new HashSet<(int x, int y)>();
    bool duplicatePosition = false;
    foreach (var position in snakeLocation) {
        // 如果添加失败,说明有重复位置,撞自己了
        if (!positionSet.Add(position)) {
            duplicatePosition = true;
            break;
        }
    }
    if (newSnakeHead.x == -1 || newSnakeHead.x == windowHeight || newSnakeHead.y == -1 || newSnakeHead.y == windowWidth || duplicatePosition) {
        message = "游戏结束!";
        RenderFrame();
        gameOver = true;
        Console.ReadLine();
    } else {
        message = "继续!";
    }
}

控制台渲染

public static void RenderFrame() {
StringBuilder Frame = new StringBuilder();
// 构造生命值区域
string lifeArea = "生命: " + string.Concat(Enumerable.Repeat(Symbols.heart, snakeHealth))
    + string.Concat(Enumerable.Repeat(Symbols.blackHeart, snakeFullHealth - snakeHealth));
string timeArea = $"时间: {(time / 60):D2}:{(time % 60):D2}";
string scoreArea = "得分: " + score.ToString();
string lengthArea = "长度: " + snakeLocation.Count();
// 构造信息栏
Frame.AppendLine($"{lifeArea} {timeArea} {scoreArea} {lengthArea}");
Frame.AppendLine();
Frame.AppendLine("💬: " + message);
Frame.AppendLine();
// 顶部围墙
Frame.AppendLine(string.Concat(Enumerable.Repeat(Symbols.Wall, windowWidth + 2)));
// 构造地图
for (int i = 0; i < map.GetLength(0); i++) {
    Frame.Append(Symbols.Wall);
    for (int j = 0; j < map.GetLength(1); j++) {
        switch (map[i, j]) {
    // 0=blank, 1=snakebody, 2=snakehead, 3=apple, 4=wall
            case (char)0:
                Frame.Append(Symbols.Blank);
                break;
            case (char)1:
                Frame.Append(Symbols.Body);
                break;
            case (char)2:
                Frame.Append(Symbols.Head);
                break;
            case (char)3:
                Frame.Append(Symbols.Apple);
                break;
            case (char)4:
                Frame.Append(Symbols.Wall);
                break;
        }
    }
    Frame.AppendLine(Symbols.Wall); // 下一行
}
// 底部围墙
Frame.AppendLine(string.Concat(Enumerable.Repeat(Symbols.Wall, windowWidth + 2)));
Console.Clear(); // 清屏
Console.WriteLine(Frame.ToString()); // 渲染到控制台
}

学习总结

(以下含有 ChatGPT 生成内容,权当存档。我不是很想把要点复读一遍)

控制台渲染

Console.OutputEncoding
Console.Clear()
Console.WriteLine
Console.Write 区别在不换行。

属性

属性用于控制对私有变量的访问,提供了灵活的封装方式。

snakeSpeed 的实现中,通过 getset 控制变量的读取与修改,并加以条件限制。

二维数列

二维数组在本项目中被用作地图 map 的数据结构,用于记录每个位置的状态(空白、墙体、蛇、苹果等)。

元组

public static (int x, int y) appleLocation;

StringBuilder

用于高效拼接字符串。

StringBuilder frame = new StringBuilder();
frame.AppendLine("游戏得分: " + score);
frame.AppendLine("蛇的长度: " + snakeLocation.Count);
Console.WriteLine(frame.ToString());