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

Celerity:一切如何完成

starIconstarIconstarIconstarIconstarIcon

5.00/5 (34投票s)

2013年1月7日

CPOL

35分钟阅读

viewsIcon

103745

所有能帮助我们按时交付竞赛游戏的精彩技巧和令人震惊的捷径

在线查看源代码:http://celerity.codeplex.com/

介绍  

本文将引导您完成使用 Visual Studio 2012 中的 XNA 4 为 Windows 8 Ultrabook™ 设计和创建类似《Celerity: Sensory Overload》的游戏的过程,并将其发布到 Intel AppUp 商店。   

Celerity Logo 

本文是 Ultrabook™ 文章竞赛亚军 的参赛作品。根据规则,它大量借鉴了原始游戏文章,但侧重于教授我们如何制作它,并深入探讨代码。   

背景   

Celerity 是 应用创新竞赛 的参赛作品。它是一款受以下因素启发的游戏:  

  • 老派隧道游戏(例如《Super Stardust》中的奖励关卡)   
  • Johnny Chung Lee 的精彩 VR 头部追踪演示 
  • 应用创新竞赛》中的这项挑战:“尽情使用传感器——倾斜它、摇晃它、提起它或旋转您的 Ultrabook™”   

如果您不熟悉 Celerity,请观看此简短视频以了解基本概念:  

Celerity YouTube Demo 

目录 

本文分为制作游戏的整体任务,然后是对竞赛经验和其他注意事项的反思。 

为了能够并行完成其中一些任务并赶上截止日期,我邀请了几位朋友帮忙:@Dave_Panic(3D 技巧大师)、@BreadPuncher(游戏理论家和平面设计师)和 @PatrickYtting(大师)。  

初步想法    

仔细阅读竞赛简报,可以清楚地看出,重点在于展示硬件传感器功能和提出创新想法。这带来了两个关键主题:游戏采用传感器控制,以及通过面部检测实现仅使用基本网络摄像头的创新性头追踪重新构想。 

倾角计自然而然地适合倾斜控制,因此第一个想法是自由飞行控制。在尝试过一些隧道游戏后,其中一些确实使用了这种方法,但我发现当控制基于更简单的隧道旋转时,游戏往往更易于玩,因此控制仅限于左右倾斜,而前进方向自动与隧道对齐。

此时,简报是“构建某种隧道游戏,该游戏能在 Ultrabook™ 上运行,使用传感器进行控制,并仅通过网络摄像头输入实现头部追踪效果”。 从那时起,我们确定了 最小可行产品。 

我怎么强调都不为过,定义最小可行产品对本次竞赛非常有帮助。时间非常紧张,并且有无限添加可能破坏截止日期的小功能的潜力。结果表明,该游戏除了最基本的功能(包含传感器触发的智能炸弹)之外,还增加了一个功能。 

最小可行产品以抽象术语定义为

  • 构成游戏的东西(以符合竞赛类别)
  • 自然且逻辑地使用 Ultrabook™ 传感器
  • 包含我们创新的基于摄像头的头部追踪想法

具体具有以下功能

  • 产品应具备穿越带有纹理的 3D 隧道的功能 
  • 产品应具备基于传感器的通过隧道旋转的转向功能 
  • 产品应具备由头部追踪信息决定的视图矩阵调整功能 
  • 产品应具备 3D 虚拟形象(飞船)和 3D 可碰撞的障碍物 
  • 产品应具备声音和音乐 
  • 产品应具备触摸响应式用户界面 
  • 产品应具备计时器(以便玩家可以尝试改进之前的尝试) 
  • 产品应符合 Intel AppUp 标准   

让 XNA 在 Windows 8 上运行  

由于我熟悉 XNA 和 C#,所以我希望使用它们来构建游戏,但是让它们在 Windows 8 上流畅运行存在一些障碍。为了使项目可行,必须克服一些直接的障碍: 

  • Visual Studio 2012 不支持 XNA 项目
  • XNA Game Studio 4 似乎无法安装在 Windows 8 上 
  • XNA Touch 被故意禁用于 Windows(天哪!) 
  • Windows 8 库(用于传感器)无法在我当前的 Windows 7 开发环境中访问  

解决方案 

为了启用 XNA Game Studio 4,我使用了 Aaron Stebner 的解决方案,主要是:  

为了让 Visual Studio 2012 识别 XNA 项目,我使用了 Steve Beaugé 的解决方案:  

  • 将 VS2010 的 XNA Game Studio 4 文件夹(位于 VS2010 的扩展文件夹中)复制到 VS2012 的扩展文件夹 

  • 使用文本编辑器手动编辑 extension.vsixmanifest 文件的副本,以包含支持的版本:  
<SupportedProducts>
  <VisualStudio Version="11.0">
    <Edition>VSTS</Edition>
    <Edition>VSTD</Edition>
    <Edition>Pro</Edition>
    <Edition>VCSExpress</Edition>
    <Edition>VPDExpress</Edition>
  </VisualStudio>
</SupportedProducts> 

如果这不是您第一次运行 VS2012,您可能会发现 VS 缓存了可用的扩展列表。您可以通过在 Visual Studio 命令提示符中输入以下命令来强制它刷新此列表: 

"C:\Program Files (x86)\Microsoft Visual Studio 11.0\Common7\IDE\devenv.exe" /setup 

一个方便的小技巧,可以在特定文件夹中打开 VS 命令提示符,可以使用 此 shell 脚本:   

VS Command Prompt

如果以上方法无效,请在 VS2010 中创建新的 XNA 项目,然后在 VS2012 中打开它。现在,XNA 项目不仅可以正常打开和使用,而且标准的 Content Pipeline 也完美运行。  

制作音乐和音效 

如果您曾开发过自己的游戏,您会明白,在添加声音、音乐和音效之前,体验是多么平淡无奇。我非常幸运有一位一流的专业作曲家加入,但对大多数人来说,您很可能会寻找现成的声音和音乐。我建议坚持使用现成的音效选项,并建议不要录制自己的音效,除非您有经验,或者找不到其他方法来获得所需的音效。糟糕的声音渲染会严重破坏游戏的沉浸感。  

简单的网络搜索“免版税音乐”或“免版税音效”应该会为您提供很多选择。这可能需要一些小费用,但质量将为您的游戏增添光彩。

请记住,务必仔细检查许可。许多“免版税”文件库中常常包含实际有限制的*文件*,而您最不想遇到的就是一家公司起诉您。  

另一种方法可能是加入在线游戏制作社区,并与可能愿意免费提供帮助或分享利润的音乐和音频专家建立联系。 

