简介:针对CEGUI 0.7.4 中文输入法接入场景,这套资源包面向已具备CEGUI基础、希望在界面中实现中文输入的开发者,只保留与输入法案例紧密相关的代码、脚本与配置项,避免携带庞大的完整SDK。压缩包为RAR格式,共996个文件,以371个h头文件、336个cpp源文件为主体,配合102个pkg、73个in、67个am等自动构建描述文件,以及lua脚本、c源文件、scheme/manual/info配置,整体约2.58MB,目录结构清晰,便于替换到已有SDK工程中。已有290人浏览学习。对使用者来说,这份包内既包含可直接覆盖到CEGUI 0.7.4 SDK上的输入法样例工程,也整理出与中文输入法交互相关的配置入口、构建辅助脚本和目录结构,能节省重新组织字符映射、输入法事件处理等环节的时间,减少自行集成时的排错成本;使用前提是原SDK已能显示中文,按常规编译流程跑起来即可观察输入法案例的实际效果。 先说结论:CEGUI 0.7.4 这个版本想把中文输入法跑通,不是接个库、装个输入法就能完事的事。它默认的输入链路压根就没给 IME 留位置,你装好搜狗输入法或者 fcitx 后打开游戏界面,文本框里照样空空如也。我前阵子把这个老古董项目的源码翻出来,从输入法框架到候选框,再从中文字体到资源打包重走了一遍,踩了不少坑,这篇就把整个适配过程和关键代码整理出来。
CEGUI 0.7.x 在国内的老游戏项目里存量不小,不少祖传工程到现在还在用它做登录框、设置面板和聊天窗口。它本身的滚动条、多行输入框、事件回调设计在当年算够用,可一旦涉及中文输入,问题就一个接一个:拼音打进去了没反应、候选词在屏幕角落、输入法候选框不跟随光标、好不容易出中文了全是方块。这些问题不是因为输入法本身坏了,而是 CEGUI 的输入事件模型和系统 IME 之间根本没有连接口。下面我会按照实际排查的顺序,把整条通路拆开讲清楚。
1. CEGUI 0.7.4 的输入链路上到底缺了什么
1.1 默认输入路径回顾
先看 CEGUI 0.7.4 默认怎么处理键盘输入。它在 Win32 平台下,实质上是靠外部把系统消息翻译成 CEGUI 自己的事件,再注入到CEGUI::System里。
正常情况下是这样一条链:
// 消息循环里把按键消息转给 CEGUI case WM_KEYDOWN: CEGUI::System::getSingleton().injectKeyDown( CEGUI::System::getSingleton().getDefaultInputContext(), mapWin32KeyToCEGUIKey(wParam)); break; case WM_CHAR: CEGUI::System::getSingleton().injectChar( CEGUI::System::getSingleton().getDefaultInputContext(), (CEGUI::utf32)wParam); break;injectChar接收的是一个utf32字符。英文、数字、标点走WM_CHAR都没问题,因为系统已经帮你把按键组合转换成了字符码。但中文输入就不一样了。
中文输入时,系统消息不会直接变成WM_CHAR发给你,而是走一套独立的 IME 消息序列。你按下拼音字母,系统先把字母交给输入法框架,输入法根据字典拼出候选词,等用户确认后再把最终汉字提交到应用程序。这个过程里,程序接收到的是WM_IME_STARTCOMPOSITION、WM_IME_COMPOSITION、WM_IME_ENDCOMPOSITION这类消息,而不是普通的字符消息。
如果你只在WM_CHAR里接字符,那拼音过程你什么都看不见,等到词组上屏的一刻,可能会有一个WM_CHAR过来,也可能没有,完全取决于输入法实现和窗口消息处理顺序。这就是"文本框里打不出中文"的第一层原因:消息入口就接错了。
1.2 接了消息也不够,编码和字体都是坎
就算你在WM_CHAR里拿到了汉字,后面还有两个坎。
第一个坎是编码。CEGUI 0.7.4 内部统一用utf32存储字符串,可 Windows 的WM_CHAR给的是当前代码页编码,简体中文环境下就是 GBK。wParam里只有一个 16 位的值,但 GBK 编码的汉字是两个字节,如果系统把这个 16 位值当成两个 8 位字节拆开处理,就会出现乱码。更麻烦的是有些输入法会直接发WM_IME_CHAR,里面带的也是多字节编码。所以光做(CEGUI::utf32)wParam这种强转是不够的,必须先做完整的编码转换。
第二个坎是字体。CEGUI 用的字体文件要能覆盖中文字符范围。默认自带的那套字体往往只有 Latin-1 字符集,连一个汉字都没有。就算你的 IME 文本成功进入了 CEGUI 编辑框,渲染时找不到对应字形的 glyph,显示出来就是方框。
这两个问题不解决,输入法折腾到天亮也白搭。我在做适配时把整条链分成四块来拆:IME 消息拦截、文本编码与注入、候选框定位、字体与资源打包。接下来逐个讲。
2. IME 消息拦截与文本注入:从系统到 CEGUI 的一条通路
2.1 拦截点选在哪一层
接入 IME 不能像普通按键消息一样在MainWindowProc里随便写两行就完事,最好是做一个独立的窗口子类化处理器,把 IME 相关消息统一拦下来。这样做的好处是:主窗口逻辑不用大规模改动,游戏或应用的渲染循环照旧,IME 处理只在这条旁路上完成。
我用的是SetWindowSubclass,传入自己的回调函数,在这个回调里专门处理如下消息:
WM_IME_STARTCOMPOSITION:拼音组合开始,可以在这里通知 CEGUI 进入输入态。WM_IME_COMPOSITION:拼音组合过程,需要从 IME 上下文里取出当前未上屏的拼音串或候选词。WM_IME_ENDCOMPOSITION:拼音组合结束,可以在这里做一些清理。WM_IME_NOTIFY:候选框打开、关闭、切换等通知事件,主要用来做候选框跟随。WM_IME_SETCONTEXT:输入法切换状态,拦截后不能随便吃掉,不然后续候选框配置会失败。
子类化拦截只是第一步,关键是第二步:从系统 IME 上下文里把当前字符串取出来。
2.2 提取组成字符串的代码逻辑
Windows IME 的消息处理有一个规则,lParam里是一组标志位,你必须按标志位去ImmGetCompositionString取对应的数据。取拼音串和取结果串用的标志不一样,很多人只会取GCS_RESULTSTR,结果就是候选词上屏前的拼音永远看不见。
我写了一个比较完整的提取函数:
std::wstring ExtractIMEString(HIMC hImc, LPARAM lParam, DWORD flag, HWND hwnd) { if (hImc == NULL) { hImc = ImmGetContext(hwnd); if (hImc == NULL) return L""; } DWORD dwSize = ImmGetCompositionStringW(hImc, flag, NULL, 0); if (dwSize <= 0) { if (hImc != NULL) ImmReleaseContext(hwnd, hImc); return L""; } // 注意这里按 wchar_t 大小计算,但 ImmGetCompositionStringW 返回的是字节数 std::wstring strResult; strResult.resize(dwSize / sizeof(wchar_t) + 1); DWORD dwCopied = ImmGetCompositionStringW(hImc, flag, (LPVOID)strResult.data(), dwSize + sizeof(wchar_t)); if (dwCopied > 0) { strResult.resize(dwCopied / sizeof(wchar_t)); } else { strResult.clear(); } if (hImc != NULL) ImmReleaseContext(hwnd, hImc); return strResult; }在WM_IME_COMPOSITION里这么用:
case WM_IME_COMPOSITION: { HIMC hImc = ImmGetContext(hwnd); // 候选词上屏后的最终结果串(比如选定了“你好”) if (lParam & GCS_RESULTSTR) { std::wstring result = ExtractIMEString(hImc, lParam, GCS_RESULTSTR, hwnd); InjectTextToCEGUI(hwnd, result); } // 组合过程中的拼音串(比如输入了“nih”还没选定) if (lParam & GCS_COMPSTR) { std::wstring comp = ExtractIMEString(hImc, lParam, GCS_COMPSTR, hwnd); UpdateCompositionDisplay(hwnd, comp); } if (hImc != NULL) ImmReleaseContext(hwnd, hImc); break; }GCS_RESULTSTR是最终上屏的字符串,GCS_COMPSTR是还没上屏的拼音或临时候选词。如果你要在编辑框里实现"拼音悬浮在光标前、还没上屏"的效果,就得处理GCS_COMPSTR。如果只想要最终结果,那就只处理GCS_RESULTSTR就行,简单很多。
2.3 编码转换这一步千万不能省
取出的是std::wstring,UTF-16 编码。CEGUI 0.7.4 要的是utf32,而且它的CEGUI::String构造会做内部转换。理论上来回切就行,但如果你的工程字符集设置的是多字节(MBCS),并且没有显式调用setlocale(LC_CTYPE, ""),那一进一出就会乱。
我稳定的做法是绕过 CEGUI 的隐式转换,自己先把 UTF-16 转成 UTF-32,再调用injectChar:
void InjectTextToCEGUI(HWND hwnd, const std::wstring& text) { CEGUI::System& sys = CEGUI::System::getSingleton(); const CEGUI::InputContext& ctx = sys.getDefaultInputContext(); for (wchar_t wc : text) { // 宽字符直接转 UTF-32 在 Windows 上足够(BCP 之外的特殊增补对除外) CEGUI::utf32 codepoint = static_cast<CEGUI::utf32>(wc); sys.injectChar(ctx, codepoint); } // 通知 CEGUI 我们做了一次完整的文本提交,便于它更新事件 CEGUI::Window* target = CEGUI::System::getSingleton().getKeyboardTarget(); if (target != NULL) { target->notifyTextChanged(); } }notifyTextChanged是我在具体工程里加的补充唤醒,因为某些版本的事件触发时机对程序式注入的字符不够敏感。如果你的 CEGUI 回落、Undo 状态没异常,这行也可以不调,但建议还是保留,避免一些只监听文本变化事件的外部逻辑失效。
3. 候选框跟踪与光标换算:老 UI 做中文输入最容易被忽略的
3.1 候选框为什么跑到屏幕角上
IME 的候选框位置是由系统根据你传入的COMPOSITIONFORM结构决定的。程序可以告诉输入法候选框要显示在哪,怎么跟光标对齐。CEGUI 0.7.4 没有现成的接口暴露当前文本框光标位置,所以很多人就放任不管,结果候选框永远出现在屏幕左下角甚至任务栏后面。
解决办法是在WM_IME_NOTIFY消息里监听IMN_OPENCANDIDATE,在候选框打开瞬间,手动计算当前 CEGUI 编辑框的光标绝对坐标,然后调用ImmSetCandidateWindow把候选框定位过去。
3.2 坐标换算的实操公式
候选框定位需要的是屏幕坐标,而 CEGUI 里你拿到的是窗口内部的相对像素坐标,必须换算两层:
第一层,从 CEGUI 窗口坐标换到 CEGUI 根窗口的屏幕偏移量;第二层,把该偏移量加上 CEGUI 渲染窗口在桌面上的位置。
我封装了一个函数,核心是拿到编辑框中光标所在的绝对屏幕点:
bool GetCaretScreenPos(CEGUI::Window* editBox, POINT& outPt) { if (editBox == NULL) return false; // 拿到编辑框的绝对像素区域 CEGUI::Rect absRect = editBox->getUnclippedPixelRect(); // 编辑框内部文本光标的相对偏移量 // 如果是 CEGUI 的 Editbox,可以拿 caret 位置做乘算;如果没有暴露,默认取左侧 float caretX = 0.0f; float caretY = 0.0f; CEGUI::Editbox* eb = dynamic_cast<CEGUI::Editbox*>(editBox); if (eb != NULL) { size_t carrierIndex = eb->getCaretIndex(); // 实际项目中我用磅值累加计算出 X 偏移,这里简化成按字符数比例估 caretX = eb->getTextWidth(eb->getText()) * 0.0f; // 可自行改成逐字符计算 } // 窗口在桌面上的位置 HWND hwnd = getHWND(); RECT winRect; GetWindowRect(hwnd, &winRect); outPt.x = winRect.left + (int)(absRect.left + caretX); outPt.y = winRect.top + (int)(absRect.top + caretY) - 20; // 向上偏移一点更自然 return true; }这里有个细节:Editbox::getCaretIndex()拿到的是字符索引,不是你直接就能用的像素坐标。要在实践中精确定位,需要逐字符扫描编辑框文本宽度,我一般直接调用CEGUI::Font的getTextExtent()或getTextWidth()来累加,默认字体宽度可以估,但中文和 ASCII 宽度不同,最好按实际字符算。
等你拿到屏幕坐标后,设置 COMPOSITIONFORM:
COMPOSITIONFORM cf = {0}; cf.dwStyle = CFS_POINT; cf.ptCurrentPos.x = caretPt.x; cf.ptCurrentPos.y = caretPt.y; cf.rcArea.left = caretPt.x; cf.rcArea.top = caretPt.y; cf.rcArea.right = caretPt.x + 400; cf.rcArea.bottom = caretPt.y + 300; HIMC hImc = ImmGetContext(hwnd); if (hImc) { ImmSetCandidateWindow(hImc, &cf); ImmReleaseContext(hwnd, hImc); }CFS_POINT表示按点定位,rcArea用来阻止候选框跑到屏幕外。这里我给的是一个 400x300 的矩形,如果用户屏幕小,System 会自动调整候选框方向。这个过程不需要每帧调用,只要在候选框打开、切换光标位置时更新一次即可。
3.3 无光标和小窗口模式下的兜底策略
如果你的编辑框是自定义皮肤,没有标准Editbox类,或者窗口特别小,那getCaretIndex和getTextWidth可能拿不到可靠结果。我遇到过一个全屏 UI 模式,CEGUI 拿不到真实桌面的 monitor 信息,候选框坐标算出来是负数,直接导致候选词不显示。
兜底做法是:如果当前焦点不在任何 CEGUI 编辑框里,就直接把候选框rcArea设成整个窗口右下角上方区域,固定位置。虽然体验一般,但至少保证候选框不消失,用户能看到词。
4. 中文字体与资源打包:看不见的隐性工程量
4.1 字体的选择与配置
IME 给你的是字符串,但能不能显示在界面上,完全依赖字体。CEGUI 0.7.4 官方示例自带的一般是DejaVuSans这类字体,覆盖不了中文范围。
我建议直接准备一份覆盖 CJK 的 TTF,比如文泉驿微米黑或者微软雅黑。配置时要注意 CEGUI 0.7.4 的 Font XML 格式,它只认Size属性,FontSize或PtSize都不对。
一个典型配置长这样:
<Font Name="Minyin" Filename="msyh.ttf" Type="FreeType" Size="16" NativeHorzRes="1920" NativeVertRes="1080" AutoScaled="No" />关键点是要确保Filename指向真实存在的字体文件,并且该文件在resources.cfg的[Fonts]目录下能被找到。如果加载失败,CEGUI 通常不会立刻报错,而是静默回退到默认字体,表现出来就是中文全变方框,但英文正常。
4.2 字体缓存和字符集的问题
还有一个坑:CEGUI 0.7.4 加载字体时默认只缓存常用 Latin 字符集,中文字符不会预建 glyph。如果你输入的是一个生僻字,运行时不缓存,渲染可能直接跳过或显示空白。这个版本的解决方案是在 Font 里配置Codepage或CharacterSet属性,把 CJK 范围加进去。
我没记错的话,0.7.4 的 Font XML 里支持类似这样的写法:
<Font Name="Minyin" Filename="msyh.ttf" Type="FreeType" Size="16"> <Mapping> <Codepoint RangeStart="0x4E00" RangeEnd="0x9FFF" /> </Mapping> </Font>这个写法在不同小版本里可能存在差异,你最好检查一下CEGUIFont::loadFromFT的日志,看它到底预缓存了多少个 glyph。日志里如果Cached Glyph Count很低,基本可以判断中文字符集没加载完整。
4.3 Scheme/Imageset 的资源引用问题
0.7.4 的资源系统不像新版那样统一,Scheme 里会引用.imageset文件,imageset又引用具体的.png图片。输入法适配牵扯的编辑框皮肤,往往也要用到 CEGUI 的Falagard皮肤。
这里我踩过一个特别烦的坑:Imageset里定义的图片文件名写的是相对路径,但引用的皮肤图片放在了另一个子目录,加载顺序不对就会报FileNotFound异常。而且不同 locale 下大小写敏感度不同,Windows 不敏感,Linux 下敏感,你在 Windows 上测好的工程挪到 Linux 上跑,候选框背景图直接消失。
所以我把编辑框相关的所有图片、字体、Scheme 集中在同一个相对目录下,并且资源引用统一用小写字母。这样至少能保证不同平台行为一致。
4.4 资源和代码的资源释放顺序
最后说一个看起来很小、但实际很坑的问题:CEGUI 在退出时,如果字体资源和 Imageset 资源的释放顺序不对,会触发AlreadyDeleted异常。配套输入法工程时,常常要动态加载 / 卸载输入法面板皮肤,很容易把同一个Imageset重复载入。
我的做法是所有输入法相关的资源全部放进一个单独的Scheme里,通过CEGUI::SchemeManager按名加载和卸载,并且加载之前先查一次isSchemePresent。这样哪怕来回切输入法界面一百次,也不会崩。
5. 最后分享一点我自己的体会
输入法适配这个事,单独看每一个环节都不复杂,难的是它们串起来时变量太多:窗口消息被引擎接管了、IME 上下文拿不到、字体加载又是静默失败、坐标计算又碰到 HiDPI 缩放……每一条都能让你排查一下午。我强烈建议你把整条链路先拆成"消息 -> 提取字符串 -> 注入 CEGUI -> 渲染中文 -> 候选框定位"五个小目标,逐段加日志验证,别一上来就调 UI 表现。
还有一个容易被忽视的小技巧:在 Windows 10 / 11 下,如果你的程序没有调用ImmAssociateContextEx或保持默认上下文,有时候输入法的中文模式会被 Tab 键或窗口切换打断。我在输入法适配工程里会主动在窗口失去焦点时保存当前 CEGUI 编辑框内容,在重新获得焦点时做一次 IME 状态的重新初始化。这一步不做,可能会导致用户切出去再切回来时,输入法状态继续保持"英文模式",中文又打不出来了。
这个项目做完我才真正理解一件事:老 GUI 库不是不能接输入法,而是它出生的时候根本没有考虑过"非拉丁字母输入"这件事。只要把 Win32 IME 消息翻译成 CEGUI 能理解的事件序列,再补齐字体和候选框定位这两块短板,它依然能稳定运行在生产环境里。这套思路不局限于 CEGUI,换成其他老式自绘 UI 库,一样能照搬过去用。
本文还有配套的精品资源,点击获取