☰
Unity WebGL中文输入法实战:候选框跟随光标与全屏适配
2026/11/4 12:20:30 网站建设 项目流程

简介:这份资源面向需要在 Unity WebGL 项目中实现中文文本输入的开发者,重点解决浏览器环境下输入法无法正常唤起、输入框不跟随光标以及全屏模式下定位错乱等常见问题。压缩包共 19 个文件,约 17.4MB,包含 html 入口页、css 样式、js 交互脚本、unityweb 构建产物以及 png 图标等资源,结构上覆盖了 WebGL 导出后的完整运行目录,便于直接部署或二次改造。已有 940 人学习下载,说明该方案在实际项目中具备一定参考价值。资源通过 JavaScript 与 Unity C# 的相互调用激活浏览器输入法,并借助鼠标事件动态调整输入法窗口位置,使光标移动与全屏切换时输入框仍能正确跟随;同时附带说明文档,可帮助读者理解通信流程、兼容性处理与样式定制思路,适合希望提升 WebGL 表单输入体验的中级开发者参考。

1. 需求先立住:为什么 Unity WebGL 的中文输入是个真问题

做过 Unity WebGL 项目的人大概都踩过同一个坑:编辑器里输入框一切正常,打包上浏览器,中文输入法一敲,候选词框飘到屏幕左上角,或者干脆不出现,玩家只能盲打。这不是玄学,是 WebGL 平台下 Unity 的输入事件和浏览器 IME(输入法编辑器)之间没有天然对齐。项目标题里点明的三件事——中文输入、输入法候选框跟随光标、全屏状态下依然可用——恰好是这个场景里最容易被忽略、又最影响交付质量的部分。这套 Demo 面向的是要做网页端表单、聊天、登录昵称、答题系统的 Unity 开发者,也适合课程设计里需要「浏览器里跑起来还能打字」的同学直接参考。它解决的不是「能不能输入英文字母」,而是「中文用户能不能像用普通网页一样自然地输入」。

2. 总体方案设计:三层结构与四个职责模块

2.1 为什么不能只靠 InputField

Unity 自带的InputField(或 TMP_InputField)在 WebGL 下依赖 Unity 自己的输入轮询,浏览器层面的 IME 组合事件(compositionstart / compositionupdate / compositionend)并不会被完整转发进来。结果就是:拼音阶段的内容 Unity 收不到,候选框位置由浏览器自行决定,通常贴在页面某个固定角落。血泪经验是,很多人第一版会尝试用Input.imeCompositionMode去调,发现它在 WebGL 构建里基本是空转。所以方案的第一条设计原则是:中文输入的主战场放在浏览器 DOM 层,Unity 只负责显示和接收最终结果。

2.2 三层结构划分

整体拆成三层,职责边界清晰,后面排查问题也按这三层定位:

层级载体职责
表现层Unity Canvas / TMP 文本渲染输入框外观、光标、已确认文本
桥接层.jslib 插件 + SendMessageDOM 事件与 Unity 方法互调
输入层隐藏的 HTML input/textarea承接真实键盘与 IME 组合事件

输入层用一个视觉上隐藏、但可聚焦的<input>或<textarea>元素。用户点击 Unity 里的输入框时,通过桥接层让这个 DOM 元素获得焦点,中文输入法自然工作;组合结束后把最终字符串回传给 Unity。候选框跟随的本质,就是让这个 DOM 元素的位置跟着 Unity 输入框在页面上的坐标走。

2.3 四个职责模块

  • 焦点管理模块:Unity 点击 → DOM focus,Unity 失焦 → DOM blur,避免两个输入源抢焦点。
  • 坐标同步模块:把 Unity 输入框的屏幕坐标换算成页面 CSS 坐标,驱动隐藏 input 定位。
  • 文本回传模块:监听input与compositionend,把确认文本发给 Unity。
  • 全屏适配模块:监听fullscreenchange,全屏切换后重新计算坐标与尺寸。

