feat(runtime): M-c批② initIdle(离线闭环)+initTycoon(经营循环)——纯追加双case,initMerge零行删改

- RuntimeSDKView 扩 saveState?/loadState? 两可选方法(loadState 返 object|null,宿主受信边界已解析,runtime侧零JSON.parse守§5.5);缺失安全跳过(旧宿主/demo退化无离线态不崩)
- initIdle:loadState→离线补发公式(钳offlineMaxMin+折扣双闸防爆)→在线点击产料+每秒自动产出→节流saveState(≥2s)→资源达targetResource通关;score=floor(resource)
- initTycoon:进货带客(onDown waitingCustomers+1)→售出配对→金币达targetCoin通关;按钮布局契约(buy中心W*0.28/H*0.70,sell W*0.72/H*0.70)承重接口;score=coin
- clampInt/localXY各init函数体内复制(沿initMerge范式禁提升);零DOM/零可抛新增;hunk全>426(initMerge:255-426零删改)

HJ-MC-TPL-EXEC-002 §5

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-10 16:46:06 +00:00
parent 7033cef382
commit 88388f7b46

View File

@ -32,6 +32,16 @@ interface RuntimeSDKView {
e: 'sdk_ready' | 'game_loaded' | 'game_start' | 'game_end' | 'game_error',
d?: Record<string, unknown>,
) => void;
/** idle 离线存档HJ-MC-TPL-EXEC-002 §6.2fire-and-forget 写键值(宿主受信边界落 localStorage */
saveState?: (key: string, value: string) => void;
/**
* idle 线 key 宿5s null/
* 宿§5.5 线 runtime
* 宿 object|nullruntime runtime
* §6.2 Promise<string|null> open_issue§6.2
* §5.5runtime §5.5 object inject
*/
loadState?: (key: string) => Promise<Record<string, unknown> | null>;
}
/**
@ -425,6 +435,357 @@ export function startRuntime(
};
}
// ===========================================================================
// 玩法分支 3idle放置挂机 + 离线产出闭环§5.2)—— 纯数值/Canvas无物理、无 DOM
// ===========================================================================
function initIdle(): void {
// —— 自包含纯函数(沿 initMerge:258 clampInt 范式逐字照抄函数体;必须定义在本函数体内,
// toString() 注入约束;严禁引用 initMerge 内符号、严禁提升到共享作用域)——
/** 整数钳制:非整数/越界回落 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;
}
/** 取指针在 canvas 内部像素坐标系的坐标(沿 initMerge:320 localXY 范式,自包含一份) */
function localXY(e: PointerEvent): [number, number] {
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];
}
/** 矩形命中判定:点 (px,py) 落在矩形 [x,x+w]×[y,y+h] 内即 true纯算术无可抛 */
function inRect(px: number, py: number, x: number, y: number, w: number, h: number): boolean {
return px >= x && px <= x + w && py >= y && py <= y + h;
}
// —— 读 config缺省/越界钳制 + 跨字段钳制兜底§5.2.1)——
const clickYield = clampInt(pkg.gameConfig?.clickYield, 1, 10, 1); // 每次点击产出资源数
const autoYieldBase = clampInt(pkg.gameConfig?.autoYield, 1, 20, 1); // 升级前每秒自动产出基准
const targetResource = clampInt(pkg.gameConfig?.targetResource, 50, 600, 100); // 通关目标资源量
let upgradeCost = clampInt(pkg.gameConfig?.upgradeCost, 10, 200, 50); // 升级成本
// §3.3 兜底③:钳到 < targetResource防「升级比通关还贵」死局LLM 违例也不致死局)
upgradeCost = Math.min(upgradeCost, targetResource - 1);
const upgradeMul = clampInt(pkg.gameConfig?.upgradeMultiplier, 2, 5, 2); // 升级倍率(整数倍)
const resourceLabel = cleanText(pkg.gameConfig?.resourceLabel, 8) || '资源'; // 资源文案缺省兜底
const offlineMaxMin = clampInt(pkg.gameConfig?.offlineMaxMinutes, 5, 120, 60); // 离线产出上限分钟
const offlineEff = clampInt(pkg.gameConfig?.offlineEfficiencyPercent, 10, 100, 50); // 离线效率折扣%
// —— 状态变量§5.2.1)——
let resource = 0; // 当前资源量(浮点累加,绘制/判定时取整)
let autoYield = autoYieldBase; // 当前每秒自动产出(升级后翻 upgradeMul 倍)
let upgraded = false; // 是否已购升级MVP 单档,买一次即满)
let lastTickMs = 0; // 上次自动产出累加时间戳game_start 后首帧置)
let lastSaveMs = 0; // 上次节流存档时间戳§5.2.4 节流 ≥2s 一次)
// —— 存档键名§5.2.4key 隔离,必须与宿主 handleStorage 白名单前缀逐字一致 §6.1.2)——
const saveKey = 'idle:' + pkg.gameId + ':' + pkg.versionId;
// —— 升级按钮布局Canvas 内部像素坐标系;底部居中一个按钮区,纯几何)——
const ubX = canvas.width * 0.25;
const ubY = canvas.height * 0.78;
const ubW = canvas.width * 0.5;
const ubH = canvas.height * 0.14;
/**
* §5.2.4(b)fire-and-forget 宿 localStorage
* saveState RuntimeSDKView 宿/demo §6.2
* try-catch 沿 sdk.track runtime AND
* JSON.stringify try-catch
*/
function persist(force: boolean): void {
if (!sdk.saveState) return; // 可选方法缺失idle 退化无离线态,安全跳过
const now = Date.now();
if (!force && now - lastSaveMs < 2000) return; // 节流:非强制时 ≥2s 一次,避免每帧写
lastSaveMs = now;
try {
// 存档值形态§5.2.4(a)):版本位 + 资源 + 速率 + 升级态 + 离开时间戳
const json = JSON.stringify({
v: 1,
resource: Math.floor(resource),
autoYield: autoYield,
upgraded: upgraded,
leftAtMs: now,
});
sdk.saveState(saveKey, json);
} catch {
/* 存档失败静默吞,不影响主循环 */
}
}
// —— 渲染玩法层(背景/标题由 drawBase 画;本层做自动产出累加 + 绘进度/提示/升级按钮)——
drawGame = function (): void {
if (!ctx) return;
// 自动产出累加(仅 started 后按帧间真实时间差与帧率解耦确定性§5.2.2
if (started && running) {
const now = Date.now();
if (lastTickMs === 0) lastTickMs = now;
const elapsedSec = (now - lastTickMs) / 1000;
if (elapsedSec > 0) {
resource += autoYield * elapsedSec; // 浮点累加,绘制时取整
lastTickMs = now;
persist(false); // 累加后节流存档(捕获在线进度)
}
// 资源达标即通关(单一来源 finishscore=最终资源量取整§5.2.3 score 语义拍板)
if (resource >= targetResource) {
persist(true); // 通关前强制存一次最终态§5.2.4(b)
finish(Math.floor(resource));
return;
}
}
const w = canvas.width;
const h = canvas.height;
// 进度文案:当前资源 / 目标资源theme 模式高亮白 / 旧分支青色 scoreColor
ctx.textAlign = 'center';
ctx.fillStyle = scoreColor;
ctx.font = 'bold 26px system-ui, sans-serif';
ctx.fillText(resourceLabel + '' + Math.floor(resource) + ' / ' + targetResource, w / 2, h * 0.42);
// 点击提示muted
ctx.fillStyle = '#8d9ab6';
ctx.font = '14px system-ui, sans-serif';
ctx.fillText(
started ? '点击产出 +' + clickYield + '/秒自动 +' + autoYield : '点击屏幕开始挂机',
w / 2,
h * 0.52,
);
// 升级按钮区未升级时绘制resource≥upgradeCost 高亮可点,否则 muted
if (!upgraded) {
const affordable = resource >= upgradeCost;
ctx.fillStyle = affordable ? 'rgba(49,230,255,0.18)' : 'rgba(255,255,255,0.05)';
ctx.fillRect(ubX, ubY, ubW, ubH);
ctx.strokeStyle = affordable ? '#31e6ff' : 'rgba(141,154,182,0.5)';
ctx.strokeRect(ubX, ubY, ubW, ubH);
ctx.fillStyle = affordable ? '#eef6ff' : '#8d9ab6';
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.fillText('升级 ×' + upgradeMul + '(花' + upgradeCost + '', w / 2, ubY + ubH / 2 + 5);
}
};
// —— 交互§5.2.3pointerdown 点击产料 / 命中升级按钮购升级)——
function onDown(e: PointerEvent): void {
if (!running) return;
markStarted(); // 首次交互发 game_start同 clicker/merge 范式)
const [px, py] = localXY(e);
// 命中升级按钮区 且 未升级 且 资源够:购升级
if (inRect(px, py, ubX, ubY, ubW, ubH) && !upgraded && resource >= upgradeCost) {
resource -= upgradeCost;
autoYield = autoYieldBase * upgradeMul; // 升级:自动产出翻 upgradeMul 倍
upgraded = true;
try {
sdk.track('idle_upgrade', { autoYield });
} catch {
/* ignore */
}
persist(true); // 升级是关键态变化,强制存一次
return;
}
// 否则:点击产料
resource += clickYield;
try {
sdk.track('idle_click', { resource: Math.floor(resource) });
} catch {
/* ignore */
}
persist(false); // 点击后节流存档
if (resource >= targetResource) {
persist(true);
finish(Math.floor(resource)); // 点击也可触发通关
}
}
// idle 仅绑 pointerdown 一种交互finish/destroy 经 unbind 解绑 + 强制存档
canvas.addEventListener('pointerdown', onDown);
unbind = function (): void {
persist(true); // destroy/finish 时强制写一次最终态§5.2.4(b)
canvas.removeEventListener('pointerdown', onDown);
};
// —— 再入补发离线产出§5.2.4(c):启动时读档 → 补发公式 → 恢复升级态)——
// loadState 为可选方法;返回 Promise<对象|null>宿主受信边界已解析好的对象5s 超时兜底 null、
// 绝不 reject§6.2。runtime 侧直接读字段、零字符串解析(解析在宿主受信边界做,守 §5.5 红线)。
// 缺失/超时/无存档 → 从 0 开始在线循环CDP 在线取证 §7.2 走此默认路径)。
if (sdk.loadState) {
sdk
.loadState(saveKey)
.then((saved: Record<string, unknown> | null) => {
// 无存档/已结束/已交互/版本不符不补发诚实边界runtime 不解析仅读字段)
if (!saved || !running || started || saved.v !== 1) return;
// 恢复升级态钳制防脏数据autoYield 不超 autoYieldBase×upgradeMul 上限)
const savedAuto =
typeof saved.autoYield === 'number' && Number.isFinite(saved.autoYield)
? Math.min(Math.max(Math.round(saved.autoYield), autoYieldBase), autoYieldBase * upgradeMul)
: autoYieldBase;
const savedRes =
typeof saved.resource === 'number' && Number.isFinite(saved.resource) && saved.resource > 0
? Math.floor(saved.resource)
: 0;
const savedLeft =
typeof saved.leftAtMs === 'number' && Number.isFinite(saved.leftAtMs) ? saved.leftAtMs : 0;
// 离线补发公式§5.2.4(c)):钳上限分钟 + 效率折扣,双闸防爆数值
const offlineSec = savedLeft > 0 ? (Date.now() - savedLeft) / 1000 : 0;
const offlineMin = Math.min(offlineSec / 60, offlineMaxMin); // 钳上限(即便改本地时钟也被钳)
const bonus = offlineMin > 0 ? Math.floor((savedAuto * offlineMin * 60 * offlineEff) / 100) : 0;
resource = savedRes + (bonus > 0 ? bonus : 0); // 补发到当前资源
autoYield = savedAuto; // 恢复升级后速率
upgraded = saved.upgraded === true; // 恢复升级态
if (bonus > 0) {
try {
sdk.track('idle_offline_bonus', { bonus, offlineMin: Math.floor(offlineMin) });
} catch {
/* ignore */
}
}
// ⚠ 诚实边界§5.2.4(c)):补发只置初始 resource不在此直接 finish此时尚未 game_start
// 时序不对);待玩家首次交互 game_start 后由 drawGame 判定通关,不绕过 game_start。
})
.catch(() => {
/* loadState 约定绝不 reject此处仅兜底失败 = 无存档从 0 开始 */
});
}
}
// ===========================================================================
// 玩法分支 4tycoon模拟经营循环§5.3)—— 纯数值/Canvas无物理、无 DOM、无 storage
// ===========================================================================
function initTycoon(): void {
// —— 自包含纯函数(沿 initMerge:258/:320 范式逐字照抄;定义在本函数体内,严禁引用 initMerge 符号)——
/** 整数钳制:非整数/越界回落 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;
}
/** 取指针在 canvas 内部像素坐标系的坐标(沿 initMerge:320 localXY 范式,自包含一份) */
function localXY(e: PointerEvent): [number, number] {
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];
}
/** 矩形命中判定:点 (px,py) 落在矩形 [x,x+w]×[y,y+h] 内即 true纯算术无可抛 */
function inRect(px: number, py: number, x: number, y: number, w: number, h: number): boolean {
return px >= x && px <= x + w && py >= y && py <= y + h;
}
// —— 读 config缺省/越界钳制 + 跨字段钳制兜底§5.3.1)——
const purchaseCost = clampInt(pkg.gameConfig?.purchaseCost, 1, 50, 5); // 进货成本
let salePrice = clampInt(pkg.gameConfig?.salePrice, 2, 100, 10); // 售价
salePrice = Math.max(salePrice, purchaseCost + 1); // §3.5 兜底③:保正差价,防「越卖越亏」
const targetCoin = clampInt(pkg.gameConfig?.targetCoin, 50, 800, 200); // 通关目标金币
let initialCoin = clampInt(pkg.gameConfig?.initialCoin, 10, 100, 20); // 初始金币
initialCoin = Math.max(initialCoin, purchaseCost); // §3.5 兜底③:保首轮可进货,防「开局进不起货」
const custLevel = clampInt(pkg.gameConfig?.customerIntervalLevel, 1, 3, 2); // 顾客节奏档位
const goodsLabel = cleanText(pkg.gameConfig?.goodsLabel, 8) || '商品'; // 商品文案缺省兜底
const customerLabel = cleanText(pkg.gameConfig?.customerLabel, 8) || '顾客'; // 顾客文案缺省兜底
// 顾客到来间隔runtime 内查表§5.3.11=慢3s / 2=中2s / 3=快1s
const custIntervalMs = custLevel === 1 ? 3000 : custLevel === 3 ? 1000 : 2000;
// —— 状态§5.3.1)——
let coin = initialCoin; // 当前金币
let stock = 0; // 当前库存(进货增、售出减)
let waitingCustomers = 0; // 待服务顾客数(进货带客 + 节奏累加售出消耗§5.3.4
let lastCustMs = 0; // 上次顾客到来时间戳game_start 后首帧置)
// —— tycoon 按钮布局契约§5.3.3canvas 内部像素坐标系;与 player 字面同一套系数,承重接口)——
const W = canvas.width;
const H = canvas.height;
// 进货按钮区(左半下方):中心 = (W*0.28, H*0.70)
const buyX = W * 0.1;
const buyY = H * 0.62;
const buyW = W * 0.36;
const buyH = H * 0.16;
// 售出按钮区(右半下方):中心 = (W*0.72, H*0.70)
const sellX = W * 0.54;
const sellY = H * 0.62;
const sellW = W * 0.36;
const sellH = H * 0.16;
// —— 渲染玩法层(背景/标题由 drawBase 画;本层做顾客节奏累加 + 通关判定 + 绘进度/双按钮)——
drawGame = function (): void {
if (!ctx) return;
// 顾客节奏表现层started 后按 custIntervalMs 累加待服务顾客CDP 取证不依赖此§7.3
if (started && running) {
const now = Date.now();
if (lastCustMs === 0) lastCustMs = now;
if (now - lastCustMs >= custIntervalMs) {
waitingCustomers += Math.floor((now - lastCustMs) / custIntervalMs);
lastCustMs = now;
}
// 通关判定(金币达标):单一来源 finishscore=最终金币§5.3.4 score 语义拍板)
if (coin >= targetCoin) {
finish(coin);
return;
}
}
const w = W;
const h = H;
ctx.textAlign = 'center';
// 金币进度(高亮主文字)
ctx.fillStyle = scoreColor;
ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText('金币 ' + coin + ' / ' + targetCoin, w / 2, h * 0.40);
// 库存 + 待客muted
ctx.fillStyle = '#8d9ab6';
ctx.font = '14px system-ui, sans-serif';
ctx.fillText(
'库存 ' + stock + ' ' + goodsLabel + ' 待客 ' + waitingCustomers + ' ' + customerLabel,
w / 2,
h * 0.50,
);
// 进货按钮goodsLabelcoin≥purchaseCost 高亮可点)
const canBuy = coin >= purchaseCost;
ctx.fillStyle = canBuy ? 'rgba(49,230,255,0.18)' : 'rgba(255,255,255,0.05)';
ctx.fillRect(buyX, buyY, buyW, buyH);
ctx.strokeStyle = canBuy ? '#31e6ff' : 'rgba(141,154,182,0.5)';
ctx.strokeRect(buyX, buyY, buyW, buyH);
ctx.fillStyle = canBuy ? '#eef6ff' : '#8d9ab6';
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.fillText('进货 ' + goodsLabel + '' + purchaseCost + '', buyX + buyW / 2, buyY + buyH / 2 + 5);
// 售出按钮customerLabelstock>0 && waitingCustomers>0 高亮可点)
const canSell = stock > 0 && waitingCustomers > 0;
ctx.fillStyle = canSell ? 'rgba(49,230,255,0.18)' : 'rgba(255,255,255,0.05)';
ctx.fillRect(sellX, sellY, sellW, sellH);
ctx.strokeStyle = canSell ? '#31e6ff' : 'rgba(141,154,182,0.5)';
ctx.strokeRect(sellX, sellY, sellW, sellH);
ctx.fillStyle = canSell ? '#eef6ff' : '#8d9ab6';
ctx.font = 'bold 15px system-ui, sans-serif';
ctx.fillText('售出(+' + salePrice + '', sellX + sellW / 2, sellY + sellH / 2 + 5);
};
// —— 交互§5.3.4pointerdown 进货 / 售出)——
function onDown(e: PointerEvent): void {
if (!running) return;
markStarted(); // 首次交互发 game_start
const [px, py] = localXY(e);
// 命中进货按钮 且 金币够:进货
if (inRect(px, py, buyX, buyY, buyW, buyH) && coin >= purchaseCost) {
coin -= purchaseCost; // 花金币
stock += 1; // 加库存
waitingCustomers += 1; // §5.3.4 进货带客保「进货→售出」严格配对CDP 无需等顾客(确定性循环)
try {
sdk.track('tycoon_buy', { coin, stock });
} catch {
/* ignore */
}
return;
}
// 命中售出按钮 且 有库存 且 有待客:售出
if (inRect(px, py, sellX, sellY, sellW, sellH) && stock > 0 && waitingCustomers > 0) {
coin += salePrice; // 得金币
stock -= 1; // 减库存
waitingCustomers -= 1; // 减待客
try {
sdk.track('tycoon_sell', { coin });
} catch {
/* ignore */
}
if (coin >= targetCoin) finish(coin); // 售出后判通关
}
}
// tycoon 仅绑 pointerdown 一种交互点击双按钮finish/destroy 经 unbind 解绑
canvas.addEventListener('pointerdown', onDown);
unbind = function (): void {
canvas.removeEventListener('pointerdown', onDown);
};
}
// ===========================================================================
// 模板分发§5.1):读 templateId → 选玩法 init未知模板走防御分支
// ===========================================================================
@ -437,6 +798,12 @@ export function startRuntime(
case 'merge':
initMerge();
break;
case 'idle': // ← 批② 追加放置挂机含离线产出闭环§5.2
initIdle();
break;
case 'tycoon': // ← 批② 追加模拟经营循环无离线态§5.3
initTycoon();
break;
default:
// 未知 templateId可审计上报 + game_error不起循环/不绑交互/不错渲染§5.4 堵静默错渲染缺口)
try {