Skip to content

feat(frontend): 技能检定与自由掷骰改用 Three.js 真 3D 骰子 (#217) - #219

Merged
Lyltrum merged 4 commits into
1024XEngineer:mainfrom
Lyltrum:feat/issue-217-dice-3d
Aug 4, 2026
Merged

feat(frontend): 技能检定与自由掷骰改用 Three.js 真 3D 骰子 (#217)#219
Lyltrum merged 4 commits into
1024XEngineer:mainfrom
Lyltrum:feat/issue-217-dice-3d

Conversation

@Lyltrum

@Lyltrum Lyltrum commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Closes #217

D100(双 D10 百分骰)/ D20 / D6 改为 Three.js 真 3D:重力翻滚、落底弹跳、自然定格,结果就是停下时朝上那一面。检定与自由掷骰共用同一套渲染,协议与后端零改动。

改了什么

文件 改动
features/dice3d/geometry.ts 立方体、五角偏方面体(真实 D10 形状,three 无内置,手写)、二十面体的逐面提取;Newell 法求面法线
features/dice3d/textures.ts 程序化骰面贴图:大理石纹 + 棱线 + 凹刻数字,D6 走点数;6/9 加下划线
features/dice3d/engine.ts 逐面独立网格 + 内芯、灯光与阴影、物理翻滚、findTopFace 自然定格、资源释放。与 React 无关,只认容器和回调
features/dice3d/shuffle.ts Fisher–Yates。结果分布完全由它保证,见「关键决策」
features/dice3d/support.ts WebGL 与 prefers-reduced-motion 探测,零依赖(要先用它决定值不值得加载 three)
features/dice3d/Dice3DStage.tsx React 封装,动态 import() 引擎;加载期间的掷骰请求会排队补发
routes/games/trpg/RoomPage.tsx DiceModal 接入 3D;删掉拖拽摇晃改为点按;splitD100 把结果拆回十位/个位用于展示
测试 洗牌均匀性(固定种子 LCG,2 万次)、可用性探测 4 例、检定降级到 2D 的回归

关键决策

为什么结果取自物理定格,而不是先取值再把目标面转上来:后者在翻滚结束时骰子姿态是随机的,要把指定面掰到朝上需要一大段额外旋转,表现为「动画放完后骰子突然又动一下,数字很突兀」。改成读此刻朝上的面之后,数字与肉眼看到的面永远一致,定格只需要一个最短弧的极小修正。

均匀性为什么由洗牌保证,而不是由物理保证:每次掷骰前用均匀随机排列把点数分配到各个面上。设物理落面为 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 typeDiceKind 也单独放在不 import three 的 types.ts 里。

为什么直接删掉摇晃手势而不是接到物理上:原来的 shakeLevel 只影响 setTimeout(500 + power * 100) 的时长,既不影响结果也没有物理含义,摇得狠只是等得久一点,是没有反馈的假交互。3D 化后若要保留就得把力度接进初速度,交互分支和测试面都会变大。本期先做点按这条确定路径,手感需要「用力掷」的话再单开 issue。

为什么降级不删掉原来的 2D 实现:WebGL 在部分老旧移动端浏览器和某些隐私模式下不可用,prefers-reduced-motion 是无障碍要求。检定是主流程的一环,不能因为渲染能力缺失就卡住,所以 2D 展示保留为回退路径而不是被替换。引擎动态加载失败同样回退。

骰盘为什么是深色:骰子是亮面树脂材质,靠高光与凹刻阴影出质感,浅底会把这两者一起洗掉、还得连灯光重做。原来的 #1a1620 是冷调近黑,在米白面板里像挖了个洞,换成暖棕径向渐变 + 内阴影,读起来像打着光的骰盘。

验证

  • 前端:60 passed、lintbuild 全绿
  • 变异检验:洗牌换回有偏的 sort(() => Math.random() - 0.5) 后,均匀性用例立刻报红(偏差 1853 / 容差 160)
  • 浏览器内实跑引擎(页面隐藏时 rAF 不触发,改用手动泵帧驱动物理循环):
    • d100 / d20 / d6 取值全部落在合法区间,约 2 秒、120 帧左右自然收敛(远低于 4.5 秒的安全上限)
    • dispose() 后 canvas 归零,无 context 泄漏
  • 几何校验:d6/d10/d20 分别 6/10/20 面,法线全唯一,无重复面或破洞;另确认新版 three 的 IcosahedronGeometry 仍是非索引化的(否则按 position 每 9 个数取三角形的做法会错)
  • 真机走通检定全流程(DeepSeek provider,追书人「心理学」检定)

过程中抓到一个真 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,两颗骰子都在画面内且未贴边裁切。

已知限制

  • 视觉细节没有精修:骰子大小、镜头角度、材质亮度、配色都还是原型的参数,只保证「看得全、读得清」。后续单开 issue 打磨。
  • npm run build 会对 523 KB 的懒加载块报一条体积警告。没有加 chunkSizeWarningLimit 压掉——它是按需加载的块,这条警告本身是有用的提醒。
  • 三种骰型的定格时长都在 2 秒上下,比原来的 0.5–1 秒假动画慢。这是真实物理收敛的代价,实测不影响检定节奏。

不在本 PR 范围

  • 掷骰音效、震动反馈
  • 多人同步观看别人掷骰的动画(当前只有本人看得到)
  • 骰子外观自定义 / 皮肤
  • D4 / D8 / D12(游戏里用不到)
  • 把摇晃力度接进物理初速度

Lyltrum added 2 commits August 4, 2026 11:31
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 换成暖棕径向渐变 + 内阴影。原色是冷调近黑,在米白面板里
像挖了个洞。不改成浅色是因为骰子靠高光与凹刻阴影出质感,浅底会把这两者洗掉,
需要连灯光一起重做。

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found one issue in the 3D fallback path. The other review passes did not turn up additional high-confidence findings.

kind={activeDiceType}
className="w-full h-48"
onSettled={(value) => settle(value, isCheckMode ? checkRequest?.clientActionId ?? null : null)}
onUnsupported={() => setUse3D(false)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

确认是真问题,已在 cf3e82d 修复。

复核过路径,跟你描述的一致:roll() 已经置了 rolling 并走 3D 分支返回、没有排任何定时器,而 onUnsupported 只翻 use3D。于是 rolling 永远不清,界面停在"骰子还在滚",既没有结果也没有重掷入口。懒加载 chunk 是一个网络请求,移动端网络抖动就会触发——恰好是这套降级本该防住的情况。

你给的两个方向里选了补完这一次掷骰而不是只重置 rolling:玩家已经表达了"我要掷",重置状态等于把这次点击丢掉、还要他再点一次;补掷走的是既有的 2D 回退路径,表现和改造前完全一致。

一个值得记下来的返工:第一版我写的是 if (activeRolling) roll2D(...),结果新增的回归用例当场报红。原因是 activeRolling 是当前渲染的闭包值——3D 失败回调可能与 roll() 处在同一个同步流程里,那时 rollingsetState 还没生效,读到的仍是旧值,判定成"没有掷骰在进行",等于没修,和原 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 全绿。
@Lyltrum
Lyltrum requested a review from LMH168 August 4, 2026 04:32

@LMH168 LMH168 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

为了方便 review,请补充 UI 图片展示其效果

@Lyltrum
Lyltrum requested a review from LMH168 August 4, 2026 04:58
@Lyltrum
Lyltrum marked this pull request as draft August 4, 2026 04:59
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 的,不重建
前端拿到的还是旧类型。
@Lyltrum

Lyltrum commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author
截屏2026-08-04 下午2 13 06

@Lyltrum
Lyltrum marked this pull request as ready for review August 4, 2026 06:15
@Lyltrum
Lyltrum merged commit 567c60f into 1024XEngineer:main Aug 4, 2026
4 checks passed

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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())}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature][前端] 技能检定与自由掷骰改用 Three.js 真 3D 骰子

2 participants