From 7bf2cd1d3b23d21beb6547aa12a6048e349c55c2 Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 12 Jun 2026 09:24:27 +0000 Subject: [PATCH] =?UTF-8?q?refactor(w-clean):=20=E6=97=A7=E7=8E=A9?= =?UTF-8?q?=E6=B3=95=E6=A8=A1=E6=9D=BF=E6=B8=85=E9=99=A4(=E5=88=9B?= =?UTF-8?q?=E5=A7=8B=E4=BA=BA=E4=BB=A42026-06-12)=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=93=B2=E5=AD=A6=E8=90=BD=E5=9C=B0=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E5=88=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game-studio runtime/index.ts 843→113行:clicker/merge/idle/tycoon 四模板游戏逻辑全删,统一「模板体系重构中」错误态(画布明示+game_error事件,不抛裸异常);运行时壳/生命周期/SDK/destroy 契约保留 - 后端:AigcTemplateConstants.SUPPORTED_TEMPLATE_IDS=List.of()+getTemplateList 返回空(提交入口/执行器双层拒绝,直插任务不烧LLM);两测试诚实适配(机制覆盖保留+清空后拒生成断言),aigc 模块 56 测全绿 - staging DB:15 表 1022 行已先归档(mini-desktop:/root/backups/w-clean-20260612/,304,606字节,可整表回滚)后物理删除;遥测/zone/player/trade/community/biz 全保留 - 验证:前端 build 绿/health 200/feed 空列表优雅(既有空态分支)/smoke 9/12(3 FAIL=feed空的预期差异,留档不掩绿) - 未动:contracts/templates schema 与 prompt registry(死内容,随 W-T1b spec v2 的 9a 能力插件清单一并清退) Co-Authored-By: Claude Fable 5 --- .../executor/AigcTemplateConstants.java | 19 +- .../service/task/AigcTaskServiceImpl.java | 35 +- .../executor/AigcGenerateExecutorTest.java | 12 +- .../service/task/AigcTaskServiceImplTest.java | 39 +- game-studio/src/host/runtime/index.ts | 828 ++---------------- 5 files changed, 96 insertions(+), 837 deletions(-) diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcTemplateConstants.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcTemplateConstants.java index e37752f6..a530e30f 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcTemplateConstants.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/executor/AigcTemplateConstants.java @@ -27,10 +27,23 @@ public final class AigcTemplateConstants { /** * 玩法模板白名单(唯一同源)。 - * M-c 批① = clicker + merge 两模板;批②(HJ-MC-TPL-EXEC-002 §6.3)已扩 idle/tycoon,四模板全收口。 - * 取值须与 PromptResourceLoader 资源 Map 的键、contracts/templates/<id>.schema.json 一一对应。 + * + * 【W-CLEAN 清场(2026-06-12 创始人明令「Delete those meaningless templates and game data from the past」)】 + * 旧四模板(clicker/merge/idle/tycoon)随「模板体系重构」整体下架——白名单清空为 {@code List.of()}。 + * 清空后语义(§6.5 同源不变量仍成立,两处消费点同时见空集,不产生「提交通过、执行 no_template_match」割裂): + * 1) {@link cn.wanxiang.game.module.aigc.service.task.AigcTaskServiceImpl#validateTemplateExists}: + * 任何 templateId 均不在空集 → 提交即抛 AIGC_TEMPLATE_NOT_EXISTS(创作入口自然返回「模板不存在」业务错); + * 2) {@link AigcExecutorProperties#supportedTemplates} 默认值据此初始化为空 → 执行器对任何任务判 no_template_match + * (纵深防御:即便有直插 DB 的历史 queued 任务也不会被生成、不烧 LLM)。 + * 注:本项目无「模板定义」DB 表,模板白名单是编译期常量同源——禁用只能改本常量(数据级无可禁用对象),此为最小改动点。 + * + * 【重构期扩模板纪律】新模板体系(Tier1 LittleJS + Runner v2,见 .agents 记忆 tiered-engine-cocos-decision)落地后, + * 在此 List 加新 templateId 即重新开放(同时 getTemplateList 加条 + PromptResourceLoader/GameConfigSchemaValidator + * 资源 Map 加行 + contracts/templates/<id>.schema.json 就位),白名单口径单点维护不变。 + * 旧四模板的契约 schema(contracts/templates/{clicker,merge,idle,tycoon}.schema.json)与 prompt 注册表条目按 + * W-CLEAN 范围(OUT)保留不动,仅本白名单清空使其「不可被生成」。 */ - public static final List SUPPORTED_TEMPLATE_IDS = List.of("clicker", "merge", "idle", "tycoon"); + public static final List SUPPORTED_TEMPLATE_IDS = List.of(); private AigcTemplateConstants() { // 纯常量持有类,禁止实例化 diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java index 556dc314..71b4600b 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java @@ -55,34 +55,13 @@ public class AigcTaskServiceImpl implements AigcTaskService { @Override public List getTemplateList() { - // T-AGC-03 模板注册表的 MVP 最小形态(HJ-AGENT-LOOP-EXEC-002 §8.4 T6,主 agent 裁决 #1 纳入): - // 返回硬编码 4 条(clicker+merge+idle+tycoon——M-c 模板波两批全收口;HJ-MC-TPL-EXEC-002 §6.4 批② 扩 idle/tycoon), - // 使 Create 页 canSubmit 依赖的模板列表非空(真实用户 UI 链路活)。 - // 注册表选型(DB 表 / Nacos 配置 / Dify 侧)落地后替换。 - TemplateRespVO clicker = new TemplateRespVO(); - clicker.setTemplateId("clicker"); - clicker.setName("点击收集"); - clicker.setDescription("点击屏幕给目标计分,达到目标即通关"); - clicker.setExamplePrompt("做一个帮奶茶店点单收银的小游戏"); - // coverUrl 暂空(§8.4:MVP 最小形态不配封面,前端按空值兜底展示) - TemplateRespVO merge = new TemplateRespVO(); - merge.setTemplateId("merge"); - merge.setName("合成升级"); - merge.setDescription("拖拽两个同级物件合成更高一级,合成到目标等级即通关"); - merge.setExamplePrompt("做一个把多肉植物合成升级的小游戏"); - // 批②(HJ-MC-TPL-EXEC-002 §6.4)扩 idle(放置挂机):点击+挂机产出资源,含离线收益 - TemplateRespVO idle = new TemplateRespVO(); - idle.setTemplateId("idle"); - idle.setName("放置挂机"); - idle.setDescription("点击和挂机自动产出资源,攒够目标即通关,离开再回来还能领离线收益"); - idle.setExamplePrompt("做一个挂机种树攒果实的小游戏"); - // 批②(HJ-MC-TPL-EXEC-002 §6.4)扩 tycoon(模拟经营):进货卖货赚差价 - TemplateRespVO tycoon = new TemplateRespVO(); - tycoon.setTemplateId("tycoon"); - tycoon.setName("模拟经营"); - tycoon.setDescription("进货卖货赚差价,金币攒到目标就通关"); - tycoon.setExamplePrompt("做一个开奶茶店进货卖货赚钱的小游戏"); - return List.of(clicker, merge, idle, tycoon); + // W-CLEAN 清场(2026-06-12 创始人明令「Delete those meaningless templates and game data from the past」): + // 旧四模板(clicker/merge/idle/tycoon)随「模板体系重构」整体下架,模板列表清空为空 List。 + // 配套效果:Create 页拿到空模板列表 → 无模板可选、canSubmit 为假;纵使前端绕过直发 generate, + // 也被 submitGenerate→validateTemplateExists(接空白名单 AigcTemplateConstants.SUPPORTED_TEMPLATE_IDS) + // 拒为 AIGC_TEMPLATE_NOT_EXISTS(创作入口自然返回「模板不存在」业务错,与白名单同源、无割裂)。 + // 新模板体系(Tier1 LittleJS + Runner v2)落地后,在此回填对应 TemplateRespVO 并同步白名单常量加项即重新开放。 + return List.of(); } @Override diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutorTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutorTest.java index 54048100..22a5022a 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutorTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/executor/AigcGenerateExecutorTest.java @@ -93,10 +93,20 @@ class AigcGenerateExecutorTest extends BaseMockitoUnitTest { return new AigcGenerateExecutor(props, aigcTaskMapper, difyCallbackService, LOADER, VALIDATOR, llmClient, clock); } - /** 就绪态配置(api-key 默认空会自禁用,就绪用例须显式注入测试密钥) */ + /** + * 就绪态配置(api-key 默认空会自禁用,就绪用例须显式注入测试密钥)。 + * + * W-CLEAN 清场(2026-06-12):生产白名单 {@code AigcTemplateConstants.SUPPORTED_TEMPLATE_IDS} 已清空, + * {@code supportedTemplates} 默认值随之为空。本组用例验证的是执行器「生成流水机制」(渲染→LLM→解析→回调→ + * 预算/看门狗/系统身份等——均属 W-CLEAN 保留的生成基础设施),需要一个可生成的模板夹具方能跑通; + * 故此处显式把 supportedTemplates 覆盖回 ["clicker"](clicker 资源仍在 PromptResourceLoader.TEMPLATE_RESOURCES + * 内,未删),让机制类用例继续覆盖被保留的基础设施,而非随白名单清空一并丢失覆盖。 + * 「白名单空集→拒生成」语义由生产常量层 + AigcTaskServiceImplTest 把守,不在本机制类用例职责内。 + */ private static AigcExecutorProperties readyProps() { AigcExecutorProperties props = new AigcExecutorProperties(); props.setApiKey("test-key"); + props.setSupportedTemplates(java.util.List.of("clicker")); // 测试本地夹具:W-CLEAN 后显式供给可生成模板 return props; } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImplTest.java index ffe32107..e9283426 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImplTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImplTest.java @@ -43,37 +43,24 @@ class AigcTaskServiceImplTest extends BaseMockitoUnitTest { // ============================== submitGenerate ============================== @Test - void testSubmitGenerate_defaultsAndQueued() { - // 白名单校验放行(§6.3):本用例聚焦提交默认值,创作者视为已通过 + void testSubmitGenerate_allTemplatesRejectedAfterWClean() { + // W-CLEAN 清场(2026-06-12 创始人明令):旧四模板随模板体系重构整体下架,白名单 + // AigcTemplateConstants.SUPPORTED_TEMPLATE_IDS 已清空为空集 → validateTemplateExists 对任何 + // templateId(含曾合法的 clicker)一律抛 AIGC_TEMPLATE_NOT_EXISTS,创作入口自然返回「模板不存在」。 + // 说明:清空白名单后,原「提交默认值落库(queued/progress/traceId/promptHash)」路径在公开 submit 入口 + // 不再可达——其覆盖随白名单清空一并下线(属设计内不可达,非遗漏);落库默认值的内部行为待新模板体系 + // 重新开放白名单后另立用例覆盖。本用例改为把守「白名单空集 → 拒生成」这条 W-CLEAN 不变量。 + // 白名单校验先放行(创作者视为已通过),把拒因精确收敛到模板存在性校验上。 when(playerApi.validateCreator(99L)).thenReturn(CommonResult.success(true)); - // 准备:insert 时回填自增 ID - doAnswer(inv -> { - AigcTaskDO d = inv.getArgument(0); - d.setId(1024L); - return 1; - }).when(aigcTaskMapper).insert(any(AigcTaskDO.class)); AigcGenerateReqVO reqVO = new AigcGenerateReqVO(); reqVO.setPrompt("做一个帮奶茶店点单收银的小游戏"); - // HJ-MC-TPL-EXEC-001 §6.5:validateTemplateExists 改接同源白名单(clicker+merge),dodge 不在集合会被拒; - // 本用例聚焦提交默认值/落库(非模板校验),故用白名单内的合法模板 clicker(语义不变,仅适配同源白名单收紧)。 - reqVO.setTemplateId("clicker"); + reqVO.setTemplateId("clicker"); // 曾合法模板,清场后亦被拒,验证「无任何模板可生成」 reqVO.setGameId(1L); - // 调用 - AigcTaskDO task = aigcTaskService.submitGenerate(reqVO, 99L); - - // 断言:返回任务 + 服务端默认字段正确 - assertEquals(1024L, task.getId()); - ArgumentCaptor captor = ArgumentCaptor.forClass(AigcTaskDO.class); - verify(aigcTaskMapper).insert(captor.capture()); - AigcTaskDO saved = captor.getValue(); - assertEquals(99L, saved.getCreatorUserId()); - assertEquals(AigcTaskStatusEnum.QUEUED.getStatus(), saved.getStatus()); // 初始排队 - assertEquals(0, saved.getProgress()); - assertEquals("clicker", saved.getTemplateId()); - assertNotNull(saved.getTraceId()); // traceId 已生成 - assertNotNull(saved.getPromptHash()); // 缓存键已计算 - assertTrue(saved.getTraceId().startsWith("aigc-")); + ServiceException ex = assertThrows(ServiceException.class, + () -> aigcTaskService.submitGenerate(reqVO, 99L)); + assertEquals(AIGC_TEMPLATE_NOT_EXISTS.getCode(), ex.getCode()); + verify(aigcTaskMapper, never()).insert(any(AigcTaskDO.class)); // 拒生成不落库(带类型消歧) } @Test diff --git a/game-studio/src/host/runtime/index.ts b/game-studio/src/host/runtime/index.ts index e6fdbab5..da03c746 100644 --- a/game-studio/src/host/runtime/index.ts +++ b/game-studio/src/host/runtime/index.ts @@ -1,17 +1,22 @@ /** - * A2 宿主引擎 | Canvas Runtime(Tier1)—— 最小渲染循环 + 资源加载 + 按 templateId 分发玩法 + * A2 宿主引擎 | Canvas Runtime(Tier1)—— 运行时壳 + 资源加载占位 + 模板分发入口 * ---------------------------------------------------------------------------- * 运行位置:游戏 iframe 内(与 SDK 同沙箱)。零三方依赖,纯 TS。 - * 硬约束(spec §3.5 / AC7):构建产物 < 15KB。源码保持精简。 * - * 职责: - * 1. 按 GamePackage(契约 #4)的 assets 清单加载资源(image/audio…),做最小完整性占位。 - * 2. 提供最小渲染循环(requestAnimationFrame)。 - * 3. 按 pkg.templateId 分发玩法(M-c 批①:clicker=点击计数 / merge=拖拽合成升级), - * 足以验证「宿主→SDK→Runtime→生命周期回灌」链路;未知模板走防御分支(game_error,不静默错渲染)。 - * 4. 通过 window.WanxiangGameSDK 在恰当时机发 game_loaded → game_start →(交互)→ game_end。 + * 【W-CLEAN 清场(2026-06-12 创始人明令「Delete those meaningless templates and game data from the past」)】 + * 旧四玩法模板(clicker / merge / idle / tycoon)的内联游戏逻辑随「模板体系重构」整体删除。 + * 本壳保留 1.x 运行时基础设施(与 SDK/bridge/GamePlayer 的生命周期契约、资源加载占位、画布渲染入口、 + * .toString() 注入约束、destroy 句柄),仅删除「游戏逻辑分支」本身——为新模板体系(Tier1 LittleJS + + * Runner v2,见 .agents 记忆 tiered-engine-cocos-decision)让位。 * - * 与 SDK 一样以工厂函数编写,便于被宿主 .toString() 注入 iframe 内联运行。 + * 清场后行为(不抛裸异常,优雅降级): + * 1) 在画布上明示「模板体系重构中」文案(任何人直接看到画布即得到清晰说明); + * 2) 通过 SDK 发 game_error(message=template_system_under_reconstruction)→ 宿主 GamePlayer 接 error + * 生命周期走既有错误态处理(而非靠 5s game_loaded 超时兜底误导为"加载失败"); + * 3) 不起渲染主循环、不绑任何交互、不发 game_loaded/game_start/game_end(无可玩玩法)。 + * 新模板体系落地后,在 startRuntime 内按 templateId 重新接入对应玩法 init 即恢复可玩。 + * + * 以工厂函数编写,便于被宿主 inject.ts 以 .toString() 注入 iframe 内联运行; * 函数体内不引用模块作用域外部符号(import 仅类型,编译擦除)。 */ @@ -32,22 +37,20 @@ 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) */ + /** idle 离线存档(保留 SDK 视图签名以兼容宿主受信边界,重构期玩法暂不使用) */ 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 侧应答端负责解析。) - */ + /** idle 离线读档(保留签名以兼容宿主受信边界,重构期玩法暂不使用) */ loadState?: (key: string) => Promise | null>; } /** * 启动 Canvas Runtime。 + * + * W-CLEAN 清场后:不再按 templateId 分发玩法,统一进入「模板体系重构中」错误态 + * (画布明示文案 + game_error;保留壳的生命周期/销毁契约)。 + * * @param canvas iframe 内的目标画布 - * @param pkg 游戏包(契约 #4);Runtime 据 templateId/assets/gameConfig 分发与运行 + * @param pkg 游戏包(契约 #4);重构期仅用于日志/遥测上下文,不再驱动玩法 * @param sdk window.WanxiangGameSDK(最小视图) */ export function startRuntime( @@ -63,781 +66,48 @@ export function startRuntime( try { sdk.__emit?.(e, d); } catch { - /* SDK 异常绝不影响 Runtime 主循环 */ + /* SDK 异常绝不影响 Runtime 壳 */ } } + // —— 在画布上明示「模板体系重构中」文案(深色底 + 居中说明;纯渲染,无可抛玩法路径)—— + // 即便取不到 2D 上下文也不抛:仍发 game_error,让宿主错误态处理统一接管(降级铁律)。 const ctx = canvas.getContext('2d'); - // 取不到 2D 上下文:判定为运行错误,但不抛(降级铁律) - if (!ctx) { - try { - sdk.reportError({ message: 'runtime: 2d context unavailable' }); - } catch { - /* ignore */ - } - life('game_error', { message: 'no_2d_context' }); - return { destroy() {} }; - } - - // —— 共享生命周期状态(clicker / merge 玩法共用单一来源,杜绝 ×N 漂移)—— - let running = true; // 主循环开关 - let rafId = 0; // requestAnimationFrame 句柄 - let started = false; // 是否已发 game_start - let startMs = 0; // 本局开始时间戳(game_start 时记;用于 game_end 的 duration_ms) - let drawGame: () => void = () => {}; // 玩法层绘制回调(由各 init 注入;draw 共享背景+标题后调用它) - let unbind: () => void = () => {}; // 交互解绑回调(由各 init 注入;finish/destroy 统一调用) - - // —— 渲染面配置(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 { - const assets = Array.isArray(pkg.assets) ? pkg.assets : []; - const imgs = assets.filter((a) => a && a.type === 'image' && typeof a.url === 'string'); - if (imgs.length === 0) return Promise.resolve(); - return new Promise((resolve) => { - let left = imgs.length; - const done = (): void => { - left -= 1; - if (left <= 0) resolve(); - }; - for (const a of imgs) { - const im = new Image(); - // onload/onerror 都计入:加载失败不阻断(降级),仅遥测一条 - im.onload = done; - im.onerror = (): void => { - try { - sdk.track('asset_load_error', { id: a.id, url: a.url }); - } catch { - /* ignore */ - } - done(); - }; - try { - im.src = a.url; - } catch { - done(); - } - } - }); - } - - // —— 共享绘制基建:背景 + 顶部标题层(玩法层由各 init 注入的 drawGame 负责)—— - function drawBase(): void { - // 类型守卫:ctx 在上方 getContext 失败时已 return,运行时此处必非空; - // 再判一次是为让 TS 在本嵌套闭包内将 ctx 收窄为非空(const 的 narrowing 不跨闭包保留)。 - if (!ctx) return; + if (ctx) { const w = canvas.width; const h = canvas.height; - 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'; - } + // 深色纯色背景(对齐 D4 --bg #070911) + ctx.fillStyle = '#070911'; ctx.fillRect(0, 0, w, h); - // 文本统一居中(每帧在任何 fillText 前设置,与旧实现等效) ctx.textAlign = 'center'; - if (theme) { - // 顶部标题区:theme 入画(为空不绘,不留空槽) - ctx.fillStyle = titleColor; - ctx.font = 'bold 18px system-ui, sans-serif'; - ctx.fillText(theme, w / 2, 44); - } + // 主文案:模板体系重构中(高亮) + ctx.fillStyle = '#eef6ff'; + ctx.font = 'bold 22px system-ui, sans-serif'; + ctx.fillText('模板体系重构中', w / 2, h / 2 - 8); + // 副文案:说明暂不可玩(muted) + ctx.fillStyle = '#8d9ab6'; + ctx.font = '14px system-ui, sans-serif'; + ctx.fillText('旧玩法模板已下架,新模板体系上线后恢复', w / 2, h / 2 + 22); } - // —— 主循环(requestAnimationFrame;永不被 SDK 阻塞)—— - function loop(): void { - if (!running) return; - drawBase(); // 共享背景 + 标题 - drawGame(); // 当前玩法层(clicker / merge 各自注入) - rafId = requestAnimationFrame(loop); - } - - /** - * 结束本局:发 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); - unbind(); // 解绑当前玩法注册的全部交互监听 - life('game_end', { score, completed: true, duration_ms: startMs ? Date.now() - startMs : 0 }); - } - - /** 首次交互统一入口:置 started 并发 game_start(clicker / merge 同范式,单一来源) */ - function markStarted(): void { - if (started) return; - started = true; - startMs = Date.now(); - life('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); - }; - } - - // =========================================================================== - // 玩法分支 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;未知模板走防御分支 - // =========================================================================== - const templateId = typeof pkg.templateId === 'string' ? pkg.templateId : ''; - switch (templateId) { - case '': // 空串向后兼容存量 30 款旧 clicker 包(templateId 缺失/为空,§5.4 裁决) - case 'clicker': - initClicker(); - break; - case 'merge': - initMerge(); - break; - case 'idle': // ← 批② 追加:放置挂机(含离线产出闭环,§5.2) - initIdle(); - break; - case 'tycoon': // ← 批② 追加:模拟经营循环(无离线态,§5.3) - initTycoon(); - 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 }); - loop(); - }) - .catch((err: unknown) => { - // 理论上 loadAssets 不 reject,这里仅兜底 - const message = err instanceof Error ? err.message : 'asset_load_failed'; - try { - sdk.reportError({ message: 'runtime: ' + message }); - } catch { - /* ignore */ - } - life('game_error', { message }); + // 可审计上报(便于排障:哪个包在重构期进入了运行时),不影响主流程 + try { + sdk.reportError({ + message: 'runtime: template system under reconstruction (templateId=' + (pkg?.templateId ?? '') + ')', }); + } catch { + /* ignore */ + } + // 发 game_error(携带 templateId 便于宿主侧定位):宿主 GamePlayer 接 error 生命周期走既有错误态, + // 不起循环 / 不绑交互 / 不发 game_loaded(无可玩玩法,杜绝靠 5s 加载超时误导用户)。 + life('game_error', { + message: 'template_system_under_reconstruction', + templateId: typeof pkg?.templateId === 'string' ? pkg.templateId : '', + }); return { destroy() { - // 宿主卸载/超时兜底:若未正常结束,停循环并清理交互监听(不补发 game_end,与旧实现一致) - running = false; - cancelAnimationFrame(rafId); - unbind(); + // 重构期无主循环 / 无交互监听需清理,destroy 为空实现(保留句柄契约,宿主卸载安全调用) }, }; }