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

游戏攻击连击:WPF 混合智能客户端用于连击计算

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (33投票s)

2009年5月10日

CPOL

37分钟阅读

viewsIcon

90432

downloadIcon

3274

一个用于计算《波斯王子》游戏攻击组合的 WPF 混合智能客户端。

预计阅读时间:约一小时。

正在寻找获得“连击大师”成就/奖杯所需的连击吗?随时 跳转到必需连击列表,该列表已在新用户配置上反复测试。

Game Attack Combos screenshots

目录

引言

当今程序员可以使用各种各样的技术和模式;例如 ASP、AJAX、MVC、MVP、MVVM、MSMQ、SOAP、XML、WSE、WCF、WPF 等等。所有这些缩写和首字母缩略词可能会让你问:“WTF?”尽管我们可以通过各种教程、书籍、博客等来学习,但动手实践并发现尚未记录的隐藏瑰宝也是有价值的。

本文涵盖的概念

本文将演示一个完整的 Windows 桌面应用程序,该应用程序通过 Windows Presentation Foundation 编写,并使用 C# 连接到通过 Windows Communication Foundation 的 Web 服务。我将详细介绍一些关键概念。其中大多数概念都超出了您在其他地方可能找到的关于这些主题的典型内容。下面列出了本文将涵盖的内容的概览。

要求

您可以 下载整个解决方案源代码 用于此演示,或 仅下载独立应用程序文件

无论您选择哪个下载选项,您都需要在计算机上成功安装 .NET Framework 3.5 with SP1 才能运行客户端应用程序。为了成功生成和运行解决方案代码,您还应该确保已安装并准备好以下项。

  • Visual Studio 2008 with SP1(如果您习惯于通过命令行使用 SDK 生成解决方案并在 IIS 中托管服务,则此项不是必需的)
  • SQL Server 2008 Express(更新 ComboServices 项目中的 web.config 文件,以确保“GameAttackCombosEntities”连接字符串已正确设置为您的实例)

程序员常常是游戏玩家

作为程序员,我们编写代码……很多。我们中的许多人还玩电子游戏来放松身心,并欣赏其他程序员创作的虚拟杰作。上个假期,我收到了一款备受期待的新游戏;《波斯王子》。这是一款精美的赛璐珞风格渲染游戏,有许多杂技谜题需要解决,以及一套丰富的战斗系统,用于应对游戏中为数不多的敌人。

我内心深处的完美主义者

在我开始玩游戏后不久,我很快意识到我内心深处的 完美主义者 感觉有必要收集所有提供的奖杯/成就(目标)。这些目标从以最短时间通关到完成一次完美的 14 连击不等。如果遇到难题,我不会因为自己去网上搜索攻略而感到羞耻,但我确实喜欢先自己尝试一下。

我自己几乎满足了所有可用目标的实现要求。然而,有一个目标让我望而却步(如果你算上最终目标是获得所有其他目标,那就是有两个)。“连击大师”目标没有明确说明。它只是要求你找出所有可能的攻击组合。我在大学里学过不少 组合数学,快速浏览游戏菜单中提供的连击列表/树状图,让我挑起了眉毛。我知道有数百种组合,而且很难相信游戏开发者会为这样一个评分较低的目标设置如此艰巨的任务。

更糟糕的是,玩家在执行每个组合中的各种连击以及连击是否会导致另一个组合时,都可以参考的连击列表屏幕上有一个错误;基本上,形成连击链。列表中的一个连击组(Elika 的魔法)显示某些连击会通往空中组,但连击树显示它应该是杂技组(树显示了正确的路径)。

Combo List ScreenCombo Tree Screen
图 1:游戏中找到的连击列表和连击树屏幕不一致。

哪些攻击组合实际上会让你获得目标?总共有多少种组合?官方游戏指南为何不列出获得目标所需的精确组合?当我搜索多个在线资源却找不到有效的解决方案理论时,我该如何满足我内心的完美主义者?

我决定写一个小程序来回答我关于“连击大师”目标的一些问题。结果发现,共有1602种可能的攻击组合。

Guru Games 诞生

如果 Ubisoft Entertainment 决定外包开发一个智能客户端应用程序,该应用程序可以为用户计算组合并提供一个流畅的界面来炒作即将发布的续集(和电影)的游戏呢?该应用程序将连接到 Web 上的服务以获取基础组合定义、用户界面皮肤、图像和其他资产。玩家随身携带的用户手册的代码将允许他/她下载游戏文件并解锁计划用于整个三部曲的战斗系统的秘密。当三部曲中的下一款游戏发布时,玩家可以输入该手册中的代码来获取下一款游戏的文件。通过与服务进行版本检查,可以自动拉取文件更新,以修复任何错误,或为新发布的下载内容 (DLC) 添加组合/目标。

事实在于虚构之中

我想象 Ubisoft 会聘请像 Guru Games 这样的公司。Guru Games 是一家虚构的开发公司,专门为游戏行业开发卫星应用程序,本文后面将讨论的“Game Attack Combos”应用程序就是由该公司创建的。缺乏更具体的名称是为了避免(实际和虚构的)版权侵犯,并防止应用程序仅限于《波斯王子》系列。Ubisoft 有可能会创建使用这种新颖的攻击组合系统的其他游戏。

假设在《波斯王子》游戏的每个副本中,手册上都印有一个代码,允许玩家下载一个免费的 Windows 客户端信息,该客户端可以计算所有可能的组合,甚至可以指示哪些组合对于奖杯或成就来说是必需的。公司网站上的一个服务可以接受来自客户端的连接,并在收到请求时发送一个文件包,该文件包基于经过验证的代码。然后,客户端应用程序使用包中找到的资产来皮肤化用户界面,并计算攻击组合的排列以向用户显示复选列表。

这个演示应用程序使用 Windows Presentation Foundation 作为客户端用户界面,并通过 HTTP 连接到 Windows Communication Foundation 服务以获取游戏组合包文件。

未来欺骗

有时,一个应用程序始于最后一刻的想法。也许创建者想在投入更多时间和金钱来进一步开发应用程序之前,先衡量其受欢迎程度。正是在这些时候,开发人员应该注意为代码库进行未来规划。我们应该始终考虑如何 重构我们的代码。本文中的“Game Attack Combo”应用程序将演示这种思维过程。我将指出在程序获得官方升级或变得更复杂时,应该实现不同模式的地方。