设计用户界面      

在使用 XNA 这样的技术时,几乎所有用户界面布局方面都是基于坐标的。没有方便的矢量面板可供使用,创建响应式用户界面(即在不同目标屏幕分辨率下都能很好地工作的用户界面)尤其具有挑战性。Intel AppUp 测试过程的一部分是确保在几种不同的分辨率下都没有文本被裁剪,因此我们必须牢记这一点。

Celerity 的方法是将用户界面划分为 3 个不同的逻辑面板:  

  • “TL” 左上角面板,包含图标和返回图标按钮 
  • “TR” 右上角面板,包含各种图标按钮,如静音 
  • “UI” 中心主面板,包含所有其他内容  

采用了一种非常简单的布局响应式方法,即:将 TL 锚定到左上角,TR 锚定到右上角,UI 锚定到屏幕中心。尽管方法很简单,但用户界面始终看起来与屏幕很好地匹配。



尽管整体布局略有流动性,但面板内部元素的布局仍然使用了传统的网格。我选择了 25x25 像素的网格,但您可以选择适合您的任何网格。使用网格意味着面板内的用户界面元素将显得有序且对齐。

一旦您有了基本布局描述,就需要确定每个用户界面元素的原点和大小的坐标。我选择在纸上在一个打印的网格上进行此操作。在我实际在代码中构建用户界面时,它是一个非常有价值的参考,因为它很快就会变得非常混乱。这是我当时的表格



在确定了这一特定网格系统后,我创建了一些用户界面的线框图。您会注意到最终的用户界面与这些略有不同,从几个屏幕合并到一个屏幕。这只是为了使应用程序尽可能简单,但也是软件开发过程中自然演变的结果。通常稍后会发现更好的做事方式,而初始设计仅是指导,并非合同。

绘制用户界面  

我邀请了 @BreadPuncher(又名 Lorc)来处理矢量图标设计。他的任务是尽量符合 Microsoft 设计语言风格,以便一切看起来都一致。对于那些不幸没有图形技能或没有朋友提供帮助的人来说,您最有可能的选择是建立人脉并寻找艺术家/设计师,或者使用现成的图形。同样,只需在互联网上搜索适合您需求的免版税图标或图形。可能需要支付少量的一次性费用,但这通常能为您的应用程序带来所需的视觉优势。如果预算允许,您当然可以委托专业人士。

在导入图形 .png 文件时,我遇到了一些棘手的问题。从 Inkscape 渲染出的文件在 XNA 中渲染时出现了 alpha 伪影。


我花了很多时间尝试各种 XNA 渲染模式的组合,尽管其中一些模式能实现预期效果,但它们会干扰其他资源的渲染,例如字体。 

幸运的是,我偶然得到一个提示,可以使用 PixelFormer 程序。虽然它用于编辑,但我们只是用它来打开 Inkscape 的图像,然后重新导出它们,但要启用预乘 Alpha。互联网上的各种来源都建议 Inkscape 已经导出了预乘 Alpha,但实际情况似乎并非如此。通过 PixelFormer 处理图像解决了问题。 

构建应用程序代码结构 

虽然这个应用程序相对较小,但将其代码库划分为逻辑元素或模块仍然有益。我不会声称在这个匆忙的项目中实现了完美的模块化分离,但这个原则是可靠的。 

95% 以上的代码可以轻松归入以下类别:  

  • 内容和内容库
  • 输入 
  • 游戏逻辑 
  • 计算机视觉 
  • 3D 世界和内容 
  • UI 元素 
  • 音频 
  • 实用类   

我为每个类别创建了一个文件夹和命名空间。Game 类本身的作用不大,只是根据上述类别实例化和协调模块之间的活动:  

  • InputModule 
  • GameLogic 
  • CVModule 
  • WorldModule 
  • UILayer 
  • AudioModule   

 这使得我们的 Game 类非常整洁。构造函数仅包含: 

public CelerityGame()
{
    // Content
    Content.RootDirectory = CeleritySettings.ContentRootDirectory;
 
    // Set Graphics Device
    graphics = GraphicsDeviceUtil.Create(this);
 
    // Modules & layers
    audio = new AudioModule();
    cv = new CVModule();
    input = new InputModule(this.Window.Handle);
    logic = new GameLogic(audio, input);
    ui = new UILayer(logic);
    world = new WorldModule(graphics, audio, input, logic);
 
    // Events
    ui.OnClose += (s, e) => { this.Exit(); };
} 

然后在每个事件方法中,Game 类都会调用任何需要处理的子模块的相同方法。例如,在 LoadContent() 方法中,会指示 UILayerWorldModule 加载它们的内容

protected override void LoadContent()
{
    // SpriteBatch
    spriteBatch = new SpriteBatch(GraphicsDevice);
 
    // Modules & Layers
    ui.Load(GraphicsDevice, spriteBatch, Content);
    world.Load(Content);
}     

编写用户界面代码 

代码中唯一变得有些混乱的部分是用户界面层。此处的代码并非最佳,但可以工作,对于小型更改来说处理起来也还可以。然而,重大的用户界面更改可能会让我痛苦不堪!将此部分视为一个未能像我设想的那样成功的想法。

我用于用户界面层的类如下: 

  • UILayer - 顶层控制器 
  • UIGeometry - 坐标、偏移量和大小参考 
  • UIControlHierarchy - 用户界面中所有复合控件的列表(即非真正的层次结构) 
  • UIEntity - 代表用户界面中的一个复合控件(例如按钮),通常使用专门的子类 
  • UIEntityItem - 代表用户界面中复合控件的一个子组件(例如按钮上的文本) 
  • UIDrawCondition - 一个类,用于表示控件被绘制或未被绘制的逻辑场景  
我确信有更好的用户界面编码方法,因此不想在此部分过多停留。如果您有兴趣,请随意浏览源代码,但我建议在自行开发复杂的 UI 引擎之前,先研究现有的 UI 库。

一种可能的方法是使用静态 ImageLibrary 类和 .resx 文件,通过类型安全名称而不是硬编码字符串来访问图像。 

首先,创建一个资源文件并定义所有图像路径

现在创建一个名为 ImageLibrary 或类似名称的静态类,并包含以下私有静态变量: 

// Gfx
static GraphicsDevice graphics; 

添加以下简单的辅助方法

static Texture2D Get(string path)
{
    return Texture2D.FromStream(graphics, TitleContainer.OpenStream(path));
}   

