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 就位),白名单口径单点维护不变。
* 旧四模板的契约 schema(contracts/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+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

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.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<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 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<string, unknown>,
) => 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<string|null> 的最小修正见交付报告 open_issue:§6.2 草案返回类型与
* §5.5「runtime 无字符串解析」不自洽,依 §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 游戏包(契约 #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<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=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<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 开始 */
});
}
}
// ===========================================================================
// 玩法分支 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 为空实现(保留句柄契约,宿主卸载安全调用)
},
};
}