逻辑上来说

演示解决方案中的几乎所有项目都引用了一个通用的逻辑库。正是这个库负责管理和操作所有与组合和包相关的事物。我鼓励您探索本文开头代码下载中的逻辑库。在解析非常简单的组合定义文件以将其转换为许多可能的实际组合时,有许多抽象正在起作用。我在这里仅讨论一些与包相关的问题。

一个漂亮的包

当我提到包时,我指的是 System.IO.Packaging 命名空间 中的那些。如果您不熟悉这个命名空间,它包含了允许您将多个文件组织到一个容器文件中的类。演示解决方案中的服务应用程序使用打包来通过 Web 传输必需的资产,客户端应用程序需要从请求的包中获取资产。包的元数据还用于管理游戏标题、版本等。

打包你的包

Zipper包中的每个文件都可以被压缩以减小最终文件的大小。事实上,抽象的 Package 类的默认实现是一个 ZipPackage。在处理包时,可能会遇到一些怪癖。当您在测试代码时遇到运行时异常时,由于嵌入的流,可能很难找出问题所在。通过 Stream 访问包的每个部分(容器中的文件)。

此外,我发现添加到包中的二进制文件压缩效果不佳。事实上,我压缩图像文件的测试导致存储大小增加。因此,我只选择压缩包中的文本文件。我选择不压缩所有二进制文件(在此情况下是图像)。

复制它

另一个需要考虑的问题是如何从包中提取文件以在包关闭后使用。我们都知道,当我们在代码中打开文件时,应尽快关闭文件以释放相关资源。这个基本原则可能会给需要访问包内的某个部分并希望该部分在包读取范围之外保留的开发人员带来麻烦。幸运的是,一旦识别出解决方案,它就很简单。必须在关闭包之前将该部分复制到内存中。此处提供的项目会将多个部分复制到 MemoryStream 以供以后使用。

为你的代码服务

面向服务的编程在过去几年中取得了长足的进步。特别是,Windows Communication Foundation (WCF) 为分布式应用程序的开发人员打破了许多障碍。有了它,我们可以从一个通用的编程模型创建许多不同类型的服务。在此场景中,Guru Games 决定实现一个可通过 HTTP 访问的服务(Web 服务)。关于 WCF,需要学习的内容远不止本文所述。请查阅 Microsoft 的 WCF 开发人员网络部分 以获取更多信息。

设置 WCF 服务项目

设置新的 WCF 项目最简单的方法是使用 Visual Studio 2008 中提供的项目模板。右键单击解决方案,然后选择添加 | 新建项目...。选择名为“WCF Service Application”的 Web 模板。命名它,然后单击确定按钮,即可在您的新项目中获得一个不错的 WCF 服务框架。

Add a new WCF project
图 2:添加新的 WCF 项目非常简单。

WCF 服务由两个实体组成。第一个是接口形式的服务契约。该接口是客户端用来识别服务提供的可用操作的。Game Attack Combos 解决方案只有一个服务和一个操作。它提供了一种方式,让客户端可以下载指定游戏代码和客户端当前包版本(如果有)的组合包文件。

[ServiceContract(Namespace  = "http://gurugames.com/packages/")]
public interface IComboPackagesService {

    [OperationContract]
    byte[] DownloadComboPackage(string gameCode, string clientPackageVersion);

}
列表 1:此项目只有一个契约和一个操作。

定义了契约后,我们就可以开始实现接口中的类了。该类验证指定的游戏代码,并从数据库中检索相应的包文件名,打开包文件,将版本与指定的客户端版本进行比较,如果较新,则将文件加载到二进制数组中返回给调用者。有关此方法的完整代码,请参阅本文开头的代码下载。

[AspNetCompatibilityRequirements(
    RequirementsMode = AspNetCompatibilityRequirementsMode.Allowed)]
public byte[] DownloadComboPackage(string gameCode, string clientPackageVersion) {
    // Prepare a binary data array.
    byte[] FileData = null;
    ...
    
    // Get the file name for the specified code.
    string PackageFileName = null;
    if (!string.IsNullOrEmpty(gameCode)) {
        PackageFileName = GetComboPackageFileNameByGameCode(gameCode);
    }
    if (!string.IsNullOrEmpty(PackageFileName)) {
        ...
        
        // Read the entire file into the array.
        using (FileStream PackageFile = File.Open(
            PackageFileName, 
            FileMode.Open, 
            FileAccess.Read, 
            FileShare.ReadWrite
        )) {
            // Open the package to get its version.
            Version CurrentVersion = null;
            using (ComboPackage Package = new ComboPackage(PackageFile)) {
                CurrentVersion = new Version(Package.Version);
            }
    
            // Check the version of the combo package file against the current
            // one specified.
            if (CurrentVersion > ClientVersion) {
                // Copy the combo package file to the data array.
                FileData = StreamHelper.CopyStreamToArray(PackageFile);
            }
        }
    }
    
    // Return the file data.
    return FileData;
}
列表 2:用于下载包文件的服务操作实现。

实现类还定义了一个允许其以 ASP.NET 兼容模式运行的属性。此处使用此属性是为了方便地将虚拟路径映射到存储在服务文件夹中的物理包文件。

服务配置更改

最后,需要对服务的默认配置进行一些更改,以支持更大的传输,如包文件的下载。必要的更改包括定义一个新的绑定,我们可以在其中指示最大消息大小和消息编码。然后,服务终结点必须指向新的绑定配置。此外,为了支持前面提到的 ASP.NET 兼容模式,必须配置服务以启用它。

<system.serviceModel>
    <bindings>
        <wsHttpBinding>
            <binding name="wsWithMtom"
                maxReceivedMessageSize="2097152"
                messageEncoding="Mtom" />
        </wsHttpBinding>
    </bindings>
    <services>
        <service ...>
            <endpoint address="" binding="wsHttpBinding" 
                bindingConfiguration="wsWithMtom" 
                contract="GG.GameAttackCombos.Services.IComboPackagesService">
                ...
            </endpoint>
            ...
        </service>
    </services>
    <serviceHostingEnvironment aspNetCompatibilityEnabled="true" />
    ...