现在,为要使用的每个图像添加一个 public static Texture2D 变量,例如:  

// Input device images
public static Texture2D InputKeys;
public static Texture2D InputGamepad;
public static Texture2D InputTilt; 
为了将所有内容整合在一起并使该类有用,请添加一个像这样的 Load 方法……
public static void Load(GraphicsDevice graphicsDevice)
{
    // Keep graphics device for Get functions
    graphics = graphicsDevice;
 
    // Input
    InputKeys = Get(ResxImg.InputKeys);
    InputGamepad = Get(ResxImg.InputGamepad);
    InputTilt = Get(ResxImg.InputTilt);
 
    // ... etc.
}  
现在可以通过访问 ImageLibrary 中的公共静态成员轻松地在代码中访问 Texture2D 数据。例如,要获取 InputKeys 图像,您将使用
var InputKeys = ImageLibrary.InputKeys;   

最后一个技巧是,您可以通过在给定纹理的加载方法中使用此替代代码,动态创建纯色通用纹理。 

TextureGrey = new Texture2D(graphics, 1, 1);
TextureGrey.SetData(new[] { Palette.OverlayGrey }); 
其中您已定义了类似的调色板
using Microsoft.Xna.Framework;
namespace Celerity.ColourPalette
{
    public static class Palette
    {
        public static Color Accent = new Color(0, 204, 255, 255);
        public static Color SecondaryAccent = new Color(255, 51, 0, 255);
        public static Color AccentPressed = new Color(0, 204, 255, 128);
        public static Color MidGrey = new Color(128, 128, 128, 255);
        public static Color OverlayGrey = new Color(76,76,76, 165);
        public static Color SemiTransparentWhite = new Color(255, 255, 255, 128);
        public static Color White = Color.White;
        public static Color Black = Color.Black;
    }
}  

编写隧道代码 

创建隧道的任务分解为两个类:一个类负责处理我们可以实际看到的隧道部分 TunnelSection,另一个类负责处理整个隧道 Tunnel。 

TunnelSection 将承担以下职责:  

  • 构建该隧道段的顶点 
  • 为每个顶点构建纹理坐标 
  • 绘制隧道  

Tunnel 将:  

  • 维护 TunnelSection 的位置(实际上是隧道在移动,而不是玩家,以防止坐标增长过大) 
  • 定义隧道整体的实际形状或曲率  

由三角形组成的隧道段具有以下属性

public float Radius { get; set; } // Radius of the tunnel walls
public int NumSegments { get; set; } // # of segments in wall (5 = pentagonal tunnel)
public int TunnelLengthInCells { get; set; } // # of rings of vertices in the tunnel section
private float cellSize; // Distance between the rings of vertices  
使用此方法创建顶点
void ConstructVertices()
{
   int numVertex = NumSegments * TunnelLengthInCells;
   vertices = new VertexPositionColorTexture[numVertex];
   float sectionAngle = 2 * (float)Math.PI / NumSegments;
   int vertexCounter = 0;                
   for (int i = 0; i < TunnelLengthInCells; i++)
   {
      for (int j = 0; j < NumSegments; j++)
      {
         Matrix rotationMatrix = Matrix.CreateRotationZ(j * sectionAngle);
         vertices[vertexCounter].Position = Vector3.Transform(
            new Vector3(0.0f, this.Radius, 0.0f), rotationMatrix);
         vertices[vertexCounter].Position.Z = -cellSize * i;
         vertexCounter++;
      }
   }
} 

第一个新顶点以 x 和 z 坐标为零,y 坐标等于所需半径创建。然后将该点围绕原点旋转适当的角度,然后移动到下一个点。对一整圈完成此操作后,重复该过程,但将点进一步远离 cellSize 定义的距离。 

由于构成隧道的方形部分应该保持方形,因此有必要计算顶点环中两点之间的距离。这是通过在 (0, radius, 0) 创建一个点,围绕 z 轴旋转适当的角度 (2 * (float)Math.PI / NumSegments),然后测量两点之间的距离来完成的。如下所示: 

float CalculateSectionSize()
{
       Vector3 point1 = new Vector3(0.0f, this.Radius, 0.0f);
       Vector3 point2 = Vector3.Transform(point1, Matrix.CreateRotationZ(2 * (float)Math.PI / NumSegments));
       return Vector3.Distance(point1, point2);
} 

有了顶点后,下一步是填充索引缓冲区。索引缓冲区告诉 GPU 使用哪些顶点构成哪个三角形。它是一个指向顶点数组中每个顶点索引的列表。  

void ConstructIndices()
{
   int indexCount = TunnelLengthInCells * NumSegments * 6;
   indices = new short[indexCount];
   int indexCounter = 0;
   for (int i = 0; i < vertices.GetUpperBound(0) - NumSegments; i += NumSegments)
   {
      for (int j = 0; j < NumSegments; j++)
      {
         // Triangle 1
         indices[indexCounter] = (short)(i + j);
         indices[indexCounter + 1] = (short)(i + j + NumSegments);
         indices[indexCounter + 2] = (short)(i + j + 1);
         if (j == NumSegments - 1) 
		 {
			indices[indexCounter + 2] = (short)i;
		 }
		 
         // Triangle 2
         if (j < NumSegments - 1)
         {
           indices[indexCounter + 3] = (short)(i + j + 1);
           indices[indexCounter + 4] = (short)(i + j + NumSegments);
           indices[indexCounter + 5] = (short)(i + j + NumSegments + 1);
         }
         else
         {
           indices[indexCounter + 3] = (short)(i + j + NumSegments);
           indices[indexCounter + 4] = (short)(i);
           indices[indexCounter + 5] = (short)(i + j + 1);
         }
		 
         indexCounter += 6;
      }
   }
}

此处代码以与创建顺序相同的顺序循环遍历顶点,并在此过程中连接三角形。诀窍是让它们全部顺时针缠绕,这样背面剔除就不会使三角形不可见,这需要一些心理可视化来弄清楚基于三角形的给定点应该连接哪些顶点。另外,请注意顶点环的末尾有一个特殊情况。如果不存在这种情况,代码将创建螺旋状沿着隧道的三角形,并在隧道段的开头和结尾留下一个三角形的间隙。 

这是一个 TunnelSection

TunnelSection 

这里有几个 TunnelSection 连接在一起形成一个 Tunnel,并带有漂亮的曲线以增加效果

编写隧道和障碍物着色器代码 

多种颜色搭配单个纹理  

