feat(runtime): M-c批① 模板分发+merge玩法——单工厂switch(templateId)+initMerge拖拽合成+game_error防御

- 分发骨架:case ''/'clicker' 合并(空串兼容存量30款旧包)/case 'merge'/default=reportError+game_error不起循环不绑监听
- initClicker:现行逻辑逐字符迁入,计分/提示文案与坐标逐像素保真,仅绑 pointerdown;life/finish/loop/markStarted/drawBase 共享单份(红线代码不复制)
- initMerge:clampInt/cleanLabels 自包含纯函数兜底(targetLevel 钳≤chainLength 防死局);§5.3.1 布局契约逐字符照钉(cols=ceil(sqrt)/topPad=round(H*0.18)/margin=W*0.06/gap=W*0.03,格中心预计算缓存);半格方形容差命中;pointerdown 产料/起拖+pointermove 预览+pointerup 任意两格同级合成;score=合成次数(拍板#6)
- finish(score) 参数化单一 emit 点,duration_ms 计数维持基线3;零 DOM/零可抛/toString() 注入约束全守

HJ-MC-TPL-EXEC-001 §5(构建+体积门待 mini-desktop)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-10 12:31:26 +00:00
parent da9d109f4a
commit cdf4d82aa6

View File

@ -1,5 +1,5 @@
/**
* A2 宿 | Canvas RuntimeTier1 + + 1 demo
* A2 宿 | Canvas RuntimeTier1 + + templateId
* ----------------------------------------------------------------------------
* iframe SDK TS
* spec §3.5 / AC7 < 15KB
@ -7,7 +7,8 @@
*
* 1. GamePackage #4 assets image/audio
* 2. requestAnimationFrame
* 3. 1 demo clicker宿SDKRuntime
* 3. pkg.templateId M-c clicker= / merge=
* 宿SDKRuntimegame_error
* 4. window.WanxiangGameSDK game_loaded game_start game_end
*
* SDK 便宿 .toString() iframe
@ -36,7 +37,7 @@ interface RuntimeSDKView {
/**
* Canvas Runtime
* @param canvas iframe
* @param pkg #4Runtime assets/gameConfig
* @param pkg #4Runtime templateId/assets/gameConfig
* @param sdk window.WanxiangGameSDK
*/
export function startRuntime(
@ -68,15 +69,13 @@ export function startRuntime(
return { destroy() {} };
}
// —— 状态 ——
let score = 0; // demo clicker 计数
// —— 共享生命周期状态clicker / merge 玩法共用单一来源,杜绝 ×N 漂移)——
let running = true; // 主循环开关
let rafId = 0; // requestAnimationFrame 句柄
let started = false; // 是否已发 game_start
let startMs = 0; // 本局开始时间戳game_start 时记;用于 game_end 的 duration_ms
// 目标分数:优先取 gameConfig.target缺省 5demo 玩法 = 点满即结束)
const target =
typeof pkg.gameConfig?.target === 'number' ? (pkg.gameConfig.target as number) : 5;
let drawGame: () => void = () => {}; // 玩法层绘制回调(由各 init 注入draw 共享背景+标题后调用它)
let unbind: () => void = () => {}; // 交互解绑回调(由各 init 注入finish/destroy 统一调用)
// —— 渲染面配置M-b②theme/scoreLabel 入画布状态区一次性预计算draw 每帧不重算)——
/**
@ -146,8 +145,8 @@ export function startRuntime(
});
}
// —— 渲染:极简文本场景(点击计数 + 提示M-b②theme 标题/派生底色/scoreLabel 入画)——
function draw(): void {
// —— 共享绘制基建:背景 + 顶部标题层(玩法层由各 init 注入的 drawGame 负责)——
function drawBase(): void {
// 类型守卫ctx 在上方 getContext 失败时已 return运行时此处必非空
// 再判一次是为让 TS 在本嵌套闭包内将 ctx 收窄为非空const 的 narrowing 不跨闭包保留)。
if (!ctx) return;
@ -172,55 +171,284 @@ export function startRuntime(
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';
ctx.fillText(score >= target ? '已通关 🎉' : '点击屏幕开始', w / 2, h / 2 + 26);
}
// —— 主循环requestAnimationFrame永不被 SDK 阻塞)——
function loop(): void {
if (!running) return;
draw();
drawBase(); // 共享背景 + 标题
drawGame(); // 当前玩法层clicker / merge 各自注入)
rafId = requestAnimationFrame(loop);
}
// —— 交互:点击 +1达标即结束本局 ——
function onPointer(): void {
if (!running) return;
if (!started) {
started = true;
startMs = Date.now();
life('game_start');
}
score += 1;
try {
sdk.track('click', { score });
} catch {
/* ignore */
}
if (score >= target) finish();
}
// —— 结束本局:发 game_end携带得分停循环 ——
function finish(): void {
/**
* game_end
* clicker / merge score score
* clicker = merge = §5.3
* game_end completed + duration_ms quality_score props.completed
* completed:true completeRate=0quality B2
*/
function finish(score: number): void {
if (!running) return;
running = false;
cancelAnimationFrame(rafId);
canvas.removeEventListener('pointerdown', onPointer);
// game_end 必须带 completed + duration_ms后端 quality_score 完玩率口径依赖 props.completed
// (达 target 即通关 = completed:true缺它则 completeRate=0、quality 永不上升数据回路失效B2 实测)。
unbind(); // 解绑当前玩法注册的全部交互监听
life('game_end', { score, completed: true, duration_ms: startMs ? Date.now() - startMs : 0 });
}
// 绑定交互
canvas.addEventListener('pointerdown', onPointer);
/** 首次交互统一入口:置 started 并发 game_startclicker / merge 同范式,单一来源) */
function markStarted(): void {
if (started) return;
started = true;
startMs = Date.now();
life('game_start');
}
// 启动序列:加载资源 → game_loaded → 起循环(等待玩家首次点击触发 game_start
// ===========================================================================
// 玩法分支 1clicker点击计数—— 行为逐字符迁自旧 startRuntime:71-221 现行逻辑)
// ===========================================================================
function initClicker(): void {
// —— 状态 ——
let score = 0; // demo clicker 计数
// 目标分数:优先取 gameConfig.target缺省 5demo 玩法 = 点满即结束)
const target =
typeof pkg.gameConfig?.target === 'number' ? (pkg.gameConfig.target as number) : 5;
// —— 渲染:极简文本场景(点击计数 + 提示M-b②scoreLabel 入画,背景/标题已由 drawBase 画)——
drawGame = function (): void {
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
// 计数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';
ctx.fillText(score >= target ? '已通关 🎉' : '点击屏幕开始', w / 2, h / 2 + 26);
};
// —— 交互:点击 +1达标即结束本局 ——
function onPointer(): void {
if (!running) return;
markStarted(); // 首次交互发 game_start
score += 1;
try {
sdk.track('click', { score });
} catch {
/* ignore */
}
if (score >= target) finish(score); // 达 target 即通关
}
// clicker 仅绑 pointerdown 一种交互(与旧实现一致)
canvas.addEventListener('pointerdown', onPointer);
unbind = function (): void {
canvas.removeEventListener('pointerdown', onPointer);
};
}
// ===========================================================================
// 玩法分支 2merge拖拽合成升级§5.3)—— 纯数值/Canvas 坐标,无物理、无 DOM、无可抛
// ===========================================================================
function initMerge(): void {
// —— 自包含纯函数(无可抛路径,沿 cleanText 范式必须定义在函数体内toString() 注入约束)——
/** 整数钳制:非整数/越界回落 def最终夹在 [lo,hi] */
function clampInt(v: unknown, lo: number, hi: number, def: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : def;
return n < lo ? lo : n > hi ? hi : n;
}
/** 物件文案数组清洗:逐项 cleanText不足 n 补「Lv{i}」超出截断§3.2 选项 A 兜底) */
function cleanLabels(v: unknown, n: number): string[] {
const src = Array.isArray(v) ? v : [];
const out: string[] = [];
for (let i = 0; i < n; i++) {
const t = cleanText(src[i], 8);
out.push(t || 'Lv' + (i + 1)); // 缺位级用占位文案,保每级有文案可绘
}
return out;
}
// —— 读 config缺省兜底与 clicker target 缺省同哲学)——
const chainLength = clampInt(pkg.gameConfig?.chainLength, 3, 6, 4); // 合成链长(等级数)
// targetLevel 钳到 ≤chainLength§3.3 兜底③LLM 违例也不致死局)
const targetLevel = clampInt(pkg.gameConfig?.targetLevel, 2, chainLength, Math.min(3, chainLength));
const boardSize = clampInt(pkg.gameConfig?.boardSize, 6, 9, 9); // 棋盘格总数
const itemLabel = cleanLabels(pkg.gameConfig?.itemLabel, chainLength); // 每级一个文案
// —— 棋盘布局§5.3.1 布局契约runtime↔player 承重接口,一字不偏)——
// 固定行列cols=ceil(sqrt(boardSize))、rows=ceil(boardSize/cols);格按行优先 0..boardSize-1 编号
const W = canvas.width;
const H = canvas.height;
const cols = Math.ceil(Math.sqrt(boardSize));
const rows = Math.ceil(boardSize / cols);
const topPad = Math.round(H * 0.18); // 标题层下方为进度文案预留
const margin = W * 0.06;
const gap = W * 0.03;
const gridH = H - topPad;
const cellW = (W - 2 * margin - (cols - 1) * gap) / cols;
const cellH = (gridH - 2 * margin - (rows - 1) * gap) / rows;
const cell = Math.min(cellW, cellH); // 正方格边长
// 各格中心坐标一次性预计算缓存draw 每帧不重算,沿 M-b② 范式)
const cx: number[] = [];
const cy: number[] = [];
for (let n = 0; n < boardSize; n++) {
const r = Math.floor(n / cols);
const c = n % cols;
cx.push(margin + c * (cell + gap) + cell / 2);
cy.push(topPad + margin + r * (cell + gap) + cell / 2);
}
// —— 棋盘状态每格物件等级0=空格)——
const levels: number[] = new Array(boardSize).fill(0);
let mergeCount = 0; // 合成次数(= game_end 的 score§5.3 拍板)
let dragging = false; // 是否拖拽中
let dragFrom = -1; // 拖拽起始格号
let dragX = 0; // 拖拽预览坐标(指针位置,纯渲染用)
let dragY = 0;
/** 命中判定:返回落点最近格号;方形半格容差内算命中,否则 -1§10 半格容差) */
function hitCell(px: number, py: number): number {
for (let n = 0; n < boardSize; n++) {
if (Math.abs(px - cx[n]) <= cell / 2 && Math.abs(py - cy[n]) <= cell / 2) return n;
}
return -1;
}
/** 取指针在 canvas 内部像素坐标系的坐标offsetX/Y 为 CSS 像素 → 按内部/CSS 比例换算) */
function localXY(e: PointerEvent): [number, number] {
// 用 canvas 内部宽高对 CSS 宽高的比例换算dpr 已隐含在比例里,与 player §5.3.1 比例映射对偶)
const sx = canvas.clientWidth ? canvas.width / canvas.clientWidth : 1;
const sy = canvas.clientHeight ? canvas.height / canvas.clientHeight : 1;
return [e.offsetX * sx, e.offsetY * sy];
}
// —— 渲染玩法层(背景/标题由 drawBase 画;本层只画网格+物件+进度+拖拽预览)——
drawGame = function (): void {
if (!ctx) return;
// 顶部进度文案:最高等级 / 目标等级
let maxLevel = 0;
for (let n = 0; n < boardSize; n++) if (levels[n] > maxLevel) maxLevel = levels[n];
ctx.textAlign = 'center';
ctx.fillStyle = scoreColor;
ctx.font = 'bold 20px system-ui, sans-serif';
ctx.fillText('最高 ' + maxLevel + ' / 目标 ' + targetLevel, W / 2, Math.round(topPad * 0.6));
// 逐格:描边 + 底;有物件则画等级色块 + 文案
ctx.font = 'bold 16px system-ui, sans-serif';
for (let n = 0; n < boardSize; n++) {
const x = cx[n] - cell / 2;
const y = cy[n] - cell / 2;
// 空格底(半透明)
ctx.fillStyle = 'rgba(255,255,255,0.04)';
ctx.fillRect(x, y, cell, cell);
// 描边muted 灰,固定色串恒合法)
ctx.strokeStyle = 'rgba(141,154,182,0.5)';
ctx.strokeRect(x, y, cell, cell);
const lv = levels[n];
if (lv > 0) {
// 按等级派生色相(确定性,整数 hue 保 hsl 串恒合法,无可抛入口)
const hue = (lv * 47) % 360;
ctx.fillStyle = 'hsl(' + hue + ',70%,45%)';
ctx.fillRect(x + cell * 0.12, y + cell * 0.12, cell * 0.76, cell * 0.76);
ctx.fillStyle = '#ffffff';
ctx.fillText(itemLabel[lv - 1], cx[n], cy[n] + 6);
}
}
// 拖拽中:在指针位置画被拖物件文案(纯渲染预览)
if (dragging && dragFrom >= 0 && levels[dragFrom] > 0) {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px system-ui, sans-serif';
ctx.fillText(itemLabel[levels[dragFrom] - 1], dragX, dragY);
}
};
// —— 交互pointerdown 产料/起拖、pointermove 更新预览、pointerup 求落点合成 ——
function onDown(e: PointerEvent): void {
if (!running) return;
markStarted(); // 首次交互发 game_start同 clicker 范式)
const [px, py] = localXY(e);
const n = hitCell(px, py);
if (n < 0) return; // 容差外不命中
if (levels[n] === 0) {
levels[n] = 1; // 空格产 1 级物件
} else {
dragFrom = n; // 有物件格:起拖
dragging = true;
dragX = px;
dragY = py;
}
}
function onMove(e: PointerEvent): void {
if (!running || !dragging) return;
const [px, py] = localXY(e);
dragX = px; // 仅更新拖拽预览坐标(纯渲染,不改棋盘状态)
dragY = py;
}
function onUp(e: PointerEvent): void {
if (!running) return;
if (dragging && dragFrom >= 0) {
const [px, py] = localXY(e);
const to = hitCell(px, py);
// 合成条件:命中 且 起止不同格 且 两格同级§5.3-3 不要求相邻,任意两格同级即可)
if (to >= 0 && to !== dragFrom && levels[to] > 0 && levels[to] === levels[dragFrom]) {
levels[to] = levels[to] + 1; // 落点格升一级
levels[dragFrom] = 0; // 起始格清空
mergeCount += 1;
try {
sdk.track('merge', { level: levels[to] });
} catch {
/* ignore */
}
// 合成后判通关:任一格等级 ≥ targetLevel → 调单一来源 finishscore=合成次数)
for (let i = 0; i < boardSize; i++) {
if (levels[i] >= targetLevel) {
finish(mergeCount);
break;
}
}
}
}
// 无论是否合成,结束本次拖拽
dragging = false;
dragFrom = -1;
}
// merge 绑三个指针事件destroy/finish 经 unbind 全部解绑
canvas.addEventListener('pointerdown', onDown);
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerup', onUp);
unbind = function (): void {
canvas.removeEventListener('pointerdown', onDown);
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerup', onUp);
};
}
// ===========================================================================
// 模板分发§5.1):读 templateId → 选玩法 init未知模板走防御分支
// ===========================================================================
const templateId = typeof pkg.templateId === 'string' ? pkg.templateId : '';
switch (templateId) {
case '': // 空串向后兼容存量 30 款旧 clicker 包templateId 缺失/为空§5.4 裁决)
case 'clicker':
initClicker();
break;
case 'merge':
initMerge();
break;
default:
// 未知 templateId可审计上报 + game_error不起循环/不绑交互/不错渲染§5.4 堵静默错渲染缺口)
try {
sdk.reportError({ message: 'runtime: unknown templateId=' + templateId });
} catch {
/* ignore */
}
life('game_error', { message: 'unknown_template', templateId });
return { destroy() {} };
}
// 启动序列:加载资源 → game_loaded → 起循环(等待玩家首次交互触发 game_start
loadAssets()
.then(() => {
life('game_loaded', { assetCount: pkg.assets?.length ?? 0 });
@ -239,10 +467,10 @@ export function startRuntime(
return {
destroy() {
// 宿主卸载/超时兜底:若未正常结束,补发 game_end 并清理
// 宿主卸载/超时兜底:若未正常结束,停循环并清理交互监听(不补发 game_end与旧实现一致
running = false;
cancelAnimationFrame(rafId);
canvas.removeEventListener('pointerdown', onPointer);
unbind();
},
};
}