</system.serviceModel>
列表 3:为了支持包下载,需要对服务配置进行更改。

服务部署

现在服务已准备好进行编译,并可以在 Web 上的某个位置进行部署。如果您已安装 Visual Studio 2008,您可以从内置的 Web 服务器运行该服务。代码下载中包含的项目为服务应用程序分配了一个静态端口,以便客户端更轻松地连接。现在是时候看看客户端应用程序了。

你真是个聪明的应用程序

要成为一个智能应用程序,它需要做的不只是用户界面工作。演示中的客户端应用程序承担了计算所有可能的攻击组合的繁重工作,并得到了包含所有适当代码的逻辑库的帮助。作为一个混合应用程序,它仍然依赖于远程服务来获取所有必需的文件和更新。

演示即一切

游戏是多媒体娱乐的缩影。因此,伴随游戏的程序必须具有相似的属性。为此解决方案开发的桌面客户端采用了 Windows Presentation Foundation (WPF),可以在比其他框架短得多的时间内提供外观更好的应用程序。WPF 有大量的知识需要掌握,而 Microsoft 的 WPF 开发人员网络部分 是一个不错的起点。

我将向您展示 WPF 中开发人员武器库中的一些标准技术。然而,我将主要关注如何将这些技术扩展到更高级的场景,以帮助解决您在为“现实世界”问题编写 WPF 应用程序时可能遇到的问题。

起初

当客户端应用程序首次启动时,它看起来没什么特别的。事实上,应用的默认皮肤使其看起来就像一个普通的 Windows 应用程序。一旦下载并打开游戏组合包,情况就会改变。

Initial main window
图 3:这里没什么特别的……现在。

平淡的开场

打开一个新的游戏组合包需要用户单击打开菜单项来启动 OpenPackageWindow。从这个窗口开始,用户会看到已下载到他/她计算机上的任何游戏。起初,没有现有的游戏。请参见图 4 中新游戏代码文本框的水印。

Initial open package window
图 4:首先必须输入新的游戏代码。

带水印的文本框

WatermarkTextBox 自定义控件由一个非常简单的代码文件和一个样式资源创建。首先,快速查看控件的代码文件,会发现一个继承自 TextBox 的类。这个新类有一个类型为 DependencyProperty 的静态只读字段。正是在这个字段中,为控件的水印文本注册了一个新的依赖属性。如果您是 WPF 新手,我强烈建议您花些时间学习 依赖属性。它们是支撑整个 WPF 框架的基石之一。CodeProject 上一位知名的 WPF 作者对依赖属性进行了 非常详细的介绍,如果您有兴趣深入研究。

作为一种便利,我还提供了一个 WatermarkText 属性,用于获取和设置依赖属性的值。这不是必需的,但它是开发自定义控件的标准协议。这种便利属性通常被称为依赖属性“包装器”。通过提供属性包装器,可以轻松地从代码中设置依赖属性的值。此外,XAML 编译器依赖于这些包装器属性。如果您希望允许开发人员通过 XAML 设置依赖属性,那么您绝对应该包含包装器属性。

最后,添加了一个静态构造函数,它重写了新控件的默认样式键。这允许控件拥有一个定义的默认样式。稍后在自定义皮肤中重写 WatermarkTextBox 的样式时,拥有这个默认样式作为基础将无需重新定义控件的模板。

public class  WatermarkTextBox : TextBox {

    public static readonly DependencyProperty WatermarkTextProperty =
        DependencyProperty.Register(
            "WatermarkText",
            typeof(string),
            typeof(WatermarkTextBox),
            new PropertyMetadata(string.Empty)
        );

    public string WatermarkText {
        get { return (string)GetValue(WatermarkTextProperty); }
        set { SetValue(WatermarkTextProperty, value); }
    }

    static WatermarkTextBox() {
        DefaultStyleKeyProperty.OverrideMetadata(
            typeof(WatermarkTextBox),
            new FrameworkPropertyMetadata(typeof(WatermarkTextBox))
        );
    }

}
列表 4:我们新的 WatermarkTextBox 控件的非常简单的代码。
注意:通常,这样的控件会作为自定义控件库分离到其自己的程序集中。为了演示目的,我选择将控件保留在客户端应用程序中。

列表 4 中的所有代码只是添加了一个依赖属性、一个包装器属性和一个单语句静态构造函数。新的文本框将如何显示水印并在获得焦点或输入文本时隐藏它?这正是默认样式发挥作用的地方。

默认样式和通用“品牌”

WPF 在项目根目录下的 Themes 文件夹中查找通用和主题特定的资源。这些资源可以提供应用程序(或控件库)中创建的控件的默认样式。我在 Themes 文件夹中添加了一个名为“Generic.xaml”的资源字典。它包含了 WatermarkTextBox 的默认样式。

<ResourceDictionary 
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:GG.GameAttackCombos.Client">

    <Style 
        TargetType="{x:Type local:WatermarkTextBox}" 
        BasedOn="{StaticResource {x:Type TextBox}}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:WatermarkTextBox}">
                    <Border 
                        Name="Bd" 
                        Background="{TemplateBinding Background}" 
                        BorderBrush="{TemplateBinding BorderBrush}" 
                        BorderThickness="{TemplateBinding BorderThickness}" 
                        SnapsToDevicePixels="true">
                        <Grid>
                            <ScrollViewer Name="PART_ContentHost" ...  />
                            <TextBlock 
                                x:Name="PART_WatermarkTextElement" 
                                Text="{TemplateBinding WatermarkText}" 
                                Visibility="Hidden" 
                                ... />
                        </Grid>
                    </Border>
                    <ControlTemplate.Triggers>
                        ...
                        <MultiTrigger>
                            <MultiTrigger.Conditions>
                                <Condition Property="IsFocused" Value="False" />
                                <Condition Property="Text" Value="" />
                            </MultiTrigger.Conditions>
                        <Setter 
                            TargetName="PART_WatermarkTextElement" 
                            Property="Visibility" 
                            Value="Visible" />
                        </MultiTrigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>
