PocketPC 的 Nerdkill 游戏






4.46/5 (27投票s)
一款使用 C# 和 .NET Compact Framework 开发的射击平台游戏。
引言
Nerdkill 是一款简单的 2D 解压游戏,玩家需要射击在屏幕上乱窜的小书呆子。我几年前首次为 BeOS 编写了这款游戏,去年我用 C# 为 .Net 和 DirectX Managed 重写了它,主要目的是为了学习 DirectX Managed API。
本文描述了将一个带有声音的简单 2D 游戏移植到 Pocket PC 的过程,可能是一个 100% 托管的应用程序,运行在 .Net Compact Framework 下,并探讨了设备和框架能力所施加的限制。
生成的完整功能游戏和完整源代码均根据 GPL 许可证提供。该游戏已在 Pocket PC 模拟器以及 Cassiopeia E-125(MIPS 140 MHz)上进行测试。
底层架构
Nerdkill C#(完整的桌面版本,可在 http://www.alfray.com/projects/Nerdkill/ 找到)的核心是一个可重用的游戏框架,它定义了一个简单的架构来承载游戏应用程序。该架构通过定义以下组件将游戏引擎与平台显示、输入、声音和资源管理分离开来:
public interface Engine.IEngineProcess;
public interface Engine.IEngine2D;
public interface Engine.IEngineSound;
public interface Engine.IEngineInput;
public interface Engine.IEngineResources;
IEngineProcess
接口用于实现游戏特定引擎。它处理由输入组件生成的事件,更新其内部状态,生成异步请求以播放声音,并最终通过位图传输精灵来刷新显示。所有其他接口构成辅助模块,为引擎提供服务。它们将引擎与资源处理的实际实现隔离开来。
在 Nerdkill C# 的 DirectX Managed 版本中,IEngine2D
实现为一个嵌入在 Windows.Form
中的窗口化 DirectX Surface,IEngineInput
使用 Windows.Form
生成的鼠标和键盘事件实现,IEngineSound
使用 DirectSound 实现,IEngineResources
通过访问嵌入在程序集中的数据文件实现。
适应 Pocket PC
Nerdkill 使用的架构非常灵活。要将游戏移植到 Pocket PC,只需重新实现 IEngine2D
和 IEngineSound
接口即可。
最初的目标是看看 Pocket PC 和 .Net Compact Framework 的组合是否足以完成手头的两项任务。理想情况下,代码应该是 100% 托管和 .Net 的。实际上,它都是托管的,但有些部分,例如声音,让我别无选择,只能通过 P/Invoke 访问 WinCE API。
移植工作的一部分,我最初并没有完全预料到,是使游戏适应 Pocket PC 有限的资源。显然,每个位图图稿都必须“缩小”以适应 240x320 的屏幕,并且声音被采样到 11 kHz/8 位/单声道 WAV 文件,以减少资源的大小。生成的程序集为 520 KB,而原始桌面程序集仅为 2.5 MB。
幸运的是,大部分游戏玩法都完全相同。但是,有一些不同之处。例如,在桌面游戏中,当鼠标接近屏幕边缘时,滚动会自动发生。由于手写笔没有 MouseOver 事件,我只是使用 Pocket PC 的导航键代替。还添加了一个菜单,可以快速暂停游戏或禁用声音,最重要的是,当应用程序失去焦点时,必须自动暂停游戏,否则游戏将在后台继续更新,使设备变得极其缓慢。
2D 渲染
由于最初的目标是 100% .Net 托管方法,以了解此框架的局限性,因此用于渲染 2D 位图的选项相当有限。2D 渲染的要求很简单:
- 2D 平台通过位图传输精灵工作。
- 精灵要么完全不透明,要么具有单一的透明色。
- 精灵直接从资源中存储的位图加载。几个小的精灵可以打包在一个位图中。
我不想使用像 GAPI 这样的专有库,尽管它会带来明显的性能提升。我最初倾向于重用经典的 Win32 方法,使用 BitBlt
等。然而,.Net 的位图操作功能非常有限,特别是对于 Compact Framework。尽管如此,它包含此处所需的一切。位图可以从资源加载。Compact Framework 不允许直接访问位图的位,除非使用 GetPixel
和 SetPixel
,这显然太慢而无法接受。另一方面,Bitmap
对象可用于为 GDI+ 创建 Graphics
上下文,允许 C# 代码直接在离屏位图中绘图。
C# 中无闪烁绘图一文解释了访问 .Net 中不可直接使用的 GDI+ 函数(例如 CreateCompatibleDC
、CreateCompatibleBitmap
、BitBlt
等)的常用 DllImport
技巧。更重要的是,它表明所需的大部分功能都存在于 .Net Compact Framework 中。诀窍在于,从资源加载的位图将不兼容(因此绘制速度非常慢),但可以通过创建新的空位图(自动兼容)、从中获取 Graphics
DC(因此是兼容 DC),然后使用 DrawImage
将独立位图绘制到兼容位图上来使其兼容。
请记住,必须通过调用 Dispose
方法来释放任何 Bitmap
和 Graphics
DC 对象。不这样做会削弱应用程序的可用资源。这在 Windows 桌面版本上可能不明显,但在 Pocket PC 上,当有限的资源耗尽时,这将变得显而易见。
此代码使用 .Net Compact Framework 从程序集资源加载位图并使其兼容
private Bitmap loadCompatibleBitmap(string filename)
{
System.Type st = this.GetType();
Assembly assembly = st.Assembly;
Stream stream = assembly.GetManifestResourceStream(
st.Namespace + "." + filename);
// Get the independent bitmap from resources
Bitmap bitmap = new Bitmap(stream);
stream.Close();
// Extract the transparency color from the upper
// left corner (a sensible common hack)
// Note: for the full .Net Framework, specify the current
// PixelFormat in the Bitmap constructor too.
Color bg_col = bitmap.GetPixel(0,0);
Bitmap compatible = new Bitmap(bitmap.Size.Width, bitmap.Size.Height);
Graphics g = Graphics.FromImage(compatible);
// Make sure the offscreen bitmap gets erased with the default
// background color. Here Black is chosen as transparency color.
g.Clear(Color.Black);
// Set the color key to what the image had...
ImageAttributes ia = new ImageAttributes();
ia.SetColorKey(bg_col, bg_col);
g.DrawImage(bitmap,
new Rectangle(0, 0, compatible.Size.Width,
compatible.Size.Height),
0, 0,
bitmap.Size.Width, bitmap.Size.Height,
GraphicsUnit.Pixel, ia);
g.Dispose();
bitmap.Dispose();
return compatible;
}
要处理透明图像,请创建 ImageAttribute
实例,使用 ImageAttribute.SetColorKey
设置透明颜色,并使用接受 ImageAttribute
的 Graphics.DrawImage
方法。在上面的示例代码中,兼容位图的透明颜色设置为黑色。实际颜色通常取决于您的图稿。
Nerdkill Pocket 的 2D 渲染实现处处遵循相同的原理
- 一些从资源加载的位图包含多个精灵。对于每个精灵,都会创建一个新的兼容位图,创建一个
Graphics
上下文,然后将精灵从独立位图绘制到兼容位图上。请注意,.Net Compact Framework 不提供可以从另一个位图提取部分的位图构造函数,因此这种解决方法是必要的。 - 所有渲染都在一个离屏缓冲区中完成。然后,该缓冲区直接在
OnPaint
中绘制到提供的Graphics
上下文中。这避免了闪烁。
请注意,在 OnPaint
中手动绘制兼容位图实际上非常快。一个仅执行此操作的示例代码在模拟器上达到了 200 fps,在我的测试机器 Cassiopeia E-125 上达到了 100 fps。
2D 渲染部分的完整源代码可在源代码存档中找到。它在 RGdiGraphics.cs 文件中实现。
声音
我找不到 100% .Net 托管的方式来播放游戏声音。相反,我找到了两种解决方案,都使用 P/Invoke 访问 WinCE API:
PlaySound
函数。- WaveOut API。
PlaySound
函数使用起来相当简单,但它一次只能播放一个声音。它可以异步播放。默认情况下,它会在开始播放下一个声音之前停止当前正在播放的声音。有一个标志可以避免这种情况,但结果是新声音将根本不会播放。它不会混合。声音也可以循环,并在请求下一个声音时停止。
在此游戏的上下文中,需要更好的声音 API。应该能够同时播放多个声音。有些声音需要自动循环。显然,使用 WaveOut 重新实现我自己的混音器是必要的。
WaveOut 有一个简单而高效的工作流程。首先需要构造和准备缓冲区。然后用数据填充它们并使用 waveOutWrite
输出。一旦缓冲区被使用,它就会返回到应用程序,应用程序可以再次填充并输出它。
该实现由以下类组成:
public class RSoundPlayer: Engine.IEngineSound;
public class RISoundReader: IDisposable;
public class RWavStreamReader: RISoundReader;
public class RWaveOut;
public class RMemAlloc;
RWaveOut
使用 DllImport
映射各种 WaveOut 方法和结构。RMemAlloc
对 LocalAlloc
和 LocalFree
执行相同操作,它们用于分配 WaveOut 缓冲区。
声音混音器不直接访问任何声音资源。它使用 RISoundReader
接口,该接口知道如何读取新的数据缓冲区。实际的读取器由 RWavStreamReader
实现,并从直接从程序集资源中提取的 Stream
构造。由于 Pocket PC 上的内存非常宝贵,因此将完整的程序集资源流读取到内存缓冲区中既不必要也不有用。可以直接从资源流访问数据。
声音数据应格式化为 WAV 文件,单声道,8 位,11.025 kHz。流读取器验证 WAV 文件头以确保这些属性。
使用 WaveOut API 非常简单:创建内存缓冲区并使用 waveOutPrepareHeader
进行“准备”;然后用数据填充它们并使用 waveOutWrite
设置播放。当 WaveOut 接口处理完每个缓冲区时,它会向 HWND 发送一条消息。窗口回调回收缓冲区。.Net Compact Framework 不允许访问 Windows.Form
的底层实现,也就是说它的 HWND 无法检索,其 WndProc 回调也无法使用。为了规避此限制,使用了 WinCE 的 .Net 特定类 Microsoft.WindowsCE.Forms.MessageWindow
:
private class RWaveOutMsgWindow: MessageWindow
{
public delegate void Callback(IntPtr waveHdrPtr);
public void SetBufferDoneCallback(Callback cb)
{
mBufferDoneCallback = cb;
}
protected override void WndProc(ref Message m)
{
if (m.Msg == RWaveOut.MM_WOM_DONE && mBufferDoneCallback != null)
mBufferDoneCallback(m.LParam);
base.WndProc(ref m);
}
private Callback mBufferDoneCallback = null;
}
public RSoundPlayer()
{
...
mWceMessageWindow = new RWaveOutMsgWindow();
mWceMessageWindow.SetBufferDoneCallback(
new RWaveOutMsgWindow.Callback(this.waveOutBufferDone));
...
}
声音混音器维护一个当前正在播放的声音列表。对于每个声音,一个结构保存当前的读取位置、总大小、停止标志和重复标志。声音混音器还维护一个可用 WaveOut 缓冲区的队列。
实际的混音器代码在具有以下工作流程的线程中运行:
- 一个事件用于向线程发出信号,表明新声音已准备好播放或以前播放的缓冲区现在可用。如果没有声音可播放也没有可用缓冲区可输出,线程只是阻塞在事件上。
public RSoundPlayer()
{
// create non-signaled (blocking) event
mMixerEvent = new AutoResetEvent(false);
// start the mixer thread
mMixerThread = new Thread(new ThreadStart(this.mixerLoop));
mMixerThread.Start();
}
private void addSound(RISoundReader reader)
{
lock(mPlayingSounds.SyncRoot)
{
RSoundData sd = new RSoundData(reader);
mPlayingSounds.Add(sd);
}
// signal the mixer another buffer can be processed
mMixerEvent.Set();
}
private void mixerLoop()
{
// wait on the event signal...
while (mMixerEvent != null && mMixerEvent.WaitOne())
...
}
- 线程循环获取第一个可用缓冲区,并合并来自每个声音的下一个传入样本。混合多个声音只是将它们添加到缓冲区的问题。每个样本都必须添加到已经存在的位置,并且必须进行剪裁,如下所示。
- 新缓冲区使用
waveOutWrite
设置为播放。
使用 unsafe C# 代码(unsafe 关键字允许 C# 代码操作指针并使用指针算术)混合缓冲区中的实际数据。这是 RSoundPlayer.cs 中 mixBuffer
方法的简化版本(完整版本可在源代码存档中找到):
int max_data = 0;
unsafe
{
int *header = (int *) waveHdr.ToPointer();
// get the address of the buffer and its size
uint *dest32 = (uint *)(header[0]);
int size = mWaveBufferSize / 4;
// initialize the buffer with 0x80 bytes, *not* zeroes!
// wave data is from -128 to +127 with a middle point offset at +128.
while(size-- > 0)
*(dest32++) = 0x80808080;
}
// accumulate all currently playing sounds in the wave out buffer
lock(mPlayingSounds.SyncRoot)
{
for(int i = mPlayingSounds.Count-1; i >= 0; i--)
{
RSoundData sd = mPlayingSounds[i] as RSoundData;
int read_size = sd.mSize - sd.mOffset;
if (read_size > kReadBufferSize)
read_size = kReadBufferSize;
// Read a block from the sound stream
sd.mReader.Read(mMixerBuffer, sd.mOffset, read_size);
sd.mOffset += read_size;
unsafe
{
unchecked
{
fixed(byte *source = mMixerBuffer)
{
int *header = (int *) waveHdr.ToPointer();
byte *dest = (byte *) (header[0]);
int rsn = read_size * mSampleSize;
if (rsn > max_data)
max_data = rsn;
byte *src = (byte *)source;
for (int c = read_size; c > 0; c--)
{
// Each byte is 0..255 but it really represents
// a sample which is -128..+127.
// So the real operation here is:
// dest = 128 + (dest-128) + (src-128);
// which is:
// dest += src-128;
// then 0..255 clipping must be done.
int a = (int)(*dest) + (int)(*(src)++) - 0x80;
if (a < 0)
a = 0;
else if (a > 0xFF)
a = 0xFF;
byte b = (byte)a;
for(int j = mSampleSize; j > 0; j--)
*(dest++) = b;
}
} // fixed
} // unchecked
} // unsafe
// end reached?
if (sd.mOffset >= sd.mSize)
{
if (sd.mRepeat)
sd.mOffset = 0;
else
// remove buffer from list
mPlayingSounds.RemoveAt(i);
}
} // for mPlayingSounds
} // lock mPlayingSounds
// Update the size really used in the buffer
unsafe
{
int *header = (int *) waveHdr.ToPointer();
// reset some members of a WaveHdr:
// set the dwBytesRecorded field to the number of actual bytes
header[2] = max_data; // public uint ;
// set the dwFlags field... only clear WHDR_DONE here
header[4] = header[4] & (~RWaveOut.WHDR_DONE);
}
混音器仅针对 8 位输出,但它可以输出到 11.025、22.050 或 44 kHz 流,单声道或立体声。这是通过将每个输入字节扩展为必要数量的字节以适应一个输出样本来完成的(不执行音频过滤)。这样,混音器代码可以保持非常简单,但具有合理的适应性。
在代码中,请注意 C# 特有的关键字 fixed 的用法。这允许代码检索托管缓冲区上的指针。这样做时,.Net 会“固定”指向的对象,以便垃圾回收器不会移动它。指针操作可以像 C/C++ 中那样使用熟悉的“*(dest++)”语法进行。
一个重要的注意事项是,由于混音器使用 unsafe 代码,因此必须以这种方式编译程序集。在 Visual Studio 中,通过将项目的“配置属性”>“生成”>“允许不安全代码块”设置为 True 来完成此操作。这也意味着运行此类程序集需要特殊权限。默认情况下,从桌面或掌上设备运行的应用程序具有此类权限。如果它作为智能客户端或在非受信任沙箱中运行,则情况并非如此。
在前面两个代码块和整个 RSoundPlayer.cs 源代码中,您还可以注意到 C# 关键字 lock。这个原语锁定托管对象以进行独占访问(互斥体)。这里用于同步对 mPlayingSound
ArrayList
的访问,该列表保存当前正在播放的声音列表。混音器线程从该列表读取,而应用程序线程向其中追加新的声音请求。
结论
.Net Compact Framework 是 Pocket PC 平台上游戏的合适选择。
主要优点当然是 CLR:编译一次,随处运行。一旦 .Net Compact Runtime 安装在设备上,无论底层架构是什么,程序集都可以部署。
然而,也存在缺点。在 Windows.Forms 中位图传输全屏图形,并仅使用 GDI+ 的托管接口执行离屏渲染并不快。它几乎无法使用。这里展示的游戏没有尝试将渲染优化到极致,我相信这会以简单且某种程度上通用的框架为代价。目前,当前引擎的最大帧率为 10 fps。它可以在模拟器上达到这个帧率,但我从未在 Cassiopeia E-125 上看到它超过 4 或 5 fps。通过禁用部分核心渲染循环,很容易注意到,需要在离屏位图中位图传输的小精灵越多,游戏就越慢。这些图形更新可能可以通过分析当前游戏的需求并将屏幕更新减少到最小来稍微加快。
.Net Compact Framework 的另一个明显限制是完全没有声音支持。这并不奇怪,因为 .Net 的重点显然是构建依赖于 Windows.Form
的应用程序。尽管如此,WinCE 的 PlaySound
甚至没有成为默认可用的托管类,这还是有点令人失望。由于这无法满足游戏的需求,因此在这里并不是一个主要问题。
部署是另一个问题。Visual Studio 7.1 具有生成 CAB 文件的能力。它生成 5 个,用于不同的架构。这最初看起来令人惊讶,因为使用 .Net 的主要目的之一是拥有一个单一的平台无关的可执行文件。现实情况是,CLR 需要安装 .Net Compact Framework,生成的 CAB 文件包含一个小的本地 DLL,它会检查是否是这种情况。因此,实际上,除非假设框架已安装,否则每个目标架构都需要一个 CAB。幸运的是,将我们自己限制在 ARM(适用于所有最近的 Pocket PC)、MIPS(适用于较旧的 Pocket PC)和可能 x86(适用于模拟器)似乎是合理的。
这里介绍的底层框架可以重用于其他应用程序。它在 GPL 许可下免费提供。我尽可能地将游戏逻辑与框架分离,希望它易于重用。如前所述,当前游戏的每秒帧数相当低。这是由于为了本文的目的,代码被编写得尽可能通用和模块化。在实际生活中,您可能希望引入一个优化阶段——分析速度瓶颈并重写部分核心/游戏循环,或者可能使用一些不安全代码来执行内部位图传输,例如。这留作读者的练习 :-)
最后,我要感谢 Mathias 允许我使用他的 Cassiopeia E-125 进行测试。由于它“仅”由 140 Mhz MIPS 处理器驱动,它绝不是目前最快的 Pocket PC,这使得它非常适合亲身体验性能问题。我还没有机会在最近的 ARM 驱动的 Pocket PC 上运行游戏。
历史
- 2004/05/27:Nerdkill Pocket 1.0 版本和原始文章。
- 2004/06/27:文章更新(错别字等)。演示项目按架构拆分下载。
此页面也镜像到 Nerdkill 主页:http://www.alfray.com/projects/Nerdkill/。