隧道和障碍物使用相同的基本纹理渲染,仅改变颜色。这支持白色隧道搭配各种彩色障碍物。不同的颜色是在代码中定义的,或者在运行时计算的,而无需生成大量相同的纹理文件。这需要一些简单的数学,您可能以前遇到过。 

首先需要一个基础的灰度纹理

Tunnel Texture White 

在着色器程序中,颜色使用每个分量的 0.0 到 1.0 的值表示。因此,例如,白色将是 { Red = 1.0, Green = 1.0, Blue = 1.0 },黑色将是 { Red = 0.0, Green = 0.0, Blue = 0.0 }

考虑到任何乘以 1.0 的值都保持不变,而任何乘以 0.0 的值将始终为 0.0,因此可以通过简单地将它们的组件相乘来将纹理贴图的颜色转换为任何基础颜色。将基础纹理中的所有白色像素 { R = 1.0, G = 1.0, B = 1.0 } 与 100% 红色 { R = 1.0, G = 0.0, B = 0.0 } 相乘得到 100% 红色。反之,与纹理中的黑色像素 { R = 0.0, G = 0.0, B = 0.0 } 相乘会得到黑色输出颜色。 

在这里,您可以看到将纹理中的每个像素与蓝色 { R = 0.0, G = 0.5, B = 1.0 } 相乘的结果: 

这在着色器程序中实现起来非常简单。首先,在着色器的参数中添加一个变量来保存颜色: 

float4 Color;  
纹理和纹理采样器定义如下: 
texture TunnelTexture;
sampler2D textureSampler = sampler_state { Texture = (TunnelTexture);
                                           MipFilter = LINEAR;
                                           MagFilter = LINEAR;
                                           MinFilter = LINEAR;
                                           AddressU = Wrap;
                                           AddressV = Clamp; };

如果您不熟悉纹理采样器,请查看许多在线教程中的一个,例如 此处是一个很好的例子。 

下一步只是将纹理采样器检索到的值与传递到 Color 参数的值相乘。如下所示: 

float4 output = Color * tex2D(textureSampler, input.TexUV);
output.a = 1.0f; // Make sure alpha is always 1.0 

深度衰减 

深度衰减或褪色到黑色/雾颜色是为场景增加深度、巧妙帮助玩家判断距离以及隐藏进入视线的物体的重要组成部分。幸运的是,在着色器代码中添加它非常容易。 

Celerity 采用了最简单的方法,即根据到远裁剪平面的距离进行淡化,当创建投影矩阵时应定义该平面。例如

projection = Matrix.CreatePerspectiveFieldOfView((float)Math.PI / 4.0f, graphics.GraphicsDevice.Viewport.AspectRatio, 0.01f, farClip);

远裁剪值会传入着色器程序,因此向着色器的参数添加一个变量。 

float FarClip; 

顶点着色器输出结构被修改为携带深度信息,如下所示: 

struct VertexShaderOutput
{
	float4 Position : POSITION0;
	float2 TexUV : TEXCOORD0;
	float Depth : TEXCOORD1;
};

顶点着色器输出函数被修改为将深度信息写入结构: 

VertexShaderOutput VertexShaderFunction(VertexShaderInput input)
{
	VertexShaderOutput output;
	float4 worldPosition = mul(input.Position, World);
	float4 viewPosition = mul(worldPosition, View);
	output.Position = mul(viewPosition, Projection);
	output.TexUV = input.TexUV;
	output.Depth = output.Position.z;
	return output;
}
现在像素着色器函数可以访问深度信息,这段代码将修改输出颜色。 
// Fade to black based on distance to FarClip
float dist = saturate(input.Depth / FarClip);

input.Depth 除以 FarClip 会得到一个介于 0.0 和 1.0 之间的值,如果当前像素位于 FarClip 距离或更远,则结果为 1.0。saturate 内置函数将确保该值不会超过 1.0。 

由于目标效果是在最大距离处淡化到黑色,因此每个分量都乘以 1.0 减去我们除法的结果。   

dist = 1.0f - dist;
output.r *= dist;
output.g *= dist;
output.b *= dist;

无深度衰减(无淡化): 

 

有深度衰减(在最大距离处淡化到黑色)

With Depth Cueing 

编写碰撞检测代码 

碰撞检测可能很难 

碰撞检测可能非常具有挑战性,尤其是在 3D 环境中。在某些情况下,数学计算可能令人费解。 准确性和计算效率之间几乎总是有权衡取舍,而平衡必须始终基于上下文。 

幸运的是,在 Celerity 中,可以通过认识到以下事实来大大简化问题:尽管游戏看起来是 3D 的,但实际上它只在 2D 中运行。玩家的飞船只能向前穿过隧道,或者绕着隧道外侧飞行。飞船从不沿 Y 轴移动,只沿 Z 和 X 轴移动。 

隧道的制作方式 

飞船沿着无限长的隧道前进。从字面上看,计算机将很快遇到浮点误差问题,因为某些变量的幅度会迅速增加,导致精度差异很大,从而导致计算中的大误差。简而言之,东西会坏掉。  

相反,飞船和摄像机保持固定,围绕原点定向。隧道本身会向摄像机移动。为了进一步简化隧道运动,隧道段仅在 Z 方向上推进。这种方法需要通过 X 和 Y 转换隧道,以使其居中于原点。还需要旋转摄像机以指向隧道,使其看起来像是玩家正在看着隧道。 

隧道和物体首先被创建为直线,没有弯曲 

Vertices are perturbed 

然后对顶点进行扰动以创建弯曲 

这一点很重要,即障碍物的前后与 XY 平面保持平行。这意味着可以通过简单的 2D 轴对齐碰撞检测来执行复杂的 3D 非轴对齐碰撞检测。 

展开隧道 

由于玩家只在 2 个维度上移动,因此要确定是否发生碰撞,只需要 3 条信息: 

  • 物体所在的角度 
  • 物体的角宽度 
  • 物体的 Z 坐标 

要形象地说明隧道如何展开,请想象 Celerity 隧道是画在卫生纸卷内侧的网格。拿一把剪刀沿着卷轴剪开,将其展开成一个平坦的网格:  

Unwrapped tunnel

隧道表面,展开形成新的 2D 坐标系 

每个障碍物的宽度都可以简单计算得出。由于障碍物与隧道壁上的每个网格正方形大小相同,因此它们的宽度等于 2p/10。之所以是 2p,是因为我们在弧度下工作,而 10 是隧道中的细分数量。Z 坐标与隧道 3D 表示中使用的 Z 坐标相同。 

创建了 CollisionRect 类来存储每个障碍物的碰撞数据: 

