WTL 项目的心理学:小写原因(一个打字程序)






4.99/5 (27投票s)
2005年4月20日
24分钟阅读

66400

2692
一次从头到尾精心打造的项目的心路历程。
引言
这是 WTL 常用者的小型快速项目之一。这里没有什么开创性的东西,我从一开始就知道它会更偏向 Windows 应用程序编程风格,而不是深入 DirectX 或其他更合适的东西,以保持简单并专注于 WTL。我总是觉得了解别人如何开发一个项目很有趣。所以,这里是关于一个提高您 C++ 打字速度的程序的设计和实现过程的记述。您可以用这个来向您的孩子展示开发程序的所有错误方法。然而,这里可能有一些价值。谁知道呢?哦,真是个谜!
背景
我当时在墨西哥城一个相当贫民区的人行道上,感觉有点倒霉。是的,你知道那种感觉。在脑海里重演事情。我能做些什么才能让年薪涨到15万美元?哎,我只挣6.5万美元。然后,当一只流浪的棕色杜宾犬经过一面涂鸦斑驳的墙壁时,启示开始了。那是因为我打字不够快。
是的,这说得通,每当我的老板过来看到我以相当快的速度敲出一些语法时,他都将其视为我生产力的一种衡量。而那些老派的家伙能以秘书的速度打出那些熟记的快速排序程序。他心里想:“是的,这孩子还不错,但何塞,他才是我的得力助手。他是《纳瓦隆的枪》。这家伙的生产力肯定比这边的马克高出十六倍。我应该给何塞加薪,表达我对他的赏识。”是的,我能从他那阴沉的小脸上读懂这一切,他冷漠地向我说了声“干得好”,然后在我还没来得及抬头时就离开了。
我不是来纠正人们的误解,也不是来教育高层管理的。那太费劲了。我只要学会打字更快,就能很快看到加薪。所以现在我动力十足。然后我开始研究打字程序。那些东西对于学习如何打英文很好,但我的指针到成员词汇量(->)没有得到多少练习。
所以作为一名工程师,我秉持着“嗯,我只管开发一个更好的打字程序”的心态。但这需要创造力、纪律、动力以及一些第四样东西。那么,当我需要灵感时通常会怎么做呢?我不知道,通常不太需要,但我看到电影里的人会长时间散步或骑摩托车欣赏风景。但我认为如果我能吃到一些墨西哥卷饼会有帮助。一罐欧洽塔和8个烤肉卷饼下肚后,我有了足够的想法,足以让微软措手不及。
第一步 - 设计
这几乎总是在我的床上听着音乐完成的。我做的第一件事是,整理所有的想法,让整个问题更具体。我通过为问题构思一句话来做到这一点。我想到了“帮助程序员在编程时更快地打字”。然后从这句话中,我以经典需求的形式构思出支持这一点的功能。
要求
- 允许人们测量他们的打字速度并记录。
- 不要无聊。
- 能够短时间使用。(程序员很忙。)
- 使用自定义词汇列表。
然后从这里我开始画出程序的主窗口以及它会是什么样子。除非你正在设计下一个 Apache,否则这是最重要的部分。其理念是界面是用户与你的软件交互的方式,它可能非常智能和出色,但正是界面所展现的个性才能赢得用户。这并不意味着你必须让它看起来很炫,带有皮肤,那只是声明的一部分。这个世界上有很多漂亮的人,却没有多少朋友。就像WinAmp3。
所以,我画着窗口,划掉一些东西,为不同的功能添加控件。这有助于可视化最终产品。不要害怕划掉东西。纸张在让你快速设计对话框方面非常有用。你可以划掉东西,使用箭头,列出项目,而无需调用 `InsertItem`。只需尽量传达信息。在我画完大部分窗口后,我开始尝试将数据分组,然后为主类想出好名字。我最不能忍受的是在文件和类使用后不得不回去重命名它们。想出好的类名很难但很重要。你不想和 `CSimpleAbstractGameTypingInformationDlgEx` 这样的东西打交道,所以我尽量把名字至少取对。
所以在大约40分钟后,我有了想要的窗口,现在我对最终产品有了一个相当清晰的构想。此时,你可以将其提升到下一个层次,开始编写功能规范、计划,然后是 UML 图。这是一个小项目,所以我要冒险,在没有适当规划的情况下直接进入原型开发。
但在那之前,我喜欢给程序一个好名字。一个身份。幸运的是,我准备了一大串程序名称,这些名称我一直保存着,以便在新项目开始时使用,这样就不会阻碍新项目。这些名字:Code Cookies、Turtle Death Squad 和 Fish Heat 都很强劲。但我最终选择了 Lowercase Cause(小写原因)。这是一个充满活力,富有声明的名字。在利润丰厚的打字辅导软件行业中,它是一个真正的竞争者。
我设计的程序是这样的:弹出带有文本的窗口,你开始输入它们的文本,完成后,按下回车键,它们就会消失。太棒了!我不需要做任何图形界面,我让窗口就是窗口。甚至会有一个模式,瞧,生存模式。如果打字不够快,窗口就会消失!哦,伙计,太刺激了。
第二步 - 实现
第 1 天
这是我开始设计对话框的地方。我就是这样开始的。主要布局我要使用的窗口。我不去费心对齐或者把流程弄对,我只是想看看有什么东西。此时调整制表位根本不在考虑范围之内。这只是帮助我构想我要创建什么。理解问题。
一旦完成,并且我能想象出它将如何工作,我便开始将信息转换为类。我将这个类命名为 `game_session`,并意识到它需要记录打字错误和正确的字符数来获取统计数据。我需要每分钟字符数和每分钟准确字符数。我只添加了足够使其工作的功能,我知道将来我还会需要更多功能和统计数据,但我为了 upfront 简单性而省略了它们。我希望尽可能快地启动并运行一些东西。`game_session` 完成了。非常基础。
class game_session { public: game_sessiong() { reset(); }; void init(HWND hwnd); void reset(); // initalize everything to 0 void increment_valid_characters(); void increment_invalid_characters(); void increment_closed_windows(); void set_timer(); void start(); // start timer void end(); bool is_over(); // whether or not the game is over BEGIN_MSG_MAP(game_session) MESSAGE_HANDLER(WM_TIMER, OnTimer) END_MSG_MAP() LRESULT OnTimer(UINT /*uMsg*/, WPARAM /*wParam*/, LPARAM /*lParam*/, BOOL& /*bHandled*/); private: HWND _hwnd; // requires dialog associated with it // for timer processing int _total_characters; int _correct_characters; int _closed_windows; CTime _start_time; CTime _end_time; long _timer; // used for timed games, 0 if not timed };
现在我很快为 `CInputDlg`、`CMainDlg` 和 `CMenuDlg` 搭建了 WTL `CDialogImpl` 骨架。我通过使用 VC++ 添加类来完成,然后删除构造函数和析构函数。然后,要让属性窗口开始填充事件,您只需要以下内容
class dialog1 : public<CDialogImpl, dialog1>
{
public:
enum { idd = IDD_DIALOG1 };
DECLARE_MSG_MAP(dialog1)
END_MSG_MAP()
}
实际上,你甚至不需要 `DECLARE_MSG_MAP`,如果你没有,当添加事件时,VC++ 会自动添加。但是,如果编译时缺少它,你会得到一个“无法初始化抽象类”的错误。因此,出于预防习惯,我启动了消息映射。之后,我很快让它们能够相互打开。
现在什么都不能用,我只有一个没有被使用的瘦弱的类定义和三个对话框类,它们除了互相显示之外什么也做不了。这是一个完美的设置,这就是我想要的。这是一个很好的基础。我不喜欢在没有测试的情况下长时间编写代码。
所以瞧,这就是目前的样子,也是我脑海中的样子。我对最终产品会有怎样的外观有很多很棒的想法,但我不喜欢在开始时就全部呈现出来。我发现最好先做出一些东西,然后随着时间的推移慢慢塑造它,而不是一开始就试图用完整的最终 UI 设计来考验我的大脑。我以前尝试过这样做,但总是出错,而且因为所有的 UML,我一开始对设计和所有东西都非常确定,导致它变得不灵活。我的新哲学,我取得了更好的结果,是尽可能简单地开始,然后根据需要进行修改。有点像 Kent Beck 的风格。
CMenuDlg
:这是用户选择新游戏的地方。他们可以调整难度。有一个生存模式,我还考虑了一个计时模式,但我没有把它放在这里,因为我花了太多时间试图让它看起来可爱。列表是用于要求 4 的短语列表:使用自定义词汇列表。除了“新游戏”按钮(它加载 CMainDlg
)之外,这里什么都不能用。
CMainDlg
:右侧是一个文本框,用户在此输入要测量的文本。左侧的列表将显示 CPM(每分钟字符数)等统计数据。
CInputDlg
:这些将是弹出的短语,等待被输入。有一个生存模式计时器,还有一个生存模式的百分比条。
这时我开始想象信息是如何流动的。`CInputDlg` 会处理发送给它的文本吗?`game_session` 应该是一个单例还是被传递?诸如此类的问题。此时,我为每个对话框添加了 `OnClose` 和 `OnInitDialog` 事件,`OnInitDialog` 附加任何成员控件,而 `OnClose` 则关闭自身或程序。
`CMainDlg` 是应用程序的框架,但它创建时是不可见的,并立即显示 `CMenuDlg`。`CMenuDlg` 会被传递一个 `game_session` 对象,它将在新游戏时填充。如果 `CMenuDlg` 关闭,应用程序就会终止。`CInputDlg`s 是从 `CMainDlg` 创建的。
我意识到在 `CMainDlg` 中处理所有事情并将其用作接收来自其他 `CInputDlg` 的所有文本的对话框会简单得多。我编写了 `SpyCharMessage`,它将在 `PreTranslateMessage` 中为每条消息调用,这将修改消息,以便我们的主编辑控件接收文本,即使它们尝试从其中一个客户端对话框获取焦点。完美,所有事情都将在 `CMainDlg` 中处理,这很好,因为否则用户将如何控制哪个窗口接收文本呢?通过点击或 Alt + Tab?那不好。这样他们可以输入文本,`CMainDlg` 可以比较他们的文本,看看是否与任何现有窗口匹配。所以我们有
if(pMsg->message == WM_CHAR) { HWND edit_hwnd = GetDlgItem(ED_TEXT); std::string edit_text = hf::get_window_text(edit_hwnd); // we want all our WM_CHARs directed at CMainDlg's edit box if(pMsg->hwnd != edit_hwnd) { ::SetFocus(edit_hwnd); pMsg->hwnd = edit_hwnd; } }
如果失去焦点,我们会在 `WM_CHAR` 时重新获得焦点,并且未发送的消息无论如何都会被重定向。现在,我接下来要做的是启动一些基本的 `CInputDlg`,并处理 `WM_CHAR` 以将其发送到正确的 `CInputDlg`。所以我修改了 `CInputDlg` 以通过构造函数接受一个字符串(短语),并添加了一个 `set_text` 函数来定义当前已输入的文本。我在 `CMainDlg` 中编写了一个函数来启动 `CInputDlg` 并将其引用保存在列表中。接下来的一个小时都在困惑中度过。我的意思是,还有什么能比这更简单呢?有一个 `WM_CHAR` 处理程序,可以遍历显示的窗口,并在输入的文本匹配一点点时设置文本。例如,如果有一个窗口显示“abc”,而你输入“a”,那么它就匹配,因为 'a' 是“abc”的开头。所以我有一个循环,试图在短语字符串的索引 0(开头)处查找输入的文本,如果它在任何窗口中都找不到,它就会截断输入的文本的最后一个字母,然后再次尝试。这是为了处理短语拼写不正确的情况,这样如果你在短语是“abc”时输入“ad”,“ad”仍然会命中,因为 'a' 是好的,它假定你把 'b' 错打成了 'd'(如果你瞄准的是“abc”,那么你确实打错了)。
std::string typed_text = hf::get_window_text(_edTyped); bool match_found = false; for(int compare_index = typed_text.size(); !match_found && compare_index >= 0; compare_index--) { input_dlg_list_t::iterator i; for(i = _input_dlgs.begin(); i != _input_dlgs.end(); i++) { CInputDlg* current_dlg = *i; std::string search_text = typed_text.substr(0, compare_index); if(current_dlg->get_text().find(search_text) == 0) { match_found = true; current_dlg->set_text(typed_text); } } }
所以一开始我在 `SpyCharMessage` 中有这个循环。循环是正常的,但是我没有得到最后输入的字符。消息会命中,但 `get_window_text` 不会拾取当前文本,它总是缺少最后一个字母。所以我当时想,哦,好吧,`PreTranslateMessage` 对吧,消息还没有被处理,所以它还没有输入到文本框中,因此我还不能用 `get_window_text` 获取它。好吧,我调用 `DefWindowProc`,不行。好吧,我把它移到文本框的 `WM_CHAR` 处理程序中。
为了做到这一点,您必须从 `CEdit` 派生,然后子类化或使用 `CContainedWindowT` 并子类化,这更方便,因为它会将消息重新路由到其父级的 `ALT_MSG_MAP(X)`,您可以通过 `CContainedWindowT` 的构造函数指定 `X`。因此,定义 `CContainedWindowT
所以现在它运行良好,你可以输入字符,看着它们一个接一个地出现在 `CInputDlg` 下面,即使它们被发送到 `CMainDlg`。每次你输入一个字母,它都会检查它是否不正确或正确。它通过检查你当前的文本是否是其中一个窗口的完美子字符串来确定这一点。我确保忽略退格键和箭头键等键,因为如果不安检,它们会增加正确字母的数量。它们可能有一个正确的子字符串,然后开始输入退格键,然后一个字母,然后退格键,然后一个字母,并且由于始终有一个正确的子字符串,因此一直生成 `WM_CHAR`,即使你根本没有进展。当你按下 Enter 键时,它会将文本与短语文本进行比较,如果它们匹配,它会通过 `DestroyInputDlg` 关闭该窗口。
现在的问题是所有对话框都显示在同一个角落,我必须手动拖动它们才能看到并输入它们。我编写了 `CMainDlg::CalculateInputDlgStartUpRect`,这需要 `CInputDlg::GetSize` 和一个 `hf::are_intersected` 函数。我从网上获取了 `are_intersected` 函数,并编写了 `GetSize`,它只是用 `DT_CALCRECT` 调用 `DrawText` 来获取传入字符串的大小。`CalculateInputDlgStartUpRect` 通过遍历现有的 `CInputDlg` 获取一个随机位置,努力确保没有人会重叠,并且它没有超出边缘。现在 `CInputDlg` 只需要正确调整其控件的大小。我将其从 `CResizeDialog` 派生,并添加调整大小映射以允许此操作。
第 2 天
输入对话框使用了一个丑陋的比例字体,所以我立刻着手修复它。我讨厌在每个对话框中创建 `HFONT`,所以我只在名为 `settings` 的单例中创建一次。接下来我开始处理程序的统计部分。游戏会计时,所以我需要做一些巧妙的处理。我希望 `game_session` 处理这个逻辑,所以它将有自己的计时器来检查计时游戏是否达到了时间限制,或者它需要跟踪的其他一百万件事。(注意:最后,计时器并没有真正做任何事情,只是检查它是否已“死亡”。教训是,不要为了不需要的灵活性而使设计复杂化。这并非全无益处,因为它有助于解耦并使 `game_session` 更独立。)我有两个选择,我可以用 `TimerProc` 调用 `SetTimer`,这需要我把所有东西都设为静态。这听起来并不是一个太糟糕的主意,因为实际上你一次只会进行一个会话。或者我可以在 `HWnd` 上使用 ID 调用 `SetTimer`。为了做到这一点,我需要跟踪一个 `HWnd` 并让 `CMainDlg CHAIN_MSG_MAP_MEMBER` 到 `_current_session`。我选择了后者,因为它似乎更自然地将 `_current_session` 作为一个非静态类,这样你就可以将其传递给 `CMenuDlg`。现在计时器已准备就绪,我们可以开始计算 CPM。
现在我编写 `InitStatistics`,它只是向列表视图添加项目并进行设置。这些项目是我的“列”,我将它们的子项设置为与该项目相关的信息。
现在编写 `get_cpm` 和 `get_awpm`,然后 `UpdateDisplay` 就很简单了。现在它看起来像一个真正的打字程序,有 CPM、AWPM 和准确度。然而,这些数字非常不稳定,每次更新都有很大的变化。我也不知道一个好的 CPM 是多少。一个能让我加薪的 CPM。
到了吃早饭的时间,我吃了些木瓜和一个玉米粉蒸肉,边吃边读关于 WPM(每分钟字数)的文章。然后我发现 WPM 是指你每分钟能打多少个5个字符的单词。哎呀,我以前不知道。我以为他们会 somehow 计算他们正在打的单词,然后除以打字时间,那就是 WPM。而且这都与你正在打字的内容有关。这就是为什么我一直在计算每分钟字符数,我怎么能把像 `_lvwStats.SetItemText(COL_CPM, 1, hf::int_to_str(_current_session.get_cpm()).c_str());` 这样的东西切割成单词,而不需要专门写一篇关于这个的文章呢?
有了这个新知识,我将所有与 CPM 和 ACPM 相关的东西替换为 WPM 和 AWPM。
接下来是等待直到第一个键被按下才计算 WPM。这通过不在 `OnChar` 被调用之前开始游戏来完成,如果游戏未开始,`OnChar` 会启动它。启动时,`_start_time` 被初始化为当前时间,这用于计算 WPM。看着对话框以 0 WPM 出现并开始计时秒数,总觉得有些不安。你感觉被骗了。就像你错过了发令枪。
接下来,我开始处理 `CInputDlg` 的整个生命周期概念,这涉及到新的 `_life` 成员和一个修改过的构造函数,该构造函数接受生命值。`CInputDlg` 本身有一个计时器,只进行倒计时。然而,它不会自行关闭,因为 `CMainDlg` 必须检查它是否“已死”并销毁它,以及非常重要地将其从内部列表中移除,这样它就不会在打字时被检查的窗口之一。
然后我添加了一些简单的东西,比如如果一个窗口关闭了就弹出新的窗口,以及将主窗口移到底部中央。然后我花了一个晚上来大修对话框,因为一切都已就绪,画图标,吃墨西哥卷饼。
第 3 天
我意识到 `CInputDlg` 相当弱,因为如果你打字很快,你没有时间去看右边的计时器,也没有时间去寻找每个对话框中时间最短的。所以我开始让它们淡入另一种颜色。此外,我也不喜欢当你打错字时,你总是无法分辨。一旦文本不再匹配,它就应该变成另一种颜色。
我开始编写自己的自定义控件,以便在文本出错时将其着色为不同的颜色。我乐于编写自己的自定义控件,尤其是这些简单的控件。它只是一个 `Rectangle` GDI 调用,然后是 `DrawText`,但我可以使用 RichEdit,或者甚至可能使用带有 `WM_CTLCOLORSTATIC` 消息的两个静态控件。在那之后,并尝试了淡入色,我决定移除带有倒计时的静态控件,而是将其作为标题栏文本。
现在循环和 `CInputDlg` 都相当稳定了,我需要加载列表。一直以来,文本都是硬编码的相同文本,但很容易添加一个函数来获取该文本。所以创建了 `phrase_list`,它将文本文件解析成短语并将其保存在列表中。`phrase_list` 有一个非常重要的 `get_random_phrase` 函数,这让一切变得简单。现在 `CreateInputDlg` 将选择一个随机短语,并确保它尚未被使用。然后,由于 `phrase_list` 的文件路径是硬编码的,我添加了 `CMenuDlg` 从当前目录填充短语文件列表的能力,并修改了 `CMenuDlg` 以接受 `phrase_list` 作为参数。`CMainDlg` 现在从用户选择的 `CMenuDlg` 获取其 `phrase_list`。
我现在开始着手处理 `high_score` 和与高分相关的对话框。我把这个留到最后,因为它不是最重要的部分,也因为我从来不喜欢解析。但这相当直接,就是管道分隔的文本。每个游戏主题只允许一个高分。当你完成一个游戏时,它会创建一个 `high_score` 对象,然后使用 `is_high_score` 将其与 `high_score_manager` 进行比较。如果为真,它就简单地添加并保存。`CHighScoresDlg` 为每个短语文件创建一个选项卡,并通过设置 `CHighScoresListCtrl` 的过滤器来工作。`CHighScoresListCtrl` 只是 `CListViewCtrl` 的一个简单派生类,它不处理任何消息,它只是添加了几个函数来使其与父级解耦。过滤器就是与高分相关的短语文件。然后它删除所有项目,并选择性地为每个与当前短语文件匹配的项目调用 `Add`。
完成了!现在我只是测试和玩了一会儿。我立刻注意到当你为一个窗口努力时,它的生命周期在你杀死它之前就结束了,这有多痛苦。你带着文本,它记录下一切都是错误的。你必须快速删除所有文本并开始一个新的。我在想 Enter 键是否应该清除文本,无论好坏。然后我想,不,IDE 中不是那样的,你必须以某种方式手动删除所有文本。然而,试图输入文本但未能完成不应该受到太多惩罚。所以至少我希望 Ctrl + A 可以工作。
这要求我从 `CEdit` 派生一个新的类 `CEditExCtrl` 来处理“全选”代码。然后我必须为加速表添加代码,并为“全选”添加 Ctrl + A。现在你可以使用 Ctrl + A 来“全选”了。
我还注意到寿命的逻辑似乎不对,所以我进行了调查。以前我只是简单地计算打完那个短语所需的时间,然后乘以当前显示的窗口数量。我错误地将其乘以当前显示的窗口数量。这导致了对于非平均文本长度的窗口来说不公平的时间,要么低于平均值,要么高于平均值。这个修复涉及到一些重新设计,以允许 `game_session` 中有一个平均长度成员,用于寿命逻辑。这有助于寿命对于难度来说感觉更自然。
现在是最后的润色,我开始编辑一些程序中要用的声音。我洒了一些 `PlaySound`,结果灾难降临!`PlaySound` 不能同时播放两个声音。哎呀,我发誓它能。这听起来太糟糕了,而且我在声音方面也下了一些功夫,所以我不想就这么放弃。
今天是很漫长的一天,而且已经很晚了,所以我决定先收集资料。我查看了常见的网站,CodeProject、MSDN、deja,看看是否有什么方法可以同时播放两个声音。大家都差不多说,是的,用 DirectSound。哎,这个项目本来不想用任何复杂的东西,但我想还是硬着头皮上吧。到目前为止,需求1、3和4都满足了。但一个没有声音的打字程序是很无聊的。我躺下睡觉,同时154 MB 的 DirectX SDK 正在下载。早上我已经准备好了代码示例。
第 4 天
醒来后,我在屏幕上发现了什么?你可能会说是一个完成的 SDK 下载,但我的兄弟们,不是。这是一个最新精选的引人注目的巧合,标题是CWaveBox,描述是“多波播放器('波形音频接口' PCM 波形封装器)”。这对我来说至关重要,正是 Y 我需要的东西,一个简单的类,我将其放入,就可以同时播放多个 .wav 文件。
这太棒了,我放上我最喜欢的伊基·波普唱片。“三王节”今年来得有点晚,但他们在夜里拜访了我。我读到这个类可以实现我所需要的功能,而且演示也很有希望。
然而,代码转换从未顺利进行。一开始我无法用 `CWaveBox` 加载我的 Waves,我很快意识到它正在检查 Wave 中设置的某些位,以判断它是否是我没有的那种 Wave。我移除了检查,它就工作了,它们在播放!不可能,不可能这么容易,现在还不到早上 6 点呢。所以我为声音添加了一个单例 `sound_system`,然后将我的所有 Waves 整合在一起,并根据你按下的键来播放它们。
然后我开始注意到所有声音在结尾处都有这种爆音或噼啪声,听起来很糟糕。我并没有那样剪辑它们,而且我所有的其他程序播放它们时都没有爆音。所以我对此进行了调查,走了一小段弯路。读到这不是双缓冲导致爆音和噼啪声的原因。我对声音编程了解不多,但我想通过查看代码,它应该是双缓冲的。然后我开始意识到它没有被正确读取。
所以我用十六进制编辑器打开了那些波形文件,发现它们开头有一些 `CWaveBox` 不期望/不了解的额外信息。由于我那 50 美元的共享软件 Wav 编辑程序会把这些随机信息放在那里,我通过调整读取传入数据块的位置,让 `CWaveBox` 能够处理我这种类型的波形文件。
我把一天的其余时间都花在调整声音和制作华丽的 `CAboutDlg` 上。制作短语文件和重构。我还快速创建了一个 Python 脚本来生成“激烈卡通咒骂”短语文件。希望您喜欢我的作品。
后果
我只用了几天,但“强烈卡通咒骂”文件真的帮了我大忙。我想我老板注意到了,因为他刚才看着我打字(我打得比以前好多了),然后我抬头一看。我一边单手打字,一边喝了口水,然后将速度提高到新的舒适 WPM。他只是做出了一个“哇”的表情,然后走了出去。今天他给我带了一些他在地铁巴尔德拉斯外面买的盗版 DVD。我会随时向你们汇报。
参考文献
使用 zenith__ 的 `CWaveBox` - CWaveBox - 用于播放 PCM 多波的 WAI 封装器,在游戏开发中很有用。
历史
- 2005年4月20日星期三:创建。