feat(runtime): M-c批② initIdle(离线闭环)+initTycoon(经营循环)——纯追加双case,initMerge零行删改
- RuntimeSDKView 扩 saveState?/loadState? 两可选方法(loadState 返 object|null,宿主受信边界已解析,runtime侧零JSON.parse守§5.5);缺失安全跳过(旧宿主/demo退化无离线态不崩) - initIdle:loadState→离线补发公式(钳offlineMaxMin+折扣双闸防爆)→在线点击产料+每秒自动产出→节流saveState(≥2s)→资源达targetResource通关;score=floor(resource) - initTycoon:进货带客(onDown waitingCustomers+1)→售出配对→金币达targetCoin通关;按钮布局契约(buy中心W*0.28/H*0.70,sell W*0.72/H*0.70)承重接口;score=coin - clampInt/localXY各init函数体内复制(沿initMerge范式禁提升);零DOM/零可抛新增;hunk全>426(initMerge:255-426零删改) HJ-MC-TPL-EXEC-002 §5 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7033cef382
commit
88388f7b46
@ -32,6 +32,16 @@ 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) */
|
||||
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 侧应答端负责解析。)
|
||||
*/
|
||||
loadState?: (key: string) => Promise<Record<string, unknown> | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -425,6 +435,357 @@ export function startRuntime(
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 玩法分支 3:idle(放置挂机 + 离线产出闭环,§5.2)—— 纯数值/Canvas,无物理、无 DOM
|
||||
// ===========================================================================
|
||||
function initIdle(): void {
|
||||
// —— 自包含纯函数(沿 initMerge:258 clampInt 范式逐字照抄函数体;必须定义在本函数体内,
|
||||
// toString() 注入约束;严禁引用 initMerge 内符号、严禁提升到共享作用域)——
|
||||
/** 整数钳制:非整数/越界回落 def,最终夹在 [lo,hi] */
|
||||
function clampInt(v: unknown, lo: number, hi: number, def: number): number {
|
||||
const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : def;
|
||||
return n < lo ? lo : n > hi ? hi : n;
|
||||
}
|
||||
/** 取指针在 canvas 内部像素坐标系的坐标(沿 initMerge:320 localXY 范式,自包含一份) */
|
||||
function localXY(e: PointerEvent): [number, number] {
|
||||
const sx = canvas.clientWidth ? canvas.width / canvas.clientWidth : 1;
|
||||
const sy = canvas.clientHeight ? canvas.height / canvas.clientHeight : 1;
|
||||
return [e.offsetX * sx, e.offsetY * sy];
|
||||
}
|
||||
/** 矩形命中判定:点 (px,py) 落在矩形 [x,x+w]×[y,y+h] 内即 true(纯算术,无可抛) */
|
||||
function inRect(px: number, py: number, x: number, y: number, w: number, h: number): boolean {
|
||||
return px >= x && px <= x + w && py >= y && py <= y + h;
|
||||
}
|
||||
|
||||
// —— 读 config(缺省/越界钳制 + 跨字段钳制兜底,§5.2.1)——
|
||||
const clickYield = clampInt(pkg.gameConfig?.clickYield, 1, 10, 1); // 每次点击产出资源数
|
||||
const autoYieldBase = clampInt(pkg.gameConfig?.autoYield, 1, 20, 1); // 升级前每秒自动产出基准
|
||||
const targetResource = clampInt(pkg.gameConfig?.targetResource, 50, 600, 100); // 通关目标资源量
|
||||
let upgradeCost = clampInt(pkg.gameConfig?.upgradeCost, 10, 200, 50); // 升级成本
|
||||
// §3.3 兜底③:钳到 < targetResource,防「升级比通关还贵」死局(LLM 违例也不致死局)
|
||||
upgradeCost = Math.min(upgradeCost, targetResource - 1);
|
||||
const upgradeMul = clampInt(pkg.gameConfig?.upgradeMultiplier, 2, 5, 2); // 升级倍率(整数倍)
|
||||
const resourceLabel = cleanText(pkg.gameConfig?.resourceLabel, 8) || '资源'; // 资源文案缺省兜底
|
||||
const offlineMaxMin = clampInt(pkg.gameConfig?.offlineMaxMinutes, 5, 120, 60); // 离线产出上限分钟
|
||||
const offlineEff = clampInt(pkg.gameConfig?.offlineEfficiencyPercent, 10, 100, 50); // 离线效率折扣%
|
||||
|
||||
// —— 状态变量(§5.2.1)——
|
||||
let resource = 0; // 当前资源量(浮点累加,绘制/判定时取整)
|
||||
let autoYield = autoYieldBase; // 当前每秒自动产出(升级后翻 upgradeMul 倍)
|
||||
let upgraded = false; // 是否已购升级(MVP 单档,买一次即满)
|
||||
let lastTickMs = 0; // 上次自动产出累加时间戳(game_start 后首帧置)
|
||||
let lastSaveMs = 0; // 上次节流存档时间戳(§5.2.4 节流 ≥2s 一次)
|
||||
|
||||
// —— 存档键名(§5.2.4:key 隔离,必须与宿主 handleStorage 白名单前缀逐字一致 §6.1.2)——
|
||||
const saveKey = 'idle:' + pkg.gameId + ':' + pkg.versionId;
|
||||
|
||||
// —— 升级按钮布局(Canvas 内部像素坐标系;底部居中一个按钮区,纯几何)——
|
||||
const ubX = canvas.width * 0.25;
|
||||
const ubY = canvas.height * 0.78;
|
||||
const ubW = canvas.width * 0.5;
|
||||
const ubH = canvas.height * 0.14;
|
||||
|
||||
/**
|
||||
* 节流存档(§5.2.4(b)):fire-and-forget 写当前态到宿主受信边界(落 localStorage)。
|
||||
* saveState 是 RuntimeSDKView 可选方法——缺失时安全跳过(旧宿主/demo 兜底兼容,§6.2);
|
||||
* 调用包 try-catch 静默(沿 sdk.track 范式),runtime 侧不引可抛路径(守五条 AND 之④)。
|
||||
* JSON.stringify 对普通对象不抛,且整体包 try-catch,无新增可抛入口。
|
||||
*/
|
||||
function persist(force: boolean): void {
|
||||
if (!sdk.saveState) return; // 可选方法缺失:idle 退化无离线态,安全跳过
|
||||
const now = Date.now();
|
||||
if (!force && now - lastSaveMs < 2000) return; // 节流:非强制时 ≥2s 一次,避免每帧写
|
||||
lastSaveMs = now;
|
||||
try {
|
||||
// 存档值形态(§5.2.4(a)):版本位 + 资源 + 速率 + 升级态 + 离开时间戳
|
||||
const json = JSON.stringify({
|
||||
v: 1,
|
||||
resource: Math.floor(resource),
|
||||
autoYield: autoYield,
|
||||
upgraded: upgraded,
|
||||
leftAtMs: now,
|
||||
});
|
||||
sdk.saveState(saveKey, json);
|
||||
} catch {
|
||||
/* 存档失败静默吞,不影响主循环 */
|
||||
}
|
||||
}
|
||||
|
||||
// —— 渲染玩法层(背景/标题由 drawBase 画;本层做自动产出累加 + 绘进度/提示/升级按钮)——
|
||||
drawGame = function (): void {
|
||||
if (!ctx) return;
|
||||
// 自动产出累加(仅 started 后;按帧间真实时间差,与帧率解耦,确定性,§5.2.2)
|
||||
if (started && running) {
|
||||
const now = Date.now();
|
||||
if (lastTickMs === 0) lastTickMs = now;
|
||||
const elapsedSec = (now - lastTickMs) / 1000;
|
||||
if (elapsedSec > 0) {
|
||||
resource += autoYield * elapsedSec; // 浮点累加,绘制时取整
|
||||
lastTickMs = now;
|
||||
persist(false); // 累加后节流存档(捕获在线进度)
|
||||
}
|
||||
// 资源达标即通关(单一来源 finish,score=最终资源量取整,§5.2.3 score 语义拍板)
|
||||
if (resource >= targetResource) {
|
||||
persist(true); // 通关前强制存一次最终态(§5.2.4(b))
|
||||
finish(Math.floor(resource));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
// 进度文案:当前资源 / 目标资源(theme 模式高亮白 / 旧分支青色 scoreColor)
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = scoreColor;
|
||||
ctx.font = 'bold 26px system-ui, sans-serif';
|
||||
ctx.fillText(resourceLabel + ':' + Math.floor(resource) + ' / ' + targetResource, w / 2, h * 0.42);
|
||||
// 点击提示(muted)
|
||||
ctx.fillStyle = '#8d9ab6';
|
||||
ctx.font = '14px system-ui, sans-serif';
|
||||
ctx.fillText(
|
||||
started ? '点击产出 +' + clickYield + '/秒自动 +' + autoYield : '点击屏幕开始挂机',
|
||||
w / 2,
|
||||
h * 0.52,
|
||||
);
|
||||
// 升级按钮区:未升级时绘制;resource≥upgradeCost 高亮可点,否则 muted
|
||||
if (!upgraded) {
|
||||
const affordable = resource >= upgradeCost;
|
||||
ctx.fillStyle = affordable ? 'rgba(49,230,255,0.18)' : 'rgba(255,255,255,0.05)';
|
||||
ctx.fillRect(ubX, ubY, ubW, ubH);
|
||||
ctx.strokeStyle = affordable ? '#31e6ff' : 'rgba(141,154,182,0.5)';
|
||||
ctx.strokeRect(ubX, ubY, ubW, ubH);
|
||||
ctx.fillStyle = affordable ? '#eef6ff' : '#8d9ab6';
|
||||
ctx.font = 'bold 15px system-ui, sans-serif';
|
||||
ctx.fillText('升级 ×' + upgradeMul + '(花' + upgradeCost + ')', w / 2, ubY + ubH / 2 + 5);
|
||||
}
|
||||
};
|
||||
|
||||
// —— 交互(§5.2.3:pointerdown 点击产料 / 命中升级按钮购升级)——
|
||||
function onDown(e: PointerEvent): void {
|
||||
if (!running) return;
|
||||
markStarted(); // 首次交互发 game_start(同 clicker/merge 范式)
|
||||
const [px, py] = localXY(e);
|
||||
// 命中升级按钮区 且 未升级 且 资源够:购升级
|
||||
if (inRect(px, py, ubX, ubY, ubW, ubH) && !upgraded && resource >= upgradeCost) {
|
||||
resource -= upgradeCost;
|
||||
autoYield = autoYieldBase * upgradeMul; // 升级:自动产出翻 upgradeMul 倍
|
||||
upgraded = true;
|
||||
try {
|
||||
sdk.track('idle_upgrade', { autoYield });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
persist(true); // 升级是关键态变化,强制存一次
|
||||
return;
|
||||
}
|
||||
// 否则:点击产料
|
||||
resource += clickYield;
|
||||
try {
|
||||
sdk.track('idle_click', { resource: Math.floor(resource) });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
persist(false); // 点击后节流存档
|
||||
if (resource >= targetResource) {
|
||||
persist(true);
|
||||
finish(Math.floor(resource)); // 点击也可触发通关
|
||||
}
|
||||
}
|
||||
|
||||
// idle 仅绑 pointerdown 一种交互;finish/destroy 经 unbind 解绑 + 强制存档
|
||||
canvas.addEventListener('pointerdown', onDown);
|
||||
unbind = function (): void {
|
||||
persist(true); // destroy/finish 时强制写一次最终态(§5.2.4(b))
|
||||
canvas.removeEventListener('pointerdown', onDown);
|
||||
};
|
||||
|
||||
// —— 再入补发离线产出(§5.2.4(c):启动时读档 → 补发公式 → 恢复升级态)——
|
||||
// loadState 为可选方法;返回 Promise<对象|null>(宿主受信边界已解析好的对象,5s 超时兜底 null、
|
||||
// 绝不 reject,§6.2)。runtime 侧直接读字段、零字符串解析(解析在宿主受信边界做,守 §5.5 红线)。
|
||||
// 缺失/超时/无存档 → 从 0 开始在线循环(CDP 在线取证 §7.2 走此默认路径)。
|
||||
if (sdk.loadState) {
|
||||
sdk
|
||||
.loadState(saveKey)
|
||||
.then((saved: Record<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;未知模板走防御分支
|
||||
// ===========================================================================
|
||||
@ -437,6 +798,12 @@ export function startRuntime(
|
||||
case 'merge':
|
||||
initMerge();
|
||||
break;
|
||||
case 'idle': // ← 批② 追加:放置挂机(含离线产出闭环,§5.2)
|
||||
initIdle();
|
||||
break;
|
||||
case 'tycoon': // ← 批② 追加:模拟经营循环(无离线态,§5.3)
|
||||
initTycoon();
|
||||
break;
|
||||
default:
|
||||
// 未知 templateId:可审计上报 + game_error,不起循环/不绑交互/不错渲染(§5.4 堵静默错渲染缺口)
|
||||
try {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user