diff --git a/game-studio/src/host/runtime/index.ts b/game-studio/src/host/runtime/index.ts index 45c1f1aa..e6fdbab5 100644 --- a/game-studio/src/host/runtime/index.ts +++ b/game-studio/src/host/runtime/index.ts @@ -32,6 +32,16 @@ interface RuntimeSDKView { e: 'sdk_ready' | 'game_loaded' | 'game_start' | 'game_end' | 'game_error', d?: Record, ) => void; + /** idle 离线存档(HJ-MC-TPL-EXEC-002 §6.2):fire-and-forget 写键值(宿主受信边界落 localStorage) */ + saveState?: (key: string, value: string) => void; + /** + * idle 离线读档:按 key 取存档(宿主回包;5s 超时兜底返回 null,绝不抛/不阻塞)。 + * 注:返回「宿主受信边界已解析好的对象」而非原始字符串——§5.5 红线要求 runtime 侧零字符串解析 + * (解析在宿主受信边界做),故此处返回 object|null;runtime 直接读字段,不在 runtime 内解析。 + * (此处对 §6.2 草案 Promise 的最小修正见交付报告 open_issue:§6.2 草案返回类型与 + * §5.5「runtime 无字符串解析」不自洽,依 §5.5 硬门取 object 返回,inject 侧应答端负责解析。) + */ + loadState?: (key: string) => Promise | null>; } /** @@ -425,6 +435,357 @@ export function startRuntime( }; } + // =========================================================================== + // 玩法分支 3:idle(放置挂机 + 离线产出闭环,§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.4:key 隔离,必须与宿主 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); // 累加后节流存档(捕获在线进度) + } + // 资源达标即通关(单一来源 finish,score=最终资源量取整,§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.3:pointerdown 点击产料 / 命中升级按钮购升级)—— + 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 | 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 开始 */ + }); + } + } + + // =========================================================================== + // 玩法分支 4:tycoon(模拟经营循环,§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.1):1=慢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.3,canvas 内部像素坐标系;与 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; + } + // 通关判定(金币达标):单一来源 finish,score=最终金币(§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, + ); + // 进货按钮(goodsLabel;coin≥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); + // 售出按钮(customerLabel;stock>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.4:pointerdown 进货 / 售出)—— + 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 {