65.9K
CodeProject 正在变化。 阅读更多。
Home

Zyan Drench,一款支持 Wifi 的 Android 游戏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (17投票s)

2013年8月5日

MIT

12分钟阅读

viewsIcon

49990

downloadIcon

1382

本文介绍了如何使用 C#、Xamarin.Android 平台和 Zyan Communication Framework 构建一款支持网络功能的 Android 游戏。

Screenshots

引言

Drench 是一款单人游戏,最初使用 Adobe Flash 开发(可以搜索“世界上最简单的 Flash 游戏”)。它相当流行,并且已经被移植到 Android 平台。在 Google Play 和 Amazon 应用商店中有许多游戏克隆,包括 Coloroid、PaintIT、Splash! 和 Floodfill 等。

尽管确切的游戏规则可能有所不同,但所有这些游戏都是单人游戏。Zyan Drench 尝试将游戏改编为两人游戏,并引入了两种新的游戏模式:与计算机对战和网络游戏模式。本文将介绍如何使用 C# 语言、Xamarin.Android 平台(Indie 版)和 Zyan Communication Framework 构建该游戏的 Android 版本,以实现网络游戏。

游戏概述

Drench 是一款益智游戏,很容易上手,但有点难解释。游戏开始时会有一个 15x15 的随机色块(像素)的棋盘。从左上角的像素开始,您需要用一种颜色填满(浸透)整个棋盘。您可以通过将左上角的像素设置为新颜色来做到这一点。当您将像素的颜色更改为与相邻像素相同的颜色时,您会扩大浸透的区域。

Single-player mode

原始的 Drench 游戏是单人游戏,棋盘大小为 15x15,步数限制为 30 步。一些游戏实现允许选择不同的棋盘大小,步数限制通常是棋盘大小的两倍,大多数实现使用 6 种颜色。我们的单人游戏将使用相同的参数以保持向后兼容。

双人模式

将游戏改编为两人游戏很简单:如果第一个玩家从左上角的像素开始,那么对手就从对角开始。游戏轮流进行,直到整个棋盘被两种颜色覆盖。浸透像素比对手多的玩家获胜。

Two-player mode

禁用颜色

在单人模式下,重复使用相同的颜色没有意义,因为该颜色的所有相邻像素已经被占用了。两人模式增加了一个限制:您不能吃掉对手的像素,因此您不能使用与对手相同的颜色。在每一轮中,有两种颜色不能使用:即您当前的颜色和对手的颜色。我们称之为禁用颜色。

游戏开发

为了模拟游戏,我们需要一个棋盘,它是一个二维像素数组。每个像素都有一个颜色,我们可以用 0 到 5 的整数来编码。为了显示棋盘,我们需要为这些数字分配任何不同的颜色(即创建调色板)。

public class DrenchGame
{
    public const int BoardSize = 15;

    public const int ColorCount = 6;

    private int[,] Board = new int[BoardSize, BoardSize];

    private Color[] Palette = new[] { Color.Red, Color.Green, ... };

    public void NewGame()
    {
        // randomize the board
    }

    public void MakeMove(int color)
    {
        // flood fill the board starting from the top-left pixel
    }

    public void CheckIfStopped()
    {
        // check if the whole board is drenched
    }
}

Drench 游戏有相当多的不同玩法模式:单人、双人、与计算机对战(有多个难度级别)以及基于网络的。所有这些都共享相同的棋盘工作规则:设置新颜色、扩展浸透区域、为新游戏随机化棋盘等。让我们将所有这些细节提取到一个单独的类中,该类代表棋盘。

DrenchBoard

下面是我们用于表示棋盘的类。就像计算机屏幕一样,像素位置由其 X 和 Y 坐标确定,其中 (0, 0) 是左上角。索引器用于访问单个像素的颜色:this[x, y]。为了方便起见,我们将坐标包装起来(就像 Perl 语言中的数组索引一样),以便 this[-1, -1] 的含义与 this[BoardSize - 1, BoardSize - 1] 相同。

