refactor(w-clean): 旧玩法模板清除(创始人令2026-06-12)——模板哲学落地第一刀

- 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 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-12 09:24:27 +00:00
parent 2cfaed2bc5
commit 7bf2cd1d3b
5 changed files with 96 additions and 837 deletions

View File

@ -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/&lt;id&gt;.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/&lt;id&gt;.schema.json 就位白名单口径单点维护不变
* 旧四模板的契约 schemacontracts/templates/{clicker,merge,idle,tycoon}.schema.json prompt 注册表条目按
* W-CLEAN 范围OUT保留不动仅本白名单清空使其不可被生成
*/
public static final List<String> SUPPORTED_TEMPLATE_IDS = List.of("clicker", "merge", "idle", "tycoon");
public static final List<String> SUPPORTED_TEMPLATE_IDS = List.of();
private AigcTemplateConstants() {
// 纯常量持有类禁止实例化

View File

@ -55,34 +55,13 @@ public class AigcTaskServiceImpl implements AigcTaskService {
@Override
public List<TemplateRespVO> getTemplateList() {
// T-AGC-03 模板注册表的 MVP 最小形态HJ-AGENT-LOOP-EXEC-002 §8.4 T6 agent 裁决 #1 纳入
// 返回硬编码 4 clicker+merge+idle+tycoonM-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.4MVP 最小形态不配封面前端按空值兜底展示
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
// 也被 submitGeneratevalidateTemplateExists接空白名单 AigcTemplateConstants.SUPPORTED_TEMPLATE_IDS
// 拒为 AIGC_TEMPLATE_NOT_EXISTS创作入口自然返回模板不存在业务错与白名单同源无割裂
// 新模板体系Tier1 LittleJS + Runner v2落地后在此回填对应 TemplateRespVO 并同步白名单常量加项即重新开放
return List.of();
}
@Override

View File

@ -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;
}

View File

@ -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.5validateTemplateExists 改接同源白名单clicker+mergedodge 不在集合会被拒
// 本用例聚焦提交默认值/落库非模板校验故用白名单内的合法模板 clicker语义不变仅适配同源白名单收紧
reqVO.setTemplateId("clicker");
reqVO.setTemplateId("clicker"); // 曾合法模板清场后亦被拒验证无任何模板可生成
reqVO.setGameId(1L);
// 调用
AigcTaskDO task = aigcTaskService.submitGenerate(reqVO, 99L);
// 断言返回任务 + 服务端默认字段正确
assertEquals(1024L, task.getId());
ArgumentCaptor<AigcTaskDO> 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

View File

@ -1,17 +1,22 @@
/**
* A2 宿 | Canvas RuntimeTier1 + + templateId
* A2 宿 | Canvas RuntimeTier1 + +
* ----------------------------------------------------------------------------
* iframe SDK TS
* spec §3.5 / AC7 < 15KB
*
*
* 1. GamePackage #4 assets image/audio
* 2. requestAnimationFrame
* 3. pkg.templateId M-c clicker= / merge=
* 宿SDKRuntimegame_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_errormessage=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<string, unknown>,
) => void;
/** idle 离线存档(HJ-MC-TPL-EXEC-002 §6.2fire-and-forget 写键值(宿主受信边界落 localStorage */
/** idle 离线存档(保留 SDK 视图签名以兼容宿主受信边界,重构期玩法暂不使用 */
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
*/
/** idle 离线读档(保留签名以兼容宿主受信边界,重构期玩法暂不使用) */
loadState?: (key: string) => Promise<Record<string, unknown> | null>;
}
/**
* Canvas Runtime
*
* W-CLEAN templateId
* + game_error/
*
* @param canvas iframe
* @param pkg #4Runtime 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 031 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 assetsimage 真实预加载,其余仅登记,骨架够用)——
// 完整性占位:统计加载完成数,全部 settled 即视为 game_loaded。
function loadAssets(): Promise<void> {
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<void>((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=0quality 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_startclicker / merge 同范式,单一来源) */
function markStarted(): void {
if (started) return;
started = true;
startMs = Date.now();
life('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);
};
}
// ===========================================================================
// 玩法分支 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未知模板走防御分支
// ===========================================================================
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 为空实现(保留句柄契约,宿主卸载安全调用)
},
};
}