class CollisionRect
{
	// 0 -- 1 - -- +
	// |    | |
	// 2 -- 3 +
	public Vector2[] points;
	public bool zUnset = true;
 
	// Angle and z are the centre point
	public CollisionRect(float tunnelCellSize, float tunnelNumSegments, float angle, float z)
	{
		float rads = (float)(2 * Math.PI);
		float widthOver2 = (float)Math.PI / tunnelNumSegments;
		float heightOver2 = tunnelCellSize / 2;
		points = new Vector2[4] 
		{
			new Vector2(rads - angle - widthOver2, z - heightOver2), 
			new Vector2(rads - angle + widthOver2, z - heightOver2), 
			new Vector2(rads - angle - widthOver2, z + heightOver2), 
			new Vector2(rads - angle + widthOver2, z + heightOver2) 
		};
	}
 
	public void SetZ(float z)
	{
		for(int i = 0; i < 4; i++)
		{
			points[i].Y += z;
		}
		
		this.zUnset = false;
	}
} 

该类非常简单。有一个 Vector2 类型的数组用于存储盒子每个角的坐标,以及一个创建盒子的构造函数。 

更新碰撞框位置时一个重要的注意事项是,**从 3D 世界位置复制 Z 坐标,而不是计算新位置**。如果计算新位置,数字的微小差异会累积,并且位置会很快不同步。 

使用一个类似的类来维护玩家在世界中的位置。由于没有简单的方法来确定飞船模型的角度大小(因为数据是隐式的,隐藏在 .fbx 模型中),因此宽度和高度是通过反复试验确定的。 

AABB 或 2D 中的轴对齐边界框碰撞

有了所有这些元素,碰撞检测就成为可能。在 2D 抽象中,这个过程很简单,因为它只关心轴对齐边界框,或 AABB。 

2 Intersecting AABBs

2 个相交的 AABB 

检测交集的算法如下:对于绿色框中的每个点,如果 x > A.xx < B.xy > B.yy < A.y,则该点在橙色框内。如果任何点在里面,它们就会相交,或“碰撞”。 

由于之前将隧道从管状展开成平坦表面的简化,有几个特殊情况需要考虑。坐标必须环绕。这是通过对位于连接/接缝处的框执行 2 次检测来实现的,在 10 个段的零基隧道中位于位置 0 或 9。一次在框的正常位置进行检测,一次根据框所在接缝的哪一侧进行 ±2p 的变换。  

编写音频代码 

我使用了 XACT,这是 Microsoft 的跨平台音频库和工具集,为 Celerity 的分层音乐和音效提供支持。它既相当直接又相当强大。  

我使用“Audio Creation Tool”导入了许多音乐层和音效。在该工具中,您可以轻松地将声音分组到“Categories”中,这些“Categories”可以在游戏代码中像音频通道一样处理。Category 可以动态发送各种参数,例如源位置(哇!)和音量。波形文件本身可以声明为循环播放,这样在游戏中就会自动播放。 

在项目进行到一半时第一次看到它,我对接受这项不熟悉的技术感到紧张,但强烈推荐它。由于 这个非常简单的 XACT 教程,我大约一个小时就上手了。 在这个项目中,我几乎没有触及 XACT 的表面,但即使是基础知识,我们也拥有了动态的音乐评分。 

这是一个用于处理游戏中音频逻辑的类 AudioModule 的演练。请注意 Play(用于一次性音效)和 PlayCue(用于循环播放的歌曲 WAV)之间的区别。 

要使用 XACT 编码,您首先需要以下 using 语句

using Microsoft.Xna.Framework.Audio; 

现在是一些简单的实例成员:  

// Startup logic
bool hasMusicStarted = false;
 
// XACT objects
AudioEngine engine;
WaveBank waveBank;
SoundBank soundBank;
 
// Channels
AudioCategory musicChannel1;
AudioCategory musicChannel2;
AudioCategory musicChannel3;
AudioCategory musicChannel4;
AudioCategory ambienceChannel;
AudioCategory sfxChannel;  

bool 只是一个我们可以检查以查看是否已开始播放音乐的标志,因此我们只执行一次。XACT 对象包含大部分功能,而各种 AudioCategory 对象代表不同的通道。 

Initialize 方法不言自明,初始化实例变量。

public void Initialize()
{
    // Init XACT objects
    engine = new AudioEngine(AudioLibrary.PathEngine);
    waveBank = new WaveBank(engine, AudioLibrary.PathWaveBank);
    soundBank = new SoundBank(engine, AudioLibrary.PathSoundBank);
 
    // Init channels
    musicChannel1 = engine.GetCategory(AudioLibrary.ChannelMusic1);
    musicChannel2 = engine.GetCategory(AudioLibrary.ChannelMusic2);
    musicChannel3 = engine.GetCategory(AudioLibrary.ChannelMusic3);
    musicChannel4 = engine.GetCategory(AudioLibrary.ChannelMusic4);
    ambienceChannel = engine.GetCategory(AudioLibrary.ChannelAmbience);
    sfxChannel = engine.GetCategory(AudioLibrary.ChannelSFX);
} 

Update 方法有点意思

public void Update(float chaosFactor)
{
    // All sounds will be multiplied by this allowing a global mute function
    float muteMultiplier = GlobalGameStates.MuteState == MuteState.Muted ? 0f : 1f;
 
    // Set looping music playing first time only
    if (!hasMusicStarted)
    {
        hasMusicStarted = true;
        if (CeleritySettings.PlayMusic)
        {
            soundBank.PlayCue(AudioLibrary.Music_Layer1);
            soundBank.PlayCue(AudioLibrary.Music_Layer2);
            soundBank.PlayCue(AudioLibrary.Music_Layer3);
            soundBank.PlayCue(AudioLibrary.Music_ShortIntro);
            soundBank.PlayCue(AudioLibrary.Ambience);
        }
    }
 
    // Set the volumes
    float normalVolume = 1f * muteMultiplier;
    ambienceChannel.SetVolume(normalVolume);
    musicChannel1.SetVolume(normalVolume);
    musicChannel2.SetVolume(DynamicVolume(chaosFactor, 0.3f) * muteMultiplier);
    musicChannel3.SetVolume(DynamicVolume(chaosFactor, 0.7f) * muteMultiplier);
    musicChannel4.SetVolume(normalVolume);
    sfxChannel.SetVolume(normalVolume);
 
    // Update the engine
    engine.Update();
} 