棋盘将执行所有计算以扩展浸透区域。每个玩家都试图从自己的位置开始浸透棋盘,这就是为什么 SetColor(x, y, color) 方法接受 xy 坐标的原因。SetColor 的确切算法将在下面讨论。

public class DrenchBoard
{
    // skipped: BoardSize, ColorCount and Board (same as above)

    public void Randomize()
    {
        // assign a random color to every pixel of the board
    }

    public void SetColor(int x, int y, int color)
    {
        // flood fill algorithm (discussed below)
    }

    public bool CheckAllColors(param int[] allowedColors)
    {
        // check if all pixels have one of the allowed colors  
    }

    public int this[int x, int y]
    {
        get
        {
            // wrap coordinate values so that Board[-1, -1]
            // means the right-bottom corner
            if (x < 0)
                x = BoardSize - x;
            if (y < 0)
                y = BoardSize - y;

            // return the color of a pixel given its coordinates
            return Board[x, y];
        }
    }
}

为了表示棋盘内的单个位置,我创建了 Point 结构:new Point(x, y)。这个结构用于洪水填充算法。该算法处理点集,为了优化比较,Point 结构实现了 IEquatable 接口

public struct Point: IEquatable<Point>
{
    private int x, y;

    public Point(int x, int y)
    {
        this.x = x;
        this.y = y;
    }
    ...
}

洪水填充算法

我不是计算机图形学专家,但洪水填充似乎不是一件难事,所以我自己发明了一种算法,其工作方式如下:对于每个像素,我检查它的四个邻居,并记住颜色相同的邻居。通过递归重复此过程,我最终得到一个与起始像素颜色相同的相邻像素的有限集合。最后,我遍历这些像素并将它们全部设置为新颜色。

在实现这个简单算法时,我将递归转换为迭代,以消耗更少的堆栈内存,并使用一个待处理点队列。但事实证明,Queue 类在处理大量像素时速度很慢。

然后我意识到处理像素的顺序根本不重要,并将 Queue 替换为 HashSet。这神奇地解决了所有性能问题!HashSet 的性能不依赖于集合的大小,因此它处理数百个项目与处理少量项目一样快。下面是我最终实现的完整洪水填充算法。

public void SetColor(int x, int y, int newColor)
{
    var color = Board[x, y];
    if (color == newColor)
    {
        return 1;
    }

    var points = new HashSet<Point>();
    var queue = new HashSet<Point>();
    queue.Add(new Point(x, y));

    var adjacents = new[] { new Point(-1, 0), new Point(0, -1), new Point(0, 1), new Point(1, 0) };
    while (queue.Any())
    {
        var point = queue.First();
        queue.Remove(point);
        points.Add(point);
        Board[point.X, point.Y] = newColor;

        // process adjacent points of the same color
        foreach (var delta in adjacents)
        {
            var newX = point.X + delta.X;
            var newY = point.Y + delta.Y;

            // skip invalid point
            if (newX < 0 || newX > BoardSize - 1 || newY < 0 || newY > BoardSize - 1)
            {
                continue;
            }

            // skip pixels of other colors
            if (Board[newX, newY] != color)
            {
                continue;
            }

            // skip already processed point
            var newPoint = new Point(newX, newY);
            if (points.Contains(newPoint))
            {
                continue;
            }

            // schedule the point for processing
            queue.Add(newPoint);
        }
    }
}

使用提供的 DrenchBoard 类进行游戏编程非常直接。

class SomeKindOfDrenchGame
{
    public void NewGame()
    {
        Board.Randomize();
    }

    public void MakeMove(int newColor)
    {
        Board.SetColor(0, 0, newColor);
    }
}

IDrenchGame 接口