列表 5:WatermarkTextBox 控件的默认样式。
注意:这个新控件的样式可以更完善,包括 WPF 处理的每个主题的默认样式(例如,Vista 的 Aero 主题、经典主题等)。但是,这超出了本文的范围。

我使用了互联网上为数不多的优秀免费工具之一来查看标准 TextBox 的默认模板和样式。 Reflector 派上了用场。这次,我使用了 Reflector 的 BAML Viewer 插件。有了它,我就可以看到用于 WPF 中所有控件主题化的 BAML(二进制 XAML)资源字典。我用它来解构 TextBox 控件,以便添加一个简单的 TextBlock 来显示自定义控件指定的纹理文本。在重写内置控件时,使用与内置控件默认模板相同的子控件(部件)名称至关重要。这样做可以使控件正常工作(例如,文本框需要与名为“PART_ContentHost”的部件协同工作,以便根据需要设置内容)。

我还添加了一个多触发器,它会在控件未获得焦点且未输入文本时将水印文本块设置为可见。为了保持文章长度,剩余的模板定义留给读者研究。

注意:此后,我添加了一个额外的 WatermarkBrush 属性,允许控件用户指定水印文本的画笔。

优质服务太难找

在用户输入游戏手册随附的虚构代码(例如,80c0-9c76-cfc7-440a-9261)并按打开按钮后,打开包窗口会尝试连接到我们之前创建的服务以下载请求的组合包。必须先向项目添加服务引用,然后才能进行此类尝试。右键单击您的项目,然后选择添加服务引用...。出现的对话框允许您输入要引用的服务的地址。对于此演示,您可以简单地单击发现按钮来添加对解决方案中服务项目的引用。确保输入有意义的命名空间,然后按确定按钮。

Add service reference
图 5:为同一解决方案中的项目添加服务引用。

服务绑定配置还需要再次进行一些更改。这些更改类似于在服务应用程序的配置文件中所做的更改。请参阅客户端应用程序的 App.config 文件以查看必要的更改。总而言之,我们必须创建一个自定义绑定,以便能够更改 maxReceivedMessageSizereaderQuotas/maxArrayLength 属性值。这允许接收来自服务(即文件下载)的较大消息。

OpenPackageWindow 现在可以执行一个异步调用,该调用会调用项目的一个静态辅助方法(有关详细信息,请参阅 ComboPackageHelper.DownloadNewPackage),该方法连接到服务并下载输入的对游戏的组合包。下载文件后,会更新包的元数据,并将输入的对游戏的名称作为更新的参考。然后,它被保存到用户的 隔离存储 中。

注意:您可以在 MSDN 上了解更多关于 WPF 线程模型 的信息。此外,您还可以研究演示客户端项目中的 OpenPackageWindow 类的 SelectPackage 方法。

处理过的文件是好事

成功下载新文件后,主窗口会打开包进行处理。首先,从隔离存储中打开的文件名存储在应用程序的属性集合中以供以后引用。接下来,从包中提取组合定义 XML 文件,并加载到逻辑库提供的精美类中。ComboDefinitions 类解析定义文件中的 XML,并生成表示定义的类图。

然后,定义被展平成实际的组合命令序列,并绑定到窗口中的主列表框。接下来,从包中提取皮肤资源文件作为内存副本,然后关闭包。最后,将皮肤加载到应用程序的资源中,使客户端获得全新的外观。

注意:在打开包时最后进行的操作是启动一个计时器,该计时器将自动保存列表中显示组合序列的复选列表的状态。这样,如果发生任何意外情况,用户就不会丢失复选列表的状态。

换上新皮肤

使用从包中读取的皮肤文件的内存副本,客户端应用程序用游戏特定的皮肤替换其默认皮肤(该默认皮肤主要用于避免某些控件按键引用的样式错误)。这比您首先想到的要容易。您真正需要的是一个包含您希望应用于用户界面的所有样式的资源字典的 XAML 流。有了这样的流,代码就相对简单了。

public void LoadSkin(Stream skinStream) {
    // Create a new resource dictionary for the skin from the specified stream 
    // via an XAML reader.
    ResourceDictionary NewSkin = XamlReader.Load(skinStream) as ResourceDictionary;

    // Replace the last dictionary with the new skin.
    Resources.MergedDictionaries.RemoveAt(Resources.MergedDictionaries.Count - 1);
    Resources.MergedDictionaries.Add(NewSkin);
}
列表 6:替换资源字典以加载新皮肤非常容易。

列表 6 显示了客户端 App 类中定义的非常简单的 LoadSkin 方法。只需调用 XamlReader.Load,并将适当的流作为参数传递,即可初始化一个新的 ResourceDictionary 实例。从那里,我删除应用程序资源中的最后一个字典,并添加新的字典。信不信由你,就是这样!用户界面会立即更新,以反映加载的皮肤中存在的新样式和模板定义。

Skinned main window
图 6:应用了打开包的皮肤的主窗口。

我偷偷藏了一个技巧。有些人可能在想,“背景图像是怎么凭空加载的?”那些熟悉 XAML 和 WPF 皮肤的人都知道,背景图像,就像图 6 中的图像一样,在 XAML 中只能通过 URI 引用。也就是说,Web 地址、物理文件路径或资源路径是引用图像的最简单方法。上面的图像是从提取皮肤的同一个包文件中动态加载的。

从 XAML 引用的动态流式传输图像

客户端应用程序(一个智能应用程序)包含一个类,专门用于从当前查看的包中按名称加载资源流。CurrentSkinResource 类定义了一个方法,用于加载与包中皮肤相关的特定名称的资源。

public Stream LoadResourceByName(string resourceName) {
    ...
    // Open any current combo package.
    using (ComboPackage Package = App.Current.OpenCurrentComboPackage()) {
        if (Package != null) {
            // Open the requested skin resource from the combo package as a copy.
            LastStreamRequested = Package.OpenSkinResourceStream(resourceName, true);
        } else {
            throw new ApplicationException(
                "There is no current combo package being viewed."
            );
        }
    }
    return LastStreamRequested;
}
列表 7:从当前组合包加载皮肤资源。

列表 7 中的代码打开当前正在查看的组合包。然后,它将皮肤资源从包中打开到一个内存流中。最后,返回内存流;供调用皮肤定义中的图像源使用。

