feat(studio): M-b② 渲染面扩展——theme/scoreLabel 入画布(解除 §6.3 不渲染边界)

- theme: djb2 哈希派生双色渐变底色 + 顶部标题入画(≤16字清洗截断, cleanText 控制字节剔除)
- scoreLabel: 替换计分文案「{scoreLabel}:n/target」(≤8字, 缺省回落'点击得分')
- demo 包同步带 theme/scoreLabel 保持 parity(target:5 不动, player 信号 c 依赖)
- 遥测/生命周期契约零触碰(game_end{score,completed,duration_ms} 蛇形逐字不动)
- 体积: startRuntime(min) 1673→2260B raw(gzip 1193B), 占 15KB 红线 14.7%
- 纯 Canvas fillText, 零 DOM 注入; theme 为空时旧包渲染逐像素零变化
- spec: 2026-06-10-Mb渲染面-execution.md(85行, 体积/契约双门禁)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-10 03:01:02 +00:00
parent 054564d334
commit 17bd6b1582
3 changed files with 143 additions and 8 deletions

View File

@ -0,0 +1,85 @@
# M-b② 渲染面扩展(theme/scoreLabel 入画布)· 执行版
- **编号**: HJ-MB2-RENDER-EXEC-001 | 2026-06-10 | 基线 commit `054564d`
- **依据**: EXEC-001(`2026-06-09-agent化生成QA闭环-execution.md`)§6.3 诚实边界原文(:243-245)——「runtime 今天只消费 `target`(runtime/index.ts:78-79);theme/scoreLabel v1 仅文本评审面(不渲染,M-b 扩渲染面)」。**本波就是解除这条**。
- **互斥**: 本波只改 `game-studio/src/host/runtime/index.ts` + `game-studio/src/host/inject.ts` 两文件;与 M-b①(game-cloud 后端 aigc Java,`2026-06-10-Mb生成下沉-execution.md`)零交集。`GamePlayer.vue`/`sdk/`/`bridge.ts`/`contract.ts`/`telemetry` 本波任何人不得改。
## 1. 目标 / 非目标
**目标**: ① `gameConfig.theme` 入画布(顶部标题 + theme 哈希派生柔和背景色系,解除恒深底青字);② `gameConfig.scoreLabel` 替换计分文案(`{scoreLabel}:{score} / {target}`);③ demo 包同步带 theme/scoreLabel 保持 parity。
**非目标**: 不改字号/dpr 约定(28px 不乘 dpr 系现状,inject.ts:112);不动玩法(clicker 计分/达标结束);不动遥测与生命周期契约任何一字;不动 GamePlayer.vue;不做多模板(dodge/runner/match 渲染)。
## 2. 亲核事实基线(文件:行号,@054564d)
| 事实 | 出处 |
|---|---|
| 画布硬编码深底 `#070911` + 青字 `#31e6ff`,文案 = 字符串「点击得分」+全角冒号+score+` / `+target | `runtime/index.ts:121-128` |
| runtime 只消费 `gameConfig.target`(缺省 5) | `runtime/index.ts:78-79` |
| game_end 契约行 `{ score, completed: true, duration_ms: ... }`(蛇形,quality 回路命门) | `runtime/index.ts:167` |
| 工厂函数 toString() 注入 iframe,函数体内禁引用模块外符号 | `runtime/index.ts:13-14``inject.ts:38-39,94,104` |
| 注入数据层 safeJson 已转义尖括号防 `</script>` 提前闭合 | `inject.ts:43,131-133` |
| demo 包 `gameConfig: { target: 5 }`(无 theme/scoreLabel) | `inject.ts:235` |
| `gameConfig: Record<string, unknown>`——读 theme/scoreLabel 无需改契约类型 | `contract.ts:169` |
| Tier1 红线原文「自研轻量 Canvas Runtime(<15KB)」「构建产物 < 15KB | `.agents/knowledge/tech-decisions.md:20,35,73``runtime/index.ts:5` |
| clicker schema 真源: title/theme/scoreLabel 非空 string + target int 5-30 | EXEC-001:91,241(gen_spike.py:44-81) |
| player 五条 AND 全部基于线缆信封(loaded/completed/durationMs>0/无错/checksum),不读画布像素 | `player_cdp.py:833-841` |
| player demo 信号 c = score==demo target 5——**buildDemoPackage 的 `target: 5` 不得动** | `player_cdp.py:93` |
| player demo 信号 a 锚定 GamePlayer.vue:125/138 warn 字符串——该文件不动即安全 | `player_cdp.py:87-91` |
## 3. 设计(两处改动,全部在 startRuntime 函数体内 / buildDemoPackage 字面量内)
### 3.1 `runtime/index.ts` — startRuntime 内新增(约 35-50 行,含中文注释)
1. **读配置(:79 旁,状态区一次性预计算,draw 每帧不重算)**:
- `theme`:仅 `typeof === 'string'` 才采纳;清洗 ASCII 控制符(正则字符类剔除码位 031 与 127;**正则一律用转义序列书写,严禁把字面控制字节写进源码**);截断 ≤16 字符(超长加省略号);否则取空串。
- `scoreLabel`:同法清洗,截断 ≤8 字符;缺省回落「点击得分」。
2. **派生色系(theme 非空时)**:函数体内 djb2 哈希 theme → hue ∈ [0,360);预计算字符串:背景渐变上 `hsl(hue,40%,18%)`、下 `hsl((hue+40)%360,45%,12%)`(深而柔和,保对比);标题色 `hsl(hue,85%,78%)`;计分主文字 `#eef6ff`;提示 muted `#8d9ab6` 不变。**theme 为空 ⇒ 完全走现行 `#070911`/`#31e6ff` 分支,旧包(仅 target)渲染逐像素零变化(向后兼容)**。
3. **draw() 改造(:115-133)**:
- 背景:theme 非空用 `ctx.createLinearGradient(0,0,0,h)` 双色渐变;空则保留 `#070911` 纯色。
- 顶部标题区:theme 非空时 `fillText(theme, w/2, 44)`(bold 18px,标题色);空则不绘(不留空槽)。
- 计分行::128 的「点击得分」字面量替换为 scoreLabel 变量,冒号/空格/拼接结构不变——缺省回落后与现文案逐字符一致。
4. **硬约束**:新增 helper(清洗/截断/哈希)必须定义在 startRuntime 函数体内(toString 注入红线);零 DOM API/零 innerHTML/零 JSON 解析/零网络——新增逻辑不得引入可抛异常路径(纯字符串与 canvas 调用;fillStyle 非法值 canvas 静默忽略),否则破坏五条 AND 之④。
5. **铁律红线(逐字不动)**::31-33 `__emit` 事件名集、:148 `life('game_start')`、:167 game_end 行、:176 `life('game_loaded',...)`、事件发射顺序与触发点。
### 3.2 `inject.ts` — 仅 buildDemoPackage(:235 一行)
```
gameConfig: { target: 5, title: 'Demo · 点击计数', theme: '霓虹深空实验室', scoreLabel: '能量' },
```
- `target: 5` 不动(player_cdp 信号 c);加 title/theme/scoreLabel 后 demo 即 clicker schema 全字段合法样本,demo 试玩直接呈现新渲染面(parity)。runtime 不读 title,无行为影响。
- 信封模板(:62-122 CHANNEL/direction/postToHost/boot)与 safeJson **零 diff**
### 3.3 注入与转义安全结论
theme/scoreLabel 全链 = JSON 数据注入(safeJson 已转义尖括号,inject.ts:131-133)→ canvas `fillText`(无 HTML/JS 解释面)。无新增执行面;清洗+截断防超长/控制符撑爆绘制。**禁 DOM 注入、禁 innerHTML 为验收红线**(见 §5-④)。
## 4. 尺寸预算(实测 @054564d,mini-desktop dist)
- **实测法**: `node /tmp/measure-runtime.cjs dist/assets/GamePlayer-*.js`(括号配平从构建产物提取被 toString 注入的压缩函数文本;脚本本波已落 mini-desktop:/tmp,md5 `e6608a77520d560cd4fb29e2fb8d3481`)。
- **现状**: startRuntime(min) **raw 1,673B / gzip 902B**;SDK 工厂 raw 1,766B / gzip 828B;整 GamePlayer chunk 17,386B / gzip ≈7.4KB。
- **红线核算**: 红线 = runtime 构建产物 < 15KB = **15,360B**(tech-decisions.md:20)。现占用 10.9%。本波预估增量(35-50 行源码,压缩后)≈ **+0.5~0.9KB raw / +0.2~0.4KB gzip** 改后 startRuntime 2.2~2.6KB raw,**占红线 <17%,余量 >12.7KB,结论:安全**。demo 包 JSON 增 ~50B,与 runtime 工厂文本预算无关。
- **门禁断言(改后必跑)**: 提取的 startRuntime(min) raw **< 15,360B(红线硬断言) < 4,096B(本波软门,防意外膨胀)**;前后差值写入交付记录
## 5. 验证门(执行顺序;严禁本机 npm build / 严禁 git 写命令 / 严禁动 :4173 serve)
1. **镜像**: 本机编辑两文件后 `ssh mini-desktop 'cat > /root/games-development-ai/game-studio/src/host/runtime/index.ts' < 本机同路径`(inject.ts 同法);两端 `md5sum` 对账一致。
2. **构建门**: `ssh mini-desktop "cd /root/games-development-ai/game-studio && npm run build"` 绿(铁律:验证门=npm run build,vue-tsc --noEmit 是假门禁)。注意 build 重写 dist——dist 不还原不部署,:4173 serve 不动,部署窗口归主 agent。
3. **体积门**: §4 门禁断言(对 build 出的新 GamePlayer chunk 跑 measure-runtime.cjs)。
4. **契约 grep 断言(对镜像后的源文件,零变化标准)**:
- `grep -c 'duration_ms' src/host/runtime/index.ts` == 3(基线 @054564d 实测即 3 处:2 注释 + 1 实行,断言口径 = 前后计数零变化;对抗核验时修正,原文误写 ==1),且 `git -C /root/games-development-ai diff game-studio/src/host/runtime/index.ts`(mini-desktop 上跑,只读)不含 :167 game_end 行、不含 `game_loaded`/`game_start`/`__emit` 任何行的删改;
- `git diff game-studio/src/host/inject.ts` 仅命中 buildDemoPackage 的 gameConfig 一行;`grep 'target: 5' src/host/inject.ts` 命中;
- `grep -rn 'innerHTML' src/host/runtime/index.ts``grep -rn 'document\.' src/host/runtime/index.ts` 零命中(禁 DOM 红线);
- 控制字节门:`grep -P '[\x00-\x08\x0b\x0c\x0e-\x1f]' src/host/runtime/index.ts src/host/inject.ts` 零命中(源码不得含字面控制字节);
- GamePlayer.vue/sdk/bridge/contract `git diff` 为空。
5. **还原(mini-desktop)**: `ssh mini-desktop "git -C /root/games-development-ai checkout -- game-studio"``git status` 复核 src 干净;**还原清单 = src/host/runtime/index.ts、src/host/inject.ts(仅此两文件被镜像)**;dist 产物不还原。
6. **部署后验收(归主 agent,非本执行者)**: 部署窗口内重建+重部署后,`player_cdp.py --self-test --version-id <真实落包>` **五条 AND 全绿不回归**(口径 player_cdp.py:833-841;纯渲染改动不触线缆面,①②③⑤理论零影响,④靠 §3.1-4 无抛路径守住)+ 浏览器实玩**截图**呈现:theme 标题入画、派生底色非恒 `#070911`、计分文案为 `{scoreLabel}:n / target`
## 6. 失败路径与回滚
- 构建红/体积门红/契约 grep 红:本机修正→重镜像→重跑;连续两轮不过则停工上报,不得绕门。
- 部署后五绿回归失败:回滚 = 还原本波两文件改动重建重部署(改动面仅渲染,无数据/契约迁移,回滚零代价);截图与 PlayReport 留档归因。
- 上游矛盾:若与 EXEC-001 §6.3 修订冲突,以 EXEC-001 为准停工上报。
## 7. 完成定义(DoD)
构建门绿 + 体积门两断言过 + 契约 grep 全过 + mini-desktop 源已还原(清单留痕)+ 本机两文件改动即交付物;随后主 agent 部署窗口完成 §5-6 五绿+截图,EXEC-001 §6.3 诚实边界条目方可改写为「theme/scoreLabel 已入渲染面(M-b②)」。

