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:
parent
da9d109f4a
commit
cdf4d82aa6
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* A2 宿主引擎 | Canvas Runtime(Tier1)—— 最小渲染循环 + 资源加载 + 1 个 demo 游戏
|
||||
* A2 宿主引擎 | Canvas Runtime(Tier1)—— 最小渲染循环 + 资源加载 + 按 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:点击计数),足以验证「宿主→SDK→Runtime→生命周期回灌」链路。
|
||||
* 3. 按 pkg.templateId 分发玩法(M-c 批①:clicker=点击计数 / merge=拖拽合成升级),
|
||||
* 足以验证「宿主→SDK→Runtime→生命周期回灌」链路;未知模板走防御分支(game_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 游戏包(契约 #4);Runtime 据 assets/gameConfig 加载与运行
|
||||
* @param pkg 游戏包(契约 #4);Runtime 据 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,缺省 5(demo 玩法 = 点满即结束)
|
||||
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=0、quality 永不上升,数据回路失效(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_start(clicker / merge 同范式,单一来源) */
|
||||
function markStarted(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
startMs = Date.now();
|
||||
life('game_start');
|
||||
}
|
||||
|
||||
// 启动序列:加载资源 → game_loaded → 起循环(等待玩家首次点击触发 game_start)
|
||||
// ===========================================================================
|
||||
// 玩法分支 1:clicker(点击计数)—— 行为逐字符迁自旧 startRuntime(:71-221 现行逻辑)
|
||||
// ===========================================================================
|
||||
function initClicker(): void {
|
||||
// —— 状态 ——
|
||||
let score = 0; // demo clicker 计数
|
||||
// 目标分数:优先取 gameConfig.target,缺省 5(demo 玩法 = 点满即结束)
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 玩法分支 2:merge(拖拽合成升级,§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 → 调单一来源 finish(score=合成次数)
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user