皮肤 XAML 通过 ObjectDataProvider 调用此方法。这些非常适合初始化一个类实例并调用其方法。关键是如何正确地将图像源绑定到它。

<ObjectDataProvider
    x:Key="LoadSkinResource" 
    ObjectType="{x:Type local:CurrentSkinResource}" 
    MethodName="LoadResourceByName">
    <ObjectDataProvider.MethodParameters>
        <system:String>PrinceOfPersia2008Background.png</system:String>
    </ObjectDataProvider.MethodParameters>
</ObjectDataProvider>
<Style x:Key="GameBackground" TargetType="{x:Type Panel}" >
    <Setter Property="Background">
        <Setter.Value>
            <ImageBrush 
                AlignmentX="Left" AlignmentY="Top" 
                Stretch="None" TileMode="None">
                <ImageBrush.ImageSource>
                    <BitmapImage BaseUri="{x:Null}" CacheOption="OnLoad">
                        <BitmapImage.StreamSource>
                            <Binding Source="{StaticResource LoadSkinResource}" />
                        </BitmapImage.StreamSource>
                    </BitmapImage>
                </ImageBrush.ImageSource>
            </ImageBrush>
        </Setter.Value>
    </Setter>
</Style>
列表 8:来自游戏皮肤的 XAML 片段,通过方法调用绑定到图像流。

定义 ObjectDataProvider 很容易。我们给它一个键,告诉它要创建的对象类型,指明要调用的方法名称,并提供方法的任何参数。在这种情况下,我们想获取 PrinceOfPersia2008Background.png 图像的流,该图像深埋在加载皮肤的同一个包中。

GameBackground 键引用的样式设置面板的 Background 属性。为了我们的需要,需要一个图像画笔,因此包含一个具有适当对齐、拉伸和平铺的图像画笔。设置 ImageBrushImageSource 属性需要使用 BitmapImage。这些通常用于从文件加载位图图像。这个使用它的 StreamSource 属性而不是 UriSourceStreamSource 主要通过代码设置,但我们的皮肤定义中没有这个选项。相反,使用绑定,其源引用了前面定义的 ObjectDataProvider。为了使所有这些正常工作,还需要两个非常重要的附加设置。

首先,BitmapImageBaseUri 属性必须设置为 null。文档指出,必须设置 UriSourceStreamSource;然而,显然存在一个 bug。当我设置 StreamSource 时,没有加载图像。再次感谢 Reflector,查看 BitmapImage 的代码会发现,为了使用 StreamSourceBaseUriUriSource 必须为 null。问题是,BaseUri 默认为空字符串(例如,“”),而不是 null。因此,在这种情况下,将其设置为 null 是必须的。这种情况仅在 XAML 中存在。

其次,BitmapImageCacheOption 属性应设置为“OnLoad”。这确保图像源立即加载并且流被释放。由于 LoadResourceByName 方法创建了一个 MemoryStream 并将其释放到公共区域,因此该流确实应该被释放而不是留给垃圾回收。此设置可确保这一点。

这似乎是一项艰巨的任务,但为了通过皮肤定义从包中加载背景图像,这是必不可少的。接下来,我们终于可以看看所有这些组合序列是如何作为游戏平台图像显示的了。

一起列出

List box of combo icons列表框不再只是一个简单的窗口视图,其中包含基础项目的列表。哦,不。通过 WPF,它们可以远不止于此。事实上,像所有 WPF 控件一样,您可以通过控件模板完全自定义其外观。但是,显示项目集合的控件还有一个额外的模板,用于每个项目。这对这个应用程序来说是一件好事。它需要以有意义的方式向用户显示攻击组合。理想情况下,它应该将每个命令序列呈现为一系列按钮图像。

传统的呈现方法需要自定义绘制整个控件,或者至少附加自定义项绘制处理程序。使用 WPF,只需向列表框添加自定义项模板即可。


<Window.Resources>
    <ResourceDictionary>
        ...
        <local:DrawingResourceKeyConverter x:Key="DrawingResourceKeyConverter" />
    </ResourceDictionary>
</Window.Resources>
...
<ListBox Grid.Row="1" Name="lbCombos" IsTextSearchEnabled="False" 
    Style="{DynamicResource ComboList}" KeyUp="lbCombos_KeyUp">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <StackPanel Orientation="Horizontal">
                <CheckBox x:Name="chkCompleted" Focusable="False" 
                    Margin="2" VerticalAlignment="Center" 
                    IsChecked="{Binding Path=IsCompleted}" />
                <ItemsControl IsTabStop="False" VerticalAlignment="Center" 
                    ItemsSource="{Binding Path=CommandSequence}">
                    <ItemsControl.ItemsPanel>
                        <ItemsPanelTemplate>
                            <StackPanel IsItemsHost="True" Orientation="Horizontal" />
                        </ItemsPanelTemplate>
                    </ItemsControl.ItemsPanel>
                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <Image Height="16" Margin="5,2,0,2"
                                ToolTip="{Binding Path=MappedButton.Id}">
                                <Image.Source>
                                    <DrawingImage Drawing="{Binding 
                                        Path=MappedButton.IconKey, Mode=OneWay, 
                                        Converter={StaticResource 
                                            DrawingResourceKeyConverter}}" />
                                </Image.Source>
                            </Image>
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
            </StackPanel>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>
列表 9:显示组合的列表框的自定义项模板包含一个嵌入的 ItemsControl

列表框的 ItemTemplate 是您可以指示列表框如何显示其每个项目的地方。列表 9 中的模板使用 StackPanel 来显示 CheckBox 和嵌入的 ItemsControl。复选框绑定到每个项目的 IsCompleted 属性。ItemsControl 是一个非常简单的控件,用于显示项目集合;很像列表框,但没有用户交互性。这个控件绑定到每个组合的 CommandSequence 列表,并被指示使用水平 StackPanel 来容纳这些 Command 项。与父控件类似,提供了一个自定义 ItemTemplate 来显示 Image 控件。最终效果是水平排列的图像列表,对应于组合序列中每个命令的映射按钮。

注意:绑定到列表的项类型为 FlattenedCombo;这是一个类,其中包含一个游戏命令列表、一个指示用户完成情况的标志,以及一个指示组合其他方面(例如成就/奖杯、最长组合等)的属性。

