From 17bd6b1582f5fd190221e71784eed28f88a7e941 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 10 Jun 2026 03:01:02 +0000 Subject: [PATCH] =?UTF-8?q?feat(studio):=20M-b=E2=91=A1=20=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E9=9D=A2=E6=89=A9=E5=B1=95=E2=80=94=E2=80=94theme/sco?= =?UTF-8?q?reLabel=20=E5=85=A5=E7=94=BB=E5=B8=83(=E8=A7=A3=E9=99=A4=20?= =?UTF-8?q?=C2=A76.3=20=E4=B8=8D=E6=B8=B2=E6=9F=93=E8=BE=B9=E7=95=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../2026-06-10-Mb渲染面-execution.md | 85 +++++++++++++++++++ game-studio/src/host/inject.ts | 2 +- game-studio/src/host/runtime/index.ts | 64 ++++++++++++-- 3 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 docs/agent-specs/2026-06-10-Mb渲染面-execution.md diff --git a/docs/agent-specs/2026-06-10-Mb渲染面-execution.md b/docs/agent-specs/2026-06-10-Mb渲染面-execution.md new file mode 100644 index 00000000..1c541a30 --- /dev/null +++ b/docs/agent-specs/2026-06-10-Mb渲染面-execution.md @@ -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 已转义尖括号防 `` 提前闭合 | `inject.ts:43,131-133` | +| demo 包 `gameConfig: { target: 5 }`(无 theme/scoreLabel) | `inject.ts:235` | +| `gameConfig: Record`——读 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 控制符(正则字符类剔除码位 0–31 与 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②)」。 diff --git a/game-studio/src/host/inject.ts b/game-studio/src/host/inject.ts index cde04756..f5aa0f7e 100644 --- a/game-studio/src/host/inject.ts +++ b/game-studio/src/host/inject.ts @@ -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', diff --git a/game-studio/src/host/runtime/index.ts b/game-studio/src/host/runtime/index.ts index 22f298af..d7441adb 100644 --- a/game-studio/src/host/runtime/index.ts +++ b/game-studio/src/host/runtime/index.ts @@ -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 控制符(码位 0–31 与 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 assets;image 真实预加载,其余仅登记,骨架够用)—— // 完整性占位:统计加载完成数,全部 settled 即视为 game_loaded。 function loadAssets(): Promise { @@ -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';