feat(frontend): 技能检定与自由掷骰改用 Three.js 真 3D 骰子 (#217) - #219
Conversation
D100(双 D10 百分骰)/ D20 / D6 三种骰型改为真 3D:大理石纹树脂材质、凹刻 数字、重力弹跳,落底静止后自然定格。检定与自由掷骰共用同一套渲染。 结果取自物理停下后朝上的那一面,而不是先取值再把目标面掰上来——后者在翻滚 结束时姿态随机,定格要做一大段额外旋转,表现为动画放完后骰子突然又动一下。 均匀性由洗牌保证而不是物理:每次掷骰前用 Fisher–Yates 把点数重新分配到各面。 设物理落面为 F(分布可能有偏)、面→点数为独立均匀排列 V,则 P(V[F]=v)=1/n 恒成立。原型用的 `sort(() => Math.random() - 0.5)` 不是均匀排列,直接决定 了结果分布,对判定成败的检定骰是正确性问题。 - features/dice3d/:几何、贴图、引擎、React 封装分层,不塞进已有 1900 行的 RoomPage;引擎与 React 无关,只认容器和回调 - three 走动态 import:首屏包 253.90 KB 不变,three 落到独立的 523 KB 懒加载 chunk,打开骰子面板才拉 - 交互从拖拽摇晃改为点按。原摇晃的 power 只影响 setTimeout 时长,不影响结果 也没有物理含义,属于假交互,一并删除 - 降级保留原 2D 展示:无 WebGL 或 prefers-reduced-motion 时走它,检定不被卡住 - 关闭面板/卸载时释放 geometry、texture、renderer r128 → 0.185 只有两处 API 需要迁移:texture.encoding / renderer.outputEncoding 改为 colorSpace / outputColorSpace。 验证: - 洗牌均匀性用固定种子 LCG 做 2 万次统计断言;变异检验把它换回有偏 sort 后, 偏差 1853 远超容差 160(期望每格 2000),用例立刻报红 - 浏览器内实跑引擎:d100/d20/d6 取值均在合法区间,约 2 秒收敛定格, dispose 后 canvas 归零 - 几何校验:6/10/20 面,法线全唯一,无重复面 - lint / typecheck / build 全绿,60 passed
canvas 被以 2 倍尺寸显示,父容器 overflow-hidden 只露出左上四分之一,表现为 「只看得到一颗骰子、而且特别大」。 根因是 setSize(w, h, false) 的第三个参数表示不写 canvas 的 CSS 尺寸。原型靠 它自己页面的 CSS 把 canvas 拉成 100%,移植时没有那段 CSS,于是 canvas 按绘制 缓冲尺寸当 CSS 像素显示——dpr=2 的屏上就是 716×384,而容器只有 358×192。 改为 setSize(w, h) 让 three 同时写 style,并给 canvas 加 display:block。 修后实测:容器 358×192、canvas 显示 358×192、缓冲 716×384(dpr 正常); 内容包围盒 x 0.17–0.90 / y 0.31–0.79,两颗骰子都在画面内且未贴边裁切。 骰盘背景从 #1a1620 换成暖棕径向渐变 + 内阴影。原色是冷调近黑,在米白面板里 像挖了个洞。不改成浅色是因为骰子靠高光与凹刻阴影出质感,浅底会把这两者洗掉, 需要连灯光一起重做。
| kind={activeDiceType} | ||
| className="w-full h-48" | ||
| onSettled={(value) => settle(value, isCheckMode ? checkRequest?.clientActionId ?? null : null)} | ||
| onUnsupported={() => setUse3D(false)} |
There was a problem hiding this comment.
When Dice3DStage calls onUnsupported after a roll has already started, this only flips use3D to false. The parent has already set rolling: true in roll(), and because the 3D path returned before scheduling the 2D timer, nothing ever clears rolling or produces a result. A dynamic import/init failure after the user taps 掷骰 leaves check mode stuck with the spinner text and no roll button/confirm path, which is exactly the fallback case this PR is trying to protect. The unsupported handler needs to either complete a 2D fallback roll or reset the active rolling state for the current request/free roll.
There was a problem hiding this comment.
确认是真问题,已在 cf3e82d 修复。
复核过路径,跟你描述的一致:roll() 已经置了 rolling 并走 3D 分支返回、没有排任何定时器,而 onUnsupported 只翻 use3D。于是 rolling 永远不清,界面停在"骰子还在滚",既没有结果也没有重掷入口。懒加载 chunk 是一个网络请求,移动端网络抖动就会触发——恰好是这套降级本该防住的情况。
你给的两个方向里选了补完这一次掷骰而不是只重置 rolling:玩家已经表达了"我要掷",重置状态等于把这次点击丢掉、还要他再点一次;补掷走的是既有的 2D 回退路径,表现和改造前完全一致。
一个值得记下来的返工:第一版我写的是 if (activeRolling) roll2D(...),结果新增的回归用例当场报红。原因是 activeRolling 是当前渲染的闭包值——3D 失败回调可能与 roll() 处在同一个同步流程里,那时 rolling 的 setState 还没生效,读到的仍是旧值,判定成"没有掷骰在进行",等于没修,和原 bug 属于同一类错误。真实场景是异步失败、中间通常有一次重渲染,第一版可能歪打正着,但不该依赖这个时序。改成用 ref 在 roll() 里同步记录在飞的掷骰,同步/异步两种失败都成立。
回归用例 completes the roll when the 3D engine fails to load after the tap:假舞台在被调用 roll() 时立刻触发 onUnsupported,模拟"玩家已经点了掷骰、chunk 这时才加载失败"。变异检验:去掉补掷逻辑只留 setUse3D(false),该用例立即报红。
61 passed(连跑 3 次确认不 flaky)、lint、typecheck、build 全绿;首屏包仍为 253.90 KB 未变。
玩家点了「掷骰」之后引擎才加载失败时,检定会彻底卡死:roll() 已经置了 rolling 并走 3D 分支返回、没有排任何定时器,而 onUnsupported 只把 use3D 翻成 false。于是 rolling 永远不清,界面停在"骰子还在滚",既没有结果也没有重掷入口 ——恰好是这套降级本该防住的情况。懒加载 chunk 是一个网络请求,移动端网络抖动 就能触发。 改成失败时把这一次掷骰用 2D 路径补完,玩家那一次点击不会丢。 在飞的掷骰用 ref 记录而不是读 state:3D 失败回调可能与 roll() 处在同一个同步 流程里,那时 rolling 的 setState 还没生效,读 state 会拿到旧值、判定成"没有 掷骰在进行",等于没修。真实场景是异步失败、中间通常有一次重渲染能歪打正着, 但不该依赖这个时序。 变异检验:去掉补掷逻辑只留 setUse3D(false),新增用例立即报红。 61 passed(连跑 3 次)、lint、typecheck、build 全绿。
LMH168
left a comment
There was a problem hiding this comment.
为了方便 review,请补充 UI 图片展示其效果
1024XEngineer#218(中文化衍生属性 + 职业自选技能)与本分支都改了 RoomPage.tsx 与其测试。 - RoomPage.tsx:两侧各加了一个 import,同时保留。 - RoomPage.test.tsx:两侧在同一位置追加测试,双方全部保留(3D 降级 2 条 + 职业自选技能页签 1 条)。 前端 68 passed、lint、typecheck、build 全绿。注意需要先 `cd trpg-sdk && npm run build`:1024XEngineer#218 改了生成的 dto.ts,而 dist/ 是 gitignore 的,不重建 前端拿到的还是旧类型。
There was a problem hiding this comment.
Found one remaining correctness issue in the 3D settle path. Security, performance, and documentation passes did not turn up additional high-confidence findings.
Verification: attempted npm test -- --run trpg-frontend/src/features/dice3d/shuffle.test.ts trpg-frontend/src/features/dice3d/support.test.ts trpg-frontend/src/routes/games/trpg/RoomPage.test.tsx, but the local vitest command failed to start with Permission denied.
| ref={dice3dRef} | ||
| kind={activeDiceType} | ||
| className="w-full h-48" | ||
| onSettled={(value) => settle(value, currentRequestId())} |
There was a problem hiding this comment.
The 3D settle callback reads currentRequestId() when the animation finishes instead of using the request id captured when roll() started. If a check.request for a different clientActionId arrives while the 3D dice is still tumbling, pendingCheck/pendingCheckDice are replaced, and this callback will write the old roll result into the new check. The 2D path avoids that by passing the captured requestId through the timeout; the 3D path needs the same captured id, for example from inFlight3DRollRef, when settling.

Closes #217
D100(双 D10 百分骰)/ D20 / D6 改为 Three.js 真 3D:重力翻滚、落底弹跳、自然定格,结果就是停下时朝上那一面。检定与自由掷骰共用同一套渲染,协议与后端零改动。
改了什么
features/dice3d/geometry.tsfeatures/dice3d/textures.tsfeatures/dice3d/engine.tsfindTopFace自然定格、资源释放。与 React 无关,只认容器和回调features/dice3d/shuffle.tsfeatures/dice3d/support.tsprefers-reduced-motion探测,零依赖(要先用它决定值不值得加载 three)features/dice3d/Dice3DStage.tsximport()引擎;加载期间的掷骰请求会排队补发routes/games/trpg/RoomPage.tsxDiceModal接入 3D;删掉拖拽摇晃改为点按;splitD100把结果拆回十位/个位用于展示关键决策
为什么结果取自物理定格,而不是先取值再把目标面转上来:后者在翻滚结束时骰子姿态是随机的,要把指定面掰到朝上需要一大段额外旋转,表现为「动画放完后骰子突然又动一下,数字很突兀」。改成读此刻朝上的面之后,数字与肉眼看到的面永远一致,定格只需要一个最短弧的极小修正。
均匀性为什么由洗牌保证,而不是由物理保证:每次掷骰前用均匀随机排列把点数分配到各个面上。设物理落面为
F(分布可能有偏)、面→点数的映射为独立的均匀排列V,则P(V[F] = v) = 1/n对任意v成立——结果均匀与物理是否有偏无关。这条推理的前提是排列必须均匀,所以原型里的
sort(() => Math.random() - 0.5)必须换掉:它不是均匀排列,而结果分布完全取决于它。变异检验把 Fisher–Yates 换回这个写法后,2 万次采样下单格偏差 1853,容差 160(期望每格 2000)——不是理论瑕疵,是能被玩家感知的分布倾斜。对判定成败的检定骰属于正确性问题。为什么 three 走动态 import:移动端首屏体积敏感。骰子面板不是进房就打开的,没理由让所有玩家在首屏付出 three 的体积。改造后主 chunk 253.90 KB(改造前 253.89 KB,基本不变),three 落到独立的 523 KB / gzip 133 KB 懒加载块,打开面板才拉。为此
Dice3DStage对引擎只能用import type,DiceKind也单独放在不 import three 的types.ts里。为什么直接删掉摇晃手势而不是接到物理上:原来的
shakeLevel只影响setTimeout(500 + power * 100)的时长,既不影响结果也没有物理含义,摇得狠只是等得久一点,是没有反馈的假交互。3D 化后若要保留就得把力度接进初速度,交互分支和测试面都会变大。本期先做点按这条确定路径,手感需要「用力掷」的话再单开 issue。为什么降级不删掉原来的 2D 实现:WebGL 在部分老旧移动端浏览器和某些隐私模式下不可用,
prefers-reduced-motion是无障碍要求。检定是主流程的一环,不能因为渲染能力缺失就卡住,所以 2D 展示保留为回退路径而不是被替换。引擎动态加载失败同样回退。骰盘为什么是深色:骰子是亮面树脂材质,靠高光与凹刻阴影出质感,浅底会把这两者一起洗掉、还得连灯光重做。原来的
#1a1620是冷调近黑,在米白面板里像挖了个洞,换成暖棕径向渐变 + 内阴影,读起来像打着光的骰盘。验证
lint、build全绿sort(() => Math.random() - 0.5)后,均匀性用例立刻报红(偏差 1853 / 容差 160)dispose()后 canvas 归零,无 context 泄漏IcosahedronGeometry仍是非索引化的(否则按 position 每 9 个数取三角形的做法会错)过程中抓到一个真 bug,已修并写进 commit:canvas 被以 2 倍尺寸显示,父容器
overflow-hidden只露出左上四分之一,表现为「只看得到一颗骰子且特别大」。根因是setSize(w, h, false)的第三参表示不写 canvas 的 CSS 尺寸——原型靠自己页面的 CSS 把 canvas 拉成 100%,移植时没有那段 CSS,于是 canvas 按绘制缓冲尺寸当 CSS 像素显示(dpr=2 时 716×384,而容器只有 358×192)。修后实测内容包围盒x 0.17–0.90 / y 0.31–0.79,两颗骰子都在画面内且未贴边裁切。已知限制
npm run build会对 523 KB 的懒加载块报一条体积警告。没有加chunkSizeWarningLimit压掉——它是按需加载的块,这条警告本身是有用的提醒。不在本 PR 范围