按键显示绘图资源

有时,绑定到属性的数据类型与该属性的类型不兼容。列表 9 中的示例就说明了这一点。用作内部 ItemsControl 项模板的 Image 控件使用 DrawingImage 作为其源。此外,其 Drawing 属性绑定到“MappedButton.IconKey”。每个 Command 对象通过其 MappedButton 属性映射到 Button 实例。每个 Button 都有一个相应的 IconKey,其中包含应用程序中包含的绘图资源的键。但是,IconKey 是一个字符串,而 Drawing 属性期望……嗯,它期望一个 Drawing 实例。值转换器是这两个不兼容类型之间的重要链接。

注意:每个按钮的绘图资源位于客户端应用程序 Resources 文件夹中的“Icons.xaml”资源字典中。

值转换器

值转换器简单地将一个值转换为目标类型。如果需要,它也可以将值转换回其原始源类型。要创建值转换器,您必须创建一个实现 IValueConverter 接口的类。该接口包含 ConvertConvertBack 方法。列表 9 中的 Drawing 绑定利用了窗口资源中定义的自定义 DrawingResourceKeyConverter

[ValueConversion(typeof(string), typeof(Drawing))]
public class DrawingResourceKeyConverter : IValueConverter {

    public object Convert(object value, Type targetType, ...) {
        if (value != null) {
            if (value is string) {
                if (targetType == typeof(Drawing)) {
                    // Get the resource with a key specified as the value.
                    return (Drawing)Application.Current.Resources[value];
                }
                ...
            }
            ...
        } else {
            return null;
        }
    }

    public object ConvertBack(object value, Type targetType, ...) {
        throw new NotImplementedException();
    }

}
列表 10:DrawingResourceKeyConverter 将字符串键转换为应用程序资源中的 Drawing

DrawingResourceKeyConverter 类将包含键的字符串转换为 Drawing,之后从应用程序的资源中检索它。这使得深度嵌入的数据绑定更易于操作,而且代码量很少。

有条件地选择数据模板

Button sets combo box用户可以从按钮/键列表中进行选择,以更改用于绘制组合命令序列的图标。这些图标主题按游戏平台分组在 ComboBox 中。这是协助用户进行选择的绝佳方式。在将集合绑定到控件的 ItemsSource 之前,按组处理集合也非常容易。


// Load the button sets.
ButtonSets = ButtonSet.LoadButtonSets(ButtonSetsFile);

// Update the UI with a grouped view of the button sets.
ICollectionView View = CollectionViewSource.GetDefaultView(ButtonSets);
View.GroupDescriptions.Add(new PropertyGroupDescription("Platform"));
cmbButtonSets.ItemsSource = ButtonSets;
列表 11:按钮集列表通过其默认集合视图按平台分组,然后绑定到 ComboBox

然而,有些图标主题在不同平台下名称相同。这在下拉列表中没问题,但在选择框(展开列表未显示时显示当前选择的区域)中不行。如果用户在“Xbox 360”下选择“Buttoned”,则选择框通常只显示“Buttoned”。为了更好地为用户服务,将 DataTemplateSelector 分配给组合框的 ItemTemplateSelector,以选择一个更具表现力的数据模板用于选择框。

<Window.Resources>
    <ResourceDictionary>
        <DataTemplate x:Key="ButtonSetByName">
            <TextBlock Text="{Binding Path=Name}" />
        </DataTemplate>
        <DataTemplate x:Key="ButtonSetByPlatformAndName">
            <StackPanel Orientation="Horizontal">
                <TextBlock Text="{Binding Path=Platform}" />
                <TextBlock Text=": " />
                <TextBlock Text="{Binding Path=Name}" />
            </StackPanel>
        </DataTemplate>

        <local:ButtonSetsTemplateSelector x:Key="ButtonSetsTemplateSelector" />
        ...
    </ResourceDictionary>
</Window.Resources>
...
<ComboBox Grid.Column="1" Name="cmbButtonSets" 
    ItemTemplateSelector="{StaticResource ButtonSetsTemplateSelector}" 
    Width="200" Margin="0,0,0,5" 
    SelectionChanged="cmbButtonSets_SelectionChanged">
    <ComboBox.GroupStyle>
        <GroupStyle>
            <GroupStyle.HeaderTemplate>
                <DataTemplate>
                    <TextBlock 
                        Text="{Binding Path=Name}" 
                        Style="{DynamicResource GroupHeader}" />
                </DataTemplate>
            </GroupStyle.HeaderTemplate>
        </GroupStyle>
    </ComboBox.GroupStyle>
</ComboBox>
列表 12:按钮集组合框为其项分配自定义数据模板选择器并设置组标题样式。

首先,两个要选择的数据模板在窗口的资源部分进行了定义。一个模板仅显示按钮集名称;另一个模板堆叠平台和名称,中间用冒号分隔。接下来,还在资源部分添加了 ButtonSetsTemplateSelector 的定义。最后,将 ComboBoxItemTemplateSelector 属性设置为上述模板选择器资源。

自定义 DataTemplateSelector 类重写了 SelectTemplate 方法。传递给它的 container 参数用于决定返回哪个数据模板。如果容器是带有 ComboBox 作为其 TemplatedParentContentPresenter,则返回带有组合平台和名称的数据模板。在所有其他情况下,返回基本模板。内容演示者的 TemplatedParent 将是 ComboBoxItem,用于下拉列表,而不是实际的 ComboBox

public class ButtonSetsTemplateSelector : DataTemplateSelector {

    public override DataTemplate SelectTemplate(object item, DependencyObject container) {
        string ResourceKey = "ButtonSetByName";

        // Get the main window.
        Window Window = Application.Current.MainWindow;

        // Test if the container is a ContentPresenter.
        ContentPresenter Presenter = container as ContentPresenter;
        if (Presenter != null) {
            ComboBox Combo = Presenter.TemplatedParent as ComboBox;
            if (Combo != null) {
                ResourceKey = "ButtonSetByPlatformAndName";
            }
        }

        return Window.FindResource(ResourceKey) as DataTemplate;
    }

}
列表 13:ButtonSetsTemplateSelector 从主窗口的资源中选择适当的数据模板。

盛大开幕