View File

@ -232,7 +232,7 @@ export function buildDemoPackage(gameId: string, versionId: string, traceId: str
gameId,
versionId,
templateId: 'clicker', // MVP P0 模板之一(最终由产品拍板,取值在 #4 描述范围内)
gameConfig: { target: 5 }, // demo 点击目标分
gameConfig: { target: 5, title: 'Demo · 点击计数', theme: '霓虹深空实验室', scoreLabel: '能量' }, // demo 点击目标分target: 5 系 player 取证锚点不得动)+ M-b② 渲染面字段 parity
assets: [], // demo 无外部资源
manifest: {
runtimeVersion: '1.0.0',

View File

@ -78,6 +78,41 @@ export function startRuntime(
const target =
typeof pkg.gameConfig?.target === 'number' ? (pkg.gameConfig.target as number) : 5;
// —— 渲染面配置M-b②theme/scoreLabel 入画布状态区一次性预计算draw 每帧不重算)——
/**
* string ASCII 031 127
*
* player AND
* startRuntime toString() iframe
*/
function cleanText(v: unknown, maxLen: number): string {
if (typeof v !== 'string') return '';
const s = v.replace(/[\u0000-\u001f\u007f]/g, '').trim();
return s.length <= maxLen ? s : s.slice(0, maxLen) + '…';
}
// theme题材文案≤16 字符);为空 ⇒ 完全走旧渲染分支(旧包逐像素零变化,向后兼容)
const theme = cleanText(pkg.gameConfig?.theme, 16);
// scoreLabel计分文案≤8 字符);缺省回落「点击得分」,回落后与现行文案逐字符一致
const scoreLabel = cleanText(pkg.gameConfig?.scoreLabel, 8) || '点击得分';
// theme 非空时由 djb2 哈希派生柔和深色系预计算字符串hue 为整数保证 hsl 串恒合法,
// 不给 addColorStop 留可抛入口fillStyle 即便非法也被 canvas 静默忽略)
let bgTop = ''; // 背景渐变上色
let bgBottom = ''; // 背景渐变下色
let titleColor = ''; // 顶部标题色
let scoreColor = '#31e6ff'; // 计分主文字:旧分支保持青色 --cyan
if (theme) {
// djb2 哈希xor 变体,>>> 0 保无符号)→ hue ∈ [0,360)
let hash = 5381;
for (let i = 0; i < theme.length; i++) {
hash = ((hash * 33) ^ theme.charCodeAt(i)) >>> 0;
}
const hue = hash % 360;
bgTop = 'hsl(' + hue + ',40%,18%)'; // 渐变上色:深而柔和,保文字对比
bgBottom = 'hsl(' + ((hue + 40) % 360) + ',45%,12%)'; // 渐变下色:邻近色相更暗
titleColor = 'hsl(' + hue + ',85%,78%)'; // 标题:同色相高亮
scoreColor = '#eef6ff'; // theme 模式下计分主文字改高亮白(彩色背景上保对比)
}
// —— 资源加载(按 #4 assetsimage 真实预加载,其余仅登记,骨架够用)——
// 完整性占位:统计加载完成数,全部 settled 即视为 game_loaded。
function loadAssets(): Promise<void> {
@ -111,21 +146,36 @@ export function startRuntime(
});
}
// —— 渲染:极简文本场景(点击计数 + 提示)——
// —— 渲染:极简文本场景(点击计数 + 提示M-b②theme 标题/派生底色/scoreLabel 入画)——
function draw(): void {
// 类型守卫ctx 在上方 getContext 失败时已 return运行时此处必非空
// 再判一次是为让 TS 在本嵌套闭包内将 ctx 收窄为非空const 的 narrowing 不跨闭包保留)。
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
// 深色背景(对齐 D4 --bg #070911
ctx.fillStyle = '#070911';
if (theme) {
// theme 模式哈希派生双色纵向渐变背景色串预计算且恒合法addColorStop 无可抛入口)
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, bgTop);
grad.addColorStop(1, bgBottom);
ctx.fillStyle = grad;
} else {
// 旧分支:深色纯色背景(对齐 D4 --bg #070911旧包逐像素零变化
ctx.fillStyle = '#070911';
}
ctx.fillRect(0, 0, w, h);
// 计数(青色 --cyan
ctx.fillStyle = '#31e6ff';
ctx.font = 'bold 28px system-ui, sans-serif';
// 文本统一居中(每帧在任何 fillText 前设置,与旧实现等效)
ctx.textAlign = 'center';
ctx.fillText('点击得分:' + score + ' / ' + target, w / 2, h / 2 - 8);
if (theme) {
// 顶部标题区theme 入画(为空不绘,不留空槽)
ctx.fillStyle = titleColor;
ctx.font = 'bold 18px system-ui, sans-serif';
ctx.fillText(theme, w / 2, 44);
}
// 计数theme 模式高亮白 / 旧分支青色 --cyan文案 = scoreLabel缺省回落与旧文案逐字符一致
ctx.fillStyle = scoreColor;
ctx.font = 'bold 28px system-ui, sans-serif';
ctx.fillText(scoreLabel + '' + score + ' / ' + target, w / 2, h / 2 - 8);
// 提示muted
ctx.fillStyle = '#8d9ab6';
ctx.font = '14px system-ui, sans-serif';