在这里,我将所有音量乘以一个静音乘数,允许我全局静音所有通道。下一部分是通过 PlayCue 方法初始触发音乐。然后设置各种通道的音量,其中一些动态地根据游戏中的紧张程度(称为“混乱因子”)进行调整。 最后,我们调用 AudioEngine 对象的 Update()。 

这是 DynamicVolume 辅助方法

float DynamicVolume(float chaosFactor, float threshold)
{
    if (threshold >= 1f)
    {
        throw new ArgumentException("Audio Module - Dynamic Volume: Threshold must be less than 1.");
    }
 
    float off = 0f;
    return chaosFactor > threshold ? (chaosFactor - threshold) / (1f - threshold) : off;
} 

接下来是许多公开的、独立的 PlayCue 方法,用于不同的游戏内音效,例如

public void PlayCrash()
{
    soundBank.PlayCue(AudioLibrary.Ship_HitsBlock);
}   

编写游戏逻辑 

游戏逻辑类非常简单。它提供了一种触发和响应游戏事件、跟踪计时以及最重要的是管理混乱因子(一个表示当前全局紧张程度的抽象数值)的手段。混乱因子会影响方块生成的密度、玩家飞船的速度以及音乐的复杂性。随着时间的推移,混乱因子会升高,游戏会变得越来越难。玩家坠毁时,此值会被重置。 

混乱因子计算如下

public float ComputeChaos(double time)
{
    return (float)(1.0 - (1.0 / Math.Exp(time / 20.0)));
}  

编写输入代码 

传感器 

访问传感器并不像我希望的那样直接。开发 Windows 8 的另一个意外是 Sensor 命名空间仅限于 WinRT,因此无法用于桌面应用程序。幸运的是,Intel 提供了解决方案

诀窍是使用文本编辑器打开 .csproj 文件并添加目标平台版本号。这允许您从 Visual Studio 添加对“Windows”的引用,否则该引用将不可用。在此库中是 Windows.Devices.Sensors 命名空间。 

我将此直接与我的 XNA 项目结合时遇到了问题,但是将传感器放在一个单独的项目中,该项目又引用了 System.Runtime.WindowsRuntime.dll,这使得所有内容都能顺利构建和互操作。我将任何直接引用 WinRT 对象的调用都封装在一个代理类中,因此我的 XNA 项目只与另一个项目中的简单数据类型进行交互。 

我目前从这个命名空间使用的传感器是:  

  • 倾角计,用于指示设备向左/右倾斜的程度以进行转向 
  • 加速度计,用于提供“摇晃”事件,用于触发智能炸弹 

读取这些值的代码非常简单: 

倾角计    

提供的读数是 PitchRollYaw;这些可以被认为是 X、Y 和 Z。为了倾斜屏幕左右,我只是将我的代理订阅到 RollReadingChanged 事件,公开最后一个读取值的公共属性,然后在每个 Update 调用中从 XNA 读取该属性。 

类似这样的东西

public class SensorProxy()
{
    const uint inclineDesiredInterval = 16;
    Inclinometer incline;
 
    public double InclineY { get; set; }
 
    public SensorProxy() 
    {
        incline = Inclinometer.GetDefault(); 
        if(incline != null) 
        {  
            // Set interval 
            uint minInclineInterval = incline.MinimumReportInterval; 
            incline.ReportInterval = minInclineInterval;  
 
            // Wire Events
            incline.ReadingChanged += (s, e) => { InclineY = e.Reading.RollDegrees; };
        }
    }
} 

加速度计   

连接加速度计比连接倾角计更容易,因为有一个预先构建的 Shaken 事件。这意味着我,作为开发人员,不必担心校准灵敏度。我只需收听事件并对其做出响应。 

我希望完全封装所有传感器命名空间对象,因为之前出现过问题,所以我将直接事件保留在代理类内部,并在处理程序中引发一个新的普通 EventHandler 事件。这是为了防止调用者必须引用 AccelerometerShakenEventArgs 对象。 

public event EventHandler OnShaken;
Accelerometer accel;
 
//...

accel = Accelerometer.GetDefault();
if(accel != null)
{
    accel.Shaken += (s, e) => { If (OnShaken != null) OnShaken(this, new EventArgs()); };
} 

屏幕方向问题  

当我第一次测试倾角计转向时,我遇到了一个有些讽刺的问题。我将屏幕向左倾斜,读取值过了一两秒钟,但然后我的屏幕开始翻转。简单的方向传感器检测到了角度的变化,并假定我想要切换到纵向模式。XNA 全屏运行时,这会以一种我只能称之为“有点疯狂”的方式进行。自动缩放的噩梦! 

这显然会干扰应用程序的核心概念之一,因此我必须及时制止。幸运的是,我的 SensorProxy 中的一个简单方法,在初始化阶段从 XNA 调用,可以解决这个问题。 

public void LockOrientation()
{
    DisplayProperties.AutoRotationProperties =  DisplayOrientations.Landscape; 
}   

XNA 在 Windows 8 上的触摸功能  

正如 Shawn Hargreaves 所写,XNA 中的触摸功能不幸地被故意禁用于 Windows,仅限于 Windows Phone 7。这意味着尽管 Microsoft.Xna.Framework.Input.Touch 命名空间确实包含易于使用的 TouchPanel 类,但它根本不起作用。它什么也没做。 

谢天谢地,Shawn 确实指出了两种使其工作的方法。为了让触摸功能在 Windows 8 上工作,我们实际上转向了 Windows 7 的触摸实现。 

我采用了 Shawn 的第一个建议,即使用 .NET Interop Library。在 .zip 文件中隐藏着关键的程序集 Windows7.Multitouch.dll。一旦我们在项目中添加了对该库的引用,在 XNA 中使用它就需要一个轻微的旁路,即我们必须为触摸处理类提供应用程序窗口的 IntPtr。这听起来很棘手,但实际上我们只是将我们主 Game 实例的以下内容传递给它

input = new InputModule(this.Window.Handle);  

在接收端,这是我的 InputModule 的构造函数,我用它来处理各种输入形式的类。

Windows7.Multitouch;
using Windows7.Multitouch.Win32Helper;
 
public InputModule(IntPtr windowHandle)
{
    touchHandler = Factory.CreateHandler<TouchHandler>(windowHandle);
    touchHandler.TouchUp += (s, e) =>
        {
            lastTouchPoint = e.Location;
            hasUnprocessedTouch = true;
        };
} 