现在应用程序有了来自最近下载的游戏组合包的新皮肤,即使是旧的 OpenPackageWindow 也看起来好多了。事实上,在下载了几个现有游戏后,它会以一个漂亮的图标列表显示它们,供用户选择。

Skinned open package window
图 7:OpenPackageWindow 显示已下载的现有游戏。

游戏组合包的图标是在另一个值转换器的帮助下显示的。当构建现有游戏列表时,图标会从包中读取并存储在二进制数组中。标题和图标数据的列表被绑定到列表框。图标数据数组通过另一个在窗口资源部分定义的值转换器的帮助直接绑定到 Image 控件。

<Window.Resources>
    <local:BinaryImageConverter x:Key="BinaryImageConverter" />
</Window.Resources>
...
<ListBox Name="lbExistingGames" ...>
    <ListBox.ItemsPanel>
        <ItemsPanelTemplate>
            <WrapPanel IsItemsHost="True" Orientation="Horizontal" />
        </ItemsPanelTemplate>
    </ListBox.ItemsPanel>
    <ListBox.ItemTemplate>
        <DataTemplate>
            <StackPanel Margin="5">
                <Border BorderBrush="Black" BorderThickness="1" 
                    Width="72" Margin="0,0,0,5">
                    <Image 
                        Source="{Binding Path=IconData, 
                        Converter={StaticResource BinaryImageConverter}}" />
                </Border>
                <TextBlock Text="{Binding Title}" />
            </StackPanel>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>
列表 14:列表框使用 BinaryImageConverter 协助,将每个项的 Image 控件绑定到 IconData 数组。

再次,实现了一个值转换器,用于将图标数据转换为图像源。此转换器将字节数组加载到 MemoryStream 中,创建一个新的 BitmapImage 对象,将其 StreamSource 属性设置为内存流,并返回图像源作为转换后的目标对象。

[ValueConversion(typeof(byte[]), typeof(ImageSource))]
public class BinaryImageConverter : IValueConverter {

    public object Convert(object value, Type targetType, ...) {
        if (value != null) {
            if (value is byte[]) {
                if (targetType == typeof(ImageSource)) {
                    // Create a MemoryStream for the binary image data.
                    byte[] Data = (byte[])value;
                    MemoryStream Stream = new MemoryStream(Data);

                    // Create a BitmapImage to hold the stream data and return it.
                    BitmapImage Image = new BitmapImage();
                    Image.BeginInit();
                    Image.CacheOption = BitmapCacheOption.OnLoad;
                    Image.StreamSource = Stream;
                    Image.EndInit();

                    return Image;
                }
                ...
            }
            ...
        } else {
            return null;
        }
    }

    ...
}
列表 15:BinaryImageConverter 通过 MemoryStream 将原始字节数组转换为 BitmapImage

更多游戏皮肤

我决定创建另一个游戏组合包,其中包含相应的组合定义、皮肤和资产。不出所料,用户必须输入有效的游戏光盘代码才能下载包(例如,720d-789a-41e7-97cc-01fc)。我猜测这个较新版本的《波斯王子》最终会成为三部曲,所以当时候合适时,Guru Game 的服务器上会出现第三款游戏和包。

Alternately skinned main window
图 8:《波斯王子 2》游戏组合包加载;以 Elika 为特色,配以新皮肤。

正确的模式

此演示解决方案中包含的客户端应用程序不遵循许多人认为是“正确模式”(例如 MVC、MVP、MVVM、DMVVM 等)的模式。编写 WPF 应用程序最快的模式之一是 Model-View-ViewModel (MVVM) 模式。我非常喜欢这种模式,并相信它为开发人员提供了出色的关注点分离。我选择在此演示解决方案中实现该模式,原因有几点。首先,我认为在另一篇文章中将客户端项目重构为适合 MVVM 模式将是一个很好的练习。其次,我不想分散本文试图传达的信息;纯 WPF 技术。最后,在短期内放弃一个合适的模式,以后再重构应用程序,这种情况在“现实世界”中时有发生。只要您意识到应用程序增长时需要进行重构,您就可以设计一个非常容易更新的项目。

解决方案中包含的其他项目

代码下载中的解决方案确实包含了一些本文未提及的项目。一个用于 WCF 服务项目访问的数据的项目包含一个 ADO.NET 实体数据模型。服务应用程序引用此数据项目,以便在客户端请求包下载时查询 SQL Server 2008 Express 数据库。

此外,还有一个小型控制台应用程序。此程序可以轻松生成一个包含客户端应用程序下载所需的所有必需资产的包文件。我使用此命令行工具快速生成了服务应用程序存储并在请求时提供的包文件。它处理将每个资产流式传输到客户端期望的包结构中的繁琐工作。

“连击大师”成就/奖杯

如果您和许多《波斯王子》游戏的玩家一样,您可能尝试了无数种攻击组合,希望能找到能奖励您特殊成就/奖杯的组合;“连击大师”。好消息是,您不必执行所有 1602 种可能的组合,尽管任务提示可能会让您这么认为。您只需要完成60种组合。其他指南可能会告诉您 62、63 种,甚至更多,但 60 是神奇数字(请记住,组合实际上是两个或更多攻击)。我在新用户配置上至少测试过此列表 10 次。在尝试实现此目标时,需要记住一些规则。

  1. 组合不能杀死敌人。即使是最后一击也不能杀死你的敌人。
  2. 敌人不能格挡组合。组合的任何部分都不能被格挡才能成功。
  3. 组合不能将敌人撞到墙上或掉下悬崖。如果您的组合链被边缘动画打断,它将不会计数。
  4. 成功完成所有 60 种组合后,您必须在获得成就/奖杯之前杀死您当前正在与之战斗的敌人。