遵循 DRY (Don't Repeat Yourself) 原则,我们希望我们的应用程序能够使用相同的 UI 来处理所有游戏模式,该 UI 看起来像一个像素棋盘,下方有几个彩色按钮。

Screenshot

玩家通过触摸彩色按钮进行移动。禁用禁用颜色的按钮。随着游戏的进行,UI 会更新棋盘上方的当前状态文本。这对所有游戏模式都是通用的,因此我们可以将其描述为一个接口。实际的游戏界面可能更复杂一些,但我们可以根据需要添加更多的方法和属性。

public interface IDrenchGame
{
    DrenchBoard Board { get; }

    void NewGame();

    void MakeMove(int color);

    bool IsStopped { get; }

    string CurrentStatus { get; }

    IEnumerable<int> ForbiddenColors { get; }

    event EventHandler GameChanged;

    event EventHandler GameStopped;
}

为了使事情更简单,我们将为所有游戏模式创建一个基类 abstract。子类将根据游戏规则重写 MakeMoveCheckIfStopped 方法。

public abstract class DrenchGameBase
{
    public virtual DrenchBoard Board { get; private set; }

    public virtual void NewGame()
    {
        Board.Randomize();
    }

    public virtual void SetColor(int x, int y, int color)
    {
        Board.SetColor(x, y, color);
        OnGameChanged();
    }

    public abstract MakeMove(int color);

    protected abstract CheckIfStopped();

    public virtual bool IsStopped { get; protected set; }

    public virtual string CurrentStatus { get; protected set; }

    public virtual IEnumerable<int> ForbiddenColors { get; protected set; }

    public event EvenHandler GameChanged;

    protected void OnGameChanged()
    {
        var gameChanged = GameChanged;
        if (gameChanged != null)
            gameChanged(this, EventArgs.Empty);
    }

    public static IEnumerable<int> Enumerate(params int[] colors)
    {
        // utility method to return an IEnumerable<int>
        return colors;
    }
}

SinglePlayerGame 和 TwoPlayerGame

使用提供的 DrenchGameBase 类,创建特定的游戏模式非常简单。通过重写 MakeMoveCheckIfStopped 方法,我们可以控制游戏的进行方式。基类使用 Board 实例执行所有计算。这是单人游戏的完整源代码。

public class SinglePlayerGame : DrenchGameBase
{
    public const int MaxMoves = 30;

    public override void NewGame()
    {
        base.NewGame();

        CurrentMove = 1;
        ForbiddenColors = Enumerate(Board[0, 0]);
        CurrentStatus = string.Format("{0} moves left. Good luck!", MaxMoves);
        OnGameChanged();
    }

    public override void MakeMove(int value)
    {
        CurrentMove++;
        CurrentStatus = string.Format("Move {0} out of {1}", CurrentMove, MaxMoves);
        ForbiddenColors = Enumerable.Repeat(value, 1);

        // set the new color
        SetColor(0, 0, value);
    }

    protected override void CheckIfStopped()
    {
        var allowedColor = Board[0, 0];
        var success = Board.CheckAllColors(allowedColor);
        if (success || CurrentMove > MaxMoves)
        {
            var result = success ? "won" : "lost";
            OnGameStopped(true, "You have {0} the game!", result);
        }
    }
}

TwoPlayerGame 会跟踪当前玩家,以便每次调用 MakeMove 时都会交替覆盖顶层或右下角像素。CheckIfStopped 检查所有像素是否只有两种颜色之一。

组装 Android 应用程序

让我们使用提供的游戏类来构建一个可运行的 Android 应用程序。典型的应用程序包含几个活动(屏幕),这些屏幕与用户交互。每个活动都包含多个视图,这些视图组合成一个层次结构以创建用户界面。我不会深入探讨 Android 应用程序结构的细节,因为关于该主题的文章已经有很多了,所以我可以专注于一些细节。

我们的主游戏活动将使用 TableLayout 来创建棋盘和按钮集。按钮是在布局设计器中创建的,而棋盘是程序化构建的,因此可以轻松地动态更改棋盘大小。棋盘屏幕的布局如下所示(大部分细节已省略)。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Stub view for the board -->
    <TableLayout android:id="@+id/boardTable"
        android:stretchColumns="*">
        <TableRow android:id="@+id/tableRow0">
        </TableRow>
    </TableLayout>
    <!-- Buttons panel -->
    <TableLayout android:id="@+id/buttonsTable"
        android:stretchColumns="*">
        <TableRow android:id="@+id/tableRow1">
            <Button android:id="@+id/button0"/>
            <Button android:id="@+id/button1"/>
            <Button android:id="@+id/button2"/>
        </TableRow>
        <TableRow android:id="@+id/tableRow2">
            <Button android:id="@+id/button3"/>
            <Button android:id="@+id/button4"/>
            <Button android:id="@+id/button5"/>
        </TableRow>
    </TableLayout>
</LinearLayout>

这是在 OnCreate 方法中填充带有色块的棋盘表的代码。

// Create board tiles
var colors = Palette;
for (var j = 0; j < BoardSize; j++)
{
    tableRow = new TableRow(BaseContext);
    tableRow.LayoutParameters = new TableLayout.LayoutParams(
        TableLayout.LayoutParams.WrapContent, 
        TableLayout.LayoutParams.WrapContent, 1f);
    table.AddView(tableRow);

    for (var i = 0; i < BoardSize; i++)
    {
        var button = new Button(BaseContext);
        button.LayoutParameters = new TableRow.LayoutParams(i);
        button.LayoutParameters.Width = 1;
        button.LayoutParameters.Height = ViewGroup.LayoutParams.MatchParent;
        button.SetBackgroundColor(colors[(i + j * 2) % 6]);
        tableRow.AddView(button);

        Tiles[i, j] = button;
    }
}

每个色块都由一个 Button 视图表示。创建具有相同重力值的行可确保所有行具有相同的高度,而设置 android:stretchColumns="*" 可使列宽度相同,这正是我们为像素棋盘所需要的。

注意:Android 设备支持不同的屏幕尺寸和宽高比,因此色块可能不总是完美的方形。

处理设备旋转

在 Android 中,Activity 对象可以在 Android 认为合适的时候创建和销毁。例如,当您旋转设备时,当前活动将从头开始重新创建,您需要加载布局并重新创建棋盘。这意味着您不能简单地将当前游戏实例存储在活动中。当前游戏实例必须放在别处。

自定义应用程序类

看起来最简单的安全存储它的方法是创建一个自定义应用程序类。Application 实例存在于进程的整个生命周期中,并且可以通过 Application 属性在所有活动中访问。唯一需要注意的棘手问题是,应用程序类是从 Java 代码创建的,因此它必须有一个特殊的构造函数,看起来像这样。

public CustomApplication(IntPtr javaReference, JniHandleOwnership transfer)
    : base(javaReference, transfer)
{
}

我需要在不同活动之间共享的所有实例都可以作为应用程序类的属性发布。

public IDrenchGame DrenchGame { get; set; }

从活动访问应用程序实例如下所示。

private CustomApplication App { get { return (CustomApplication)Application; } }

...
var currentGame = App.DrenchGame;

Game 实例通过诸如 GameChangedGameStopped 之类的事件与棋盘活动进行交互。ActivityOnResume 中订阅这些事件,并在 OnPause 方法中取消订阅。

protected override void OnResume()
{
    base.OnResume();

    DrenchGame.GameChanged += UpdateTiles;
    DrenchGame.GameStopped += StopGame;
}

protected override void OnPause()
{
    base.OnPause();

    DrenchGame.GameChanged -= UpdateTiles;
    DrenchGame.GameStopped -= StopGame;
}

取消订阅游戏事件非常重要:活动的活动事件处理程序将阻止其实例被垃圾回收,并可能导致内存泄漏。

启动游戏并显示棋盘活动

创建游戏后,唯一剩下的就是启动将与用户交互的活动。

App.DrenchGame = new SinglePlayerGame(); // or any other game class!
StartActivity(typeof(DrenchBoardActivity));

我们可以创建一个带有多选项的主菜单:开始单人游戏、双人游戏、与 Android 对战等。我们菜单项的每个处理程序的工作方式都相同,只是创建的游戏类不同。

添加网络支持

多人网络游戏模式需要远程玩家之间进行特殊的同步。例如,两个玩家应该拥有相同的棋盘才能进行游戏。游戏必须在两个玩家都准备好之后才能开始。如果一个玩家退出了游戏,游戏就不能继续,等等。我们的 IDrenchGame 接口不足以处理所有这些:我们将需要额外的方法和事件。

为了节省网络带宽,我们不会在每次转弯时都通过线路发送整个游戏状态。相反,每一方都将维护自己的 DrenchBoard 实例,我们只会发送每个移动和游戏状态更新的轻量级事件。

IDrenchGameServer 接口

IDrenchGame 接口添加新成员没有意义。网络特定的方法和事件对于本地游戏没有意义。相反,让我们引入一个新接口来与游戏服务器进行互操作,该接口扩展了 IDrenchGame

public interface IDrenchGameServer : IDrenchGame
{
    void Join(); // join the remote game

    void Leave(); // leave the remote game

    bool IsReady { get; } // both players are ready

    event EventHandler GameStarted;

    event EventHandler<MoveEventArgs> Moved; // other player made a move
}

使用此接口假定以下协议。

  • IDrenchGameServer 建立连接。
  • 订阅 GameStartedMoved 事件。
  • 调用 Join 方法开始新游戏。
  • 调用 Move 方法进行您的移动。
  • 处理 Moved 事件以响应对手的移动。
  • 处理 GameStopped 事件(继承自 IDrenchGame)以停止当前游戏。
  • 调用 NewGame(也继承自 IDrehchGame)开始新游戏。
  • 如果您想中止当前游戏,请调用 Leave 方法,以便服务器可以停止。
  • 取消订阅服务器事件。
  • 断开与服务器的连接。

DrenchGameServer 和 DrenchGameClient

让我们创建两个特殊的游戏类来实现上述协议。对于这些类,我决定重用我的 TwoPlayerGame 类,该类已经实现了双人游戏模式所需的所有功能。

我的两个类都使用 TwoPlayerGame 的一个 private 实例来本地管理游戏状态。例如,IsStoppedCurrentStatus 属性直接取自 InnerGame 实例。

public class DrenchGameServer : DrenchGameBase, IDrenchGameServer
{
    public DrenchGameServer()
    {
        InnerGame = new TwoPlayerGame();
    }

    private TwoPlayerGame InnerGame { get; private set; }

    public override bool IsStopped
    {
        get { return InnerGame.IsStopped; }
        protected set { ... }
    }

    public override string CurrentStatus
    {
        get { return InnerGame.CurrentStatus; }
        protected set { ... }
    }
}

实现服务器特定方法 JoinLeave 非常简单。我们只需要确保 Join 方法只能调用一次(我们总是与一个对手对战)。

public void Join()
{
    if (IsReady)
    {
        // this exception will travel across the wire to the client
        throw new InvalidOperationException("Second player " + 
            "already joined the game. Try another server.");
    }

    IsReady = true;
    OnGameStarted();
}

public void Leave()
{
    IsReady = false;
    IsStopped = true;
    OnGameStopped(false, "Second player has left the game.");
}

DrenchGameClient 类除了本地 innerGame 实例外,还持有一个对远程 IDrenchGameServer 的引用。它连接到服务器,复制棋盘数据,订阅服务器事件,并调用 Join 方法。

public class DrenchGameClient : DrenchGameBase
{
    public DrenchGameClient(IDrenchGameServer server)
    {
        Server = server;
        InnerGame.Board.CopyFromFlipped(Server.Board);
        InnerGame.SkipMove();
        UpdateStatus();
        JoinServer();
    }

    public async void JoinServer()
    {
        await Task.Factory.StartNew(() =>
        {
            Server.GameStarted += ServerGameStarted;
            Server.GameStopped += ServerGameStopped;
            Server.Moved += ServerMoved;
            Server.Join();
        });
    }
    ...
}

注意 JoinServer 方法是异步的。由于网络延迟,远程调用比本地调用慢数千倍。为了确保我们的游戏不会冻结 UI,我们需要异步执行远程调用。请注意,Xamarin.Android 的稳定分支仍然不支持 async/await 模式,因此您需要最新版本的框架才能编译此代码。

另一个值得关注的点是 DrenchGameClient 如何进行移动。它所做的唯一事情就是调用服务器的方法并处理服务器的事件。Game 客户端不更改其 InnerGame 的状态:它完全由远程服务器控制。请注意,MakeMove 方法也是异步的,因为它涉及远程调用。

public override async void MakeMove(int value)
{
    await Task.Factory.StartNew(() => Server.MakeMove(value));
}

private void ServerMoved(object sender, MoveEventArgs e)
{
    InnerGame.MakeMove(e.Color);
    UpdateStatus();
}

托管 DrenchGameServer

为了在网络上共享游戏服务器,我们将使用 Zyan Communication Framework。这个库不需要对我们的类进行任何额外的处理,因此我们可以像往常一样发布 DrenchGameServer 实例。下图概述了 Zyan 应用程序的典型架构(请注意,除了 ZyanComponentHostZyanConnection 类之外,这些内部结构不会出现在我们的应用程序代码中)。青色框表示 Zyan 库类,黄色框表示应用程序代码。

Zyan architecture

要启动服务器,我们需要创建一个使用 TCP 协议的 ZyanComponentHost 实例。游戏服务器以及 zyan 主机实例将存储在我们的自定义应用程序类的属性中,就像其他共享实例一样。

public void StartServer()
{
    if (ZyanHost == null)
    {
        // set up duplex tcp protocol, no security required
        var portNumber = Settings.PortNumber;
        var authProvider = new NullAuthenticationProvider();
        var useEncryption = false;
        var protocol = new TcpDuplexServerProtocolSetup(portNumber, authProvider, useEncryption);

        // start the server
        ZyanHost = new ZyanComponentHost(Settings.ZyanHostName, protocol);
    }

    // create server game
    var server = new DrenchGameServer();
    DrenchGame = server;

    // register game component, so a client can connect to the server
    ZyanHost.RegisterComponent<IDrenchGameServer, DrenchGameServer>(server);
}

注意:Android 版 Zyan 库目前唯一可用的传输协议是双工 TCP 协议。

连接到 DrenchGameServer

要连接到 Zyan component host 发布的游戏服务器,需要执行以下步骤。

  • 通过创建 ZyanConnection 类来建立连接。
  • 为远程 IDrenchGameServer 创建一个代理。
  • 创建一个 DrenchGameClient,并将服务器代理作为构造函数参数传递。

让我们向自定义应用程序类添加一个方法。

public IDrenchGameServer ConnectToServer(string hostName)
{
    var protocol = new TcpDuplexClientProtocolSetup(encryption: false);
    var url = protocol.FormatUrl(host, Settings.PortNumber, Settings.ZyanHostName);

    var zyanConnection = new ZyanConnection(url, protocol);
    return zyanConnection.CreateProxy<IDrenchGameServer>();
}
...

// this method is used as follows (note the non-blocking asynchronous call):
var server = await Task.Factory.StartNew(() => App.ConnectToServer(Settings.ServerAddress));

// and now -- already familiar code to start a game:
App.DrenchGame = new DrenchGameClient(server);
StartActivity(typeof(DrenchBoardActivity));

因此,我们刚刚为支持 Wifi 的 Android 创建了一个简单的多人游戏。尽管本文没有涵盖应用程序的每一个小细节,但我希望它展示了所有重要的要点。欢迎任何反馈!

附注:该游戏已上传到 Google Play 应用商店,最新的源代码可在 Codeplex 和 github 上找到。

Get it on GooglePlay QR code

参考文献

历史

  • 2013 年 8 月 5 日 初始发布
© . All rights reserved.