虽然 IntPtr 类型对某些人来说可能很吓人,但我们让内置类处理细节。我们只需要处理简单友好的 TouchUp 事件(或根据您的需求选择其他事件)。您可能可以从上面看出,这在技术上不是多点触控实现。我们简化的用户界面实际上并不需要完全的多点触控,因为目前只能处理单按钮点击,但将来更新中进行拇指控制武器时,我可能会重新设计它。 

编写头部追踪代码  

Celerity 中的头部追踪 

在上面的 Johnny Lee 的视频中,他能够通过红外 (IR) LED 和红外摄像头检测用户在 3D 空间中的位置。 

这种效果很棒,但需要特殊的红外传感器,并且用户需要佩戴发光设备。我需要仅使用 Intel® Ultrabook™ 上的传感器来创建这种效果,而无需其他设备。幸运的是,Ultrabook™ 集成了网络摄像头。 

该应用程序轮询网络摄像头以获取帧,并将它们传递给计算机视觉 (CV) 图像处理库 EMGUCV。这会返回一个矩形,表示在摄像机视野边界框内检测到的面部。当用户移动面部时,矩形会相对于边界移动,从而为我提供用户面部的相对 X/Y 偏移量。3D 世界视图的 X 和 Y 可以相对于用户自身的物理位置进行偏移。请注意,我们需要水平翻转图像,因为网络摄像头看到的与我们相反。 

这种效果可以进一步扩展,因为代表用户面部的矩形本身就具有大小。当用户靠近网络摄像头时,该矩形会变大,因此我们也可以确定相对 Z 位置。 

如果用户一开始头部大致居中且不太近,那会很有帮助,我们在开场菜单设计中鼓励这一点。在那里,用户可以在开始之前看到他们是否大致“校准”了。 

使用 EMGUCV 

尽管该库的基本使用相对简单,但程序的这一方面并非没有挑战。最初,XNA 中 EMGUCV 的性能非常糟糕,因为 XNA 采用了基本单线程方法。 

为了避免过于复杂,我选择限制轮询速率,并像这样放置对我的 QueryCamera() 方法的调用: 

Parallel.Invoke(() => QueryCamera(elapsedMilliseconds)); 

这对我的桌面开发机器来说效果非常好,但对 Ultrabook™ 效果不佳。原因似乎是原型 Ultrabook™ 缺乏驱动程序,因为使用带有驱动程序而不是 Ultrabook™ 自带设备的第三方网络摄像头效果很好。 

使用第三方网络摄像头对于竞赛来说是不切实际的,而头部追踪是我们参赛作品的主要卖点,所以我改用了变通方法。我提供了 3 种操作“模式”,实际上是相机的 3 种轮询速度:关闭、慢速和快速。Ultrabook™ 只能处理每秒大约 8 次的帧请求,而台式机的“快速”模式运行速度约为每秒 30 次请求,非常流畅。 

这是 CVModule 中最感兴趣的方法。不要被最后的公式吓到,它们只是将绝对矩形位置转换为相对位置。所有棘手的面部检测本身都由库在 DetectHaarCascade() 调用中处理。 

void DetectFaces()
{
    if (minFaceSize == null || minFaceSize.IsEmpty)
    {
        minFaceSize = new DR.Size(grayframe.Width / 8, grayframe.Height / 8);
    }
 
    // There's only one channel (greyscale), hence the zero index
    var faces = grayframe.DetectHaarCascade(
                    haar,
                    scaleFactor,
                    minNeighbours,
                    HAAR_DETECTION_TYPE.DO_ROUGH_SEARCH,
                    minFaceSize
                    )[0];
 
    IsFaceDetected = faces.Any();
 
    if (IsFaceDetected)
    {
        foreach (var face in faces)
        {
            if (isFirstFaceCapture)
            {
                // If first time then set entire history to current frame
                isFirstFaceCapture = false;
                currentEMA.Width = face.rect.Width;
                currentEMA.Height = face.rect.Height;
                previousEMA.Width = face.rect.Width;
                previousEMA.Height = face.rect.Height;
            }
 
            lastX = face.rect.X;
            lastY = face.rect.Y;
            lastWidth = face.rect.Width;
            lastHeight = face.rect.Height;
 
            // New smoothing stuff
            currentEMA.Width = (int)(alphaEMA * lastWidth + inverseAlphaEMA * previousEMA.Width);
            currentEMA.Height = (int)(alphaEMA * lastHeight + inverseAlphaEMA * previousEMA.Height);
            previousEMA.Width = currentEMA.Width;
            previousEMA.Height = currentEMA.Height;
        }
    }
 
    // Draw an ellipse round the face
    DR.PointF ellipseCenterPoint = new DR.PointF(lastX + lastWidth / 2.0f, lastY + lastHeight / 2.0f);
    DR.SizeF ellipseSize = new DR.SizeF(currentEMA.Width, currentEMA.Height);
    FaceEllipse = new Ellipse(ellipseCenterPoint, ellipseSize, 0);
 
    // Public stats
    FaceCentrePercentX = 1 - ellipseCenterPoint.X / (float)grayframe.Width;
    FaceCentrePercentY = ellipseCenterPoint.Y / (float)grayframe.Width;
    FaceSizePercentWidth = ellipseSize.Width / (float)grayframe.Width;
    FaceSizePercentHeight = ellipseSize.Height / (float)grayframe.Height;
 
    // Head Pos for feeding into world camera (range of -1f to 1f)
    HeadPos.X = -1f + (2f * FaceCentrePercentX);
    HeadPos.Y = -1f + (2f * FaceCentrePercentY);
    HeadPos.Z = -1f + (2f * FaceSizePercentHeight);
} 

创建安装程序  

这一步几乎导致我们的提交被拒绝。对于不熟悉创建安装程序的人来说,一个繁琐而神秘的脚本世界在等待着。在这个阶段出现任何小错误,您的目标商店都会拒绝您的提交。  

如果您有时间和耐心,我建议那些希望发布 XNA 游戏的人研究 WiX,但我只有几个小时的时间来制作安装程序,所以我选择了一个昂贵但简单的解决方案:Advanced Installer 专业版。它对 XNA 开发者的优点是,它开箱即用地理解 XNA GS 4 的依赖关系,因此支持 XNA 就像勾选一个框一样简单。 

这是 Advanced Installer 中对我有效的简单设置的视觉指南

产品详细信息 

此表单非常简单,但不要忘记在此屏幕上为您的安装程序设置一个图标: 

Product Details 

安装参数 

我相信 Intel 已经改变了他们对静默安装的要求政策,但请使用这些设置以防万一:  

Install Parameters 

数字签名