2.4 坐标换算的关键思路

Unity WebGL 的 Canvas 在页面上不一定铺满,还可能有 DPR(设备像素比)缩放。常见翻车点是直接拿 Unity 的屏幕坐标当 CSS 像素用,结果候选框偏移一大截。正确做法是先取 Canvas 的getBoundingClientRect(),再把 Unity 坐标按 Canvas 实际显示尺寸与渲染尺寸的比例映射过去。下面这段是桥接层里负责定位的核心逻辑,语言为 JavaScript:

// 将 Unity 传入的输入框屏幕坐标换算为页面 CSS 坐标 function syncInputPosition(x, y, w, h) { const canvas = document.querySelector('#unity-canvas'); const rect = canvas.getBoundingClientRect(); // Unity 坐标原点在左下,DOM 原点在左上,需要翻转 y const scaleX = rect.width / canvas.width; const scaleY = rect.height / canvas.height; const cssLeft = rect.left + x * scaleX; const cssTop = rect.top + (canvas.height - y - h) * scaleY; const input = document.getElementById('unity-ime-input'); input.style.left = cssLeft + 'px'; input.style.top = cssTop + 'px'; input.style.width = (w * scaleX) + 'px'; input.style.height = (h * scaleY) + 'px'; }

参数说明:x/y/w/h是 Unity 侧传出的输入框矩形,单位是渲染像素;canvas.width/height是渲染分辨率,rect.width/height是页面实际显示尺寸,两者比值就是缩放系数。y 轴翻转是最容易漏的一步,漏了候选框就会上下颠倒。这段函数在窗口 resize、滚动、全屏切换时都要重新调用一次,否则位置会「记仇」停在旧坐标。

3. 桥接层落地:.jslib 插件与 C# 双向通信

3.1 .jslib 的注册与调用约定

Unity WebGL 与浏览器交互的标准入口是Plugins目录下的.jslib文件。它本质是一段会被合并进构建产物的 JS,通过mergeInto挂到全局库上,C# 侧用[DllImport("__Internal")]声明同名函数即可调用。设计上我一般把「初始化 DOM 输入元素」「设置焦点」「同步坐标」「销毁」四个函数暴露出去,C# 只关心调用时机,不关心 DOM 细节。这样以后换输入框样式或改定位算法,只动 JS 不动 C#。

// IMEBridge.jslib mergeInto(LibraryManager.library, { IME_Init: function () { if (document.getElementById('unity-ime-input')) return; const el = document.createElement('textarea'); el.id = 'unity-ime-input'; el.style.position = 'fixed'; el.style.opacity = '0'; // 视觉隐藏但保留可聚焦 el.style.zIndex = '9999'; el.style.border = 'none'; el.style.resize = 'none'; document.body.appendChild(el); // 组合结束才把确认文本回传,避免拼音中间态污染 el.addEventListener('compositionend', function (e) { SendMessage('IMEBridge', 'OnImeCommit', e.data); }); el.addEventListener('input', function (e) { if (!e.isComposing) { SendMessage('IMEBridge', 'OnImeCommit', el.value); } }); }, IME_Focus: function () { const el = document.getElementById('unity-ime-input'); if (el) { el.value = ''; el.focus(); } }, IME_Blur: function () { const el = document.getElementById('unity-ime-input'); if (el) el.blur(); } });

这里有两个设计取舍值得说清。第一,用opacity: 0而不是display: none,因为后者会让元素无法获得焦点,输入法直接失效,这是最常见的翻车点之一。第二,input事件里判断e.isComposing,组合过程中不回调,只在compositionend时提交,否则拼音字母会一段段漏进 Unity 文本框,用户看到的就是「打一个字跳一串字母」。

3.2 C# 侧的接收与状态管理

