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:
parent
2cfaed2bc5
commit
7bf2cd1d3b
@ -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<String> SUPPORTED_TEMPLATE_IDS = List.of("clicker", "merge", "idle", "tycoon");
|
||||
public static final List<String> SUPPORTED_TEMPLATE_IDS = List.of();
|
||||
|
||||
private AigcTemplateConstants() {
|
||||
// 纯常量持有类,禁止实例化
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 为空实现(保留句柄契约,宿主卸载安全调用)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user