A = 杂技;G = 护手;M = 魔法;S = 剑
  1. A,G
  2. A,G,A,G
  3. A,G,A,M,G
  4. A,G,A,M,M
  5. A,G,A,M,S
  6. A,G,A,S
  7. A,M,G
  8. A,M,M,G
  9. A,M,M,M
  10. A,M,M,S
  11. A,M,S,G
  12. A,M,S,M,G
  13. A,M,S,M,M
  14. A,M,S,M,S
  15. A,M,S,S
  1. A,S,S,S
  2. G,A
  3. G,A,G
  4. G,A,M,G
  5. G,A,M,M
  6. G,A,M,S
  7. G,A,S
  8. G,G
  9. G,M,A
  10. G,M,G
  11. G,M,M,A
  12. G,M,M,G
  13. G,M,M,M
  14. G,M,M,S
  15. G,M,S,A
  1. G,M,S,G
  2. G,M,S,M,A
  3. G,M,S,M,G
  4. G,M,S,M,M
  5. G,M,S,M,S
  6. G,M,S,S
  7. G,S
  8. M,A
  9. M,G
  10. M,M,A
  11. M,M,G
  12. M,M,M
  13. M,M,S
  14. M,S,A
  15. M,S,G
  1. M,S,M,A
  2. M,S,M,G
  3. M,S,M,M
  4. M,S,M,S
  5. M,S,S
  6. S,A
  7. S,G
  8. S,M
  9. S,S,A
  10. S,S,G
  11. S,S,M
  12. S,S,S,A
  13. S,S,S,G
  14. S,S,S,M
  15. S,S,S,S

计算“连击大师”

应用程序必须经过一些周折才能正确计算出获得“连击大师”奖励所需的组合。计算的理论是包含组合列表屏幕中的所有组合,但找到到达每个组合的最短路径。

例如,Elika/魔法组合中的一些组合会导向升/护手组合。然而,通过魔法组合到达升组不是最短路径。因为,您可以通过普通组以一个命令到达升组,所以从那里构建组合并完全跳过魔法组会更短。当然,必须包含魔法组合才能涵盖所有组合,但也可以通过普通组以一个命令到达它。为了包含投掷组中的组合,您只能通过杂技组到达它们。因此,到达投掷组的最短路径是从普通组到杂技组再到投掷组(即,A,G,...)。

/// <summary>
/// Recursively builds a list of shortest path sequences that are necessary to complete 
/// in order to receive the "Combo Specialist" achievement/trophy.
/// </summary>
/// <param name="group">The initial combo group to calculate from.</param>
/// <param name="startedSequence">Any started sequence to build onto.</param>
private void BuildShortestPathSequences(ComboGroup group, List<Command> startedSequence) {
    // Create a new sequence of commands and initialize it with any started sequence.
    List<Command> Sequence = null;
    if (startedSequence == null) {
        startedSequence = new List<Command>();
    }

    // Traverse each combo in the group, add it to the list of shortest path 
    // combos, and check its next group for the need to continue down that path.
    Dictionary<ComboGroup, AttackCombo> CombosToContinue = 
        new Dictionary<ComboGroup, AttackCombo>();
    foreach (AttackCombo Combo in group.AttackCombos) {
        // Create a new build sequence for this combo from any started sequence
        // plus its own command sequence.
        Sequence = new List<Command>(startedSequence);
        Sequence.AddRange(Combo.CommandSequence);

        // Add this sequence as a new shortest path combo, if it has more than 1 command.
        if (Sequence.Count > 1) {
            ShortestPathCombos.Add(new FlattenedCombo(Sequence));
        }

        if (Combo.NextGroupInChain != null) {
            // Check this combo's next group against any existing ones to continue.
            if (CombosToContinue.ContainsKey(Combo.NextGroupInChain)) {
                // Compare the existing combo to continue for this group with the current
                // combo by their command sequence counts.
                AttackCombo ComboToContinue = CombosToContinue[Combo.NextGroupInChain];
                if (Combo.CommandSequence.Count < ComboToContinue.CommandSequence.Count) {
                    // Swap the combo to continue for this new shorter one.
                    CombosToContinue[Combo.NextGroupInChain] = Combo;
                }
            } else if (!GroupsToFollow.Contains(Combo.NextGroupInChain)) {
                // Add the combo as one to continue for the next group.
                GroupsToFollow.Add(Combo.NextGroupInChain);
                CombosToContinue.Add(Combo.NextGroupInChain, Combo);
            }
        }
    }

    // Build onto the combos to continue recursively.
    foreach (AttackCombo Combo in CombosToContinue.Values) {
        // Create a new build sequence for this combo from any started sequence
        // plus its own command sequence.
        Sequence = new List<Command>(startedSequence);
        Sequence.AddRange(Combo.CommandSequence);

        BuildShortestPathSequences(Combo.NextGroupInChain, Sequence);
    }
}
列表 16:计算“连击大师”命令序列的递归方法。

列表 16 中的代码使用已加载的组合定义对象图来计算必需的组合。对 BuildShortestPathSequences 方法的初始调用使用 StartingComboGroup(定义为普通组)作为开始,没有启动序列。从那里,它将组中的所有组合添加到全局列表中,并确定是否应继续组合的下一个组。在处理完当前组中的所有组合后,任何应该继续到下一组的组合都会被递归处理。

维护两个全局列表,用于跟踪组合是否应该在树的更深处继续。决策是基于树上方的一个组是否会处理相同的下一组。如果是,则此更深的分支无需浪费时间执行此操作。

《波斯王子》的历史

Prince of Persia - 1989对于不了解《波斯王子》系列的人来说,它始于 1989 年,是一款横版平台游戏,主角是波斯王子,他与邪恶的维齐尔对抗,维齐尔在苏丹远征期间掌权。关于《波斯王子》系列,您可以在 Wikipedia 上找到 更多信息


免责声明

Guru Games 是一家虚构公司,Game Attack Combos 应用程序仅用于演示目的。Ubisoft 拥有《波斯王子》游戏系列的所有权利,并且没有聘请我为他们的游戏开发任何此类应用程序。

整个开发场景都是虚构的,仅用于演示混合智能客户端的多个 .NET 技术,该技术实际上用于解决我在 2008 年发布的《波斯王子》游戏中获得 100% 完成度时遇到的问题。Ubisoft 未宣布最新《波斯王子》游戏的续集是否会使用与第一款游戏相同的战斗系统。他们也未暗示该战斗系统将在任何未来游戏中使用。同样,这都是为了证明本文中演示的应用程序的推测。

请不要联系 Ubisoft 关于此应用程序的问题。他们已经够忙的了,因为他们正在为近期热门游戏开发各种续集。

© . All rights reserved.