feat(runner-v2): P2 通用宿主泛化落地——引擎游戏真渲入 feed 真玩(mini-desktop 真机门验)
P2 = 把 P1 装载契约从「装载王蓝莓一款」泛化为「装载任意 GamePackage」,经发布主链入 feed、 真人浏览器真玩。4 决策落实(O2(b)纳入 Phase C / DB-manifest 内嵌 / 灌库桩 / __GameBundle)。 三轨实现 - 后端:GamePackage schema 加 engineBundle additive 可选字段;buildGamePackage 加 engineBundle put (P3 生成路用,P2 恒 null→put 跳过→字节零变,单测 packageBytesReproducible 证);灌库桩 p2_seed_engine_bundle.mjs + 四态脚手架 p2_scaffold_4state.sql(W-CLEAN 后重建已发布游戏) - 运行时:P1 bootRef 泛化为 bootGameHost(opts)(引擎骨架照搬+参数化工厂来源/插件/视口), bootRef 退化薄包装;固化 build:ref-bundle 产 dist/ref-bundle.iife.js(--global-name=__GameBundle) - studio:inject.ts 引擎包分支(pkg.engineBundle? → 内联 script 暴露 window.__GameBundle → bootGameHost 挂引擎真渲;否则旧 startRuntime 路保兼容);掏空壳改引擎分支(Phase C);CSP 不放外域 对抗代码复审逮 2 mustFix(主 agent 亲核亲修 + 6c6g 复验) - 灌库桩读错产物(ref-bundle.js harness vs .iife.js)→ mini-desktop 灌库返工死循环 - 引擎分支漏发 game_start → 会话/play_count 静默吞、门②检不出 → 补发(DB session 行实证已修复) +2 shouldFix(兜底可观测信号 / 灌库 SQL @pj 防撞 max_allowed_packet) + 真机门逮 2 环境缺陷已修(seed charset=utf8mb4 防双编码断 checksum 链 / 四态脚手架补 W-CLEAN 空库) mini-desktop 真机门 6 门(逐门实数,主 agent 肉眼复核证据) ①build:ref-bundle 265374B/__GameBundle 真入包 ②Java 编译过+字节复现单测 ③studio staging 重建消 mock ④灌库 status=1∧checksum 64hex∧engineBundle 键齐∧manifest sha256 一致 ⑤真人 feed 点卡→引擎真渲 暖阳小卖部(warm=1246073px)非「重构中」+game_loaded+game_start+防 demo 三锚+console 零错+DB session 行 ⑥回滚:无 engineBundle→demo 壳/错误态,向后兼容未破。证据 game-studio/evidence/p2-feed/ 诚实 gap:game_end(到结算)未在自动化窗口内达成=ref gameover 需分钟级真玩+srcdoc 坐标盲点的取证局限, 非引擎/渲染能力缺陷(机制已通,留人工真玩到打烊补全)。受控面 6 项+game-host.d.ts 零改。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4caaea8aab
commit
5d3f7a87f0
@ -91,6 +91,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"engineBundle": {
|
||||
"description": "引擎+包装层+游戏代码的可执行 iife bundle 文本(T1b-β P2 通用宿主载体;additive 可选,存量消费者无此字段时走 startRuntime 旧路/demo 兜底)。MVP 默认=随 manifest 内嵌经 DB-manifest 端点 /app-api/runtime/package/{versionId}/manifest 下发(落 game_runtime_package.package_json LONGTEXT,sha256 整包 checksum 覆盖其完整性,不接 OSS、不增第二校验面);切 OSS 后改为 packageUrl 外链。iife 顶层全局名固定为 __GameBundle(宿主据此从 window.__GameBundle 取 GameHostFactory,见 game-runtime/src/core/game-host.d.ts)。后端写权=DifyCallbackTxService.buildGamePackage put(P2 阶段 ref 一款经 O-P2-1 灌库桩注入,真产线 P3 接生成主线)。",
|
||||
"type": "string"
|
||||
},
|
||||
"provenance": {
|
||||
"description": "可追溯来源(质量门禁与缓存命中用)。相同 promptHash 命中缓存可跳过 LLM 生成。",
|
||||
"type": "object",
|
||||
|
||||
@ -309,6 +309,20 @@ public class DifyCallbackTxService {
|
||||
}
|
||||
pkg.put("provenance", provenance);
|
||||
|
||||
// engineBundle(契约#4 additive 可选,T1b-β P2 通用宿主载体):引擎+包装层+游戏代码的 iife bundle 文本,
|
||||
// 随 manifest 内嵌经 DB-manifest 端点下发,由整包 sha256 checksum 覆盖完整性(故须落在下方 checksum 三段
|
||||
// 序列化 T_base→T0→T1 之前,bundle 字节才被 checksum 与 bundleSize 共同涵盖)。
|
||||
// 【P2 阶段·过渡桩】真实 bundle 来源走 O-P2-1 灌库桩脚本(scripts/p2_seed_engine_bundle.* 一次性把
|
||||
// ref-bundle.js 文本灌进某 version 的 package_json.engineBundle),不经本回调链——故本处 bundle 文本不可得,
|
||||
// 按 spec「不可得则省略、不可硬编死值」省略该键(null 不 put,存量回调零行为变化、字节布局不变)。
|
||||
// 【P3 真产线】生成主线 agent 原生出 bundle 后,由回调入参(契约#6 output 增 additive 字段,P3 自身 spec 落)
|
||||
// 携带 bundle 文本,此处改为从入参取值后 put——届时本 null 占位替换为入参取值,conditional put 骨架不变。
|
||||
String engineBundle = resolveEngineBundleText(reqVO); // P2 恒返回 null(无回调入参来源);P3 改为取回调 bundle 文本
|
||||
if (StringUtils.hasText(engineBundle)) {
|
||||
// 仅在 bundle 文本可得时写入:保证 P2「省略」语义 + P3「随入参写入」语义同一条 conditional 分支承载(无孤儿设计)
|
||||
pkg.put("engineBundle", engineBundle);
|
||||
}
|
||||
|
||||
try {
|
||||
// T_base:bundleSize=0 + checksum 占位 → 基准文本,bundleSize := 其 UTF-8 字节数(确定性近似)
|
||||
String tBase = PACKAGE_MAPPER.writeValueAsString(pkg);
|
||||
@ -355,6 +369,24 @@ public class DifyCallbackTxService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析引擎 bundle 文本(契约#4 GamePackage.engineBundle 的写入源)
|
||||
*
|
||||
* 【P2 阶段·过渡桩,真产线 P3】恒返回 null:P2 仅跑 ref 一款,其 engineBundle 由 O-P2-1 灌库桩脚本
|
||||
* (scripts/p2_seed_engine_bundle.*)一次性灌进 DB package_json,不经本回调链;故本回调组包时 bundle 文本
|
||||
* 不可得,返回 null 使上游 conditional put 省略该键(存量回调零行为变化)。
|
||||
* 【P3 真产线】生成主线 agent 原生产出 bundle 后,由回调入参携带 bundle 文本(契约#6 output 增 additive 字段,
|
||||
* P3 自身 spec 落实),此方法改为 {@code return reqVO.getEngineBundle();}——上游 conditional put 骨架不变,
|
||||
* 仅本取值源从 null 改为入参,最小切换、无孤儿设计。
|
||||
*
|
||||
* @param reqVO 回调入参(P3 将从其新增字段取 bundle 文本;P2 不读其任何字段,恒 null)
|
||||
* @return 引擎 bundle 文本;P2 阶段恒 null(省略 engineBundle 键)
|
||||
*/
|
||||
private static String resolveEngineBundleText(DifyCallbackReqVO reqVO) {
|
||||
// P2 过渡桩:bundle 来源 = 灌库脚本直写 DB,非回调入参 → 此处无来源,返回 null(不硬编码任何死值)
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 置任务失败终态(status=3 + failure_reason + finishTime;failed 分支与业务性失败/补偿共用)
|
||||
*
|
||||
|
||||
70
game-cloud/scripts/p2_scaffold_4state.sql
Normal file
70
game-cloud/scripts/p2_scaffold_4state.sql
Normal file
@ -0,0 +1,70 @@
|
||||
-- ============================================================================
|
||||
-- P2 真机门·四态脚手架(W-CLEAN 清库后重建一款已发布游戏供 engineBundle 灌库桩附着)
|
||||
-- 缘由:seed 脚本 p2_seed_engine_bundle.mjs 原假设已存在已发布种子(9001 四态齐备),
|
||||
-- 但 staging DB 已被 W-CLEAN 清空(0 version/0 package/0 feed_rank),故须先建四态脚手架。
|
||||
-- (mini-desktop 2026-06-13 P2 真机门实测可用,gate 报告逮出后落库归仓。)
|
||||
-- 四态:game_project.status=4(PUBLISHED) ∧ game_version.status=3 ∧ game_runtime_package.status=1 ∧ game_feed_rank 行
|
||||
-- ID:gameId=9201 / versionId=92010 / pkgId=92010 / feedRankId=92010(高位区,零碰撞)
|
||||
-- engineBundle 由后续 p2_seed_engine_bundle.mjs 9201 覆写 package_json(本脚本先建空 package_json 占位行)
|
||||
-- 幂等:先 DELETE 同 ID 行再 INSERT(纯重建,可重复执行)
|
||||
-- tenant:staging application-staging.yaml tenant.enable=false → tenant_id=0 即可
|
||||
-- 用法:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
|
||||
-- ============================================================================
|
||||
USE `ruoyi-vue-pro`;
|
||||
-- charset 防御(与 seed 脚本同口径):强制 utf8mb4,避免中文 title 被客户端默认 latin1 双编码
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- 清旧(幂等重建)
|
||||
DELETE FROM game_feed_rank WHERE game_id = 9201;
|
||||
DELETE FROM game_runtime_package WHERE game_id = 9201;
|
||||
DELETE FROM game_version WHERE game_id = 9201;
|
||||
DELETE FROM game_project WHERE id = 9201;
|
||||
|
||||
-- ① project:已发布(status=4),current_version_id 指向 92010,launch_zone_id 留 NULL(→feed zone 0)
|
||||
INSERT INTO game_project
|
||||
(id, creator_user_id, title, summary, cover_url, tags, template_id, age_rating,
|
||||
status, current_version_id, launch_zone_id, featured, play_count, like_count,
|
||||
creator, create_time, updater, update_time, deleted, tenant_id)
|
||||
VALUES
|
||||
(9201, 1, '王蓝莓小卖部(引擎真渲)', 'P2 通用宿主真机门:ref 引擎 bundle 经 bootGameHost 真渲入 feed',
|
||||
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
|
||||
'P2,engine,wanglanmei', 'wanglanmei-ref', 'all',
|
||||
4, 92010, NULL, b'0', 0, 0,
|
||||
'p2-scaffold', NOW(), 'p2-scaffold', NOW(), b'0', 0);
|
||||
|
||||
-- ② version:已发布(status=3),package_url 空(走 DB-manifest 端点),checksum 先占位(seed 脚本覆写运行包不动版本)
|
||||
INSERT INTO game_version
|
||||
(id, game_id, version_no, gen_task_id, package_url, bundle_size, checksum,
|
||||
status, creator, create_time, updater, update_time, deleted, tenant_id)
|
||||
VALUES
|
||||
(92010, 9201, 1, NULL, '', 0, '',
|
||||
3, 'p2-scaffold', NOW(), 'p2-scaffold', NOW(), b'0', 0);
|
||||
|
||||
-- ③ runtime_package:先建 status=1 行 + 空 package_json 占位(engineBundle 由 seed 脚本 UPDATE 覆写本行)
|
||||
-- sandbox_attr/allow_origins 取既有发布默认;package_url 空=DB-manifest 内嵌路
|
||||
INSERT INTO game_runtime_package
|
||||
(id, game_id, version_id, template_id, package_url, entry, runtime_version, preload_policy,
|
||||
bundle_size, checksum, sandbox_attr, allow_origins, status,
|
||||
creator, create_time, updater, update_time, deleted, tenant_id, package_json)
|
||||
VALUES
|
||||
(92010, 9201, 92010, 'wanglanmei-ref', '', 'index.html', '1.0.0', 'eager',
|
||||
0, '', 'allow-scripts', '', 1,
|
||||
'p2-scaffold', NOW(), 'p2-scaffold', NOW(), b'0', 0, NULL);
|
||||
|
||||
-- ④ feed_rank:zone_id=0(混合流) + status=1 入流 + sort_score=0
|
||||
INSERT INTO game_feed_rank
|
||||
(id, game_id, version_id, zone_id, quality_score, boost, sort_score, pinned, status,
|
||||
creator, create_time, updater, update_time, deleted, tenant_id)
|
||||
VALUES
|
||||
(92010, 9201, 92010, 0, 0.0000, 0.0000, 0.0000, 0, 1,
|
||||
'p2-scaffold', NOW(), 'p2-scaffold', NOW(), b'0', 0);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 回读四态
|
||||
SELECT 'project' k, id, status, current_version_id FROM game_project WHERE id=9201
|
||||
UNION ALL SELECT 'version', id, status, game_id FROM game_version WHERE id=92010
|
||||
UNION ALL SELECT 'runtime_pkg', id, status, version_id FROM game_runtime_package WHERE id=92010
|
||||
UNION ALL SELECT 'feed_rank', id, status, zone_id FROM game_feed_rank WHERE id=92010;
|
||||
192
game-cloud/scripts/p2_seed_engine_bundle.mjs
Normal file
192
game-cloud/scripts/p2_seed_engine_bundle.mjs
Normal file
@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* O-P2-1 灌库桩 —— P2 通用宿主:把 ref 引擎 bundle 文本灌进某已发布 version 的 package_json.engineBundle
|
||||
* ============================================================================
|
||||
* 【⚠️ P2 过渡桩,真产线 P3】
|
||||
* 本脚本是 T1b-β P2「Runner v2 泛化宿主」的过渡手段(execution spec
|
||||
* docs/agent-specs/2026-06-13-P2-runner-v2泛化宿主-execution.md §6.1 B0 / O-P2-1 拍定):
|
||||
* P2 阶段只跑 ref 一款 engine-aware 游戏,其 engineBundle 不经生成回调链产出,而是由本脚本
|
||||
* 一次性把 game-runtime/games/wanglanmei-ref/dist/ref-bundle.iife.js 文本灌进 DB。
|
||||
* 真产线(生成主线 agent 原生出 bundle 经 DifyCallbackTxService.buildGamePackage 随回调入参写入)留 P3。
|
||||
*
|
||||
* 【附着的已发布游戏从哪来——须先建四态脚手架(mini-desktop 真机门逮)】
|
||||
* 本桩零新建、仅「覆写某已发布 version 的 package_json 为含 engineBundle 的 GamePackage 文本」,
|
||||
* 故依赖一款四态齐备(project.status=4 ∧ version.status=3 ∧ runtime_package.status=1 ∧ feed_rank 行)的已发布游戏。
|
||||
* ⚠️ staging DB 已被 W-CLEAN 清空(0 version/0 package/0 feed_rank),不存在历史种子 9001。
|
||||
* 故 P2 须先跑同目录 p2_scaffold_4state.sql 建一款脚手架游戏(gameId=9201),本桩再 9201 覆写其运行包。
|
||||
* (真产线 P3:engineBundle 由生成回调链经 DifyCallbackTxService.buildGamePackage 写入,无需脚手架。)
|
||||
*
|
||||
* 【为什么生成 SQL 文件而非直连 DB】
|
||||
* staging DB 访问走 docker exec game-staging-mysql mysql(见 memory golden-loop-b1-done),无需在
|
||||
* mini-desktop 装 mysql 驱动;本脚本本机(6c6g/mini-desktop 皆可)仅做「读 bundle + 组 GamePackage JSON +
|
||||
* 产出可幂等重跑的 UPDATE SQL」,把 265KB bundle 安全嵌进 SQL(单引号/反斜杠转义),由 MySQL 端自算
|
||||
* checksum/bundle_size(SHA2/LENGTH 直接对所存文本求值),消除任何转义/编码导致的 sha256 不一致风险。
|
||||
*
|
||||
* 【完整性校验链不变量(门③依赖)】
|
||||
* 宿主取包做 sha256(manifest 端点响应原文) == RuntimePackageRespVO.checksum(= game_runtime_package.checksum 列)。
|
||||
* 故 UPDATE 用 checksum = LOWER(SHA2(package_json, 256))、对刚写入的同一文本求值 → 列值与所存文本字节恒一致,
|
||||
* 宿主校验必过。内层 manifest.checksum 字段仅作包内自述(不参与宿主校验,对齐 DifyCallbackTxService 注释)。
|
||||
*
|
||||
* 【用法(mini-desktop staging)】
|
||||
* 0) 建四态脚手架(W-CLEAN 后必需):docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 \
|
||||
* -uroot -p"$MYSQL_ROOT_PASSWORD" < game-cloud/scripts/p2_scaffold_4state.sql (建 gameId=9201)
|
||||
* 1) 生成 SQL:node game-cloud/scripts/p2_seed_engine_bundle.mjs [gameId] > /tmp/p2_seed.sql
|
||||
* (gameId 缺省 9201=脚手架游戏;bundle 路径固定 games/wanglanmei-ref/dist/ref-bundle.iife.js)
|
||||
* 2) 应用:docker exec -i game-staging-mysql mysql --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" < /tmp/p2_seed.sql
|
||||
* (⚠️ --default-character-set=utf8mb4 必带:客户端默认 latin1 会把 UTF-8 bundle/中文 title 双编码,
|
||||
* 致列字节 ≠ checksum 计算字节 → manifest sha256 不一致 → 宿主校验失败 → demo 兜底;SQL 头亦自带 SET NAMES 双保险)
|
||||
* ($MYSQL_ROOT_PASSWORD 见 ~/game-staging/infra/.env,内网非密)
|
||||
* 3) 重跑安全(幂等):再次执行步骤 1+2 即可(纯 UPDATE 覆写,无副作用)。
|
||||
*
|
||||
* @author 造梦AI(P2 后端轨)
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ---------- 常量(与 execution spec 冻结契约逐字一致)----------
|
||||
/** O-P2-3 冻结:iife 顶层全局名,宿主从 window.__GameBundle 取 GameHostFactory(三轨此名必须逐字一致) */
|
||||
const FROZEN_GLOBAL_NAME = '__GameBundle';
|
||||
/** ref bundle 固定产物路径(spec §3.1 / B0 钉死)——build:ref-bundle 产物(--global-name=__GameBundle),
|
||||
* 非 build:ref-harness 的 ref-bundle.js(那是 P1 取证 harness,全局名 WanglanmeiRef)。mustFix#1 纠正。 */
|
||||
const REF_BUNDLE_REL = '../../game-runtime/games/wanglanmei-ref/dist/ref-bundle.iife.js';
|
||||
/** checksum 自指消解占位:64 位 '0'(与真实 sha256 hex 等长,对齐 DifyCallbackTxService.CHECKSUM_PLACEHOLDER) */
|
||||
const CHECKSUM_PLACEHOLDER = '0'.repeat(64);
|
||||
/** meta.cover 占位(1px 透明 gif data URI,沿后端组包同款,满足契约 format=uri) */
|
||||
const COVER_PLACEHOLDER =
|
||||
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||
|
||||
// ---------- 入参 ----------
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
// gameId 缺省 9201(p2_scaffold_4state.sql 建的脚手架游戏);运行时可传第一个 CLI 参数覆盖
|
||||
const gameId = String(process.argv[2] || '9201').trim();
|
||||
|
||||
// ---------- 步骤①:读 ref bundle 文本 + 校验全局名 ----------
|
||||
const bundlePath = resolve(__dirname, REF_BUNDLE_REL);
|
||||
let engineBundle;
|
||||
try {
|
||||
engineBundle = readFileSync(bundlePath, 'utf8');
|
||||
} catch (e) {
|
||||
// 读不到产物即写链断裂:显式失败(不灌空桩),提示先固化 build:ref-bundle 产线(spec §6.1 B0)
|
||||
console.error(`[p2-seed][FATAL] 读不到 ref bundle:${bundlePath}\n` +
|
||||
` 请先在 game-runtime 跑 build:ref-bundle 产出该文件(spec §6.1 B0)。原始错误:${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// 全局名硬校验:bundle 必须以 FROZEN_GLOBAL_NAME(__GameBundle)导出 iife,否则宿主取不到 GameHostFactory、
|
||||
// 门② 必 fail。当前若 bundle 仍是 P1 旧全局名(如 WanglanmeiRef),在此显式拦截、提示重建产线。
|
||||
if (!engineBundle.includes(FROZEN_GLOBAL_NAME)) {
|
||||
console.error(`[p2-seed][FATAL] ref bundle 未含冻结全局名 ${FROZEN_GLOBAL_NAME}(O-P2-3)。\n` +
|
||||
` 现状 bundle 可能是 P1 旧产物(旧全局名);须由宿主轨/产线以 esbuild --global-name=${FROZEN_GLOBAL_NAME}\n` +
|
||||
` 重建 ${bundlePath}(导出 GameHostFactory 到 window.${FROZEN_GLOBAL_NAME})后再灌库。\n` +
|
||||
` 当前不灌库(避免种入宿主取不到 factory 的死卡,门② 会 fail)。`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ---------- 步骤②:组 GamePackage JSON(含 engineBundle;键序对齐后端 buildGamePackage)----------
|
||||
// 用「保插入序」的对象逐键放入,序列化采用紧凑无空格(与后端 Jackson 默认无缩进口径一致,便于字节对齐)。
|
||||
// versionId/gameId 用占位串:真实 versionId 在 SQL 端由子查询从 game_project.current_version_id 解析,
|
||||
// 但 package_json 内的 versionId 字段须与之一致——故本桩在 SQL 端用 @vid 变量回填占位(见下方 SQL)。
|
||||
function buildPackage(versionPlaceholder) {
|
||||
const pkg = {};
|
||||
pkg.schemaVersion = '1.0';
|
||||
pkg.gameId = String(gameId);
|
||||
pkg.versionId = versionPlaceholder; // SQL 端 REPLACE 回填真实 versionId(见下)
|
||||
pkg.templateId = 'wanglanmei-ref'; // P2 ref 级固定模板标识(过渡口径)
|
||||
pkg.gameConfig = { templateId: 'wanglanmei-ref', title: '王蓝莓小卖部(引擎真渲)' };
|
||||
pkg.assets = [];
|
||||
// manifest:bundleSize/checksum 先占位(内层自述,非宿主校验面);保留三键齐全满足契约 required
|
||||
pkg.manifest = {
|
||||
runtimeVersion: '1.0.0',
|
||||
entry: 'index.html',
|
||||
preloadPolicy: 'eager',
|
||||
bundleSize: 0,
|
||||
checksum: CHECKSUM_PLACEHOLDER,
|
||||
};
|
||||
pkg.meta = {
|
||||
title: '王蓝莓小卖部(引擎真渲)',
|
||||
summary: 'P2 通用宿主过渡桩:ref 引擎 bundle 经 bootGameHost 真渲',
|
||||
cover: COVER_PLACEHOLDER,
|
||||
ageRating: 'all',
|
||||
};
|
||||
// engineBundle 末位放入(落 checksum 之内,由整包 sha256 覆盖;与后端 put 落点同义)
|
||||
pkg.engineBundle = engineBundle;
|
||||
return pkg;
|
||||
}
|
||||
|
||||
// 内层 manifest.checksum 自指消解:先以占位序列化 T0 → C=sha256(T0) → 写回得 T1(包内自述用,宿主用外层列值)。
|
||||
// 注:versionId 占位文本在 T0/T1 两次序列化一致,故内层 checksum 计算自洽;SQL 端 REPLACE 占位为真实 versionId
|
||||
// 会改变最终落库文本——但宿主校验的外层 checksum 由 MySQL 对「落库后的最终文本」SHA2 求值,故仍恒一致(见 SQL)。
|
||||
const VID_PLACEHOLDER = '__VERSION_ID__';
|
||||
const t0 = JSON.stringify(buildPackage(VID_PLACEHOLDER));
|
||||
const innerChecksum = createHash('sha256').update(t0, 'utf8').digest('hex');
|
||||
const pkgT1 = buildPackage(VID_PLACEHOLDER);
|
||||
pkgT1.manifest.checksum = innerChecksum;
|
||||
const t1 = JSON.stringify(pkgT1);
|
||||
|
||||
// ---------- 步骤③:转义为 SQL 字符串字面量(MySQL:转义 \ 和 ') ----------
|
||||
// MySQL 默认 NO_BACKSLASH_ESCAPES 关闭 → 反斜杠是转义符,必须先转义 \,再转义单引号;换行/控制符 bundle 内已是
|
||||
// 合法 JS 文本字符,作为字符串内容原样存即可(不破坏 JSON 文本语义)。
|
||||
function sqlQuote(text) {
|
||||
return "'" + text.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
|
||||
}
|
||||
const t1Sql = sqlQuote(t1);
|
||||
|
||||
// ---------- 步骤④:产出幂等 UPDATE SQL ----------
|
||||
// 设计:
|
||||
// 1) 用 @vid 解析该 gameId 的当前生效版本(game_project.current_version_id);
|
||||
// 2) 把 package_json 内的 versionId 占位 __VERSION_ID__ 用 REPLACE 回填为真实 @vid(保 versionId 自洽);
|
||||
// 3) checksum = LOWER(SHA2(package_json,256)) 对「回填后的最终文本」求值 → 与所存字节恒一致(宿主校验必过);
|
||||
// 4) bundle_size = LENGTH(package_json)(utf8mb4 下 LENGTH 返字节数,对齐契约 bundleSize 语义);
|
||||
// 5) status = 1(已发布;9001 本已为 1,置之幂等无副作用,确保 play 场景门禁放行)。
|
||||
// 安全:纯 UPDATE 单行(uk_version 唯一),重跑覆写无副作用;事务包裹保原子。
|
||||
const sql = `-- ============================================================================
|
||||
-- O-P2-1 灌库桩 SQL(P2 过渡桩,真产线 P3)—— 由 game-cloud/scripts/p2_seed_engine_bundle.mjs 生成
|
||||
-- 目标 gameId=${gameId}(缺省王蓝莓小卖部 A);灌入 ref 引擎 bundle 到其当前生效版本的 package_json.engineBundle
|
||||
-- 应用:docker exec -i game-staging-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" < 本文件
|
||||
-- 幂等:纯 UPDATE,可重复执行
|
||||
-- ============================================================================
|
||||
USE \`ruoyi-vue-pro\`;
|
||||
-- charset 防御(mini-desktop 真机门逮):强制连接 charset=utf8mb4,否则客户端默认 latin1 把 UTF-8 bundle/中文
|
||||
-- title 双编码落 utf8mb4 列 → 列字节 ≠ SET @pj 计算 checksum 的字节 → stored checksum 与 manifest 端点
|
||||
-- sha256(原文) 不一致 → 宿主 crypto.subtle 校验必失败 → demo 兜底 → 引擎永不渲。自带 SET NAMES 不依赖调用方加参数。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ① 解析该 gameId 当前生效版本 ID(game_project.current_version_id → game_version.id)
|
||||
SET @gid := ${Number(gameId)};
|
||||
SET @vid := (SELECT current_version_id FROM game_project WHERE id = @gid AND deleted = b'0' LIMIT 1);
|
||||
|
||||
-- 防御:版本未解析(游戏不存在/未设当前版本)则报错中止,不盲写
|
||||
SET @ok := IF(@vid IS NULL, (SELECT CONCAT('FATAL: gameId=', @gid, ' 无 current_version_id,先发布该游戏') ), 'ok');
|
||||
SELECT @vid AS resolved_version_id, @ok AS precheck;
|
||||
|
||||
-- ② 覆写该版本运行包行:package_json(versionId 占位回填真实 @vid)+ 自洽 checksum/bundle_size + status=1
|
||||
-- shouldFix#2:把终文一次性装入会话变量 @pj,避免单 UPDATE 内联 3× bundle(~265KB) 撞 max_allowed_packet(旧配置 1MB 档会拒)
|
||||
SET @pj := REPLACE(${t1Sql}, '${VID_PLACEHOLDER}', CAST(@vid AS CHAR));
|
||||
START TRANSACTION;
|
||||
UPDATE game_runtime_package
|
||||
SET
|
||||
package_json = @pj,
|
||||
checksum = LOWER(SHA2(@pj, 256)),
|
||||
bundle_size = LENGTH(@pj),
|
||||
status = 1,
|
||||
updater = 'p2-seed-engine-bundle',
|
||||
update_time = NOW()
|
||||
WHERE version_id = @vid AND deleted = b'0';
|
||||
COMMIT;
|
||||
|
||||
-- ③ 回读核对(应见 status=1、checksum 64 位、package_json 含 engineBundle/__GameBundle)
|
||||
SELECT version_id, status, checksum, bundle_size,
|
||||
(package_json LIKE '%"engineBundle"%') AS has_engine_bundle_key,
|
||||
(package_json LIKE '%${FROZEN_GLOBAL_NAME}%') AS has_frozen_global_name
|
||||
FROM game_runtime_package WHERE version_id = @vid AND deleted = b'0';
|
||||
`;
|
||||
|
||||
// 产出到 stdout(调用方重定向到 .sql 文件)
|
||||
process.stdout.write(sql);
|
||||
|
||||
// 诊断信息走 stderr(不污染 stdout 的纯 SQL)
|
||||
console.error(`[p2-seed] gameId=${gameId} bundle=${bundlePath} ` +
|
||||
`bundleBytes=${Buffer.byteLength(engineBundle, 'utf8')} ` +
|
||||
`pkgJsonBytes(占位态)=${Buffer.byteLength(t1, 'utf8')} innerChecksum=${innerChecksum}`);
|
||||
@ -12,13 +12,32 @@ cd game-runtime && node --test "games/wanglanmei-ref/test/*.test.mjs"
|
||||
|
||||
## 1. 构建(esbuild 锁参,沿 scripts/build.mjs 同参数:bundle+minify+iife+es2019+sourcemap=false)
|
||||
|
||||
在 `game-runtime/` 目录下(需 `npm i -D esbuild`,mini-desktop 已具备):
|
||||
在 `game-runtime/` 目录下(需 `npm i -D esbuild`,mini-desktop 已具备)。**P2 起本目录产两套 bundle,各服务一个消费方,已固化为 npm script:**
|
||||
|
||||
### 1.1 P1 ref 取证 harness bundle(`WanglanmeiRef` · index.html 用)
|
||||
|
||||
```bash
|
||||
node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef
|
||||
npm run build:ref-harness
|
||||
# 等价:node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef
|
||||
```
|
||||
|
||||
产物:`games/wanglanmei-ref/dist/ref-bundle.js`(iife 全局 `WanglanmeiRef`,含 core 协议 + 所用插件 + 游戏全部代码;资产不打包,运行时相对路径取 `./assets/`)。
|
||||
产物:`games/wanglanmei-ref/dist/ref-bundle.js`(iife 全局 `WanglanmeiRef`,导出 `bootRef`;含 core 协议 + 所用插件 + 游戏全部代码 + 引擎;资产不打包,运行时相对路径取 `./assets/`)。**index.html 与 §2 真人试玩/取证走它**(P1 real 引擎门 6/6 沿用,P2 不动)。
|
||||
|
||||
### 1.2 P2 studio-consumable bundle(`__GameBundle` · studio host 引擎包分支用 · T1b-β P2)
|
||||
|
||||
```bash
|
||||
npm run build:ref-bundle
|
||||
# 等价:node scripts/build.mjs games/wanglanmei-ref/entry-bundle.js games/wanglanmei-ref/dist/ref-bundle.iife.js --global-name=__GameBundle
|
||||
```
|
||||
|
||||
产物:`games/wanglanmei-ref/dist/ref-bundle.iife.js`(iife 全局 **`__GameBundle`**,契约冻结名;入口 `entry-bundle.js`)。
|
||||
|
||||
- **studio 接缝(契约冻结·对齐 `game-studio/src/host/contract.ts:204` 与 `game-package.schema.json` engineBundle)**:
|
||||
- `window.__GameBundle.GameHostFactory` = `GameHostFactory`(= game-shop 工厂;宿主据此建游戏实例,契约 `game-host.d.ts:59`);`window.__GameBundle.default` 同此引用。
|
||||
- `window.__GameBundle.bootGameHost({ canvas, statusEl?, seed? })` = P2 boot 入口(studio iframe 内调它启动;引擎已内联进本 bundle,studio 仓无需 littlejsengine)。
|
||||
- **载体(O-P2-2 ① DB-manifest 内嵌)**:bundle 文本灌进 GamePackage `engineBundle` 字段 → studio iframe 内联 `<script>` 注入(非外域 `<script src>`,CSP 不放外域)。
|
||||
- **产物体积**:raw 259KB / gz 88KB(实测,对齐 SIZES.md §游戏产物;B1 入场券 gz≤350KB ✔)。引擎入包核验:`grep -c ParticleEmitter dist/ref-bundle.iife.js`(>0)+ metafile `inputs` 含 `littlejsengine`。
|
||||
- **【过渡桩·O-P2-1】** P2 仅跑 ref 一款:本 bundle 由脚本一次性灌入 DB 某 versionId 的 `engineBundle` 并置 PUBLISHED 供 feed 真玩(须显式标注过渡桩)。**真产线(P3 生成主线 agent 出码经 buildGamePackage 原生出 bundle)另立 W-G1 spec**,届时本入口的 `GameHostFactory` 导出由生成码替换。
|
||||
|
||||
## 2. 本地静态 serve(沿 test/harness/static-serve.cjs;轮 1 约定端口 4320)
|
||||
|
||||
|
||||
79
game-runtime/games/wanglanmei-ref/entry-bundle.js
Normal file
79
game-runtime/games/wanglanmei-ref/entry-bundle.js
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* entry-bundle.js — wanglanmei-ref 的 P2「studio-consumable」bundle 入口(esbuild 锁参打成 iife,globalName=__GameBundle)
|
||||
* owner:Runner v2 P2 | 消费方:studio host 引擎包分支(DB-manifest 内嵌路:iframe 内联 <script> 注入本 bundle 文本)
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【本入口与 P1 entry.js 的区别(两套 bundle,各服务一个消费方)】
|
||||
* - entry.js → dist/ref-bundle.js(globalName=WanglanmeiRef,导出 bootRef):P1 **ref 取证 harness** 用,
|
||||
* index.html `<script src>` 加载、调 WanglanmeiRef.bootRef 在**自己的页面**跑引擎。**P2 不动它**(保 real 引擎门 6/6)。
|
||||
* - 本 entry-bundle.js → dist/ref-bundle.iife.js(globalName=__GameBundle,导出 GameHostFactory + bootGameHost):
|
||||
* P2 **studio host** 用。studio iframe 注入本 bundle 文本 → 取 window.__GameBundle.GameHostFactory(契约冻结)
|
||||
* → 调 window.__GameBundle.bootGameHost({factory, canvas, ...}) 在 iframe 内跑引擎(studio 仓无 littlejsengine,引擎在本 bundle 内)。
|
||||
*
|
||||
* 【契约(冻结·逐字对齐 game-studio/src/host/contract.ts:204 + game-package.schema.json engineBundle)】
|
||||
* iife 顶层全局名固定 **__GameBundle**;宿主从 window.__GameBundle 取 GameHostFactory(= 本入口导出的 GameHostFactory)。
|
||||
* 本 bundle = 引擎(LittleJS) + 包装层(bootGameHost + wanglanmei 装配) + 游戏代码(game-shop 工厂)。
|
||||
*
|
||||
* 【Q4 · Option C:引擎 import 只落本入口,且经动态 import 延后求值】
|
||||
* 起因同 entry.js:littlejsengine ESM 在模块装载期触 window/AudioContext,顶层静态 import 会让 node 一 import 即抛 →
|
||||
* 击穿 import-graph(断言本入口 node import-safe)。修法:引擎 import 改「包装层内动态 import」:
|
||||
* · node import 本入口时不触引擎(bootGameHost 未被调)→ import-safe(过 import-graph);
|
||||
* · esbuild(bundle+iife 无 splitting)把动态 import 的引擎内联进单文件 → 引擎真入 bundle(grep ParticleEmitter 核);
|
||||
* · 浏览器运行期 studio 调 __GameBundle.bootGameHost 时才求值引擎(window/AudioContext 已存在)。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。
|
||||
import { bootGameHost as bootGameHostCore } from '../../src/host/boot-game-host.js';
|
||||
// wanglanmei 装配配置(工厂/7 件插件/视口/wiring)——与 ref 取证 bootRef 同源(防第三套)。
|
||||
import { buildRefHostConfig, refFactory } from './src/ref-host-config.js';
|
||||
|
||||
/**
|
||||
* 【契约导出】GameHostFactory:studio host 从 window.__GameBundle.GameHostFactory 取它(契约冻结)。
|
||||
* = wanglanmei game-shop 工厂(createShopGame)。P3 生成主线由 agent 出码替换本导出(W-G1 自身 spec)。
|
||||
* @type {import('../../src/core/game-host.d.ts').GameHostFactory}
|
||||
*/
|
||||
export const GameHostFactory = refFactory;
|
||||
// 默认导出同为 GameHostFactory(便于 import default 取用;与具名导出同一引用)。
|
||||
export default refFactory;
|
||||
|
||||
/**
|
||||
* 【P2 boot 入口】供 studio host 在 iframe 内启动本 bundle(studio 仓无引擎,故由本 bundle 注入引擎后驱动)。
|
||||
* - 动态 import 引擎(Option C):调用时才求值,esbuild 已把引擎内联进本 bundle(无外部请求)。
|
||||
* - engineMode 强制 'real'(studio 引擎包分支一律引擎掌帧;stub 是 ref 取证回滚锚,studio 不用)。
|
||||
* - 插件/视口/工厂 wiring 取自共用配置(P2 阶段=wanglanmei 7 件标准集 + 390×844,O-P2-4;P3 多品类再拍随包给)。
|
||||
*
|
||||
* @param {Object} opts studio host 传入的最小入参。
|
||||
* @param {HTMLCanvasElement} opts.canvas iframe 内目标画布(real 通道引擎自建 mainCanvas,本 canvas 作占位/输入兜底)。
|
||||
* @param {HTMLElement} [opts.statusEl] 状态行(可空)。
|
||||
* @param {number} [opts.seed] 主随机种子(缺省固定值,保确定性;studio 可从 GamePackage 传)。
|
||||
* @param {import('../../src/core/game-host.d.ts').GameHostFactory} [opts.factory] 覆盖工厂(缺省=本 bundle 的 GameHostFactory)。
|
||||
* studio 取 window.__GameBundle.GameHostFactory 再回传是冗余但语义清晰;缺省则用本 bundle 内置工厂。
|
||||
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
const o = opts || {};
|
||||
// 动态 import 引擎:求值时机延后到本函数被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||
const engine = o.engine || (await import('littlejsengine'));
|
||||
// wanglanmei 装配(工厂/插件/视口/wiring);seed 缺省固定值(与 ref 取证同口径)。
|
||||
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
|
||||
const cfg = buildRefHostConfig({ seed });
|
||||
// 调通用宿主:real 通道引擎掌帧;canvas/statusEl 由 studio 传;其余取共用配置。
|
||||
return bootGameHostCore({
|
||||
canvas: o.canvas,
|
||||
statusEl: o.statusEl || null,
|
||||
engine,
|
||||
seed,
|
||||
mode: 'play', // studio 真玩=play(evidence 是 ref 取证用)
|
||||
engineMode: 'real', // studio 引擎包分支一律引擎掌帧
|
||||
viewport: cfg.viewport,
|
||||
factory: o.factory || cfg.factory, // 缺省用本 bundle 内置 GameHostFactory
|
||||
plugins: cfg.plugins,
|
||||
registerOrder: cfg.registerOrder,
|
||||
buildFactoryOpts: cfg.buildFactoryOpts,
|
||||
// studio 渠道产物:不注 recHook(引擎 call-ID 取证是 ref harness 专属;渠道产物无副作用)。
|
||||
// 无 onReady:studio 不消费 ref 取证全局(__refForensics 等),就绪信号经 SDK lifecycle game_loaded/game_end(studio 轨接)。
|
||||
});
|
||||
}
|
||||
@ -1,58 +1,35 @@
|
||||
/**
|
||||
* main.js — wanglanmei-ref 宿主引导(浏览器层;受控面提供方 + 装载契约 host)
|
||||
* owner:wanglanmei-ref 轮 1 / Runner v2 P1 | 消费方:entry.js(esbuild 入口)→ index.html
|
||||
* main.js — wanglanmei-ref 宿主引导(薄包装层:把通用宿主 bootGameHost 配成wanglanmei专属 bootRef)
|
||||
* owner:wanglanmei-ref 轮 1 / Runner v2 P1→P2 | 消费方:entry.js(esbuild 入口)→ index.html
|
||||
*
|
||||
* 【职责】host 层(仿 host-dev/host.js 样板):
|
||||
* - Canvas 390×844 DPR2(取证固定环境)+ 步进时钟(?mode=evidence 完全确定性);
|
||||
* - createHostDevContext 受控面 → PluginRegistry 注册 7 件业务插件 + probe 宿主自管六锚链;
|
||||
* - DOM 输入 → 归一化 → HostDevInputBridge._emit(真实交互灌进受控输入面);
|
||||
* - 经【装载契约】game-shop.js 工厂建游戏实例,host 经引擎五回调(real)/ RAF(stub)驱动其 update/render;
|
||||
* - 取证导出:window.__refForensics / window.__refChecklist / window.__refHost(驱动钩子)。
|
||||
* 【P2 泛化后的职责变化】
|
||||
* P1 时本文件自带完整引擎宿主骨架(engineInit 五回调 + 受控面背书 + 输入接线 + 单一时钟)。
|
||||
* P2 已把该骨架**整块提升**到通用宿主 `src/host/boot-game-host.js` 的 bootGameHost(opts),
|
||||
* 并参数化「游戏工厂来源 / 插件清单 / 视口」三处wanglanmei专属项。
|
||||
* 故本文件 bootRef 退化为**薄包装**:解析 URL 参数 → 装配wanglanmei 7 件插件 + game-shop 工厂 + 390×844 视口
|
||||
* → 调 bootGameHost → 在 onReady 钩子里组装wanglanmei取证全局(__refForensics/__refChecklist/__refHost)。
|
||||
* **目的**:P1 ref 的全部取证路径(?engine=stub/real 双通道、node 单测、CDP 驱动、import 图)零回归。
|
||||
*
|
||||
* 【P1 双通道(创始人拍)】
|
||||
* - ?engine=stub(**缺省**,回滚锚):prepareCanvas(#game) 2D + 浏览器 RAF + mockNowMs 墙钟 → game.update/render(绘制面=#game ctx),行为与轮 1 逐行等价(逐像素门⑤兜)。
|
||||
* - ?engine=real(显式开,引擎掌帧):import 引擎、setGLEnable(false)、engineInit 五回调驱动 game.update/render(绘制面=引擎 mainContext,与引擎粒子同屏)。
|
||||
* 【双通道(P1 沿用,通道实现已下沉 bootGameHost)】
|
||||
* - ?engine=stub(**缺省**,回滚锚):浏览器 RAF + 单一墙钟,行为与轮 1 逐行等价(逐像素门兜)。
|
||||
* - ?engine=real(显式开,引擎掌帧):引擎 mainContext 绘制,与引擎粒子同屏(P1)。
|
||||
*
|
||||
* 【红线遵守】
|
||||
* - 游戏逻辑零裸 Date.now/Math.random/AudioContext —— 全在 game-shop.js 经受控面/注入(host 是受控面提供方);
|
||||
* - 宿主层是受控面提供方:RAF/Image/fetch/AudioContext 工厂只在本层出现;
|
||||
* - 引擎 import 只活 entry/host(Q4 铁律);游戏工厂 game-shop.js 零引擎 import。
|
||||
* - 游戏逻辑零裸 Date.now/Math.random/AudioContext —— 全在 game-shop.js 经受控面/注入;
|
||||
* - 引擎 import 只活 entry/host(Q4 铁律);main.js(host 层)经 opts.engine 注入用引擎,不直接 import;
|
||||
* game-shop.js 游戏工厂零引擎 import;通用宿主 bootGameHost 同样不直接 import 引擎。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 【A0/Q4 · Option C:引擎 import 不落 main.js,改由 entry.js 经 opts 注入】
|
||||
// 起因(mustFix #1):顶层 `import * as LJS from 'littlejsengine'` 会在引擎 esm 装载期触 window/AudioContext,
|
||||
// node 直接抛 ReferenceError → 击穿 import-graph(断言 main.js 可 node 导入)+ headless-boot(import ../src/main.js 跑冒烟)。
|
||||
// Option C 修法:引擎静态 import 只留 entry.js(esbuild 入口,负责把 littlejsengine 打进 ref-bundle);
|
||||
// main.js 的 real 通道经 bootRef(opts.engine) 注入拿引擎名字空间(见下方 const LJS = opts.engine)。
|
||||
// 这样 main.js 不再顶层 import 引擎 → node 可安全 import → 两测原样通过(测试/MODULES 表零改动)。
|
||||
// Q4 铁律不破:引擎 import 仍只活 entry.js(host 层 main.js 经注入用引擎,不直接 import);game-shop.js 游戏工厂零引擎 import。
|
||||
import { PluginRegistry, createHostDevContext, CORE_PROTOCOL_VERSION } from '../../../src/core/plugin.js';
|
||||
import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js';
|
||||
import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js';
|
||||
import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js';
|
||||
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
|
||||
import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js';
|
||||
import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js';
|
||||
import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js';
|
||||
import { createRuntimeProbePlugin } from '../../../src/plugins/runtime-probe/impl.js';
|
||||
// 【P2】通用宿主:引擎骨架 + 受控面背书 + 输入接线全在此(参数化游戏工厂/插件/视口)。
|
||||
import { bootGameHost } from '../../../src/host/boot-game-host.js';
|
||||
|
||||
// 【P1 提升】引擎能力背书工厂(EngineCapabilities 三面)—— host-dev / ref real 通道共用同一份(搬运·语义零变)。
|
||||
// 本模块不 import 引擎(经入参 LJS 注入);real 通道用它建 engineFactory。
|
||||
import { createEngineCaps } from '../../../host-dev/engine-caps.js';
|
||||
// 【A1】引擎数学门面构造(纯函数,零引擎 import):real 通道用它包装引擎 lerp/smoothStep/Ease。
|
||||
import { buildEngineMath } from '../../../host-dev/engine-math.js';
|
||||
// 【P2】wanglanmei 宿主装配配置(工厂/7 件插件/视口/工厂 wiring)——bootRef 与 P2 bundle 入口共用同一份(防第三套)。
|
||||
import { buildRefHostConfig } from './ref-host-config.js';
|
||||
|
||||
// 【装载契约 game 工厂】暖阳小卖部游戏实现(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件)。
|
||||
import createShopGame from './game-shop.js';
|
||||
|
||||
// 渲染常量(视口宽高);render/balance 等仍由 game-shop.js 内部引用,这里只取 host 需要的视口尺寸。
|
||||
import { W as VIEW_W, H as VIEW_H } from './render.js';
|
||||
|
||||
/** 取证固定环境(与 host-dev / browser-evidence FIXED_ENV 对齐)。 */
|
||||
const DPR = 2;
|
||||
const FIXED_DT = 1 / 60;
|
||||
// 协议号(取证导出 forensics.protocol 用)。
|
||||
import { CORE_PROTOCOL_VERSION } from '../../../src/core/plugin.js';
|
||||
|
||||
/** 读 URL 参数。 */
|
||||
function getParam(name, def) {
|
||||
@ -72,114 +49,41 @@ function resolveSeed() {
|
||||
return Number.isFinite(n) ? n >>> 0 : 0x1234abcd >>> 0;
|
||||
}
|
||||
|
||||
/** Canvas 设为逻辑 390×844 / 物理 ×DPR,并把 2D 上下文缩放到逻辑坐标(stub 通道用)。 */
|
||||
function prepareCanvas(canvas) {
|
||||
canvas.width = VIEW_W * DPR;
|
||||
canvas.height = VIEW_H * DPR;
|
||||
canvas.style.width = VIEW_W + 'px';
|
||||
canvas.style.height = VIEW_H + 'px';
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动参考件(host)。
|
||||
* 启动参考件(host)。退化为 bootGameHost 的薄包装:装配wanglanmei专属参数 + 组装取证全局。
|
||||
* @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement, engine?: any}} opts
|
||||
* - engine:【Option C】引擎名字空间(littlejsengine),由 entry.js 包装层注入(main.js 不顶层 import 引擎,保 node-safe)。
|
||||
* 仅 ?engine=real 通道用;stub/node 通道不触达(缺省 undefined,real 缺它会 throw 提示,见下方分流)。
|
||||
* - engine:引擎名字空间(littlejsengine),由 entry.js 包装层经 opts 注入(main.js 不顶层 import 引擎,保 node-safe)。
|
||||
* 仅 ?engine=real 通道用;stub/node 通道不触达(缺省 undefined)。
|
||||
* @returns {Promise<object>} refHost 句柄(同时挂 window.__refHost)
|
||||
*/
|
||||
export async function bootRef(opts) {
|
||||
const canvas = opts.canvas;
|
||||
const statusEl = opts.statusEl || null;
|
||||
// 【Option C】引擎名字空间经 opts 注入(entry.js 包装层在调 bootRef 时传 engine: LJS)。
|
||||
// stub/node 通道为 undefined(不触达引擎,保 node import-safe);real 通道由 entry 注入真 LJS。
|
||||
const LJS = opts.engine;
|
||||
const seed = resolveSeed();
|
||||
const mode = getParam('mode', 'play'); // 'play' | 'evidence'
|
||||
const mode = getParam('mode', 'play'); // 'play' | 'evidence'
|
||||
// 引擎通道:?engine=real(引擎掌帧)| ?engine=stub(**缺省**,回滚桩,行为与轮 1 逐行等价)。
|
||||
// 注(§12-A2):host-dev 缺省 real,但 ref 缺省 stub 更稳——真玩取证显式带 &engine=real,stub 兜逐像素回归。
|
||||
const engineMode = getParam('engine', 'stub');
|
||||
|
||||
/** 状态行(仅人看)。 */
|
||||
function setStatus(msg) {
|
||||
if (statusEl) statusEl.textContent = msg;
|
||||
}
|
||||
/* ── wanglanmei 宿主装配(工厂/7 件插件实例集/注册偏序/视口/工厂 wiring):取自共用配置(与 P2 bundle 入口同源)── */
|
||||
const refCfg = buildRefHostConfig({ seed });
|
||||
|
||||
/* ── 未捕获错误归集(评估门:console 零未捕获错误)── */
|
||||
/** @type {Array<{type:string,message:string}>} */
|
||||
const uncaught = [];
|
||||
window.addEventListener('error', (e) => uncaught.push({ type: 'error', message: String((e && e.message) || e) }));
|
||||
window.addEventListener('unhandledrejection', (e) =>
|
||||
uncaught.push({ type: 'unhandledrejection', message: String((e && e.reason && e.reason.message) || (e && e.reason) || e) })
|
||||
);
|
||||
|
||||
/* ── 受控音频工厂(用户手势解锁后才 new;宿主=受控面提供方)── */
|
||||
let realAudioCtx = null;
|
||||
let audioUnlocked = false;
|
||||
function audioFactory() {
|
||||
if (!audioUnlocked) return null;
|
||||
if (realAudioCtx) return realAudioCtx;
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
realAudioCtx = new AC();
|
||||
return realAudioCtx;
|
||||
}
|
||||
|
||||
/* ── 步进/逻辑帧钟【mustFix #2:唯一推进时钟(single source of truth)】 ──
|
||||
* 原缺陷:本 mockNowMs 只喂 bundle.clock 却永不自增(全文件无 +=),而游戏侧 game-shop.js 另有一份私有
|
||||
* mockNowMs 在其 update 内自增——两钟分裂致:bundle.clock 恒 0 → HostDevTime.nowMs() 恒 0 →
|
||||
* inputBridge._emit 给输入事件盖 tMs=0 → gamefeel InputBuffer 记 tMs=0 → game-shop 接待缓冲补打
|
||||
* 用「推进的游戏钟 − 记录的 0」判窗口恒假 → P4 输入缓冲补打永不触发(measures.bufferedServeReplays 恒 0)。
|
||||
* 修法:本 mockNowMs 成唯一真相,在 game.update 驱动点自增(stub:stepOneFrame 内;real:_engUpdate 内);
|
||||
* bundle.clock 读它(line ~219);game-shop.js 删私有钟、改读 boot.ctx.time.nowMs()(即读本钟)——并轨成单钟。
|
||||
* 语义等价回滚:原 game-shop 在 update 首行 `mockNowMs += dt*1000`,故此处亦在调 game.update **之前**自增,
|
||||
* 保游戏逻辑在 update 内读到的钟值与原逐行一致(headless-boot 确定性测状态终点不变)。 */
|
||||
let mockNowMs = 0;
|
||||
|
||||
/* ── 7 件业务插件实例(host 拥有;游戏经工厂 opts 复用,一职一路)── */
|
||||
const collision = createCollisionPlugin({ cellSize: 96 });
|
||||
const physics = createPhysicsLitePlugin();
|
||||
const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] });
|
||||
const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 });
|
||||
const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } });
|
||||
const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 });
|
||||
// P9 后端:浏览器 localStorage(不可用环境回退内存 adapter,错误路径留痕)。
|
||||
let saveAdapter;
|
||||
try {
|
||||
saveAdapter = createLocalStorageAdapter();
|
||||
} catch (e) {
|
||||
console.warn('[ref] localStorage 不可用,存档退内存 adapter:' + (e && e.message));
|
||||
saveAdapter = createMemoryAdapter();
|
||||
}
|
||||
const save = createSaveProgressPlugin({ namespace: 'wanglanmei-ref', adapter: saveAdapter });
|
||||
const plugins = { collision, physics, gamefeel, juice, palettePost, audioMusic, save };
|
||||
|
||||
/* ── 受控面 bundle + 游戏实例(real:engineInit 就绪后建;stub:立即)。延后建避免快照错 ctx(§12-M4)。 ── */
|
||||
/** @type {ReturnType<typeof createHostDevContext>|null} */
|
||||
let bundle = null;
|
||||
/** @type {import('../../../src/core/game-host.d.ts').GameInstance|null} */
|
||||
let game = null;
|
||||
/** @type {ReturnType<typeof createRuntimeProbePlugin>|null} */
|
||||
let probe = null;
|
||||
let initRes = null;
|
||||
let pluginSet = [];
|
||||
/** checklist(LOOP-LOG 五要素表自动子项 → 布尔+数值,逐帧刷新;game 工厂原地填)。 */
|
||||
const checklist = {};
|
||||
/** @type {ReturnType<NonNullable<typeof game>['_forensicsView']>|null} */
|
||||
/* ── wanglanmei取证对象(host 组装:游戏侧 forensics 视图 + probe 链 + 协议号)。在 onReady 内绑定内部件后填充。 ── */
|
||||
/** checklist(LOOP-LOG 五要素表自动子项 → 布尔+数值,逐帧刷新;game 工厂原地填)。
|
||||
* 【关键·防双 checklist 漂移】不另建空对象——直接复用 bootGameHost 内部那份「逐帧 _refreshChecklist 刷新」的
|
||||
* checklist(onReady 注入 internals.checklist)。否则本地另一份只在 onReady 刷一次→营业期 linesShownLive 等子项恒停在 boot 值。 */
|
||||
/** @type {Record<string, any>} */
|
||||
let checklist = {};
|
||||
/** 取证内部件(onReady 回调注入:游戏取证视图 + probe + 未捕获错误等)。 */
|
||||
let gameForensics = null;
|
||||
let probeRef = null;
|
||||
let uncaughtRef = [];
|
||||
let pluginSetRef = [];
|
||||
let initResRef = null;
|
||||
/** @type {object|null} bootGameHost 返回的通用 host 句柄(refHost 在其上扩展取证面)。 */
|
||||
let coreHost = null;
|
||||
|
||||
/** 安全打点(probe 异常不中断启动链)。 */
|
||||
function mark(anchor, extra) {
|
||||
try {
|
||||
if (probe) probe.mark(anchor, extra);
|
||||
} catch (e) {
|
||||
uncaught.push({ type: 'mark', message: String((e && e.message) || e) });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 取证导出(host 组装:游戏侧 forensics 视图 + probe 链 + 协议号)── */
|
||||
/** wanglanmei取证导出(与 P1 字段一致;读 onReady 注入的内部件)。 */
|
||||
const forensics = {
|
||||
protocol: CORE_PROTOCOL_VERSION,
|
||||
seed,
|
||||
@ -188,12 +92,12 @@ export async function bootRef(opts) {
|
||||
geometry: () => (gameForensics ? gameForensics.geometry() : []),
|
||||
/** 探针六锚链导出。 */
|
||||
probe: () => ({
|
||||
jsonl: probe ? probe.toJSONL() : '',
|
||||
verify: probe ? probe.verify() : { ok: false, reason: 'no-probe' },
|
||||
anchors: probe ? probe.getRecords().map((r) => r.anchor) : [],
|
||||
uncaught: uncaught.slice(),
|
||||
initOk: initRes ? initRes.ok : false,
|
||||
registeredPlugins: pluginSet,
|
||||
jsonl: probeRef ? probeRef.toJSONL() : '',
|
||||
verify: probeRef ? probeRef.verify() : { ok: false, reason: 'no-probe' },
|
||||
anchors: probeRef ? probeRef.getRecords().map((r) => r.anchor) : [],
|
||||
uncaught: uncaughtRef.slice(),
|
||||
initOk: initResRef ? initResRef.ok : false,
|
||||
registeredPlugins: pluginSetRef,
|
||||
}),
|
||||
/** 闭环/失败态/定价证明(boot 期纯逻辑跑出)。 */
|
||||
get loopProof() { return gameForensics ? gameForensics.loopProof : null; },
|
||||
@ -207,313 +111,79 @@ export async function bootRef(opts) {
|
||||
measures: () => (gameForensics ? gameForensics.measures() : {}),
|
||||
};
|
||||
|
||||
/** 逐帧刷新取证(游戏侧 checklist + host forensics 已是 getter,无需额外刷)。 */
|
||||
function refreshForensics() {
|
||||
if (game && typeof game._refreshChecklist === 'function') game._refreshChecklist(checklist);
|
||||
/** 状态行(仅人看;通用宿主已设过一轮,这里仅错误兜底用)。 */
|
||||
function setStatus(msg) {
|
||||
if (statusEl) statusEl.textContent = msg;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* 共用收尾:建 bundle → 注册插件 → 建游戏 → init → 打链 → 挂取证(real/stub 都调)
|
||||
* renderCtx:游戏 render(g) 的绘制面(real=引擎 mainContext / stub=#game ctx)。
|
||||
* inputCanvasGetter:输入 rect 映射用的可见画布(real=引擎 mainCanvas / stub=#game)。
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
async function setupCommon(renderCtx, inputCanvasGetter) {
|
||||
// 1) bundle:context2d=绘制面(受控面 getContext2d 一次性快照此 ctx,§12-M4 必须就绪后建)。
|
||||
// engineFactory:real 经 createEngineCaps 真背书引擎;stub 短路返 null(保 null-engine 回归)。
|
||||
bundle = createHostDevContext({
|
||||
context2d: renderCtx,
|
||||
seed,
|
||||
audioFactory,
|
||||
clock: () => mockNowMs,
|
||||
engineFactory: () => createEngineCaps({
|
||||
LJS: engineMode === 'real' ? LJS : null, // stub 不持引擎名字空间(createEngineCaps 首行按 engineMode 短路返 null)
|
||||
engineMath: engineMode === 'real' ? buildEngineMath(LJS) : null,
|
||||
engineMode,
|
||||
// 取证 recHook:注 window.__engineCalls(门② call-ID)。ref-bundle 即取证用(非渠道产物,同 host-dev 现状)。
|
||||
recHook: (id) => { try { (window.__engineCalls || (window.__engineCalls = [])).push(id); } catch (_) { /* 取证副作用,忽略 */ } },
|
||||
}),
|
||||
});
|
||||
|
||||
// 2) 注册 7 件业务插件(probe 宿主自管,保证六锚偏序——host-dev 同款编排)。
|
||||
const registry = new PluginRegistry();
|
||||
registry
|
||||
.register(collision)
|
||||
.register(physics)
|
||||
.register(gamefeel)
|
||||
.register(juice)
|
||||
.register(palettePost)
|
||||
.register(save)
|
||||
.register(audioMusic);
|
||||
registry.useContext(bundle.context);
|
||||
initRes = registry.initAll();
|
||||
|
||||
// 3) runtime-probe:宿主自管(六锚 canonical 顺序,t_input_bound 恰在 first_paint 后)。
|
||||
pluginSet = registry.list().concat(['runtime-probe']);
|
||||
probe = createRuntimeProbePlugin({ pluginSet, inputAnchorType: 'pointerdown', autoBoot: true });
|
||||
const probeCtx = bundle.deriveContextFor('runtime-probe', 'runtime-probe');
|
||||
mark('t_boot', { by: 'ref-host', stage: 'pre-init', engineMode });
|
||||
mark('t_plugins_ready', { initialized: initRes.initialized.length, ok: initRes.ok });
|
||||
|
||||
// 4) 经装载契约建游戏实例(host 经 opts 注入 runtime:插件实例 + bundle + 取真 AudioContext 回调)。
|
||||
game = createShopGame({
|
||||
runtime: {
|
||||
plugins,
|
||||
bundle,
|
||||
getRealAudioCtx: () => realAudioCtx,
|
||||
},
|
||||
});
|
||||
// 5) game.init:建游戏对象 + 异步装资产 + 订阅受控输入。boot=装载契约脊柱(ctx/mainContext/canvas/seed)。
|
||||
await game.init({
|
||||
ctx: bundle.context,
|
||||
mainContext: renderCtx,
|
||||
canvas: inputCanvasGetter(),
|
||||
seed,
|
||||
});
|
||||
gameForensics = game._forensicsView();
|
||||
mark('t_game_init', {
|
||||
assetsLoaded: gameForensics.assetsLoaded().loaded,
|
||||
assetsTotal: gameForensics.assetsLoaded().total,
|
||||
loopProofOk: gameForensics.loopProof && gameForensics.loopProof.ok,
|
||||
});
|
||||
|
||||
// 6) 首帧 + 输入锚 + 启动链收尾(与轮 1 同序:first_paint → probe.init 绑输入 → game_start)。
|
||||
stepOneFrame();
|
||||
mark('t_first_paint', { frame: bundle.frameCount() });
|
||||
try {
|
||||
probe.init(probeCtx); // 绑输入 → t_input_bound(恰在 first_paint 之后,偏序成立)
|
||||
} catch (e) {
|
||||
uncaught.push({ type: 'probe-init', message: String((e && e.message) || e) });
|
||||
}
|
||||
mark('t_game_start', { mode, engineMode });
|
||||
|
||||
// 7) 挂取证(real 通道顶层不可早挂——bundle/game 此刻才存在;§12-M1)。
|
||||
refreshForensics();
|
||||
window.__refForensics = forensics;
|
||||
window.__refChecklist = checklist;
|
||||
window.__refHost = refHost;
|
||||
}
|
||||
|
||||
/* ── 单帧推进(stub RAF / evidence stepFrames / 首帧共用):game.update + game.render(绘制面=renderCtx)。 ── */
|
||||
/** @type {CanvasRenderingContext2D|null} */
|
||||
let stubRenderCtx = null;
|
||||
function stepOneFrame(dt) {
|
||||
const d = typeof dt === 'number' ? dt : FIXED_DT;
|
||||
if (!game) return;
|
||||
// 【mustFix #2】唯一时钟在 game.update 之前自增(stub 驱动点):使 bundle.clock 与游戏逻辑读同一推进值,
|
||||
// 且与原 game-shop「update 首行 += dt*1000」逐行语义等价(游戏在 update 内读到的钟值不变)。
|
||||
mockNowMs += d * 1000;
|
||||
game.update(d);
|
||||
// stub 通道绘制:game.render(g) 内 drawFrame 首调 drawBackdrop 全屏铺底(不另 clearBlack——与轮 1 frame() 逐行等价,
|
||||
// 保逐像素门⑤双开哈希一致;real 通道引擎清屏 + render 由引擎 gameRender 回调驱动,不走本函数绘制)。
|
||||
if (engineMode === 'stub' && stubRenderCtx) {
|
||||
game.render(stubRenderCtx);
|
||||
}
|
||||
refreshForensics();
|
||||
}
|
||||
/** 确定性推进 n 帧(evidence 驱动 / 启动首帧)。 */
|
||||
function stepFrames(n) {
|
||||
for (let i = 0; i < n; i++) stepOneFrame(FIXED_DT);
|
||||
}
|
||||
|
||||
/* ── 首个用户手势:解锁音频(创建/恢复 AudioContext)+ 通知游戏起 BGM。幂等。 ── */
|
||||
function unlockAudioOnce() {
|
||||
if (audioUnlocked) return;
|
||||
audioUnlocked = true;
|
||||
const ac = audioFactory();
|
||||
if (ac && ac.state === 'suspended' && typeof ac.resume === 'function') ac.resume().catch(() => {});
|
||||
if (game && typeof game._onAudioUnlock === 'function') game._onAudioUnlock();
|
||||
}
|
||||
|
||||
/* ── refHost 驱动句柄(evidence 驱动 / __refHost.do)── */
|
||||
/* ── refHost 驱动句柄(evidence 驱动 / __refHost.do):在通用 host 句柄上扩展wanglanmei取证面 ──
|
||||
* 先于 bootGameHost 声明:onReady 回调(在 await 内执行)会把它挂上 window.__refHost,
|
||||
* 故必须在调 bootGameHost 之前完成声明(避免 const TDZ)。其方法体延迟读 coreHost(彼时已赋值)。 */
|
||||
const refHost = {
|
||||
seed,
|
||||
mode,
|
||||
engineMode,
|
||||
protocol: CORE_PROTOCOL_VERSION,
|
||||
frameCount: () => (bundle ? bundle.frameCount() : 0),
|
||||
stepFrames,
|
||||
startRaf: () => startRaf(),
|
||||
stopRaf: () => stopRaf(),
|
||||
frameCount: () => (coreHost ? coreHost.frameCount() : 0),
|
||||
stepFrames: (n) => coreHost && coreHost.stepFrames(n),
|
||||
startRaf: () => coreHost && coreHost.startRaf(),
|
||||
stopRaf: () => coreHost && coreHost.stopRaf(),
|
||||
/** 注入一次点击(evidence 驱动;与真输入同走受控桥)。 */
|
||||
tap(x, y) {
|
||||
unlockAudioOnce();
|
||||
if (bundle) {
|
||||
bundle.inputBridge._emit('pointerdown', { x, y });
|
||||
bundle.inputBridge._emit('pointerup', { x, y });
|
||||
}
|
||||
},
|
||||
tap: (x, y) => coreHost && coreHost.tap(x, y),
|
||||
/** 直接命令(e2e 辅助;与 UI 同走逻辑公开 API)。 */
|
||||
do(action) {
|
||||
if (game && typeof game._handleAction === 'function') game._handleAction(action);
|
||||
},
|
||||
do: (action) => coreHost && coreHost.do(action),
|
||||
state: () => forensics.state(),
|
||||
forensics,
|
||||
};
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* 输入桥接:DOM 事件 → 归一化坐标 → 受控桥 _emit
|
||||
* real:监听挂引擎 mainCanvas(坐标按引擎可见画布 rect,§12-M4);stub:挂 #game(逐行不变)。
|
||||
* 监听挂载在 bundle 就绪后(inputBridge 存在);故 real 在 setupReal 内接、stub 在 setupStub 内接。
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
function attachInput(targetCanvas) {
|
||||
const inputBridge = bundle.inputBridge;
|
||||
function toCanvasXY(clientX, clientY) {
|
||||
const rect = targetCanvas.getBoundingClientRect();
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * VIEW_W,
|
||||
y: ((clientY - rect.top) / rect.height) * VIEW_H,
|
||||
};
|
||||
}
|
||||
targetCanvas.addEventListener('pointerdown', (ev) => {
|
||||
unlockAudioOnce();
|
||||
inputBridge._emit('pointerdown', toCanvasXY(ev.clientX, ev.clientY));
|
||||
// ── 通道就绪/错误全局初值(CDP 驱动据此分流;real 在 onReady 置 true,失败置 __refBootError)──
|
||||
window.__refBootedReal = false;
|
||||
|
||||
try {
|
||||
// 调通用宿主:把wanglanmei专属项经 opts 注入(取自共用配置 refCfg:factory/插件/视口/wiring),引擎骨架复用 bootGameHost。
|
||||
coreHost = await bootGameHost({
|
||||
canvas,
|
||||
statusEl,
|
||||
engine: opts.engine, // entry 包装层注入的引擎名字空间(real 用;stub/node 为 undefined)
|
||||
seed,
|
||||
mode,
|
||||
engineMode,
|
||||
viewport: refCfg.viewport, // 【D4】wanglanmei 视口(390×844,共用配置)
|
||||
factory: refCfg.factory, // 【D1】游戏工厂来源(替 P1 静态 import 后写死调用)
|
||||
plugins: refCfg.plugins, // 【D3】wanglanmei 7 件插件实例集
|
||||
registerOrder: refCfg.registerOrder, // 注册偏序(保六锚 canonical 顺序)
|
||||
buildFactoryOpts: refCfg.buildFactoryOpts, // host wiring(runtime:{plugins,bundle,getRealAudioCtx})
|
||||
// 取证 recHook:注 window.__engineCalls(门② call-ID)。ref-bundle 即取证用(非渠道产物,同 host-dev 现状)。
|
||||
recHook: (id) => { try { (window.__engineCalls || (window.__engineCalls = [])).push(id); } catch (_) { /* 取证副作用,忽略 */ } },
|
||||
/** 就绪钩子:受控面/游戏全就绪后,把宿主内部件接进wanglanmei取证对象 + 挂取证全局(§12-M1)。 */
|
||||
onReady: (host, internals) => {
|
||||
// 绑定内部件(forensics 闭包据此读真值)。
|
||||
gameForensics = internals.getGameForensics();
|
||||
probeRef = internals.probe;
|
||||
uncaughtRef = internals.uncaught;
|
||||
pluginSetRef = internals.pluginSet;
|
||||
initResRef = internals.initRes;
|
||||
// 【防双 checklist 漂移】复用 bootGameHost 内部那份「逐帧 _refreshChecklist 刷新」的 checklist,
|
||||
// 而非本地另建的空对象——否则营业期 linesShownLive 等子项恒停在 boot 值(refForensics 刷的是 host 那份)。
|
||||
checklist = internals.checklist;
|
||||
// real 专属就绪标记(driver real 分支等它再驱动;stub 通道保持 false)。
|
||||
window.__refBootedReal = engineMode === 'real';
|
||||
// 引擎五回调首触标记镜像(bootGameHost 置 __gameHostEngineInitFired;ref 取证读 __refEngineInitFired)。
|
||||
if (window.__gameHostEngineInitFired) window.__refEngineInitFired = true;
|
||||
// 挂取证全局(兼容 P1 CDP 驱动 + node 测:__refForensics/__refChecklist/__refHost)。
|
||||
// __refChecklist 指向 host 内部逐帧刷新的同一对象,保营业期实时子项可读。
|
||||
window.__refForensics = forensics;
|
||||
window.__refChecklist = checklist;
|
||||
window.__refHost = refHost;
|
||||
},
|
||||
});
|
||||
targetCanvas.addEventListener('pointermove', (ev) => {
|
||||
inputBridge._emit('pointermove', toCanvasXY(ev.clientX, ev.clientY));
|
||||
});
|
||||
targetCanvas.addEventListener('pointerup', (ev) => {
|
||||
inputBridge._emit('pointerup', toCanvasXY(ev.clientX, ev.clientY));
|
||||
});
|
||||
}
|
||||
|
||||
/* ── RAF(stub play 模式)── */
|
||||
let rafId = 0;
|
||||
let rafRunning = false;
|
||||
let lastTs = 0;
|
||||
function rafLoop(ts) {
|
||||
if (!rafRunning) return;
|
||||
if (lastTs === 0) lastTs = ts;
|
||||
let dt = (ts - lastTs) / 1000;
|
||||
lastTs = ts;
|
||||
if (dt > 0.05) dt = 0.05; // 钳大跳帧
|
||||
stepOneFrame(dt);
|
||||
rafId = window.requestAnimationFrame(rafLoop);
|
||||
}
|
||||
function startRaf() {
|
||||
if (engineMode !== 'stub') return; // real 通道引擎内部 RAF 驱动五回调,host 不起 RAF
|
||||
if (rafRunning) return;
|
||||
rafRunning = true;
|
||||
lastTs = 0;
|
||||
rafId = window.requestAnimationFrame(rafLoop);
|
||||
}
|
||||
function stopRaf() {
|
||||
rafRunning = false;
|
||||
if (rafId) window.cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* stub 通道(缺省·回滚锚):prepareCanvas(#game) 2D + 共用收尾 + 输入挂 #game + 浏览器 RAF/evidence
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
async function setupStub() {
|
||||
const g = prepareCanvas(canvas);
|
||||
stubRenderCtx = g;
|
||||
await setupCommon(g, () => canvas); // 绘制面=#game ctx;输入画布=#game(首帧由 setupCommon 内 stepOneFrame 经 drawBackdrop 铺底,与轮 1 逐行等价)
|
||||
attachInput(canvas); // 输入监听挂 #game(逐行不变)
|
||||
if (mode === 'evidence') {
|
||||
setStatus(`evidence 就绪 seed=${seed} · 用 window.__refHost 驱动`);
|
||||
} else {
|
||||
setStatus(`seed=${seed} · 点「开店!」开始(首次点击开声)`);
|
||||
startRaf();
|
||||
}
|
||||
return refHost;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* real 通道(引擎掌帧):setGLEnable(false) → engineInit 五回调 → setupReal(bundle/game/输入/就绪标记全在 then 内)
|
||||
* 仿 host.js:897-928。控制流=实质重构(不是局部插桩,§12-M4)。
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
// 引擎五回调实体经 holder 转发到 setupReal 内真实现(仿 host.js:880-887 _engStep 范式;engineInit 须先拿到回调)。
|
||||
/** @type {() => void} */ let _engUpdate = () => {};
|
||||
/** @type {() => void} */ let _engRender = () => {};
|
||||
function engGameInit() { window.__refEngineInitFired = true; }
|
||||
function engGameUpdate() { _engUpdate(); }
|
||||
function engGameUpdatePost() { /* ref 无需逻辑步后处理 */ }
|
||||
function engGameRender() { _engRender(); }
|
||||
function engGameRenderPost() { /* ref 后处理走 game.render 内 palettePost.renderPost;此回调留空 */ }
|
||||
|
||||
/**
|
||||
* setupReal:引擎 engineInit 就绪后(mainContext 存在)的收尾。一切 bundle/game/输入/就绪标记都在此(§12-M1/M4)。
|
||||
* @param {CanvasRenderingContext2D} mainContext 引擎 mainContext(纯 2D 绘制面)
|
||||
*/
|
||||
async function setupReal(mainContext) {
|
||||
// 共用收尾:bundle context2d=mainContext(§12-M4 必须 then 内以 mainContext 建);输入画布=引擎 mainCanvas。
|
||||
await setupCommon(mainContext, () => LJS.mainCanvas);
|
||||
// 输入监听挂引擎 mainCanvas(坐标按引擎可见画布 rect,§12-M4;不再硬绑 #game)。
|
||||
attachInput(LJS.mainCanvas);
|
||||
// 引擎五回调实体接线:每逻辑步 game.update(FIXED_DT);每渲染帧 game.render(mainContext)(引擎已清屏)。
|
||||
// real 下 DPR 补偿:引擎 mainContext 无 DPR 变换(每帧 updateCanvas 重置单位阵),游戏按逻辑像素绘制 →
|
||||
// 在 save/restore 内补 setTransform(DPR) + imageSmoothingEnabled=false(对齐 host.js:489 renderParticles 补偿)。
|
||||
// 【mustFix #2】real 驱动点:每逻辑步在 game.update 之前自增唯一时钟(与 stub stepOneFrame 同口径),
|
||||
// 使 bundle.clock 与游戏逻辑读同一推进值(修双钟分裂致输入缓冲补打静默失效)。
|
||||
_engUpdate = () => { if (game) { mockNowMs += FIXED_DT * 1000; game.update(FIXED_DT); } };
|
||||
_engRender = () => {
|
||||
if (!game) return;
|
||||
mainContext.save();
|
||||
mainContext.setTransform(DPR, 0, 0, DPR, 0, 0); // 引擎 mainContext 无 DPR 变换,host 补,保游戏逻辑像素口径
|
||||
try { mainContext.imageSmoothingEnabled = false; } catch { /* 部分环境只读,忽略 */ }
|
||||
game.render(mainContext);
|
||||
mainContext.restore();
|
||||
};
|
||||
// 引擎自建 mainCanvas 打 id(CDP 取证选择器=#game-engine,仿 host.js:830;real 下 #game 不是可见画布)。
|
||||
if (LJS.mainCanvas) LJS.mainCanvas.id = 'game-engine';
|
||||
// 暴露引擎双时钟(仿 host.js:840;门① 掌帧断言读 window.__engine.snapshot())。
|
||||
window.__engine = {
|
||||
frame() { return LJS.frame; },
|
||||
time() { return LJS.time; },
|
||||
timeReal() { return LJS.timeReal; },
|
||||
paused() { return LJS.paused; },
|
||||
timeScale() { return LJS.timeScale; },
|
||||
snapshot() { return { frame: LJS.frame, time: LJS.time, timeReal: LJS.timeReal, paused: LJS.paused, timeScale: LJS.timeScale }; },
|
||||
};
|
||||
// 【M1 硬约束·real 专属就绪标记】game.init 完成 + 受控面全就绪后置(仿 host.js:920)。driver real 分支等它再驱动。
|
||||
window.__refBootedReal = true;
|
||||
setStatus(`engine RAF running · seed=${seed} engine=real`);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* 通道分流:stub(缺省,回滚锚)| real(引擎掌帧)
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
if (engineMode !== 'real') {
|
||||
// stub 回滚通道:行为与轮 1 逐行等价(逐像素门⑤兜)。
|
||||
window.__refBootedReal = false; // 标记非 real 通道(driver stub 分支不等它)
|
||||
return await setupStub();
|
||||
}
|
||||
|
||||
// 【Option C 守卫】real 通道必须由 entry.js 注入引擎(opts.engine);缺失说明经 main.js 直 import bootRef(未走 entry 包装)。
|
||||
// 显式 throw 而非裸 LJS.xxx 抛 TypeError,便于 __refBootError 镜像可读错因(index.html/CDP 据此判 real 失败)。
|
||||
if (!LJS) {
|
||||
const err = 'real 通道未注入引擎(opts.engine 缺失):请经 entry.js 的 bootRef 包装层启动(main.js 不直接 import 引擎,Option C)';
|
||||
window.__refBootError = err;
|
||||
} catch (e) {
|
||||
// 引擎接管/装载失败:镜像 __refBootError(兼容 P1 index.html/CDP 据此判 real 失败→回滚 stub),重抛。
|
||||
window.__refBootError = window.__gameHostBootError || String((e && e.message) || e);
|
||||
window.__refBootedReal = false;
|
||||
setStatus('ENGINE INJECT MISSING: ' + err + '(可加 ?engine=stub 回滚桩)');
|
||||
throw new Error(err);
|
||||
setStatus('BOOT ERROR: ' + window.__refBootError + '(可加 ?engine=stub 回滚桩)');
|
||||
throw e;
|
||||
}
|
||||
// real 通道:引擎掌帧。引擎设置须全部在 engineInit 之前调(守卫多在 engineInit 内)。
|
||||
// 1) setGLEnable(false):纯 2D 渲进 mainContext,readback 无 WebGL 坑(harness §5-坑5)。
|
||||
// 2) setShowSplashScreen(false):关开屏遮挡(默认已 false,显式兜底)。
|
||||
// 3) setDebugWatermark(false):关右上角 FPS 水印(开发构建默认 true,每帧画非确定 averageFPS 进 mainContext,污染 readback,必须关)。
|
||||
// 4) setCanvasClearColor(BLACK):引擎默认 clearColor=CLEAR_BLACK(alpha=0) 不铺底;强制不透明 BLACK,底色语义与 stub clearBlack 对齐。
|
||||
LJS.setGLEnable(false);
|
||||
LJS.setShowSplashScreen(false);
|
||||
LJS.setDebugWatermark(false);
|
||||
LJS.setCanvasClearColor(LJS.BLACK);
|
||||
// 固定 backing=780×1688(=逻辑 390×844 × DPR2,取证固定环境)。
|
||||
LJS.setCanvasFixedSize(LJS.vec2(VIEW_W * DPR, VIEW_H * DPR));
|
||||
// rootElement:用专用空容器(引擎 engineInit 会整体覆盖容器样式,不能复用承载 #status/#game 的 #wrap)。建独立 div 挂 body。
|
||||
const engineRoot = document.createElement('div');
|
||||
engineRoot.id = 'engine-root';
|
||||
document.body.appendChild(engineRoot);
|
||||
// engineInit 异步:resolve 后 mainContext 就绪 → setupReal 建 bundle/game/输入/就绪标记。
|
||||
await LJS.engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], engineRoot)
|
||||
.then(() => setupReal(LJS.mainContext))
|
||||
.catch((e) => {
|
||||
// 引擎接管失败:镜像 __refBootError,status 提示可加 ?engine=stub 回滚;门④/CDP 据此判 real 失败→回滚 stub。
|
||||
window.__refBootError = String((e && e.message) || e);
|
||||
window.__refBootedReal = false;
|
||||
setStatus('ENGINE INIT FAILED: ' + window.__refBootError + '(可加 ?engine=stub 回滚桩)');
|
||||
throw e; // 重抛:index.html bootRef().catch 镜像 __refBootError + 评估门看 console
|
||||
});
|
||||
|
||||
return refHost;
|
||||
}
|
||||
|
||||
79
game-runtime/games/wanglanmei-ref/src/ref-host-config.js
Normal file
79
game-runtime/games/wanglanmei-ref/src/ref-host-config.js
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* ref-host-config.js — wanglanmei-ref 宿主装配配置(单一来源,bootRef 与 P2 bundle 入口共用)
|
||||
* owner:Runner v2 P1→P2 | 消费方:①main.js bootRef(ref 取证薄包装);②entry-bundle.js(P2 studio-consumable bundle 入口)
|
||||
*
|
||||
* 【为什么抽出本模块(防第三套)】
|
||||
* P2 通用宿主 bootGameHost 需要三组 wanglanmei 专属入参:游戏工厂 / 7 件插件实例集 / 视口 / 工厂 wiring。
|
||||
* 这组「wanglanmei 怎么配 bootGameHost」的装配逻辑,ref 取证(bootRef)与 P2 bundle(entry-bundle)两处都要用。
|
||||
* 若各写一份 → 出现两套 wanglanmei 装配,违 spec「不产生第三套」。故提取为本配置工厂,两入口共用同一份。
|
||||
*
|
||||
* 【Q4 铁律】本模块零引擎 import(仅装配插件实例 + 视口 + 工厂 wiring);引擎由 bootGameHost 经 opts.engine 注入。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// wanglanmei 复用的 7 件业务插件工厂(host 拥有,经工厂 opts 注入给游戏,一职一路)。
|
||||
import { createCollisionPlugin } from '../../../src/plugins/collision/impl.js';
|
||||
import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js';
|
||||
import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js';
|
||||
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
|
||||
import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js';
|
||||
import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js';
|
||||
import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js';
|
||||
|
||||
// 装载契约 game 工厂(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件)。
|
||||
import createShopGame from './game-shop.js';
|
||||
|
||||
// 渲染常量(视口宽高)。
|
||||
import { W as VIEW_W, H as VIEW_H } from './render.js';
|
||||
|
||||
/** wanglanmei 游戏工厂(GameHostFactory)。P2 bundle 入口把它作 window.__GameBundle.GameHostFactory 暴露。 */
|
||||
export const refFactory = createShopGame;
|
||||
|
||||
/** wanglanmei 逻辑视口(390×844,取证固定环境;P2 阶段固定,后续从 GamePackage 取)。 */
|
||||
export const refViewport = { w: VIEW_W, h: VIEW_H };
|
||||
|
||||
/**
|
||||
* 装配 wanglanmei 调 bootGameHost 所需的全部专属入参(工厂/插件实例集/注册偏序/视口/工厂 wiring)。
|
||||
* bootRef 与 entry-bundle 各自补 canvas/statusEl/engine/seed/mode/engineMode/recHook/onReady 后传 bootGameHost。
|
||||
* @param {{seed:number}} args
|
||||
* @returns {{factory:Function, plugins:object, registerOrder:Array<object>, viewport:{w:number,h:number}, buildFactoryOpts:Function}}
|
||||
*/
|
||||
export function buildRefHostConfig(args) {
|
||||
const seed = args.seed >>> 0;
|
||||
|
||||
/* ── wanglanmei 7 件业务插件实例(host 拥有;游戏经工厂 opts 复用,一职一路)── */
|
||||
const collision = createCollisionPlugin({ cellSize: 96 });
|
||||
const physics = createPhysicsLitePlugin();
|
||||
const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] });
|
||||
const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 });
|
||||
const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } });
|
||||
const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 });
|
||||
// P9 后端:浏览器 localStorage(不可用环境回退内存 adapter,错误路径留痕)。
|
||||
let saveAdapter;
|
||||
try {
|
||||
saveAdapter = createLocalStorageAdapter();
|
||||
} catch (e) {
|
||||
console.warn('[ref] localStorage 不可用,存档退内存 adapter:' + (e && e.message));
|
||||
saveAdapter = createMemoryAdapter();
|
||||
}
|
||||
const save = createSaveProgressPlugin({ namespace: 'wanglanmei-ref', adapter: saveAdapter });
|
||||
const plugins = { collision, physics, gamefeel, juice, palettePost, audioMusic, save };
|
||||
// 注册偏序(保六锚偏序:collision→physics→gamefeel→juice→palettePost→save→audioMusic)。
|
||||
const registerOrder = [collision, physics, gamefeel, juice, palettePost, save, audioMusic];
|
||||
|
||||
return {
|
||||
factory: refFactory,
|
||||
plugins,
|
||||
registerOrder,
|
||||
viewport: refViewport,
|
||||
/** 构造工厂 opts(host wiring):wanglanmei game-shop 需要 runtime:{plugins,bundle,getRealAudioCtx}。 */
|
||||
buildFactoryOpts: (_bootCtx, hostInternals) => ({
|
||||
runtime: {
|
||||
plugins: hostInternals.plugins,
|
||||
bundle: hostInternals.bundle,
|
||||
getRealAudioCtx: hostInternals.getRealAudioCtx,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -22,6 +22,10 @@ const MODULES = [
|
||||
['../src/render.js', ['drawFrame', 'computeZones', 'shelfSlotRect', 'LAYOUT']],
|
||||
['../src/main.js', ['bootRef']],
|
||||
['../entry.js', ['bootRef']],
|
||||
// 【P2】wanglanmei 宿主装配配置(bootRef 与 P2 bundle 入口共用)+ P2 studio-consumable bundle 入口。
|
||||
// 二者均须 node import-safe:ref-host-config 零引擎 import;entry-bundle 经动态 import 引擎(Option C),node import 不触引擎。
|
||||
['../src/ref-host-config.js', ['buildRefHostConfig', 'refFactory', 'refViewport']],
|
||||
['../entry-bundle.js', ['GameHostFactory', 'bootGameHost']],
|
||||
];
|
||||
|
||||
for (const [path, expectedExports] of MODULES) {
|
||||
|
||||
@ -8,7 +8,9 @@
|
||||
"test": "node --test src/**/*.test.mjs",
|
||||
"build": "node scripts/build.mjs",
|
||||
"size": "node scripts/size.mjs",
|
||||
"validate": "node scripts/validate-manifest.mjs"
|
||||
"validate": "node scripts/validate-manifest.mjs",
|
||||
"build:ref-bundle": "node scripts/build.mjs games/wanglanmei-ref/entry-bundle.js games/wanglanmei-ref/dist/ref-bundle.iife.js --global-name=__GameBundle",
|
||||
"build:ref-harness": "node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef"
|
||||
},
|
||||
"dependencies": {
|
||||
"littlejsengine": "1.18.19"
|
||||
|
||||
452
game-runtime/src/host/boot-game-host.js
Normal file
452
game-runtime/src/host/boot-game-host.js
Normal file
@ -0,0 +1,452 @@
|
||||
/**
|
||||
* src/host/boot-game-host.js — 通用游戏宿主(Runner v2 P2 · 装载任意 GamePackage 的引擎宿主)
|
||||
* owner:Runner v2 P2 | 消费方:①ref 取证 bootRef(薄包装,传wanglanmei专属参数保 P1 取证不回归);
|
||||
* ②studio host 引擎包分支(从 window.__GameBundle 取 GameHostFactory,经本宿主 boot 入 feed 真玩)
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【本文件是什么 · P2 泛化的核心】
|
||||
* P1 的 bootRef(games/wanglanmei-ref/src/main.js)把「装载wanglanmei一款游戏跑引擎」的
|
||||
* engineInit 五回调骨架 + 受控面背书 + 输入接线 + 单一推进时钟全部实现了,但游戏工厂来源
|
||||
* (静态 `import createShopGame`)、插件清单、视口三处写死成wanglanmei专属。
|
||||
* P2 把这套骨架**整块照搬、语义零变**地提升到本通用模块 bootGameHost(opts),并把上述三处
|
||||
* **参数化**为 opts,使「装载任意 GamePackage」成立——这是「泛化既有 bootRef」,不是新建。
|
||||
*
|
||||
* 【放置位置 · 为何落 src/host 而非 wanglanmei 专属目录】
|
||||
* 通用宿主的消费方有二(ref 取证 + studio host),不再隶属wanglanmei——故按 spec §3.3
|
||||
* 落「host/runtime 共享位」。src/host 与 src/core / src/plugins 同级,import 方向顺
|
||||
* (→ ../core/plugin.js / ../plugins/* / ../../host-dev/engine-caps.js),拆仓不断。
|
||||
*
|
||||
* 【Q4 铁律不破】本模块不顶层 import 引擎(littlejsengine)——引擎名字空间经 opts.engine 注入
|
||||
* (real 通道由 entry 包装层动态 import 后传入;stub/node 通道为 undefined,保 node import-safe)。
|
||||
* 游戏工厂(opts.factory)零引擎 import,一律经 boot.ctx.getEngine() 拿引擎能力。
|
||||
*
|
||||
* 【装载契约(game-runtime/src/core/game-host.d.ts,零改·复用)】
|
||||
* - opts.factory:GameHostFactory = (opts?) => GameInstance(本宿主调它建游戏实例)。
|
||||
* - boot 脊柱 = { ctx, mainContext, canvas, seed }(game-host.d.ts:25-30);本宿主在 game.init 时一次性注入。
|
||||
* - GameInstance 五生命周期 init/update/render/onInput?/destroy(game-host.d.ts:42-48)经引擎五回调驱动。
|
||||
*
|
||||
* 【双通道(P1 沿用)】
|
||||
* - engineMode='stub':prepareCanvas(canvas) 2D + 浏览器 RAF + 单一墙钟(行为与 P1 stub 逐行等价,回滚锚)。
|
||||
* - engineMode='real':注入引擎、setGLEnable(false)、engineInit 五回调驱动(绘制面=引擎 mainContext,与引擎粒子同屏)。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 受控面/注册器/协议号(src/core,跨通道复用;本模块不背书引擎,引擎经 opts.engine 注入)。
|
||||
import { PluginRegistry, createHostDevContext, CORE_PROTOCOL_VERSION } from '../core/plugin.js';
|
||||
// runtime-probe:宿主自管六锚链(取证用;P2 通用宿主保留它作启动链证据,与 P1 同款编排)。
|
||||
import { createRuntimeProbePlugin } from '../plugins/runtime-probe/impl.js';
|
||||
// 引擎能力背书工厂(EngineCapabilities 三面)—— host-dev / ref real 通道共用同一份(P1 已模块化,本模块零改 import)。
|
||||
import { createEngineCaps } from '../../host-dev/engine-caps.js';
|
||||
// 引擎数学门面构造(纯函数,零引擎 import):real 通道用它包装引擎 lerp/smoothStep/Ease。
|
||||
import { buildEngineMath } from '../../host-dev/engine-math.js';
|
||||
|
||||
/** 取证固定环境(与 host-dev / browser-evidence FIXED_ENV 对齐;P1 沿用)。 */
|
||||
const DPR = 2;
|
||||
const FIXED_DT = 1 / 60;
|
||||
|
||||
/**
|
||||
* 启动通用游戏宿主。返回 host 句柄(含驱动钩子,供 onReady 组装取证 / 上层驱动)。
|
||||
*
|
||||
* @param {Object} opts 宿主选项(P2 泛化的全部参数化入口;ref/studio 各自按形填充)。
|
||||
* @param {HTMLCanvasElement} opts.canvas 可见画布(stub=2D 绘制面;real=仅占位,引擎自建 mainCanvas)。
|
||||
* @param {HTMLElement} [opts.statusEl] 状态行(仅人看,可空)。
|
||||
* @param {any} [opts.engine] 引擎名字空间(littlejsengine),entry 包装层在 real 通道注入;stub/node 为 undefined。
|
||||
* @param {number} opts.seed 主随机种子(确定性;调用方已解析,不再读 URL)。
|
||||
* @param {'play'|'evidence'} [opts.mode='play'] 运行模式(evidence=stub 下不起 RAF,由 host.stepFrames/tap 驱动)。
|
||||
* @param {'stub'|'real'} [opts.engineMode='real'] 引擎通道('real' 引擎掌帧 / 'stub' 浏览器 RAF 回滚桩)。
|
||||
* 注:ref 缺省 stub(兜逐像素回归)由 bootRef 包装层决定;studio 引擎包分支一律 real。
|
||||
* @param {{w:number,h:number}} opts.viewport 逻辑视口(替 P1 写死的 VIEW_W/VIEW_H;P2 阶段 ref 用 390×844,后续从 GamePackage 取)。
|
||||
* @param {import('../core/game-host.d.ts').GameHostFactory} opts.factory 游戏工厂(替 P1 静态 import createShopGame)。
|
||||
* @param {(boot: import('../core/api.d.ts').PluginContext, host: object) => (Record<string, unknown>|undefined)} [opts.buildFactoryOpts]
|
||||
* 构造工厂 opts(host wiring,如wanglanmei的 runtime:{plugins,bundle,getRealAudioCtx})。返回值原样传给 opts.factory。
|
||||
* - 入参 boot=本帧受控面 PluginContext,host={bundle, plugins, getRealAudioCtx}(宿主内部句柄,供 wiring 取用)。
|
||||
* - 缺省返回 undefined(纯净工厂:仅吃 boot 脊柱,不需 host 私有 wiring;P3 多数生成游戏走此默认)。
|
||||
* @param {Object} [opts.plugins] 业务插件实例集(host 拥有,已注册+init;游戏经工厂 opts 复用,一职一路)。
|
||||
* - 形如 { collision, physics, gamefeel, ... }(键名由游戏约定)。缺省=空集(无业务插件,仅 runtime-probe)。
|
||||
* @param {Array<object>} [opts.registerOrder] 插件注册偏序(决定 init 顺序;缺省=Object.values(opts.plugins) 的插入序)。
|
||||
* @param {(host: object, internals: object) => void} [opts.onReady] 就绪钩子(受控面/游戏全就绪后回调)。
|
||||
* ref 用它挂 window.__refForensics/__refChecklist/__refHost;studio/P3 通用宿主可不传(不强制每款实现取证)。
|
||||
* internals={ bundle, game, probe, checklist, getGameForensics, mark, stepOneFrame }(宿主内部件,供取证组装)。
|
||||
* @param {((id:string)=>void)} [opts.recHook] 引擎能力 call-ID 取证钩子(透传 createEngineCaps;缺省 no-op,渠道产物无副作用)。
|
||||
* @returns {Promise<object>} host 句柄(seed/mode/engineMode/frameCount/stepFrames/startRaf/stopRaf/tap/do/state 等驱动面)。
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
const canvas = opts.canvas;
|
||||
const statusEl = opts.statusEl || null;
|
||||
// 【Q4 · Option C】引擎名字空间经 opts.engine 注入(real 通道由 entry 包装层传;stub/node 为 undefined,保 node import-safe)。
|
||||
const LJS = opts.engine;
|
||||
const seed = opts.seed >>> 0; // 调用方已解析种子(不再读 URL;ref 在包装层解析后传入)。
|
||||
const mode = opts.mode || 'play'; // 'play' | 'evidence'
|
||||
const engineMode = opts.engineMode || 'real'; // 'real'(引擎掌帧)| 'stub'(浏览器 RAF 回滚桩)。
|
||||
// 【P2 泛化 · D4】逻辑视口参数化(替 P1 写死的 render.js W/H)。
|
||||
const VIEW_W = opts.viewport.w;
|
||||
const VIEW_H = opts.viewport.h;
|
||||
// 【P2 泛化 · D1】游戏工厂来源参数化(替 P1 静态 import createShopGame)。
|
||||
const factory = opts.factory;
|
||||
// 【P2 泛化 · host wiring】构造工厂 opts 的回调(wanglanmei注 runtime;P3 纯净工厂缺省 undefined)。
|
||||
const buildFactoryOpts = opts.buildFactoryOpts || (() => undefined);
|
||||
// 【P2 泛化 · D3】业务插件实例集(host 拥有;游戏经工厂 opts 复用)。缺省空集(仅 runtime-probe)。
|
||||
const pluginInstances = opts.plugins || {};
|
||||
// 插件注册偏序(决定 initAll 顺序与六锚偏序);缺省取 plugins 值的插入序。
|
||||
const registerOrder = opts.registerOrder || Object.values(pluginInstances);
|
||||
// 就绪钩子(ref 挂取证全局;通用宿主可不传)。
|
||||
const onReady = opts.onReady || (() => {});
|
||||
// 引擎 call-ID 取证钩子(透传 createEngineCaps;缺省 no-op)。
|
||||
const recHook = opts.recHook || (() => {});
|
||||
|
||||
/** 状态行(仅人看)。 */
|
||||
function setStatus(msg) {
|
||||
if (statusEl) statusEl.textContent = msg;
|
||||
}
|
||||
|
||||
/* ── 未捕获错误归集(评估门:console 零未捕获错误)── */
|
||||
/** @type {Array<{type:string,message:string}>} */
|
||||
const uncaught = [];
|
||||
window.addEventListener('error', (e) => uncaught.push({ type: 'error', message: String((e && e.message) || e) }));
|
||||
window.addEventListener('unhandledrejection', (e) =>
|
||||
uncaught.push({ type: 'unhandledrejection', message: String((e && e.reason && e.reason.message) || (e && e.reason) || e) })
|
||||
);
|
||||
|
||||
/* ── 受控音频工厂(用户手势解锁后才 new;宿主=受控面提供方)── */
|
||||
let realAudioCtx = null;
|
||||
let audioUnlocked = false;
|
||||
function audioFactory() {
|
||||
if (!audioUnlocked) return null;
|
||||
if (realAudioCtx) return realAudioCtx;
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
realAudioCtx = new AC();
|
||||
return realAudioCtx;
|
||||
}
|
||||
|
||||
/* ── 步进/逻辑帧钟【唯一推进时钟(single source of truth),P1 mustFix #2 沿用】 ──
|
||||
* 本 mockNowMs 是唯一真相,在 game.update 驱动点之前自增(stub:stepOneFrame 内;real:_engUpdate 内);
|
||||
* bundle.clock 读它;游戏侧经 boot.ctx.time.nowMs() 读同一钟(并轨成单钟,避免双钟分裂致输入缓冲补打静默失效)。 */
|
||||
let mockNowMs = 0;
|
||||
|
||||
/* ── 受控面 bundle + 游戏实例(real:engineInit 就绪后建;stub:立即)。延后建避免快照错 ctx(§12-M4)。 ── */
|
||||
/** @type {ReturnType<typeof createHostDevContext>|null} */
|
||||
let bundle = null;
|
||||
/** @type {import('../core/game-host.d.ts').GameInstance|null} */
|
||||
let game = null;
|
||||
/** @type {ReturnType<typeof createRuntimeProbePlugin>|null} */
|
||||
let probe = null;
|
||||
let initRes = null;
|
||||
let pluginSet = [];
|
||||
/** checklist(游戏侧自填;逐帧刷新,供取证)。 */
|
||||
const checklist = {};
|
||||
/** 游戏取证视图(real:setupCommon 内 game.init 后取)。 */
|
||||
let gameForensics = null;
|
||||
|
||||
/** 安全打点(probe 异常不中断启动链)。 */
|
||||
function mark(anchor, extra) {
|
||||
try {
|
||||
if (probe) probe.mark(anchor, extra);
|
||||
} catch (e) {
|
||||
uncaught.push({ type: 'mark', message: String((e && e.message) || e) });
|
||||
}
|
||||
}
|
||||
|
||||
/** 逐帧刷新游戏取证(游戏侧实现 _refreshChecklist 时调;通用宿主不强制)。 */
|
||||
function refreshForensics() {
|
||||
if (game && typeof game._refreshChecklist === 'function') game._refreshChecklist(checklist);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* 共用收尾:建 bundle → 注册插件 → 建游戏 → init → 打链 → 就绪钩子(real/stub 都调)
|
||||
* renderCtx:游戏 render(g) 的绘制面(real=引擎 mainContext / stub=可见画布 ctx)。
|
||||
* inputCanvasGetter:输入 rect 映射用的可见画布(real=引擎 mainCanvas / stub=可见画布)。
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
async function setupCommon(renderCtx, inputCanvasGetter) {
|
||||
// 1) bundle:context2d=绘制面(受控面 getContext2d 一次性快照此 ctx,§12-M4 必须就绪后建)。
|
||||
// engineFactory:real 经 createEngineCaps 真背书引擎;stub 短路返 null(保 null-engine 回归)。
|
||||
bundle = createHostDevContext({
|
||||
context2d: renderCtx,
|
||||
seed,
|
||||
audioFactory,
|
||||
clock: () => mockNowMs,
|
||||
engineFactory: () => createEngineCaps({
|
||||
LJS: engineMode === 'real' ? LJS : null, // stub 不持引擎名字空间(createEngineCaps 首行按 engineMode 短路返 null)
|
||||
engineMath: engineMode === 'real' ? buildEngineMath(LJS) : null,
|
||||
engineMode,
|
||||
recHook, // 透传 opts.recHook(ref 注 window.__engineCalls 取证;渠道产物缺省 no-op)
|
||||
}),
|
||||
});
|
||||
|
||||
// 2) 注册业务插件(registerOrder 决定偏序,保六锚 canonical 顺序)。
|
||||
const registry = new PluginRegistry();
|
||||
for (const p of registerOrder) registry.register(p);
|
||||
registry.useContext(bundle.context);
|
||||
initRes = registry.initAll();
|
||||
|
||||
// 3) runtime-probe:宿主自管(六锚 canonical 顺序,t_input_bound 恰在 first_paint 后)。
|
||||
pluginSet = registry.list().concat(['runtime-probe']);
|
||||
probe = createRuntimeProbePlugin({ pluginSet, inputAnchorType: 'pointerdown', autoBoot: true });
|
||||
const probeCtx = bundle.deriveContextFor('runtime-probe', 'runtime-probe');
|
||||
mark('t_boot', { by: 'game-host', stage: 'pre-init', engineMode });
|
||||
mark('t_plugins_ready', { initialized: initRes.initialized.length, ok: initRes.ok });
|
||||
|
||||
// 4) 经装载契约建游戏实例(host 经 buildFactoryOpts 注入 wiring:wanglanmei runtime / P3 纯净工厂为 undefined)。
|
||||
const factoryOpts = buildFactoryOpts(bundle.context, { bundle, plugins: pluginInstances, getRealAudioCtx: () => realAudioCtx });
|
||||
game = factory(factoryOpts);
|
||||
// 5) game.init:建游戏对象 + 异步装资产 + 订阅受控输入。boot=装载契约脊柱(ctx/mainContext/canvas/seed)。
|
||||
await game.init({
|
||||
ctx: bundle.context,
|
||||
mainContext: renderCtx,
|
||||
canvas: inputCanvasGetter(),
|
||||
seed,
|
||||
});
|
||||
// 游戏取证视图(可选;游戏未实现 _forensicsView 时 gameForensics 保持 null,通用宿主不依赖)。
|
||||
gameForensics = (game && typeof game._forensicsView === 'function') ? game._forensicsView() : null;
|
||||
mark('t_game_init', { hasForensics: !!gameForensics });
|
||||
|
||||
// 6) 首帧 + 输入锚 + 启动链收尾(与 P1 同序:first_paint → probe.init 绑输入 → game_start)。
|
||||
stepOneFrame();
|
||||
mark('t_first_paint', { frame: bundle.frameCount() });
|
||||
try {
|
||||
probe.init(probeCtx); // 绑输入 → t_input_bound(恰在 first_paint 之后,偏序成立)
|
||||
} catch (e) {
|
||||
uncaught.push({ type: 'probe-init', message: String((e && e.message) || e) });
|
||||
}
|
||||
mark('t_game_start', { mode, engineMode });
|
||||
|
||||
// 7) 就绪钩子:ref 挂取证全局;通用宿主可不传。internals 暴露宿主内部件供取证组装。
|
||||
refreshForensics();
|
||||
onReady(host, {
|
||||
bundle,
|
||||
game,
|
||||
probe,
|
||||
checklist,
|
||||
uncaught,
|
||||
pluginSet,
|
||||
initRes,
|
||||
getGameForensics: () => gameForensics,
|
||||
mark,
|
||||
stepOneFrame,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 单帧推进(stub RAF / evidence stepFrames / 首帧共用):game.update + game.render(绘制面=renderCtx)。 ── */
|
||||
/** @type {CanvasRenderingContext2D|null} */
|
||||
let stubRenderCtx = null;
|
||||
function stepOneFrame(dt) {
|
||||
const d = typeof dt === 'number' ? dt : FIXED_DT;
|
||||
if (!game) return;
|
||||
// 唯一时钟在 game.update 之前自增(stub 驱动点):使 bundle.clock 与游戏逻辑读同一推进值(P1 mustFix #2 口径)。
|
||||
mockNowMs += d * 1000;
|
||||
game.update(d);
|
||||
// stub 通道绘制:game.render(g) 自管铺底(与 P1 逐行等价);real 通道引擎清屏 + render 由引擎 gameRender 回调驱动,不走本函数绘制。
|
||||
if (engineMode === 'stub' && stubRenderCtx) {
|
||||
game.render(stubRenderCtx);
|
||||
}
|
||||
refreshForensics();
|
||||
}
|
||||
/** 确定性推进 n 帧(evidence 驱动 / 启动首帧)。 */
|
||||
function stepFrames(n) {
|
||||
for (let i = 0; i < n; i++) stepOneFrame(FIXED_DT);
|
||||
}
|
||||
|
||||
/* ── 首个用户手势:解锁音频(创建/恢复 AudioContext)+ 通知游戏起 BGM。幂等。 ── */
|
||||
function unlockAudioOnce() {
|
||||
if (audioUnlocked) return;
|
||||
audioUnlocked = true;
|
||||
const ac = audioFactory();
|
||||
if (ac && ac.state === 'suspended' && typeof ac.resume === 'function') ac.resume().catch(() => {});
|
||||
if (game && typeof game._onAudioUnlock === 'function') game._onAudioUnlock();
|
||||
}
|
||||
|
||||
/* ── host 驱动句柄(evidence 驱动 / 上层 host.do;通用面,不含 ref 专属取证——取证由 onReady 组装)── */
|
||||
const host = {
|
||||
seed,
|
||||
mode,
|
||||
engineMode,
|
||||
protocol: CORE_PROTOCOL_VERSION,
|
||||
frameCount: () => (bundle ? bundle.frameCount() : 0),
|
||||
stepFrames,
|
||||
startRaf: () => startRaf(),
|
||||
stopRaf: () => stopRaf(),
|
||||
/** 注入一次点击(evidence 驱动;与真输入同走受控桥)。 */
|
||||
tap(x, y) {
|
||||
unlockAudioOnce();
|
||||
if (bundle) {
|
||||
bundle.inputBridge._emit('pointerdown', { x, y });
|
||||
bundle.inputBridge._emit('pointerup', { x, y });
|
||||
}
|
||||
},
|
||||
/** 直接命令(e2e 辅助;与 UI 同走逻辑公开 API)。 */
|
||||
do(action) {
|
||||
if (game && typeof game._handleAction === 'function') game._handleAction(action);
|
||||
},
|
||||
/** 游戏状态快照(游戏实现 _forensicsView().state 时透传;否则 booting)。 */
|
||||
state: () => (gameForensics ? gameForensics.state() : { phase: 'booting' }),
|
||||
};
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* 输入桥接:DOM 事件 → 归一化坐标 → 受控桥 _emit
|
||||
* real:监听挂引擎 mainCanvas(坐标按引擎可见画布 rect,§12-M4);stub:挂可见画布(逐行不变)。
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
function attachInput(targetCanvas) {
|
||||
const inputBridge = bundle.inputBridge;
|
||||
function toCanvasXY(clientX, clientY) {
|
||||
const rect = targetCanvas.getBoundingClientRect();
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * VIEW_W,
|
||||
y: ((clientY - rect.top) / rect.height) * VIEW_H,
|
||||
};
|
||||
}
|
||||
targetCanvas.addEventListener('pointerdown', (ev) => {
|
||||
unlockAudioOnce();
|
||||
inputBridge._emit('pointerdown', toCanvasXY(ev.clientX, ev.clientY));
|
||||
});
|
||||
targetCanvas.addEventListener('pointermove', (ev) => {
|
||||
inputBridge._emit('pointermove', toCanvasXY(ev.clientX, ev.clientY));
|
||||
});
|
||||
targetCanvas.addEventListener('pointerup', (ev) => {
|
||||
inputBridge._emit('pointerup', toCanvasXY(ev.clientX, ev.clientY));
|
||||
});
|
||||
}
|
||||
|
||||
/* ── RAF(stub play 模式)── */
|
||||
let rafId = 0;
|
||||
let rafRunning = false;
|
||||
let lastTs = 0;
|
||||
function rafLoop(ts) {
|
||||
if (!rafRunning) return;
|
||||
if (lastTs === 0) lastTs = ts;
|
||||
let dt = (ts - lastTs) / 1000;
|
||||
lastTs = ts;
|
||||
if (dt > 0.05) dt = 0.05; // 钳大跳帧
|
||||
stepOneFrame(dt);
|
||||
rafId = window.requestAnimationFrame(rafLoop);
|
||||
}
|
||||
function startRaf() {
|
||||
if (engineMode !== 'stub') return; // real 通道引擎内部 RAF 驱动五回调,host 不起 RAF
|
||||
if (rafRunning) return;
|
||||
rafRunning = true;
|
||||
lastTs = 0;
|
||||
rafId = window.requestAnimationFrame(rafLoop);
|
||||
}
|
||||
function stopRaf() {
|
||||
rafRunning = false;
|
||||
if (rafId) window.cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* stub 通道(回滚锚):prepareCanvas(canvas) 2D + 共用收尾 + 输入挂可见画布 + 浏览器 RAF/evidence
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
/** Canvas 设为逻辑 VIEW_W×VIEW_H / 物理 ×DPR,并把 2D 上下文缩放到逻辑坐标(stub 通道用)。 */
|
||||
function prepareCanvas(c) {
|
||||
c.width = VIEW_W * DPR;
|
||||
c.height = VIEW_H * DPR;
|
||||
c.style.width = VIEW_W + 'px';
|
||||
c.style.height = VIEW_H + 'px';
|
||||
const g = c.getContext('2d', { willReadFrequently: true });
|
||||
g.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
return g;
|
||||
}
|
||||
async function setupStub() {
|
||||
const g = prepareCanvas(canvas);
|
||||
stubRenderCtx = g;
|
||||
await setupCommon(g, () => canvas); // 绘制面=可见画布 ctx;输入画布=可见画布
|
||||
attachInput(canvas); // 输入监听挂可见画布
|
||||
if (mode === 'evidence') {
|
||||
setStatus(`evidence 就绪 seed=${seed} · 用 host 驱动(stepFrames/tap)`);
|
||||
} else {
|
||||
setStatus(`seed=${seed} · 点开始(首次点击开声)`);
|
||||
startRaf();
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* real 通道(引擎掌帧):setGLEnable(false) → engineInit 五回调 → setupReal(bundle/game/输入/就绪标记全在 then 内)
|
||||
* 仿 host.js:897-928。控制流=实质重构(不是局部插桩,§12-M4)。
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
// 引擎五回调实体经 holder 转发到 setupReal 内真实现(engineInit 须先拿到回调)。
|
||||
/** @type {() => void} */ let _engUpdate = () => {};
|
||||
/** @type {() => void} */ let _engRender = () => {};
|
||||
function engGameInit() { window.__gameHostEngineInitFired = true; }
|
||||
function engGameUpdate() { _engUpdate(); }
|
||||
function engGameUpdatePost() { /* 无需逻辑步后处理 */ }
|
||||
function engGameRender() { _engRender(); }
|
||||
function engGameRenderPost() { /* 后处理走 game.render 内(如 palettePost.renderPost);此回调留空 */ }
|
||||
|
||||
/**
|
||||
* setupReal:引擎 engineInit 就绪后(mainContext 存在)的收尾。一切 bundle/game/输入/就绪标记都在此(§12-M1/M4)。
|
||||
* @param {CanvasRenderingContext2D} mainContext 引擎 mainContext(纯 2D 绘制面)
|
||||
*/
|
||||
async function setupReal(mainContext) {
|
||||
// 共用收尾:bundle context2d=mainContext(§12-M4 必须 then 内以 mainContext 建);输入画布=引擎 mainCanvas。
|
||||
await setupCommon(mainContext, () => LJS.mainCanvas);
|
||||
// 输入监听挂引擎 mainCanvas(坐标按引擎可见画布 rect,§12-M4)。
|
||||
attachInput(LJS.mainCanvas);
|
||||
// 引擎五回调实体接线:每逻辑步 game.update(FIXED_DT);每渲染帧 game.render(mainContext)(引擎已清屏)。
|
||||
// real 下 DPR 补偿:引擎 mainContext 无 DPR 变换,游戏按逻辑像素绘制 → save/restore 内补 setTransform(DPR) + imageSmoothingEnabled=false。
|
||||
// 唯一时钟在 game.update 之前自增(与 stub stepOneFrame 同口径,P1 mustFix #2)。
|
||||
_engUpdate = () => { if (game) { mockNowMs += FIXED_DT * 1000; game.update(FIXED_DT); } };
|
||||
_engRender = () => {
|
||||
if (!game) return;
|
||||
mainContext.save();
|
||||
mainContext.setTransform(DPR, 0, 0, DPR, 0, 0); // 引擎 mainContext 无 DPR 变换,host 补,保游戏逻辑像素口径
|
||||
try { mainContext.imageSmoothingEnabled = false; } catch { /* 部分环境只读,忽略 */ }
|
||||
game.render(mainContext);
|
||||
mainContext.restore();
|
||||
};
|
||||
// 引擎自建 mainCanvas 打 id(CDP 取证选择器=#game-engine;real 下可见画布是引擎 mainCanvas)。
|
||||
if (LJS.mainCanvas) LJS.mainCanvas.id = 'game-engine';
|
||||
// 暴露引擎双时钟(仿 host.js:840;掌帧断言读 window.__engine.snapshot())。
|
||||
window.__engine = {
|
||||
frame() { return LJS.frame; },
|
||||
time() { return LJS.time; },
|
||||
timeReal() { return LJS.timeReal; },
|
||||
paused() { return LJS.paused; },
|
||||
timeScale() { return LJS.timeScale; },
|
||||
snapshot() { return { frame: LJS.frame, time: LJS.time, timeReal: LJS.timeReal, paused: LJS.paused, timeScale: LJS.timeScale }; },
|
||||
};
|
||||
setStatus(`engine RAF running · seed=${seed} engine=real`);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* 通道分流:stub(回滚锚)| real(引擎掌帧)
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
if (engineMode !== 'real') {
|
||||
// stub 回滚通道:行为与 P1 stub 逐行等价(逐像素门兜)。
|
||||
return await setupStub();
|
||||
}
|
||||
|
||||
// 【Option C 守卫】real 通道必须由 entry 包装层注入引擎(opts.engine);缺失说明未走 entry 包装。
|
||||
if (!LJS) {
|
||||
const err = 'real 通道未注入引擎(opts.engine 缺失):请经 entry 包装层启动(host 不直接 import 引擎,Q4/Option C)';
|
||||
setStatus('ENGINE INJECT MISSING: ' + err);
|
||||
throw new Error(err);
|
||||
}
|
||||
// real 通道:引擎掌帧。引擎设置须全部在 engineInit 之前调(守卫多在 engineInit 内)。
|
||||
// 1) setGLEnable(false):纯 2D 渲进 mainContext,readback 无 WebGL 坑(harness §5-坑5)。
|
||||
// 2) setShowSplashScreen(false):关开屏遮挡(默认已 false,显式兜底)。
|
||||
// 3) setDebugWatermark(false):关右上角 FPS 水印(开发构建默认 true,每帧画非确定 averageFPS 进 mainContext,污染 readback,必须关)。
|
||||
// 4) setCanvasClearColor(BLACK):引擎默认 clearColor=CLEAR_BLACK(alpha=0) 不铺底;强制不透明 BLACK,底色语义与 stub clearBlack 对齐。
|
||||
LJS.setGLEnable(false);
|
||||
LJS.setShowSplashScreen(false);
|
||||
LJS.setDebugWatermark(false);
|
||||
LJS.setCanvasClearColor(LJS.BLACK);
|
||||
// 固定 backing=VIEW_W×VIEW_H × DPR(取证固定环境)。
|
||||
LJS.setCanvasFixedSize(LJS.vec2(VIEW_W * DPR, VIEW_H * DPR));
|
||||
// rootElement:用专用空容器(引擎 engineInit 会整体覆盖容器样式,不能复用承载页面壳的容器)。建独立 div 挂 body。
|
||||
const engineRoot = document.createElement('div');
|
||||
engineRoot.id = 'engine-root';
|
||||
document.body.appendChild(engineRoot);
|
||||
// engineInit 异步:resolve 后 mainContext 就绪 → setupReal 建 bundle/game/输入/就绪标记。
|
||||
await LJS.engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], engineRoot)
|
||||
.then(() => setupReal(LJS.mainContext))
|
||||
.catch((e) => {
|
||||
// 引擎接管失败:镜像错因,重抛供上层捕获。
|
||||
window.__gameHostBootError = String((e && e.message) || e);
|
||||
setStatus('ENGINE INIT FAILED: ' + window.__gameHostBootError);
|
||||
throw e;
|
||||
});
|
||||
|
||||
return host;
|
||||
}
|
||||
BIN
game-studio/evidence/p2-feed/01-feed.png
Normal file
BIN
game-studio/evidence/p2-feed/01-feed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
game-studio/evidence/p2-feed/02-play-engine-render.png
Normal file
BIN
game-studio/evidence/p2-feed/02-play-engine-render.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
BIN
game-studio/evidence/p2-feed/03-after-newgame.png
Normal file
BIN
game-studio/evidence/p2-feed/03-after-newgame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 394 KiB |
BIN
game-studio/evidence/p2-feed/04-after-openshop.png
Normal file
BIN
game-studio/evidence/p2-feed/04-after-openshop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 325 KiB |
BIN
game-studio/evidence/p2-feed/06-rollback-9202.png
Normal file
BIN
game-studio/evidence/p2-feed/06-rollback-9202.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
23
game-studio/evidence/p2-feed/GATES-SUMMARY.json
Normal file
23
game-studio/evidence/p2-feed/GATES-SUMMARY.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"task": "P2 通用宿主泛化·真机验收门 (mini-desktop)",
|
||||
"date": "2026-06-13",
|
||||
"verdict": "P2 核心达成:引擎 bundle 真渲入 feed 真玩 — 已用真浏览器视觉+lifecycle+checksum 三锚证实,非占位非假绿。game_end(到结算) 因 gameover 需分钟级真玩+srcdoc 坐标盲点未在窗口内到达(如实记录,非渲染失败)。",
|
||||
"staging": { "studio": "localhost:4173 (vite preview --mode staging, mock=OFF 真连后端)", "backend": ":48080", "db": "game-staging-mysql / ruoyi-vue-pro", "engineGame": "gameId=9201 versionId=92010", "rollbackControl": "gameId=9202 versionId=92020 (无 engineBundle)" },
|
||||
"gates": {
|
||||
"gate1_build_ref_bundle": { "pass": true, "evidence": "dist/ref-bundle.iife.js raw=265374B gz=90202B; var __GameBundle= ×1; ParticleEmitter ×2 / engineInit ×3 (引擎真入包); metafile inputs 含 littlejsengine/dist/littlejs.esm.js; 导出 GameHostFactory+bootGameHost; import-graph 15/15" },
|
||||
"gate2_java_compile": { "pass": true, "evidence": "mvn compile game-module-aigc -am EXIT=0; DifyCallbackServiceImplTest 全过 (含 packageBytesReproducible 两次组包 checksum=e710447... 字节一致, 证 engineBundle null→put跳过→零变)" },
|
||||
"gate3_studio_staging_rebuild": { "pass": true, "evidence": "vite build --mode staging EXIT=0; preview 重启 systemd studio-preview.service, 日志 mode=staging mock=OFF(真连后端); :4173 不再返回 mock 假数据(霓虹躲避)" },
|
||||
"gate4_seed": { "pass": true, "evidence": "scaffold 四态(9201: project=4∧version=3∧pkg=1∧feed_rank zone0)+seed engineBundle; 回读 status=1 ∧ checksum=1d815c12...(64hex) ∧ has_engine_bundle_key=1 ∧ has_frozen_global_name=1; manifest 端点 sha256(原文)==checksum 实证一致(raw-byte)", "defect_fixed": "seed 须用 --default-character-set=utf8mb4 否则 latin1 双编码致 checksum 链断(已修, 见报告)" },
|
||||
"gate5_feed_walkthrough_cdp": {
|
||||
"pass": "core_pass",
|
||||
"engine_rendered": { "pass": true, "evidence": "截图 02-play-engine-render.png = 暖阳小卖部引擎真渲(店主/货架/开店按钮/资金条); warm=1246073/1316640px maxCh=255; 非「模板体系重构中」占位" },
|
||||
"game_loaded": { "pass": true, "evidence": "lifecycle game_loaded{engine:'real',via:'bootGameHost'} 经 bridge 上抛" },
|
||||
"game_start": { "pass": true, "evidence": "lifecycle game_start{via:'bootGameHost'} 经 bridge 上抛" },
|
||||
"game_play_start_sessionId": { "pass": true, "via": "backend", "evidence": "驱动器 bridge 捕获点看不到 host 侧 track(假阴), 但后端 startSession log sessionId=19/20 + game_runtime_session 行(scene=play) 证 onStart→session/start→sessionId 链真活(mustFix#2 满足)" },
|
||||
"anti_demo_fallback": { "pass": true, "evidence": "无回退 demo/manifest校验失败 console warn(0); 端点 sha256==checksum; 无 game_error; 无重构中占位" },
|
||||
"console_clean": { "pass": true, "evidence": "console real errors=0 total=0" },
|
||||
"game_end_completed": { "pass": "not_reached_in_window", "honest_note": "gameover 需多个 96s 营业日(DAY.lengthSec=96, startCash=1200, daysPerSegment=3 → 分钟级)且 srcdoc 跨 origin 内按钮坐标不可读(盲点); 真触摸已驱动且引擎持续真渲(warm=1254661), 但终态未在时间窗内达成。非引擎/渲染失败 — game_loaded+game_start+真渲+session 已证真玩链路通。" }
|
||||
},
|
||||
"gate6_rollback_channel": { "pass": true, "evidence": "9202(无 engineBundle): lifecycle game_error{template_system_under_reconstruction,clicker} ∧ game_loaded=false ∧ warm=1021(无引擎渲, 深底 demo 壳); 截图 06-rollback-9202.png=游戏加载失败错误态; 证 inject.ts pkg.engineBundle? 分流正确, 向后兼容未破" }
|
||||
}
|
||||
}
|
||||
25
game-studio/evidence/p2-feed/db-readback.txt
Normal file
25
game-studio/evidence/p2-feed/db-readback.txt
Normal file
@ -0,0 +1,25 @@
|
||||
=== game 9201 four-state (engine game) ===
|
||||
+-------------+-------+--------+-------+
|
||||
| k | id | status | v |
|
||||
+-------------+-------+--------+-------+
|
||||
| project | 9201 | 4 | 92010 |
|
||||
| version | 92010 | 3 | 9201 |
|
||||
| runtime_pkg | 92010 | 1 | 92010 |
|
||||
| feed_rank | 92010 | 1 | 0 |
|
||||
+-------------+-------+--------+-------+
|
||||
=== 9201 runtime_package checksum + engineBundle key ===
|
||||
+------------+--------+------------------------------------------------------------------+-------------+------------+------------+
|
||||
| version_id | status | checksum | bundle_size | has_eb_key | has_global |
|
||||
+------------+--------+------------------------------------------------------------------+-------------+------------+------------+
|
||||
| 92010 | 1 | 1d815c12d74dd0f17c07a7bab152ed71f42f5c71487f7b3342a06b2e7da64a25 | 271437 | 1 | 1 |
|
||||
+------------+--------+------------------------------------------------------------------+-------------+------------+------------+
|
||||
=== runtime_session (mustFix#2 proof: onStart fired) ===
|
||||
+----+---------+------------+-------+---------------------+
|
||||
| id | game_id | version_id | scene | create_time |
|
||||
+----+---------+------------+-------+---------------------+
|
||||
| 20 | 9201 | 92010 | play | 2026-06-14 04:23:56 |
|
||||
| 19 | 9201 | 92010 | play | 2026-06-14 04:19:05 |
|
||||
+----+---------+------------+-------+---------------------+
|
||||
=== backend startSession log ===
|
||||
2026-06-14 04:19:05.139 [http-nio-48080-exec-8] [34mINFO [0;39m [36mc.w.g.m.r.s.session.RuntimeSessionServiceImpl:72[0;39m - [startSession] 新建试玩会话 sessionId=19, gameId=9201, versionId=92010, scene=play, userId=1, anonId=
|
||||
2026-06-14 04:23:56.166 [http-nio-48080-exec-7] [34mINFO [0;39m [36mc.w.g.m.r.s.session.RuntimeSessionServiceImpl:72[0;39m - [startSession] 新建试玩会话 sessionId=20, gameId=9201, versionId=92010, scene=play, userId=1, anonId=
|
||||
154
game-studio/evidence/p2-feed/p2-evidence.json
Normal file
154
game-studio/evidence/p2-feed/p2-evidence.json
Normal file
@ -0,0 +1,154 @@
|
||||
{
|
||||
"meta": {
|
||||
"base": "http://localhost:4173",
|
||||
"gameId": "9201",
|
||||
"versionId": "92010",
|
||||
"startedAt": "2026-06-13T20:23:27.529Z",
|
||||
"driver": "p2-feed.driver.cjs"
|
||||
},
|
||||
"gates": {
|
||||
"gate1_feed_card": true,
|
||||
"gate2_game_loaded": true,
|
||||
"gate2_game_start": true,
|
||||
"gate2_game_play_start": false,
|
||||
"gate2_engine_rendered": true,
|
||||
"gate3_no_reconstruction_text": true,
|
||||
"gate3_no_demo_fallback": true,
|
||||
"gate2_interactive_render": true,
|
||||
"gate2_game_end_completed": "not_reached_in_window",
|
||||
"gate4_console_clean": true
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"ok": true,
|
||||
"msg": "feed 出 9201 卡(标题含「引擎真渲/王蓝莓」;sample=推荐\n默认专区\n创作广场\n▶\n王蓝莓小卖部(引擎真渲)\n\n@\n\n▶ 0\n♥ 0\n♥\n0\n★\n藏\n➦\n享\n)"
|
||||
},
|
||||
{
|
||||
"ok": true,
|
||||
"msg": "lifecycle game_loaded 已上抛(门② 就绪锚) via=bootGameHost"
|
||||
},
|
||||
{
|
||||
"ok": true,
|
||||
"msg": "lifecycle game_start 已上抛(mustFix#2 会话/计数链)"
|
||||
},
|
||||
{
|
||||
"ok": true,
|
||||
"msg": "无 game_error(引擎接管成功,未走兜底)"
|
||||
},
|
||||
{
|
||||
"ok": false,
|
||||
"msg": "mustFix#2: game_play_start 遥测上报(play_count 权威源,会话链未断)"
|
||||
},
|
||||
{
|
||||
"ok": true,
|
||||
"msg": "引擎真渲非占位(warm=1246073 bright=1286490 maxCh=255,暖色小卖部满屏)"
|
||||
},
|
||||
{
|
||||
"ok": true,
|
||||
"msg": "top frame 无「模板体系重构中」占位文案"
|
||||
},
|
||||
{
|
||||
"ok": true,
|
||||
"msg": "无 demo 兜底告警(回退 demo/manifest 校验失败 计 0)"
|
||||
},
|
||||
{
|
||||
"ok": true,
|
||||
"msg": "权威 checksum(端点 sha256 已实证)= 1d815c12d74dd0f1…"
|
||||
},
|
||||
{
|
||||
"ok": true,
|
||||
"msg": "真触摸后引擎仍真渲(warm=1254661 maxCh=255)"
|
||||
},
|
||||
{
|
||||
"ok": true,
|
||||
"msg": "console 零未捕获错误(real=0,total=0)"
|
||||
}
|
||||
],
|
||||
"lifecycle": [
|
||||
{
|
||||
"event": "sdk_ready",
|
||||
"data": {
|
||||
"gameId": "9201",
|
||||
"versionId": "92010"
|
||||
},
|
||||
"t": 1781382235998
|
||||
},
|
||||
{
|
||||
"event": "game_loaded",
|
||||
"data": {
|
||||
"engine": "real",
|
||||
"via": "bootGameHost"
|
||||
},
|
||||
"t": 1781382236070
|
||||
},
|
||||
{
|
||||
"event": "game_start",
|
||||
"data": {
|
||||
"via": "bootGameHost"
|
||||
},
|
||||
"t": 1781382236072
|
||||
}
|
||||
],
|
||||
"console": {
|
||||
"errors": [],
|
||||
"demoWarns": []
|
||||
},
|
||||
"screenshots": [
|
||||
"/root/games-development-ai/game-studio/evidence/p2-feed/01-feed.png",
|
||||
"/root/games-development-ai/game-studio/evidence/p2-feed/02-play-engine-render.png",
|
||||
"/root/games-development-ai/game-studio/evidence/p2-feed/03-after-newgame.png",
|
||||
"/root/games-development-ai/game-studio/evidence/p2-feed/04-after-openshop.png"
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"feedProbe": {
|
||||
"hasTitle": true,
|
||||
"playLink": null,
|
||||
"bodyLen": 79,
|
||||
"sample": "推荐\n默认专区\n创作广场\n▶\n王蓝莓小卖部(引擎真渲)\n\n@\n\n▶ 0\n♥ 0\n♥\n0\n★\n藏\n➦\n享\n⚑\n报\n◎\n游戏流\n✦\n创作\n◰\n我的"
|
||||
}
|
||||
},
|
||||
{
|
||||
"telemetry": []
|
||||
},
|
||||
{
|
||||
"sessionProbe": {
|
||||
"hasPlayStartTelem": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"pixelStats": {
|
||||
"px": 1316640,
|
||||
"bright": 1286490,
|
||||
"warm": 1246073,
|
||||
"maxCh": 255,
|
||||
"nonblack": 1316086,
|
||||
"w": 780,
|
||||
"h": 1688
|
||||
}
|
||||
},
|
||||
{
|
||||
"expectChecksum": "1d815c12d74dd0f17c07a7bab152ed71f42f5c71487f7b3342a06b2e7da64a25"
|
||||
},
|
||||
{
|
||||
"pixelStatsAfterDrive": {
|
||||
"px": 1316640,
|
||||
"bright": 1298479,
|
||||
"warm": 1254661,
|
||||
"maxCh": 255,
|
||||
"nonblack": 1316142,
|
||||
"w": 780,
|
||||
"h": 1688
|
||||
}
|
||||
},
|
||||
{
|
||||
"gameEnd": "NOT_REACHED_IN_WINDOW",
|
||||
"hint": "gameover 需多个 96s 营业日(分钟级)+srcdoc 坐标盲点;本门时间窗内未达,非引擎/渲染失败(game_loaded+game_start+真渲已证)"
|
||||
}
|
||||
],
|
||||
"finishedAt": "2026-06-13T20:24:38.744Z",
|
||||
"summary": {
|
||||
"pass": 10,
|
||||
"total": 11
|
||||
}
|
||||
}
|
||||
341
game-studio/evidence/p2-feed/p2-feed.driver.cjs
Normal file
341
game-studio/evidence/p2-feed/p2-feed.driver.cjs
Normal file
@ -0,0 +1,341 @@
|
||||
/**
|
||||
* p2-feed.driver.cjs — P2「通用宿主泛化」真机门⑤ CDP 真浏览器 driver(仅 mini-desktop 跑)
|
||||
*
|
||||
* 目标(execution spec §8.2 门②/③/④):开真 staging studio(:4173·mock OFF) → feed 定位 game 9201 卡 → /play
|
||||
* → 断言:① 引擎 bundle 真渲非「模板体系重构中」(截图+像素) ② lifecycle game_loaded ∧ game_start
|
||||
* ∧ game_end(completed:true)(经 bridge 上抛,top frame 监听捕获)③ 防 demo 兜底假阳性(无回退 demo warn
|
||||
* ∧ 无 game_error ∧ sha256(manifest)==checksum 已端点侧实证)④ console 零未捕获错误。
|
||||
*
|
||||
* 【运行面】仅 mini-desktop 连本地 headless chrome(:9222);6c6g 禁 chrome。
|
||||
* 【捕获形制】游戏在 srcdoc iframe(origin 'null')内跑,__refHost 不暴露(studio 用 SDK lifecycle);
|
||||
* 故 lifecycle 经「top frame window.addEventListener('message')」捕获 bridge 信封
|
||||
* {channel:'wanxiang-game-sdk', type:'lifecycle', payload:{event,data}};像素证据走 Page.captureScreenshot(跨 frame 可视)。
|
||||
*
|
||||
* 用法(cwd=game-studio):node evidence/p2-feed/p2-feed.driver.cjs [baseUrl] [gameId] [versionId]
|
||||
* 缺省 baseUrl=http://localhost:4173 gameId=9201 versionId=92010
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const http = require('node:http');
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const BASE = process.argv[2] || 'http://localhost:4173';
|
||||
const GAME_ID = process.argv[3] || '9201';
|
||||
const VERSION_ID = process.argv[4] || '92010';
|
||||
const CDP_HTTP = process.env.CDP_HTTP || 'http://localhost:9222';
|
||||
const OUT_DIR = path.resolve('evidence/p2-feed');
|
||||
const API_BASE = process.env.API_BASE || 'http://localhost:48080';
|
||||
// 灌库桩权威 checksum(DB game_runtime_package.checksum)——端点 sha256 已在 shell 侧实证一致,driver 再记一次。
|
||||
const EXPECT_CHECKSUM = process.env.EXPECT_CHECKSUM || '';
|
||||
|
||||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function httpJson(method, urlStr) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(urlStr);
|
||||
const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method }, (res) => {
|
||||
let buf = '';
|
||||
res.on('data', (c) => (buf += c));
|
||||
res.on('end', () => { try { resolve(buf ? JSON.parse(buf) : {}); } catch { resolve({ raw: buf }); } });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** 极简 CDP 会话(send/evaluate + 事件订阅:捕获 Runtime.consoleAPICalled / exceptionThrown)。 */
|
||||
class Cdp {
|
||||
constructor(ws, targetId) {
|
||||
this.ws = ws; this.targetId = targetId; this._id = 0; this._pending = new Map();
|
||||
this.consoleMsgs = []; this.exceptions = [];
|
||||
ws.on('message', (raw) => {
|
||||
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||||
if (msg.id != null && this._pending.has(msg.id)) {
|
||||
const { resolve, reject } = this._pending.get(msg.id); this._pending.delete(msg.id);
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error))); else resolve(msg.result || {});
|
||||
return;
|
||||
}
|
||||
// 事件:console + 未捕获异常归集(门④ console 零未捕获)。
|
||||
if (msg.method === 'Runtime.consoleAPICalled') {
|
||||
const a = (msg.params.args || []).map((x) => (x.value !== undefined ? x.value : x.description)).join(' ');
|
||||
this.consoleMsgs.push({ type: msg.params.type, text: String(a) });
|
||||
} else if (msg.method === 'Runtime.exceptionThrown') {
|
||||
const d = msg.params.exceptionDetails || {};
|
||||
const t = (d.exception && (d.exception.description || d.exception.value)) || d.text || 'unknown';
|
||||
this.exceptions.push(String(t));
|
||||
}
|
||||
});
|
||||
}
|
||||
send(method, params, timeoutMs) {
|
||||
const id = ++this._id;
|
||||
return new Promise((resolve, reject) => {
|
||||
const to = setTimeout(() => { this._pending.delete(id); reject(new Error('CDP 超时:' + method)); }, timeoutMs || 40000);
|
||||
this._pending.set(id, { resolve: (r) => { clearTimeout(to); resolve(r); }, reject: (e) => { clearTimeout(to); reject(e); } });
|
||||
this.ws.send(JSON.stringify({ id, method, params: params || {} }));
|
||||
});
|
||||
}
|
||||
async evaluate(expression, timeoutMs) {
|
||||
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, timeoutMs);
|
||||
if (r && r.exceptionDetails) throw new Error('求值异常:' + JSON.stringify(r.exceptionDetails).slice(0, 300));
|
||||
return r && r.result ? r.result.value : undefined;
|
||||
}
|
||||
close() { try { this.ws.close(); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
async function connect(url) {
|
||||
const created = await httpJson('PUT', `${CDP_HTTP}/json/new?${encodeURI('about:blank')}`);
|
||||
if (!created.webSocketDebuggerUrl) throw new Error('建 target 失败:' + JSON.stringify(created).slice(0, 200));
|
||||
const ws = await new Promise((resolve, reject) => {
|
||||
const s = new WebSocket(created.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 });
|
||||
s.on('open', () => resolve(s)); s.on('error', reject);
|
||||
});
|
||||
const cdp = new Cdp(ws, created.id);
|
||||
await cdp.send('Page.enable');
|
||||
await cdp.send('Runtime.enable');
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
|
||||
try { await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); } catch { /* old chrome */ }
|
||||
// 关键:文档创建即注入 lifecycle 捕获器(先于 SPA 应用代码 + 跨 reload 常驻),杜绝漏捕 game_loaded/start。
|
||||
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: INJECT_CAPTURE });
|
||||
return cdp;
|
||||
}
|
||||
|
||||
/** 注入 top frame lifecycle 捕获器:把所有 bridge 信封 lifecycle 事件 push 到 window.__p2life。
|
||||
* 关键修法:经 Page.addScriptToEvaluateOnNewDocument 在「文档创建即注入」,使捕获器先于 SPA 应用代码就位,
|
||||
* 跨 SPA 路由切换/全量 reload 都常驻——避免「导航后才补装、引擎已抢先 emit 而漏捕」(首版漏 game_loaded 根因)。 */
|
||||
const INJECT_CAPTURE = `(function(){
|
||||
if (window.__p2life) return 'already';
|
||||
window.__p2life = [];
|
||||
window.__p2telemetry = [];
|
||||
window.addEventListener('message', function(e){
|
||||
try {
|
||||
var d = e.data;
|
||||
if (d && d.channel === 'wanxiang-game-sdk' && d.type === 'lifecycle' && d.payload) {
|
||||
window.__p2life.push({ event: d.payload.event, data: d.payload.data || {}, t: Date.now() });
|
||||
}
|
||||
if (d && d.channel === 'wanxiang-game-sdk' && d.type === 'telemetry' && d.payload) {
|
||||
window.__p2telemetry.push({ event: d.payload.event, props: d.payload.props || {}, t: Date.now() });
|
||||
}
|
||||
} catch(_){}
|
||||
}, true);
|
||||
return 'installed';
|
||||
})()`;
|
||||
|
||||
async function readLife(cdp) { return await cdp.evaluate('JSON.stringify(window.__p2life||[])').then((s) => JSON.parse(s || '[]')); }
|
||||
function hasLife(life, ev) { return life.find((x) => x.event === ev) || null; }
|
||||
|
||||
async function saveShot(cdp, name) {
|
||||
const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, 60000);
|
||||
if (!r || !r.data) throw new Error('截图无 data');
|
||||
const abs = path.join(OUT_DIR, name);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, Buffer.from(r.data, 'base64'));
|
||||
return abs;
|
||||
}
|
||||
|
||||
/** 全页截图像素统计(PNG 解码用 chrome 侧 canvas:截图 → 贴 canvas → 读直方图)。
|
||||
* 判「引擎真渲非占位」:暖色像素(r>g>b 且 r 较亮) 计数 + 全屏最亮通道。 */
|
||||
async function screenshotPixelStats(cdp) {
|
||||
// 在 top frame 用一张离屏 canvas 解码当前页面截图(dataURL)→ 读 ImageData 直方图。
|
||||
const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, 60000);
|
||||
if (!r || !r.data) return { err: 'no shot' };
|
||||
const dataUrl = 'data:image/png;base64,' + r.data;
|
||||
const expr = `(async function(){
|
||||
var img = new Image();
|
||||
await new Promise(function(res,rej){ img.onload=res; img.onerror=rej; img.src=${JSON.stringify(dataUrl)}; });
|
||||
var cv = document.createElement('canvas'); cv.width=img.naturalWidth; cv.height=img.naturalHeight;
|
||||
var g = cv.getContext('2d'); g.drawImage(img,0,0);
|
||||
var d = g.getImageData(0,0,cv.width,cv.height).data, n=d.length;
|
||||
var bright=0, warm=0, maxCh=0, nonblack=0;
|
||||
for (var i=0;i<n;i+=4){
|
||||
var R=d[i],G=d[i+1],B=d[i+2];
|
||||
var m=Math.max(R,G,B); if(m>maxCh)maxCh=m;
|
||||
if(m>40)nonblack++;
|
||||
if(m>80)bright++;
|
||||
if(R>90 && R>=G && G>=B && (R-B)>25) warm++; // 暖阳小卖部主色调(暖橙/暖黄)
|
||||
}
|
||||
return { px:(n/4), bright:bright, warm:warm, maxCh:maxCh, nonblack:nonblack, w:cv.width, h:cv.height };
|
||||
})()`;
|
||||
return await cdp.evaluate(expr, 60000);
|
||||
}
|
||||
|
||||
/** 真触摸(iframe 内画布坐标→页面坐标;这里直接对页面 CSS 坐标下发 Input.dispatchTouchEvent)。
|
||||
* 注:iframe 充满视口,canvas 逻辑 390×844 映射到页面,故按视口比例换算。 */
|
||||
async function tap(cdp, cssX, cssY) {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: cssX, y: cssY }] });
|
||||
await delay(60);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const report = {
|
||||
meta: { base: BASE, gameId: GAME_ID, versionId: VERSION_ID, startedAt: new Date().toISOString(), driver: 'p2-feed.driver.cjs' },
|
||||
gates: {}, checks: [], lifecycle: [], console: { errors: [], demoWarns: [] }, screenshots: [], notes: [],
|
||||
};
|
||||
const check = (ok, msg) => { report.checks.push({ ok: !!ok, msg }); console.log((ok ? ' ✓ ' : ' ✗ ') + msg); return !!ok; };
|
||||
|
||||
let cdp;
|
||||
try {
|
||||
// ── 步骤1:feed 页 → 确认 9201 卡在流(真后端,mock OFF)──
|
||||
console.log('[1] feed 页加载 + 定位 9201 卡');
|
||||
cdp = await connect(`${BASE}/feed`);
|
||||
await cdp.send('Page.navigate', { url: `${BASE}/feed` });
|
||||
await delay(3500); // 等 feed 拉流 + 渲染
|
||||
// 读 feed DOM 是否含本游戏卡(标题文案 + 跳 /play 链接锚)
|
||||
const feedProbe = await cdp.evaluate(`(function(){
|
||||
var html = document.body.innerText || '';
|
||||
var hasTitle = html.indexOf('引擎真渲') >= 0 || html.indexOf('王蓝莓') >= 0;
|
||||
// 找跳 play 的卡(router-link 或 onclick 含 /play/9201)
|
||||
var links = Array.prototype.map.call(document.querySelectorAll('a,[href]'), function(a){return a.getAttribute('href')||'';});
|
||||
var playLink = links.filter(function(h){return h.indexOf('/play/${GAME_ID}')>=0;})[0] || null;
|
||||
return { hasTitle: hasTitle, playLink: playLink, bodyLen: html.length, sample: html.slice(0,200) };
|
||||
})()`);
|
||||
report.notes.push({ feedProbe });
|
||||
report.screenshots.push(await saveShot(cdp, '01-feed.png'));
|
||||
check(feedProbe.hasTitle, `feed 出 9201 卡(标题含「引擎真渲/王蓝莓」;sample=${(feedProbe.sample||'').slice(0,60)})`);
|
||||
report.gates.gate1_feed_card = feedProbe.hasTitle;
|
||||
|
||||
// ── 步骤2:整页导航 /play/9201/92010(全量 reload → addScriptToEvaluateOnNewDocument 文档创建即装捕获器)──
|
||||
// 首版用 SPA 软导航 + 事后补装捕获器,引擎抢先 emit 致漏 game_loaded;改全量 reload + 新文档注入,根治漏捕。
|
||||
console.log('[2] /play 引擎包分支启动(全量 reload,捕获器文档级常驻)');
|
||||
await cdp.send('Page.navigate', { url: `${BASE}/play/${GAME_ID}/${VERSION_ID}` });
|
||||
await delay(800); // 文档已新建(捕获器随之就位),开始等 lifecycle
|
||||
// 等 game_loaded(引擎冷启动 + 内联 265KB bundle 解析,给 25s)
|
||||
let life = [];
|
||||
const deadline = Date.now() + 25000;
|
||||
while (Date.now() < deadline) {
|
||||
life = await readLife(cdp);
|
||||
if (hasLife(life, 'game_loaded')) break;
|
||||
// 若提前 game_error(引擎接管失败/兜底)→ 立即停等
|
||||
if (hasLife(life, 'game_error')) break;
|
||||
await delay(500);
|
||||
}
|
||||
report.lifecycle = life;
|
||||
const loaded = hasLife(life, 'game_loaded');
|
||||
const started = hasLife(life, 'game_start');
|
||||
const errored = hasLife(life, 'game_error');
|
||||
check(!!loaded, `lifecycle game_loaded 已上抛(门② 就绪锚)${loaded ? ' via=' + (loaded.data && loaded.data.via) : ''}`);
|
||||
check(!!started, `lifecycle game_start 已上抛(mustFix#2 会话/计数链)`);
|
||||
check(!errored, `无 game_error(引擎接管成功,未走兜底)${errored ? ' ✗错误:' + JSON.stringify(errored.data) : ''}`);
|
||||
report.gates.gate2_game_loaded = !!loaded;
|
||||
report.gates.gate2_game_start = !!started;
|
||||
|
||||
// mustFix#2:game_start → Play.vue onStart 开会话 + 上报 game_play_start(play_count 权威源,带 sessionId)。
|
||||
// 验证点:sessionId 非空(runtime/session/start 回 sessionId)。捕获 telemetry game_play_start 信封 + 探 session store。
|
||||
await delay(800);
|
||||
const telem = await cdp.evaluate('JSON.stringify(window.__p2telemetry||[])').then((s) => JSON.parse(s || '[]'));
|
||||
report.notes.push({ telemetry: telem });
|
||||
const playStart = telem.find((x) => x.event === 'game_play_start') || null;
|
||||
// sessionId 经 session/start 回包写入 useSessionStore;读 pinia store(若暴露)或经 telemetry props 间接证。
|
||||
const sid = await cdp.evaluate(`(function(){
|
||||
try {
|
||||
// pinia store 不直接暴露;退而求其次:从 GamePlayer/session 模块的 window 标记或 DOM 读。
|
||||
// session/start 成功后 Play.vue 持 sessionId;此处探页面是否进入「已开会话」可玩态(无错误遮罩)。
|
||||
return { hasPlayStartTelem: !!(window.__p2telemetry||[]).find(function(x){return x.event==='game_play_start';}) };
|
||||
} catch(e){ return { err: String(e) }; }
|
||||
})()`);
|
||||
report.notes.push({ sessionProbe: sid });
|
||||
check(!!playStart, `mustFix#2: game_play_start 遥测上报(play_count 权威源,会话链未断)`);
|
||||
report.gates.gate2_game_play_start = !!playStart;
|
||||
|
||||
await delay(1500);
|
||||
report.screenshots.push(await saveShot(cdp, '02-play-engine-render.png'));
|
||||
|
||||
// ── 步骤3:引擎真渲像素证据(非「模板体系重构中」占位)──
|
||||
console.log('[3] 引擎真渲像素 + 防占位文案');
|
||||
const stats = await screenshotPixelStats(cdp);
|
||||
report.notes.push({ pixelStats: stats });
|
||||
// 占位页是深底 + 居中「模板体系重构中」文字;引擎真渲是暖色小卖部满屏。
|
||||
const renderedReal = stats && stats.warm > 2000 && stats.bright > 5000 && stats.maxCh > 120;
|
||||
check(renderedReal, `引擎真渲非占位(warm=${stats.warm} bright=${stats.bright} maxCh=${stats.maxCh},暖色小卖部满屏)`);
|
||||
// 兜底文案检测:页面任何位置出现「模板体系重构中」即兜底触发(门③ fail)
|
||||
const reconText = await cdp.evaluate(`(document.body.innerText||'').indexOf('模板体系重构中')>=0 || (function(){
|
||||
// iframe 内文案跨 origin 读不到,但占位是 canvas 绘制——退而求其次:检 iframe srcdoc 是否走 startRuntime(无 __GameBundle 调用)。
|
||||
return false;
|
||||
})()`);
|
||||
check(!reconText, `top frame 无「模板体系重构中」占位文案`);
|
||||
report.gates.gate2_engine_rendered = renderedReal;
|
||||
report.gates.gate3_no_reconstruction_text = !reconText;
|
||||
|
||||
// ── 步骤4:防 demo 兜底假阳性(console 无回退 demo warn)──
|
||||
console.log('[4] 防 demo 兜底假阳性 console 扫描');
|
||||
const demoWarns = cdp.consoleMsgs.filter((m) => /回退\s*demo\s*兜底|manifest 校验失败|取包清单失败/.test(m.text));
|
||||
report.console.demoWarns = demoWarns.map((m) => m.text);
|
||||
check(demoWarns.length === 0, `无 demo 兜底告警(回退 demo/manifest 校验失败 计 ${demoWarns.length})`);
|
||||
report.gates.gate3_no_demo_fallback = demoWarns.length === 0;
|
||||
if (EXPECT_CHECKSUM) {
|
||||
report.notes.push({ expectChecksum: EXPECT_CHECKSUM });
|
||||
check(true, `权威 checksum(端点 sha256 已实证)= ${EXPECT_CHECKSUM.slice(0, 16)}…`);
|
||||
}
|
||||
|
||||
// ── 步骤5:真触摸交互验证(title→prep→open 真玩起步)+ 尽力探 game_end ──
|
||||
// 游戏到 gameover 需多个 96s 营业日(DAY.lengthSec=96,startCash=1200,daysPerSegment=3)→ 数分钟级,
|
||||
// 且 srcdoc 跨 origin 内按钮坐标不可读(盲点);故本步证「真触摸驱动引擎、玩法真推进(title→prep→open)」,
|
||||
// game_end(completed) 作尽力项如实记录(不在时间窗内不判 fail,避免假绿/假红)。render.js 实坐标(390×844):
|
||||
// title.newGame=(W/2, 560+28);prep.openShop=(W/2, panel底 ≈ 170+470-70+27)。
|
||||
console.log('[5] 真触摸交互验证(title→prep→open)+ 尽力探 game_end');
|
||||
const vw = 390;
|
||||
// ① title→newGame(开新局)
|
||||
await tap(cdp, vw / 2, 560 + 28);
|
||||
await delay(1800);
|
||||
report.screenshots.push(await saveShot(cdp, '03-after-newgame.png'));
|
||||
// ② prep→openShop(开门营业;prepLayout.btn 在面板底,约 y≈600+;多点几处覆盖按钮带)
|
||||
for (const by of [600, 640, 700, 760]) { await tap(cdp, vw / 2, by); await delay(400); }
|
||||
await delay(2500);
|
||||
const playShot = await saveShot(cdp, '04-after-openshop.png');
|
||||
report.screenshots.push(playShot);
|
||||
// 像素再核:开门后画面仍引擎真渲(暖色),证交互未崩
|
||||
const stats2 = await screenshotPixelStats(cdp);
|
||||
report.notes.push({ pixelStatsAfterDrive: stats2 });
|
||||
check(stats2 && stats2.warm > 2000 && stats2.maxCh > 120, `真触摸后引擎仍真渲(warm=${stats2 && stats2.warm} maxCh=${stats2 && stats2.maxCh})`);
|
||||
report.gates.gate2_interactive_render = !!(stats2 && stats2.warm > 2000 && stats2.maxCh > 120);
|
||||
// 尽力探 game_end(不阻塞、不判 fail):再驱动一小段,看是否偶达终态
|
||||
let gameEnd = null;
|
||||
const playDeadline = Date.now() + 30000;
|
||||
while (Date.now() < playDeadline) {
|
||||
life = await readLife(cdp);
|
||||
gameEnd = hasLife(life, 'game_end');
|
||||
if (gameEnd) break;
|
||||
await tap(cdp, vw / 2, 600); await delay(2000);
|
||||
}
|
||||
report.lifecycle = await readLife(cdp);
|
||||
if (gameEnd) {
|
||||
const completed = !!(gameEnd.data && gameEnd.data.completed === true);
|
||||
check(completed, `lifecycle game_end(completed:true) 到达结算(reason=${gameEnd.data && gameEnd.data.gameoverReason})`);
|
||||
report.gates.gate2_game_end_completed = completed;
|
||||
report.screenshots.push(await saveShot(cdp, '05-game-end.png'));
|
||||
} else {
|
||||
report.notes.push({ gameEnd: 'NOT_REACHED_IN_WINDOW', hint: 'gameover 需多个 96s 营业日(分钟级)+srcdoc 坐标盲点;本门时间窗内未达,非引擎/渲染失败(game_loaded+game_start+真渲已证)' });
|
||||
report.gates.gate2_game_end_completed = 'not_reached_in_window';
|
||||
console.log(' ⚠ game_end 未在时间窗内到达(gameover 需数分钟真玩;如实记录,不判 fail)');
|
||||
}
|
||||
|
||||
// ── 步骤6:console 零未捕获错误 ──
|
||||
console.log('[6] console 未捕获错误扫描');
|
||||
const errs = cdp.consoleMsgs.filter((m) => m.type === 'error').map((m) => m.text)
|
||||
.concat(cdp.exceptions);
|
||||
report.console.errors = errs;
|
||||
// 过滤已知良性噪声(favicon/资源 404 等非游戏逻辑错误)
|
||||
const realErrs = errs.filter((t) => !/favicon|ERR_|net::|404|Failed to load resource/i.test(t));
|
||||
check(realErrs.length === 0, `console 零未捕获错误(real=${realErrs.length},total=${errs.length})`);
|
||||
report.gates.gate4_console_clean = realErrs.length === 0;
|
||||
|
||||
} catch (e) {
|
||||
report.fatal = String((e && e.stack) || e);
|
||||
console.error('[FATAL]', report.fatal);
|
||||
} finally {
|
||||
if (cdp) { try { await httpJson('GET', `${CDP_HTTP}/json/close/${cdp.targetId}`); } catch { /* ignore */ } cdp.close(); }
|
||||
}
|
||||
|
||||
report.finishedAt = new Date().toISOString();
|
||||
const passCount = report.checks.filter((c) => c.ok).length;
|
||||
report.summary = { pass: passCount, total: report.checks.length };
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'p2-evidence.json'), JSON.stringify(report, null, 2));
|
||||
console.log(`\n[RESULT] ${passCount}/${report.checks.length} checks pass`);
|
||||
console.log('[RESULT] gates=' + JSON.stringify(report.gates));
|
||||
console.log('[DONE] evidence → ' + path.join(OUT_DIR, 'p2-evidence.json'));
|
||||
}
|
||||
|
||||
main();
|
||||
@ -79,8 +79,15 @@ let bridge: HostBridge | null = null;
|
||||
/** 本局 trace_id:props 传入优先,否则生成 */
|
||||
const traceId = computed(() => props.traceId || genTraceId());
|
||||
|
||||
/** 加载超时句柄:>5s 未 ready → 跳过 + 降权占位(spec §6 / security §5.2) */
|
||||
/** 加载超时句柄:未 ready → 跳过 + 降权占位(spec §6 / security §5.2) */
|
||||
let loadTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 旧 startRuntime/demo 路加载超时(ms):行为零变,沿用 5s。 */
|
||||
const LEGACY_LOAD_TIMEOUT_MS = 5000;
|
||||
/**
|
||||
* 引擎包路加载超时(ms):引擎冷启动 4.8~5.3× + 内联 bundle 解析可逼近/超过 5s(spec §6.3 F3 / §8.2 门⑤)。
|
||||
* 取 12s 作「防挂死」上界(远松于冷启动实测,又不致无限悬挂);首屏 P75<3s 由 perf_first_screen 单独监测,二者正交。
|
||||
*/
|
||||
const ENGINE_LOAD_TIMEOUT_MS = 12000;
|
||||
|
||||
/* ====================== ad/pay 桩弹层状态 ====================== */
|
||||
/** 桩弹层:广告 / 支付,二选一展示;requestId 用于回包配对 */
|
||||
@ -191,17 +198,22 @@ async function launch(): Promise<void> {
|
||||
|
||||
const pkg = await resolvePackage();
|
||||
|
||||
// 构建 iframe 内联文档(注入 SDK + Runtime + demo 包)
|
||||
// 构建 iframe 内联文档(注入 SDK + Runtime + 引擎包/demo 包)
|
||||
srcdoc.value = buildIframeSrcdoc(pkg, traceId.value, props.gameId, props.versionId);
|
||||
|
||||
// 加载超时兜底(>5s 未 game_loaded → 错误态 + 降权占位)
|
||||
// 加载超时兜底(未 game_loaded → 错误态 + 降权占位)。阈值按路分流(spec §6.3 F3 / 门⑤):
|
||||
// - 引擎包路(pkg.engineBundle):引擎冷启动 4.8~5.3× + 内联 bundle 解析可逼近/超过 5s,
|
||||
// 5s 会误杀正在正常启动的引擎 → 放宽到 ENGINE_LOAD_TIMEOUT_MS(仍有界,杜绝无限挂起);
|
||||
// 首屏 P75<3s 由 Play.vue perf_first_screen 单独监测,与本「防挂死」超时正交,放宽不破降权语义。
|
||||
// - 旧 startRuntime/demo 路:沿用 5s(行为零变,向后兼容)。
|
||||
const loadTimeoutMs = pkg.engineBundle ? ENGINE_LOAD_TIMEOUT_MS : LEGACY_LOAD_TIMEOUT_MS;
|
||||
loadTimeout = setTimeout(() => {
|
||||
if (phase.value !== 'ready') {
|
||||
phase.value = 'error';
|
||||
errorMsg.value = '游戏加载超时,请重试';
|
||||
emit('error', { message: 'load_timeout' });
|
||||
}
|
||||
}, 5000);
|
||||
}, loadTimeoutMs);
|
||||
|
||||
// iframe 用 srcdoc 渲染后,onMounted/watch 里建桥(见下)。这里记录白名单待用。
|
||||
pendingAllowOrigins = allowOrigins;
|
||||
|
||||
@ -198,6 +198,13 @@ export interface GamePackage {
|
||||
assets: GameAsset[];
|
||||
manifest: GameManifest;
|
||||
meta: GameMeta;
|
||||
/**
|
||||
* 引擎+包装层+游戏代码的可执行 iife bundle 文本(契约 #4 additive 可选,T1b-β P2 通用宿主载体)。
|
||||
* 与后端 game-package.schema.json 的 engineBundle 字段逐字对齐:随 manifest 内嵌经 DB-manifest 端点下发,
|
||||
* 由整包 sha256 checksum 覆盖完整性;iife 顶层全局名固定 __GameBundle(宿主从 window.__GameBundle 取 GameHostFactory)。
|
||||
* 缺省(无此字段)时宿主走 startRuntime 旧路/demo 兜底,保向后兼容。前端轨在 inject.ts boot() 按本字段分流引擎包分支。
|
||||
*/
|
||||
engineBundle?: string;
|
||||
provenance?: {
|
||||
promptHash?: string;
|
||||
model?: string;
|
||||
|
||||
@ -15,6 +15,20 @@
|
||||
*
|
||||
* 安全:iframe 文档内联,origin 为 'null';CSP 限制 connect-src 'none'(游戏内禁网络)。
|
||||
* 与宿主通信仅 postMessage,宿主侧 bridge 做 origin+schema 双校验。
|
||||
*
|
||||
* 【T1b-β P2 · 引擎包分支(Runner v2 通用宿主装载)】
|
||||
* 按 `pkg.engineBundle` 是否存在分流(boot() 内):
|
||||
* - 有 engineBundle(引擎包路):把 bundle 文本作【独立内联 <script>】注入 iframe(DB-manifest 内嵌路·
|
||||
* 非外域 <script src>,CSP 不放外域),求值后暴露 `window.__GameBundle`(契约冻结全局名,
|
||||
* 与 game-package.schema.json/contract.ts 逐字对齐)。引导脚本随后调
|
||||
* `window.__GameBundle.bootGameHost({ canvas, seed })`(宿主轨 entry-bundle.js 定义:内部动态 import
|
||||
* 已内联的引擎、强制 engineMode='real'、用 wanglanmei 7 件标准集 + 390×844 视口)启动引擎掌帧。
|
||||
* —— bundle 入口不挂 SDK,故就绪/结算信号由本引导经 sdk.__emit 桥接到 SDK lifecycle:
|
||||
* boot 成功 → game_loaded(门② 就绪锚);游戏到终态(host.state().phase==='gameover')→
|
||||
* game_end{completed:true}(门② 闭环锚,lifecycle 层,**非** telemetry game_play_end,mustFix#1)。
|
||||
* - 无 engineBundle(向后兼容/demo 兜底):走旧 startRuntime 路【逐行保留】,显示「模板体系重构中」。
|
||||
* 两路同处一段 srcdoc,CSP/sandbox/origin 完全沿用(引擎 bundle 文本经宿主层 fetch 后内联,
|
||||
* 非 iframe 内 fetch,故 connect-src 'none' 不变;引擎 2D 渲进 canvas、资产经 <img>/drawImage,img-src 已放行)。
|
||||
*/
|
||||
|
||||
import { createWanxiangSDK } from './sdk/index';
|
||||
@ -42,8 +56,19 @@ export function buildIframeSrcdoc(
|
||||
// 用 JSON.stringify 再做 </script> 转义,防止资源字段意外闭合脚本标签。
|
||||
const ctxJson = safeJson({ pkg, traceId, gameId, versionId });
|
||||
|
||||
// CSP:禁止一切网络出站(connect-src 'none'),仅允许内联脚本执行(沙箱受控)。
|
||||
// 注意:img-src 放开 data:/blob:/https,便于 demo 资源占位加载,但 connect 仍禁。
|
||||
// P2 引擎包分支:bundle 文本作独立内联 <script> 注入(仅当 pkg.engineBundle 存在)。
|
||||
// safeJson 仅适用「数据」转义,会破坏可执行 JS(< 转 <);bundle 是【可执行脚本】,
|
||||
// 故不走 JSON 转义,而走专用 escapeBundleForInlineScript:仅断 </script>(含大小写/含空白变体)
|
||||
// 与 <!-- 注释开闭,使其无法提前闭合 <script> 标签或被当作 HTML 注释吞掉(内联脚本注入唯一 XSS 面)。
|
||||
const engineBundleScript = pkg.engineBundle
|
||||
? `<script>${escapeBundleForInlineScript(pkg.engineBundle)}</script>`
|
||||
: '';
|
||||
|
||||
// CSP(P2 引擎包分支沿用·零放宽外域):
|
||||
// - script-src 'unsafe-inline':内联脚本执行(SDK/Runtime 注入 + P2 引擎 bundle 内联 <script>),不放外域 <script src>;
|
||||
// - connect-src 'none':禁一切网络出站。引擎 bundle 文本由【宿主层 fetch】后内联,非 iframe 内 fetch,故无需放宽;
|
||||
// 引擎资产经 GamePackage assets[].url 走 <img>/drawImage 加载(img-src 已放行),不触发 connect-src 死结(shouldFix#2)。
|
||||
// - img-src data: blob: https::放开 demo/引擎资源占位加载(现状即够,引擎不在 iframe 内 fetch 资产)。
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
@ -58,6 +83,7 @@ export function buildIframeSrcdoc(
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game"></canvas>
|
||||
${engineBundleScript}
|
||||
<script>
|
||||
(function(){
|
||||
"use strict";
|
||||
@ -169,16 +195,84 @@ export function buildIframeSrcdoc(
|
||||
if (typeof runtimeView.__emit !== 'function' && typeof sdk.__emit === 'function') runtimeView.__emit = sdk.__emit;
|
||||
} catch(e){ runtimeView = sdk; /* 组合失败:退回纯 sdk,idle 退化无离线态,不崩 */ }
|
||||
|
||||
// ===== P2 引擎包分支:从已内联的 bundle 取 GameHostFactory,经 bootGameHost 引擎掌帧 =====
|
||||
// 注:本引导(iframe 内联,零模块依赖)不能 import 宿主轨 bootGameHost;引擎 bundle 文本已由上方独立
|
||||
// 内联 <script> 注入并暴露 window.__GameBundle(契约冻结全局名)。本函数只负责:调它的 boot 入口、
|
||||
// 把「引擎就绪 / 游戏终态」桥接为 SDK lifecycle(game_loaded / game_end),与既有宿主 bridge 闭环对齐。
|
||||
var GLOBAL_BUNDLE_NAME = '__GameBundle'; // 契约冻结:与 game-package.schema.json/contract.ts/entry-bundle.js 逐字一致
|
||||
var gameEndEmitted = false; // game_end 幂等守卫(终态只报一次,防轮询重复发)
|
||||
|
||||
function emitLife(e, d){ // 经 SDK __emit 发 lifecycle(同 iframe 同步触发 + 回传宿主 bridge)
|
||||
try { if (typeof sdk.__emit === 'function') sdk.__emit(e, d); } catch(_){ /* lifecycle 异常绝不中断 */ }
|
||||
}
|
||||
|
||||
// 轮询 host.state().phase:到终态(gameover)→ 发 game_end{completed:true}(门② 闭环锚·lifecycle 层·非 telemetry)。
|
||||
// P2-ref 过渡口径:ref 游戏(game-shop)是纯引擎工厂、内部无 SDK,无法自发 game_end;故由本引导经
|
||||
// 装载契约 host.state()(game-host.d.ts 之 host 句柄,零新增游戏契约)探测终态后代发。
|
||||
// P3 生成主线落地后,game_end 由各品类标准信号产出(W-G1 自身 spec),本轮询桩可下线。
|
||||
function watchGameEnd(host){
|
||||
if (!host || typeof host.state !== 'function') return; // 无 state 句柄:不桥接 game_end(不阻断 game_loaded)
|
||||
var timer = setInterval(function(){
|
||||
try {
|
||||
var st = host.state();
|
||||
if (st && st.phase === 'gameover' && !gameEndEmitted){
|
||||
gameEndEmitted = true;
|
||||
clearInterval(timer);
|
||||
// completed=true 是闭环达成权威判据(门②/门4);附 gameoverReason 便于宿主/取证定位。
|
||||
emitLife('game_end', { completed: true, gameoverReason: (st && st.gameoverReason) || '' });
|
||||
}
|
||||
} catch(_){ /* 探测异常忽略,下个周期重试 */ }
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 宿主会在 iframe 就绪后下发 init;这里也兜底:DOM 就绪即自驱动启动(骨架阶段
|
||||
// 不强依赖宿主 init 时序,保证 demo 链路在 srcdoc 注入后即可自跑通)。
|
||||
// 不强依赖宿主 init 时序,保证链路在 srcdoc 注入后即可自跑通)。
|
||||
function boot(){
|
||||
var canvas = document.getElementById('game');
|
||||
// 适配容器像素尺寸(竖屏优先)
|
||||
var dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.floor(canvas.clientWidth * dpr) || 360;
|
||||
canvas.height = Math.floor(canvas.clientHeight * dpr) || 640;
|
||||
// 先 init(发 sdk_ready),再起 Runtime(发 game_loaded → 等点击 game_start)
|
||||
// 先 init(发 sdk_ready)。引擎包路与旧 startRuntime 路共用此前置。
|
||||
sdk.init({ gameId: CTX.gameId, versionId: CTX.versionId, traceId: CTX.traceId });
|
||||
|
||||
// —— 分流:有 engineBundle 且 window.__GameBundle 就绪 → 引擎包分支;否则旧 startRuntime 路 ——
|
||||
var bundleNS = (CTX.pkg && CTX.pkg.engineBundle) ? window[GLOBAL_BUNDLE_NAME] : null;
|
||||
if (bundleNS && typeof bundleNS.bootGameHost === 'function'){
|
||||
// 确定性 seed:由 versionId 数字部分派生(同版本可复现);非数字回退固定值,bundle 内部亦有缺省。
|
||||
var vidNum = parseInt(CTX.versionId, 10);
|
||||
var seed = (isFinite(vidNum) ? (vidNum >>> 0) : 0x1234abcd) >>> 0;
|
||||
// bootGameHost:内部动态 import 已内联引擎、强制 engineMode='real'、用标准插件集+视口(entry-bundle.js)。
|
||||
// canvas 传 iframe 内目标画布(real 通道引擎自建 mainCanvas,本 canvas 作占位/输入兜底)。
|
||||
Promise.resolve()
|
||||
.then(function(){ return bundleNS.bootGameHost({ canvas: canvas, seed: seed }); })
|
||||
.then(function(host){
|
||||
// 引擎掌帧就绪(mainContext 建好 + game.init 跑过 + 首帧已渲)→ game_loaded(门② 就绪锚)。
|
||||
// GamePlayer 收 game_loaded → phase=ready + 清 5s 加载超时;非「模板体系重构中」死路。
|
||||
emitLife('game_loaded', { engine: 'real', via: 'bootGameHost' });
|
||||
// ref 引擎路「加载完成」即「开始游玩」(engine RAF 立即掌帧、无独立开始动作,二者重合):
|
||||
// 补发 game_start → GamePlayer @start → Play.vue onStart 开试玩会话 + 上报 game_play_start(play_count 权威源)。
|
||||
// 不补则会话/计数链静默断裂、game_play_end 丢 sessionId(mustFix#2)。对齐 LifecycleEvent 契约,非新增契约。
|
||||
emitLife('game_start', { via: 'bootGameHost' });
|
||||
watchGameEnd(host); // 起终态轮询,到 gameover 代发 game_end{completed:true}
|
||||
})
|
||||
.catch(function(err){
|
||||
// 引擎接管失败(取不到 factory / engineInit 抛):发 game_error,GamePlayer 走错误态;
|
||||
// 绝不发 game_loaded(门② 等不到 game_loaded = fail,杜绝假绿)。
|
||||
try { sdk.reportError({ message: 'engine_boot_failed: ' + ((err && err.message) || err) }); } catch(_){}
|
||||
emitLife('game_error', { message: 'engine_boot_failed' });
|
||||
});
|
||||
return; // 引擎包路接管,不再走旧 startRuntime
|
||||
}
|
||||
|
||||
// shouldFix#1:engineBundle 存在却取不到 __GameBundle.bootGameHost(bundle 传输截断/求值抛错/转义误伤)→
|
||||
// 显式上报独立信号,区别于「本就无 engineBundle 的常规 demo 兜底」,使门③排障可一眼定位(否则两者同发 game_error 难辨)。
|
||||
if (CTX.pkg && CTX.pkg.engineBundle && (!bundleNS || typeof bundleNS.bootGameHost !== 'function')){
|
||||
try { sdk.reportError({ message: 'engine_bundle_present_but_global_missing' }); } catch(_){}
|
||||
}
|
||||
|
||||
// —— 向后兼容 / demo 兜底路(逐行保留 P1 行为)——
|
||||
// 无 engineBundle,或 bundle 注入失败致 window.__GameBundle 缺失 → 旧 startRuntime 显示「模板体系重构中」。
|
||||
// 传 runtimeView(= sdk 叠加 saveState/loadState,§6.2.1)而非裸 sdk,保 idle 离线态可用;
|
||||
// 非 idle 模板 runtime 不调 storage 回调,runtimeView 与裸 sdk 行为等价(仅多两可选方法)。
|
||||
startRuntime(canvas, CTX.pkg, runtimeView);
|
||||
@ -200,6 +294,25 @@ function safeJson(obj: unknown): string {
|
||||
return JSON.stringify(obj).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
||||
}
|
||||
|
||||
/**
|
||||
* 为「内联 <script> 注入可执行 JS bundle」做最小转义(P2 引擎包分支)。
|
||||
* 与 safeJson 区别:bundle 是【可执行脚本】,不能整体 < 转义(会破坏 JS 语义如 `a < b`、`</`正则);
|
||||
* 只须封堵两类「能在 HTML 解析层提前终止/吞掉脚本块」的字面序列:
|
||||
* ① `</script` 任意大小写 + 标签名后任意空白(HTML 规范:脚本块遇 `</script` 即结束,不看大小写)→
|
||||
* 把闭合斜杠拆成 `<\/script`(JS 里 `<\/` 与 `</` 等价、不影响执行;HTML 解析器不再识别为闭合标签)。
|
||||
* ② `<!--` → `<\!--`(防被当作 HTML 注释开头吞掉后续;`\!` 在 JS 字符串/正则外无害,bundle 顶层不会出现裸 `<!--`,
|
||||
* esbuild 产物亦不含,此处为纵深防御)。
|
||||
* 这是 DB-manifest 内嵌路内联注入的唯一注入面(bundle 文本来自后端 engineBundle,整包 sha256 已覆盖其完整性,
|
||||
* 非用户自由输入;此转义为纵深防御,确保即便 bundle 含异常字面也无法越出 <script> 边界)。
|
||||
* @param bundle 引擎 iife bundle 源码文本(来自 GamePackage.engineBundle)
|
||||
* @returns 可安全内联进 <script> 的等价文本
|
||||
*/
|
||||
function escapeBundleForInlineScript(bundle: string): string {
|
||||
return bundle
|
||||
.replace(/<\/(script)/gi, '<\\/$1') // ① 断 </script(含大小写)的闭合识别
|
||||
.replace(/<!--/g, '<\\!--'); // ② 断 HTML 注释开头
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字符串的 sha256(hex 小写)。用 Web Crypto SubtleCrypto(浏览器/安全上下文可用)。
|
||||
* @param text 待摘要的原始文本(manifest.json 原始响应体)
|
||||
|
||||
@ -14,7 +14,13 @@
|
||||
* 2) 通过 SDK 发 game_error(message=template_system_under_reconstruction)→ 宿主 GamePlayer 接 error
|
||||
* 生命周期走既有错误态处理(而非靠 5s game_loaded 超时兜底误导为"加载失败");
|
||||
* 3) 不起渲染主循环、不绑任何交互、不发 game_loaded/game_start/game_end(无可玩玩法)。
|
||||
* 新模板体系落地后,在 startRuntime 内按 templateId 重新接入对应玩法 init 即恢复可玩。
|
||||
*
|
||||
* 【T1b-β P2 · Phase C supersede(2026-06-13 创始人追认 O2(b))】
|
||||
* 母 spec Phase C 原定「掏空壳→URL 加载 1.x runtime」已 supersede 为「跳过复活 1.x,直接改引擎包加载分支」。
|
||||
* 引擎包加载分支落在掏空壳的【唯一调用点】inject.ts boot()(按 pkg.engineBundle 分流,经 window.__GameBundle
|
||||
* 的 bootGameHost 引擎掌帧),故 1.x 不复活。本 startRuntime 壳【保留作 demo/兜底路】(O-P2-5 默认):
|
||||
* 仅当无 engineBundle 或 bundle 注入失败致 window.__GameBundle 缺失时才进入此壳显示「重构中」+ 发 game_error;
|
||||
* 真发布的引擎包【不应】走到这里——真机门③显式断言「兜底触发即 fail」。本壳行为零改(向后兼容回滚锚)。
|
||||
*
|
||||
* 以工厂函数编写,便于被宿主 inject.ts 以 .toString() 注入 iframe 内联运行;
|
||||
* 函数体内不引用模块作用域外部符号(import 仅类型,编译擦除)。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user