C# 侧建一个IMEBridge单例,负责接收 JS 回传的文本并分发给当前激活的输入框。SendMessage的目标对象名必须和场景里挂载的 GameObject 名一致,名字对不上就是「黑匣子」——浏览器控制台没报错,Unity 里也没反应。我一般把接收方法写成静态入口,内部再转发给实例,避免场景切换后引用丢失。

using UnityEngine; using System.Runtime.InteropServices; public class IMEBridge : MonoBehaviour { public static IMEBridge Instance { get; private set; } public System.Action<string> OnTextCommitted; #if UNITY_WEBGL && !UNITY_EDITOR [DllImport("__Internal")] private static extern void IME_Init(); [DllImport("__Internal")] private static extern void IME_Focus(); [DllImport("__Internal")] private static extern void IME_Blur(); #endif void Awake() { if (Instance != null) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); #if UNITY_WEBGL && !UNITY_EDITOR IME_Init(); #endif } // 由 JS 通过 SendMessage 调用,方法名必须与 jslib 中一致 public void OnImeCommit(string text) { OnTextCommitted?.Invoke(text); } public void RequestFocus() { #if UNITY_WEBGL && !UNITY_EDITOR IME_Focus(); #endif } }

#if UNITY_WEBGL && !UNITY_EDITOR这个条件编译不能省。编辑器里没有__Internal库,不包起来会在编辑器直接报 DllNotFoundException,导致整个项目在开发阶段就跑不起来,很多人第一反应以为是插件坏了。参数上,OnImeCommit的字符串是最终确认文本,不是增量,所以接收方要么整体替换,要么自己维护光标位置做插入,别直接拼接。

3.3 全屏状态下的坐标重算

全屏切换会改变 Canvas 的显示尺寸,之前算好的 CSS 坐标全部失效。方案里监听fullscreenchange,在事件回调里通知 Unity 重新上报输入框位置,再走一遍syncInputPosition。注意全屏事件有几百毫秒的过渡动画,太早取getBoundingClientRect()拿到的是过渡中的中间值,候选框会先跳一下再归位。通常的做法是延迟一帧或监听resize兜底,两者结合最稳。

4. 输入框组件实现:光标、候选框跟随与文本同步

4.1 光标位置如何驱动候选框

候选框跟随的精度取决于「光标在 Unity 输入框里的位置」能不能实时映射到 DOM 元素上。做法是:Unity 侧每次光标移动(点击、方向键、输入后)都计算光标相对输入框的局部坐标,加上输入框自身在屏幕上的位置,得到光标的屏幕矩形,再调用坐标同步函数。这样隐藏 textarea 的左上角就贴着光标,浏览器 IME 候选框默认出现在聚焦元素附近,视觉上就实现了跟随。精度要求不高时,直接用输入框整体矩形也够用;要求高就把光标宽度算进去,让候选框起点对齐字符左边缘。

4.2 文本回传后的显示同步

回传文本到达 Unity 后,不要直接text = received了事。中文输入存在「组合中」和「已确认」两种状态,如果用户在组合中途点了别处,compositionend可能不触发,导致丢字。稳妥做法是在blur时也做一次兜底提交,把 textarea 当前value发回去。另外 TMP 文本的富文本标签、换行符要过滤,否则用户粘贴带标签的内容会把显示搞乱。下面是一个接收并清洗的示例:

void HandleCommit(string raw) { if (string.IsNullOrEmpty(raw)) return; // 去掉可能破坏 TMP 富文本的尖括号,保留正常中文与符号 string clean = System.Text.RegularExpressions.Regex.Replace(raw, "<[^>]*>", ""); inputField.text = clean; inputField.caretPosition = clean.Length; // 光标移到末尾 }

清洗规则按项目需要调整,但「先清洗再赋值」这个顺序别反,反了会出现文本已经渲染、标签才被移除的闪烁。

4.3 移动端与桌面端的差异处理

桌面浏览器 IME 走组合事件,移动端软键盘的行为差异更大:部分浏览器在软键盘弹出时focus()需要用户手势触发,程序化聚焦会被拦截。方案里对移动端保留一个「点击输入框才聚焦」的显式路径,不依赖自动聚焦。另外移动端全屏(尤其是横屏)会触发视口变化,坐标同步要跟着visualViewport的 resize 走,否则候选框会偏到键盘上方看不见的位置。这块没有银弹,实测为主,把visualViewport的offsetTop也纳入计算通常能解决大半。

5. 避坑与排查:这类项目最容易翻车的五个点

5.1 现象:输入法候选框固定在左上角不动

原因:隐藏 input 没有跟随坐标,或者用了display: none导致元素无布局盒,浏览器把候选框放到默认位置。解决:确认元素是position: fixed+opacity: 0,并在每次光标移动、resize、全屏切换后调用坐标同步;用浏览器开发者工具临时把opacity改成 1,肉眼确认元素确实贴在输入框上。

5.2 现象:拼音字母一段段漏进 Unity 文本框

原因:只监听了input没判断isComposing,组合中间态被当成确认文本提交。解决:input回调里加if (e.isComposing) return;,主提交走compositionend;同时在blur做兜底提交,防止组合中途失焦丢字。

5.3 现象:编辑器里正常,打包后报 DllNotFoundException

原因:[DllImport("__Internal")]没有用#if UNITY_WEBGL && !UNITY_EDITOR包住,编辑器环境找不到该库。解决:所有 WebGL 专属的 extern 声明和调用都加条件编译;同时确认.jslib放在Assets/Plugins下且文件名与函数注册一致。

5.4 现象:SendMessage 没反应,控制台也不报错

原因:SendMessage的第一个参数是 GameObject 名,场景里对象改名或没挂载IMEBridge就会静默失败。解决:把接收对象固定为常驻单例并DontDestroyOnLoad,名字写死不要动态改;排查时在 JS 侧console.log打印目标名,和 Unity 层级面板逐个核对。

5.5 现象:全屏后候选框位置偏移或消失

原因:全屏过渡期间取到的 Canvas 尺寸是中间值,或全屏后没有重新同步坐标。解决:监听fullscreenchange与resize双保险,延迟一帧再算坐标;全屏下确认getBoundingClientRect()返回的是最终尺寸,必要时用requestAnimationFrame包一层。

6. 验收与答辩:怎么证明这套输入方案真的能用

验收这件事,别只看「能打出中文」就收工。我一般按一张检查表逐项过,答辩时也照这张表讲,评委一听就知道你是真跑过流程的:

验收项操作通过标准
基础中文输入输入「你好世界」文本完整、无拼音残留
候选框跟随在输入框不同位置点击后输入候选框贴着光标,不飘到角落
组合中途失焦打拼音未选词时点击空白处不丢字、不串字
全屏切换进入/退出全屏后再次输入候选框位置正确
窗口缩放拖动浏览器窗口改变大小坐标同步无偏移
移动端软键盘手机浏览器点击输入键盘弹出、候选框可见

答辩时最容易被问的是「为什么不用 Unity 原生 InputField 硬扛」。回答抓住一点:WebGL 下 IME 组合事件不经过 Unity 输入系统,原生方案拿不到拼音中间态,候选框位置也不可控,所以必须把输入层下沉到 DOM。再补一句坐标换算里 y 轴翻转和 DPR 缩放的处理,基本就能把技术深度讲透。演示顺序建议先桌面后移动、先普通再全屏,把最容易翻车的全屏场景放最后压轴,成功了印象分最高。

血泪经验是,这类桥接项目最怕「编辑器里好好的」这种假象,我后来每次改完.jslib都强制走一遍「清缓存 → 重新构建 → 换一个没开过的浏览器窗口」的流程,因为浏览器缓存旧 JS 导致的「改了没生效」坑过不止一次。希望这套结构和检查表能帮到你,少走几段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询