Advanced Installer 的一个好处是,您可以直接将您的证书交给它,并让它为您签名安装程序。我发现这比通过命令行使用 SignTool.exe 更简单快捷。 

先决条件 

这是让我觉得 Advanced Installer 物有所值的一个屏幕。在这里,您告诉它应用程序依赖于用户是否已安装 XNA GS 4,因此它会自动为他们安装。 

Pre-Requisites 

启动条件

先决条件的另一面是防止应用程序在用户拥有不兼容的操作系统时安装。 

Launch Conditions 

文件和文件夹 

在这里,您可以添加要安装到目标计算机上的文件和文件夹。这通常只是您的 Release 文件夹的转储。此外,您还可以添加一个应用程序快捷方式放在用户的桌面上。 

Files & Folders 

通过 Intel AppUp 发布 

由于完整的 XNA 仅支持桌面,不支持 WinRT,因此 Windows 应用商店不是一个选项。Intel 的 AppUp 商店提供了一个很好的桌面替代方案。AppUp SDK 提供了许可证密钥、升级机制等您期望的所有功能。 

为了尽量减少 Celerity 在截止日期前被拒绝的可能性,我选择保持简单,没有实现 SDK。虽然 SDK 可能非常有用,但 Celerity 中的任何内容都不依赖于它。 

虽然我不建议使用 SDK,但这里有一些使提交过程简单化并提高接受几率的技巧 

测试,测试,再测试! 

您需要在目标操作系统(完全全新安装)上测试您的安装程序。很容易忘记告诉安装程序应用程序需要 .NET Framework 或类似的东西。这些容易犯的错误也很容易发现,所以不要懒惰,在全新安装上测试安装程序。如果您有相关软件,可以在虚拟机中进行测试。在备用 PC 上测试。在朋友的 PC 上测试。 wherever you can. 

此外,对于硬件依赖代码,请确保在硬件上进行测试。这是显而易见的,但很容易被跳过。“当然我的游戏手柄代码能工作!看看它,它太简单了!”。我们曾经也这样想。快速测试表明,DPad 的左右方向是反的。糟糕。代码在有或没有那个小 *-1 的情况下看起来都一样,所以用实际硬件进行测试。 

原则不仅仅是让您的程序尽可能好。用户可能容忍的小安装程序缺陷可能意味着商店的拒绝。  

关键词  

在 AppUp 提交页面的应用程序描述和关键词部分,您可以有机会在商店的搜索结果中展示您的应用程序。准确且简洁地描述您的应用程序,但也要考虑您的用户将在商店中如何找到您的应用程序。 

拼写和语法 

每次您输入用户将看到的内容时,您的形象都会受到影响。仔细检查,并请他人检查。这适用于您应用程序的商店描述和您应用程序中的任何文本。这不仅会惹恼用户并让他们对您评价降低,而且许多错误可能导致商店拒绝。 

引人注目的截图 

您的应用程序的视觉效果可能有限,但请确保截图能让用户了解应用程序的作用和方式。他们很可能会查看截图,然后根据截图决定是否愿意花费时间或金钱下载您的应用程序。如果截图上几乎没有内容,他们就没有理由去冒险。 

应用程序图标 

AppUp 会对您提供的图像应用半透明的图层,以使所有商店应用看起来一致。我直到为时已晚才意识到,如果您有一个大部分为白色的背景(就像我们的),效果会稍微丢失,并且与鲜艳多彩的图标相比,您的图标看起来会受到影响。别误会,我喜欢我们目前的图标,但只有事后看来,我才认识到利用稍后发生的视觉过滤的机会。值得考虑。 

定价 

请如实描述您的应用程序。您是否真的应该对商店中 5% 的顶级应用程序收费?您的应用程序是否提供与 Acid Music 同级别的功能和质量?如果这是您的第一个应用程序,请考虑免费提供。当您倾注心血开发一个应用程序时,这很难做到,但同时您更愿意选择什么。销量非常有限且收入很少,还是零收入但有很多人享受并谈论您的应用程序。您可以使用您的首次发布来试探一下想法,为了虚荣心,或者可能为了展示您的能力。免费提供人们享受的应用程序并非悲剧。 

可用性  

不言而喻,您支持的操作系统越多,提供的国家/地区越多,遇到您应用程序的人就越多。另一方面,我将重复一遍,您*必须*在您提供的每个操作系统上进行测试。  

性能要求 

我发现填写此提交部分非常困难。作为开发人员,我怎么知道要为我的应用程序分配多少 Windows Experience Index 分数?我所能提供的建议是:从高开始,让您的应用程序获得批准,然后通过未来的更新逐步扩展。当您被拒绝时,您就知道您的分数太低了。这对我们是有效的!不是最理想的,但开发人员能做什么呢? 

如果有人对这个问题有更好的建议,请在下方留言,我真的很感兴趣。  

反思、结论和结束语 

我相信这是我参加的第一个与代码相关的竞赛。这是一次疲惫、令人兴奋、令人心惊肉跳、充满挑战且出人意料的社交体验。竞赛页面上的帖子数量以及人们文章中的评论证明了社区为自己提供了大量的支持和建设性的批评。 

在截止日期前几天与一位同事的谈话中,他问我们是否会按时完成,我老实说不知道。我们取得了巨大的进展,并且功能完成和问题解决的速度非常快,我们正好处于边缘。我知道还有 6 个独立且不相关的主要问题尚未解决(第一次编写安装程序、第一次签名应用程序、让 XNA 触摸功能正常工作、未实现的碰撞检测、让传感器正常工作以及一个令人担忧的仅仅显示黑屏的倾向)。然而,在那狂热的阶段,一种奇怪的平静感笼罩着我,因为我知道,无论是否获胜,甚至是否及时被接受,我们都比我们想象的更进一步。 

我们为自己感到骄傲。 

我的最后一条建议是关于我们如何能在项目最后几个小时内解决那些最后的问题。在网上搜索解决方案,其中一些在上下文中被认为是不可行的。 它们是如何解决的? 

纯粹的、顽强的决心。 

在那几天里,我给自己上了一堂深刻的课,这在我遇到过的任何软件开发书籍中似乎都没有提到。我现在真诚地相信,一个有效程序员的一个特质,我自己也曾短暂体验过,并且现在能在其他人身上认出,那就是那种坚决不放弃一个问题的态度,它会带来一个奇怪的解决方案。 

我将以爱因斯坦的一句话结束:“不是我有多聪明,而是我花在问题上的时间更长。” 

Celerity Screenshot

Celerity Screenshot

© . All rights reserved.