feat(runner-v2): P3 L0 通用装载壳——L1 生成 worker 的产出靶载体(node 5 门验过)
P3 第一块基石:把 wanglanmei-ref/entry-bundle.js 的「studio-consumable 引擎包入口」泛化为「任意生成游戏」通用壳——L1 worker 只写 generated-factory.js(一个 GameHostFactory),套本壳 esbuild 打 __GameBundle bundle 即得 studio 可消费引擎包,避免 20 个 prompt 各造一遍 7 插件 wiring。
新增(零改既有契约):① src/host/generic-host-config.js=buildGenericHostConfig(标准 7 能力插件集+六锚偏序+390×844 视口+wiring,零wanglanmei专属);② games/_generic/entry-generic.js=通用入口壳(动态 import 引擎 Option C node-safe 与 entry-bundle 同款;调用形 {canvas,seed} 逐字对齐 inject.ts:248;暴露 window.__GameBundle.{GameHostFactory,bootGameHost,default});③ games/_generic/generated-factory.js=占位 stub(L1 出码替换靶)+ P0 latch 终态范本(到 TARGET 置 gameover 驻留不可逆,_forensicsView().state().phase 暴露给 watchGameEnd);④ test/generic-shell.test.mjs=node 侧守门零依赖。
5 门验过(6c6g node,主笔亲跑复核):①esbuild iife __GameBundle.bootGameHost=function ②import-safe(node-load window 零触)③回归 src 161/161+games 57/57 ④B1 raw 203.8KB/gz 69KB ✔ ⑤占位工厂经受控面 init→tap→latch gameover 驻留。dist/ 已 gitignore 不入库。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dd4d883bbe
commit
3108009500
90
game-runtime/games/_generic/entry-generic.js
Normal file
90
game-runtime/games/_generic/entry-generic.js
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* entry-generic.js — P3 generic bundle 入口壳(模板·esbuild 锁参打成 iife,globalName=__GameBundle)
|
||||
* owner:P3/W-G1 生成主线 L0 | 消费方:studio host 引擎包分支(DB-manifest 内嵌路,与 wanglanmei-ref/entry-bundle.js 同消费方)
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【本入口是什么 · 与 wanglanmei-ref/entry-bundle.js 的关系】
|
||||
* entry-bundle.js 是 wanglanmei 一款游戏的「studio-consumable」bundle 入口(工厂写死 import game-shop)。
|
||||
* 本 entry-generic.js 把它泛化成「任意生成游戏」的通用入口壳:
|
||||
* · 游戏工厂来源参数化 → `import createGame from './generated-factory.js'`(L1 生成 worker 写入的产出靶);
|
||||
* · 装配走 buildGenericHostConfig(零wanglanmei专属标准能力插件集),不再走 buildRefHostConfig。
|
||||
* 这是 L1 生成 worker 的「套壳靶」:worker 只需产出 generated-factory.js(一个 GameHostFactory),
|
||||
* 套进本壳 esbuild 打成 __GameBundle bundle,即得一个 studio 可消费的引擎包——避免 20 个 prompt 各造一遍
|
||||
* 「7 插件 wiring + boot 入口」(见 execution §3.2 套壳分级框 / §7-S1)。
|
||||
*
|
||||
* 【契约(冻结·逐字对齐)】
|
||||
* - iife 顶层全局名固定 **__GameBundle**(与 game-package.schema.json/contract.ts/inject.ts 逐字一致);
|
||||
* studio iframe 注入本 bundle 文本 → 取 window.__GameBundle.bootGameHost 启动。
|
||||
* - boot 入口调用形 **{canvas, seed}** 逐字对齐 game-studio/src/host/inject.ts:248
|
||||
* (`bundleNS.bootGameHost({ canvas: canvas, seed: seed })`)。
|
||||
* - 本 bundle = 引擎(LittleJS) + 包装层(bootGameHost + generic 装配) + 生成游戏代码(generated-factory)。
|
||||
*
|
||||
* 【Q4 · Option C:引擎 import 只落本入口,且经动态 import 延后求值】
|
||||
* littlejsengine ESM 在模块装载期触 window/AudioContext,顶层静态 import 会让 node 一 import 即抛 →
|
||||
* 击穿 import-safe。修法(与 entry-bundle 逐字同款):引擎 import 改「bootGameHost 内动态 import」:
|
||||
* · node import 本入口时不触引擎(bootGameHost 未被调)→ import-safe;
|
||||
* · esbuild(bundle+iife 无 splitting)把动态 import 的引擎内联进单文件 → 引擎真入 bundle;
|
||||
* · 浏览器运行期 studio 调 __GameBundle.bootGameHost 时才求值引擎(window/AudioContext 已存在)。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。
|
||||
import { bootGameHost as bootGameHostCore } from '../../src/host/boot-game-host.js';
|
||||
// generic 装配配置(标准能力插件集 / 偏序 / 视口 / wiring;零wanglanmei专属)。
|
||||
import { buildGenericHostConfig } from '../../src/host/generic-host-config.js';
|
||||
// 【游戏工厂来源·参数化】L1 生成 worker 写入的生成工厂(默认导出 GameHostFactory,零引擎 import)。
|
||||
// 占位期 = games/_generic/generated-factory.js 的占位 stub;L1 出码替换该文件即换游戏,本壳零改。
|
||||
import createGame from './generated-factory.js';
|
||||
|
||||
/**
|
||||
* 【契约导出】GameHostFactory:studio host 从 window.__GameBundle.GameHostFactory 取它(契约冻结)。
|
||||
* = 生成工厂(createGame)。L1 出码替换 generated-factory.js 的默认导出即换它。
|
||||
* @type {import('../../src/core/game-host.d.ts').GameHostFactory}
|
||||
*/
|
||||
export const GameHostFactory = createGame;
|
||||
// 默认导出同为 GameHostFactory(便于 import default 取用;与具名导出同一引用)。
|
||||
export default createGame;
|
||||
|
||||
/**
|
||||
* 【boot 入口】供 studio host 在 iframe 内启动本 bundle(studio 仓无引擎,故由本 bundle 注入引擎后驱动)。
|
||||
* - 动态 import 引擎(Option C):调用时才求值,esbuild 已把引擎内联进本 bundle(无外部请求)。
|
||||
* - engineMode 强制 'real'(studio 引擎包分支一律引擎掌帧)。
|
||||
* - 插件/视口/wiring 取自 buildGenericHostConfig(标准能力插件集 + 390×844)。
|
||||
* - 工厂取 createGame(本 bundle 内 generated-factory 默认导出;studio 也可经 opts.factory 覆盖,缺省即用它)。
|
||||
*
|
||||
* @param {Object} opts studio host 传入的最小入参(调用形 {canvas, seed} 逐字对齐 inject.ts:248)。
|
||||
* @param {HTMLCanvasElement} opts.canvas iframe 内目标画布(real 通道引擎自建 mainCanvas,本 canvas 作占位/输入兜底)。
|
||||
* @param {HTMLElement} [opts.statusEl] 状态行(可空)。
|
||||
* @param {number} [opts.seed] 主随机种子(缺省固定值,保确定性;studio 由 versionId 派生后传)。
|
||||
* @param {any} [opts.engine] 引擎名字空间覆盖(缺省=动态 import littlejsengine;测试可注入桩引擎)。
|
||||
* @param {import('../../src/core/game-host.d.ts').GameHostFactory} [opts.factory] 工厂覆盖(缺省=本 bundle 的 createGame)。
|
||||
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
const o = opts || {};
|
||||
// 动态 import 引擎:求值时机延后到本函数被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||
// opts.engine 覆盖优先(测试可注桩引擎,免拉真引擎);缺省动态 import 真引擎。
|
||||
const engine = o.engine || (await import('littlejsengine'));
|
||||
// seed 缺省固定值(与 ref/inject 同口径:非数字回退 0x1234abcd);规整无符号 32 位。
|
||||
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
|
||||
// generic 装配(标准能力插件集 / 偏序 / 视口 / wiring)。
|
||||
const cfg = buildGenericHostConfig({ seed });
|
||||
// 调通用宿主:real 通道引擎掌帧;canvas/statusEl 由 studio 传;其余取 generic 装配。
|
||||
return bootGameHostCore({
|
||||
canvas: o.canvas,
|
||||
statusEl: o.statusEl || null,
|
||||
engine,
|
||||
seed,
|
||||
mode: 'play', // studio 真玩=play
|
||||
engineMode: 'real', // studio 引擎包分支一律引擎掌帧
|
||||
viewport: cfg.viewport, // {w:390,h:844}(P3 钉死)
|
||||
factory: o.factory || cfg.factory || createGame, // 缺省用本 bundle 内置生成工厂
|
||||
plugins: cfg.plugins,
|
||||
registerOrder: cfg.registerOrder,
|
||||
buildFactoryOpts: cfg.buildFactoryOpts,
|
||||
// studio 渠道产物:不注 recHook(引擎 call-ID 取证是 ref harness 专属;渠道产物无副作用)。
|
||||
// 无 onReady:studio 不消费 ref 取证全局;就绪信号经 SDK lifecycle game_loaded/game_end(studio 轨接)。
|
||||
});
|
||||
}
|
||||
178
game-runtime/games/_generic/generated-factory.js
Normal file
178
game-runtime/games/_generic/generated-factory.js
Normal file
@ -0,0 +1,178 @@
|
||||
/**
|
||||
* generated-factory.js — 【占位 stub】生成工厂占位(L1 出码替换本文件)
|
||||
* owner:P3/W-G1 生成主线 L0 | 消费方:entry-generic.js(import 本默认导出作 GameHostFactory)+ L1 生成 worker(出码替换靶)
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【占位说明 · 务必先读】
|
||||
* 本文件是 L0 通用装载壳的「占位工厂」——用途仅为让 entry-generic.js 立即可 esbuild + 可启动验证。
|
||||
* **L1 生成 worker 出码时整文件替换本文件**,写入真实生成游戏的 GameHostFactory(默认导出)。
|
||||
* 本占位同时是 L1 的「最小范本」:演示三条硬约束——
|
||||
* ① 纯工厂、零引擎 import(引擎能力一律经 boot.ctx.getEngine(),本占位极简未用引擎能力,但保持零 import);
|
||||
* ② 实现并默认导出一个 GameHostFactory((opts?) => GameInstance,对齐 game-host.d.ts:59);
|
||||
* ③ **P0 latch 终态**:到 N 分进入 'gameover' 并**驻留**(不自动重开),经 _forensicsView().state().phase 暴露。
|
||||
*
|
||||
* 【为何必须演示 latch 终态(P3 硬约束)】
|
||||
* studio host 经 watchGameEnd 轮询 host.state().phase(inject.ts:217),到 'gameover' 代发 game_end{completed:true}
|
||||
* (门② 闭环锚)。轮询是「间隔采样」——若终态是「瞬时 phase」(某帧置 gameover 下帧又变),轮询会漏报致闭环静默断裂
|
||||
* (见 memory runner-v2-p1-loading-contract P3 硬约束)。故终态必须**驻留**:一旦 gameover,state().phase 恒为 'gameover'。
|
||||
*
|
||||
* 【极简玩法】点击画布计分;到 TARGET_SCORE 分进入 gameover 终态驻留。无引擎能力依赖,可在 stub/real/node 任一通道跑。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/** 目标分(点击累计到此分进入终态)。占位用极小值,便于启动验证快速到达终态。 */
|
||||
const TARGET_SCORE = 3;
|
||||
|
||||
/**
|
||||
* 【装载契约 GameHostFactory】建占位游戏实例(纯工厂,零引擎 import)。
|
||||
* @param {Object} [opts] 工厂选项(host wiring + additive 扩展位;本占位不用 runtime,演示纯净工厂)。
|
||||
* @returns {import('../../src/core/game-host.d.ts').GameInstance}
|
||||
*/
|
||||
export default function createGenericPlaceholderGame(opts) {
|
||||
// 本占位不复用任何能力插件(演示「纯净工厂仅吃 boot 脊柱」路径);opts.runtime 留给真实游戏用。
|
||||
void opts;
|
||||
|
||||
/* ── 闭包态(init 内由 boot 填充;render 读绘制面)── */
|
||||
/** @type {import('../../src/core/game-host.d.ts').GameHostBootContext|null} */
|
||||
let boot = null;
|
||||
/** @type {CanvasRenderingContext2D|null} 绘制面(=boot.mainContext,render 的 g 同此面)。 */
|
||||
let mainCtx = null;
|
||||
let seed = 0;
|
||||
// 受控输入订阅句柄(destroy 时取消,幂等)。
|
||||
let subTap = null;
|
||||
|
||||
// 游戏态:分数 + 阶段('playing' → 'gameover' latch 驻留)。
|
||||
let score = 0;
|
||||
/** @type {'booting'|'playing'|'gameover'} */
|
||||
let phase = 'booting';
|
||||
let elapsedMs = 0; // 逻辑时长(update 累计;仅作旁证,不驱动终态)。
|
||||
|
||||
/**
|
||||
* 计一次点击分:仅 playing 阶段加分;到 TARGET_SCORE 进入 gameover 并驻留(latch,不可逆)。
|
||||
* 这是 P0 latch 终态的核心——一旦置 gameover,本函数后续点击不再改 phase(终态驻留)。
|
||||
*/
|
||||
function scoreOne() {
|
||||
if (phase !== 'playing') return; // 终态/未就绪不计分(gameover 后点击无效=驻留)
|
||||
score += 1;
|
||||
if (score >= TARGET_SCORE) {
|
||||
phase = 'gameover'; // latch:置入终态后恒为 gameover(watchGameEnd 轮询任一时刻读到的都是 gameover)
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* 装载契约 GameInstance 五件(init/update/render/onInput?/destroy)
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* init(boot):建游戏对象、订阅受控输入、保存绘制面。boot=装载契约脊柱 {ctx, mainContext, canvas, seed}。
|
||||
* @param {import('../../src/core/game-host.d.ts').GameHostBootContext} b
|
||||
*/
|
||||
function init(b) {
|
||||
boot = b;
|
||||
mainCtx = b.mainContext; // render 的绘制面(real=引擎 mainContext / stub=可见画布 ctx)
|
||||
seed = b.seed >>> 0;
|
||||
score = 0;
|
||||
elapsedMs = 0;
|
||||
phase = 'playing'; // init 后即可玩
|
||||
// 订阅受控输入:每个 pointerdown 计一次分(经 boot.ctx.getInput(),不触裸 DOM)。
|
||||
// ctx.getInput().on 返回订阅句柄(含 cancel),destroy 时取消(与 ref 同口径)。
|
||||
try {
|
||||
const input = boot.ctx.getInput();
|
||||
if (input && typeof input.on === 'function') {
|
||||
subTap = input.on('pointerdown', () => scoreOne());
|
||||
}
|
||||
} catch (_) {
|
||||
// 输入面缺失(极简通道)不致命:onInput 备选入口仍可计分(见下)。
|
||||
subTap = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* update(dt):逻辑步(dt 秒,引擎恒定步长)。占位仅累计逻辑时长作旁证;终态由点击驱动,update 不改 phase。
|
||||
* @param {number} dt
|
||||
*/
|
||||
function update(dt) {
|
||||
if (phase === 'booting') return;
|
||||
elapsedMs += (typeof dt === 'number' ? dt : 1 / 60) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* render(g):绘制段(g=boot 注入的绘制面)。占位画极简文本(分数/阶段),证明绘制面可用。
|
||||
* 注:占位用 fillText 简画;真实生成游戏在此画美术(引擎粒子已自渲落同一 g)。
|
||||
* @param {CanvasRenderingContext2D} g
|
||||
*/
|
||||
function render(g) {
|
||||
const ctx2d = g || mainCtx;
|
||||
if (!ctx2d) return;
|
||||
try {
|
||||
// 极简铺底(占位:纯色背景,证明绘制面可写)。坐标按逻辑像素(host 已在 real 外层补 DPR 变换)。
|
||||
ctx2d.fillStyle = phase === 'gameover' ? '#2b1a1a' : '#10243a';
|
||||
ctx2d.fillRect(0, 0, 390, 844);
|
||||
ctx2d.fillStyle = '#e8e8e8';
|
||||
ctx2d.font = '24px sans-serif';
|
||||
ctx2d.fillText(phase === 'gameover' ? 'GAME OVER' : ('SCORE ' + score), 24, 64);
|
||||
ctx2d.font = '14px sans-serif';
|
||||
ctx2d.fillText('tap to score · target ' + TARGET_SCORE, 24, 96);
|
||||
} catch (_) {
|
||||
// 绘制面只读/异常不致命(占位绘制非验收项;终态判据走 state().phase)。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* onInput(ev):可选输入入口(不便用 ctx.getInput 的极简通道备选)。pointerdown 计分。
|
||||
* 与 init 内 ctx.getInput().on 订阅二者择一生效;host 通常走 ctx.getInput(),此入口兜底极简通道。
|
||||
* @param {{type:string, x?:number, y?:number, key?:string}} ev
|
||||
*/
|
||||
function onInput(ev) {
|
||||
if (ev && ev.type === 'pointerdown') scoreOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* destroy():卸载卫生(取消输入订阅,幂等)。P3 装载多游戏复位用。
|
||||
*/
|
||||
function destroy() {
|
||||
try { if (subTap && subTap.cancel) subTap.cancel(); } catch (_) { /* 幂等 */ }
|
||||
subTap = null;
|
||||
boot = null;
|
||||
mainCtx = null;
|
||||
phase = 'booting';
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════
|
||||
* GameInstance + host wiring 扩展面
|
||||
* _forensicsView / _handleAction 给 host 取证与 host.do/state() 用(非契约成员,host wiring)。
|
||||
* host.state() 读 _forensicsView().state().phase → watchGameEnd 据此判终态(必须暴露 phase)。
|
||||
* ════════════════════════════════════════════════════════════════════ */
|
||||
return {
|
||||
// ── 装载契约 GameInstance 五件 ──
|
||||
init,
|
||||
update,
|
||||
render,
|
||||
onInput,
|
||||
destroy,
|
||||
// ── host wiring 扩展(非契约成员)──
|
||||
/**
|
||||
* @internal host 取证:返回 forensics 视图。host.state() 透传 state()(含 phase,watchGameEnd 判终态依赖)。
|
||||
* state() 返回 JSON 安全快照;phase 为 latch 终态(gameover 驻留)。
|
||||
*/
|
||||
_forensicsView: () => ({
|
||||
seed,
|
||||
state: () => ({
|
||||
phase, // 'booting' | 'playing' | 'gameover'(gameover 后驻留,watchGameEnd 轮询读到恒为 gameover)
|
||||
score,
|
||||
target: TARGET_SCORE,
|
||||
elapsedMs,
|
||||
gameoverReason: phase === 'gameover' ? 'reached_target_score' : '',
|
||||
}),
|
||||
}),
|
||||
/**
|
||||
* @internal host wiring:host.do(action) 透传到游戏逻辑公开 API。
|
||||
* 占位支持 {type:'tap'} 计分(e2e 辅助;与 UI 点击同走 scoreOne 逻辑公开 API)。
|
||||
*/
|
||||
_handleAction: (action) => {
|
||||
if (action && action.type === 'tap') scoreOne();
|
||||
},
|
||||
};
|
||||
}
|
||||
126
game-runtime/games/_generic/test/generic-shell.test.mjs
Normal file
126
game-runtime/games/_generic/test/generic-shell.test.mjs
Normal file
@ -0,0 +1,126 @@
|
||||
/**
|
||||
* generic-shell.test.mjs — P3 generic 通用装载壳 node 侧守门(import-safe + 占位工厂 latch 终态)
|
||||
* owner:P3/W-G1 生成主线 L0 | 运行:node --test(零依赖,本机 6c6g 可跑;浏览器真渲门留 mini-desktop)
|
||||
*
|
||||
* 【本测覆盖三件(本机 node 可验的最强证据;像素级真渲证据留集成段 mini-desktop)】
|
||||
* ① import-safe:entry-generic.js / generic-host-config.js / generated-factory.js 在 node import 即安全
|
||||
* (顶层零 window/AudioContext 调用,引擎经动态 import 延后求值,Option C)。导出齐备。
|
||||
* ② generic-host-config:buildGenericHostConfig 返回标准 7 件插件集 + 偏序 + 390×844 视口 + wiring,且 node 侧可装配。
|
||||
* ③ 占位工厂 latch 终态:经「真实受控面 bundle(createHostDevContext,node 侧可建,不需 window/引擎)」驱动占位工厂
|
||||
* init→getInput().on→pointerdown 计分→到 TARGET 进入 gameover 并**驻留**(再点不复活),
|
||||
* 验 _forensicsView().state().phase 终态 latch(= host.state() 透传给 watchGameEnd 的判据,inject.ts:217)。
|
||||
*
|
||||
* 【为何不直接跑 bootGameHost】bootGameHost 真启动需 window/document/RAF/引擎(浏览器面),node 侧不可跑——
|
||||
* 故 node 侧用 setupCommon 同款手法(建 bundle→factory(opts)→game.init→inputBridge._emit 驱动)验「占位工厂经受控面通道」
|
||||
* 到 latch 终态;bootGameHost 整壳真启动(含引擎掌帧)的真渲门留 mini-desktop(见 staging-ops.md)。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
/* ── ① import-safe + 导出齐备 ───────────────────────────────────────────── */
|
||||
const MODULES = [
|
||||
['../../../src/host/generic-host-config.js', ['buildGenericHostConfig', 'GENERIC_VIEWPORT']],
|
||||
['../generated-factory.js', ['default']],
|
||||
['../entry-generic.js', ['GameHostFactory', 'bootGameHost', 'default']],
|
||||
];
|
||||
|
||||
for (const [path, expectedExports] of MODULES) {
|
||||
test(`import-safe:${path} 可在 node 导入且导出齐备(顶层不触 window/引擎)`, async () => {
|
||||
const mod = await import(path);
|
||||
for (const name of expectedExports) {
|
||||
assert.ok(name in mod, `${path} 缺导出 ${name}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ── ② generic-host-config:标准插件集 + 偏序 + 视口 + wiring ──────────────── */
|
||||
test('generic-host-config:返回标准 7 件插件集 + 六锚偏序 + 390×844 视口 + wiring(node 侧可装配)', async () => {
|
||||
const { buildGenericHostConfig, GENERIC_VIEWPORT } = await import('../../../src/host/generic-host-config.js');
|
||||
const cfg = buildGenericHostConfig({ seed: 42 });
|
||||
// 标准 7 件插件实例齐备。
|
||||
for (const k of ['collision', 'physics', 'gamefeel', 'juice', 'palettePost', 'audioMusic', 'save']) {
|
||||
assert.ok(cfg.plugins[k], `缺标准插件 ${k}`);
|
||||
}
|
||||
// 注册偏序 = 7 件(保六锚 canonical 顺序)。
|
||||
assert.equal(cfg.registerOrder.length, 7, '注册偏序应为 7 件');
|
||||
// 视口 = P3 钉死 390×844。
|
||||
assert.deepEqual(cfg.viewport, { w: 390, h: 844 });
|
||||
assert.deepEqual(GENERIC_VIEWPORT, { w: 390, h: 844 });
|
||||
// wiring:buildFactoryOpts 经 runtime 暴露 plugins/bundle/getRealAudioCtx。
|
||||
const fo = cfg.buildFactoryOpts(null, { bundle: { tag: 'b' }, plugins: cfg.plugins, getRealAudioCtx: () => null });
|
||||
assert.ok(fo.runtime && fo.runtime.plugins && fo.runtime.bundle, 'buildFactoryOpts 应返回 runtime.{plugins,bundle}');
|
||||
});
|
||||
|
||||
/* ── ③ 占位工厂 latch 终态(经真实受控面 bundle 驱动,node 侧可跑)──────────── */
|
||||
test('占位工厂:经受控面通道 init→tap 计分→到 TARGET 进入 gameover 并驻留(P0 latch 终态)', async () => {
|
||||
const createGame = (await import('../generated-factory.js')).default;
|
||||
const { createHostDevContext } = await import('../../../src/core/plugin.js');
|
||||
|
||||
// 建真实受控面 bundle(node 侧可建:不需 window/引擎;context2d 给最小桩,绘制非本测验收项)。
|
||||
// 这是 setupCommon 同款手法(boot-game-host.js:165):bundle.context 即 boot.ctx,inputBridge._emit 即 host.tap 的底层。
|
||||
const fillRectCalls = [];
|
||||
const ctx2dStub = {
|
||||
fillStyle: '',
|
||||
font: '',
|
||||
fillRect: (...a) => fillRectCalls.push(a),
|
||||
fillText: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
setTransform: () => {},
|
||||
};
|
||||
let nowMs = 0;
|
||||
const bundle = createHostDevContext({
|
||||
context2d: ctx2dStub,
|
||||
seed: 42,
|
||||
audioFactory: () => null, // 受控音频:node 侧无 AudioContext,返 null(占位工厂不依赖)
|
||||
clock: () => nowMs,
|
||||
// 无 engineFactory:getEngine() 返 null(占位工厂零引擎能力依赖,符合纯净工厂路径)
|
||||
});
|
||||
|
||||
// factory(opts) → game.init(boot)(boot=装载契约脊柱;ctx=受控面,触发占位工厂的 getInput().on 订阅)。
|
||||
const game = createGame(undefined); // 占位工厂演示纯净工厂:不传 runtime
|
||||
await game.init({
|
||||
ctx: bundle.context,
|
||||
mainContext: ctx2dStub,
|
||||
canvas: null,
|
||||
seed: 42,
|
||||
});
|
||||
|
||||
// 初始:init 后 phase=playing,score=0。
|
||||
let st = game._forensicsView().state();
|
||||
assert.equal(st.phase, 'playing', 'init 后应为 playing');
|
||||
assert.equal(st.score, 0);
|
||||
const TARGET = st.target;
|
||||
assert.ok(TARGET >= 1, 'target 应 ≥1');
|
||||
|
||||
// 经受控输入桥 _emit('pointerdown')(= host.tap 的底层路径)逐次计分,到 TARGET 进终态。
|
||||
for (let i = 0; i < TARGET; i++) {
|
||||
nowMs += 16;
|
||||
bundle.inputBridge._emit('pointerdown', { x: 10, y: 10 });
|
||||
}
|
||||
st = game._forensicsView().state();
|
||||
assert.equal(st.phase, 'gameover', '到 TARGET 分应进入 gameover 终态');
|
||||
assert.equal(st.score, TARGET, 'gameover 时分数应=TARGET');
|
||||
assert.equal(st.gameoverReason, 'reached_target_score');
|
||||
|
||||
// 【latch 驻留】终态后再点多次:phase 恒为 gameover,分数不再涨(watchGameEnd 任一时刻轮询读到的都是 gameover)。
|
||||
for (let i = 0; i < 5; i++) {
|
||||
nowMs += 16;
|
||||
bundle.inputBridge._emit('pointerdown', { x: 10, y: 10 });
|
||||
}
|
||||
st = game._forensicsView().state();
|
||||
assert.equal(st.phase, 'gameover', 'latch:终态后再点应恒为 gameover(不复活)');
|
||||
assert.equal(st.score, TARGET, 'latch:终态后分数不再变');
|
||||
|
||||
// update/render 可调不抛(绘制面桩可写);render 走到 fillRect(绘制面真被写)。
|
||||
game.update(1 / 60);
|
||||
game.render(ctx2dStub);
|
||||
assert.ok(fillRectCalls.length > 0, 'render 应写绘制面(fillRect 被调)');
|
||||
|
||||
// destroy 幂等可调。
|
||||
game.destroy();
|
||||
game.destroy();
|
||||
});
|
||||
106
game-runtime/src/host/generic-host-config.js
Normal file
106
game-runtime/src/host/generic-host-config.js
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* src/host/generic-host-config.js — 通用宿主装配配置(P3 generic 壳·零wanglanmei专属·标准能力插件集)
|
||||
* owner:P3/W-G1 生成主线 L0 | 消费方:games/_generic/entry-generic.js(generic bundle 入口壳)
|
||||
*
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
* 【本文件是什么 · 为何抽出】
|
||||
* 通用宿主 bootGameHost(opts) 需要四组装配入参:标准能力插件集 / 注册偏序 / 视口 / 工厂 wiring。
|
||||
* ref-host-config.js 把「wanglanmei 怎么配 bootGameHost」固化了,但其中夹了 wanglanmei 专属游戏代码
|
||||
* (import game-shop 工厂、render.js 视口常量、save namespace='wanglanmei-ref')。
|
||||
* P3 生成主线下,20 个 prompt 各自产出一份 GameHostFactory,若各自重写「7 件插件实例化 + 偏序 + wiring」
|
||||
* 会造 20 份重复装配。故本文件把「装配标准能力插件集」泛化为零专属的单一来源,所有 generic bundle 共用:
|
||||
* · plugins = 能力库标准集(collision/physics/gamefeel/juice/palettePost/save/audioMusic 七件实例);
|
||||
* 这是「插件库能力面」——生成游戏经 boot.ctx.getEngine() / 已注册插件用,不必自己实例化。
|
||||
* · registerOrder = 同 ref 偏序(保六锚 canonical 顺序,启动链取证依赖此偏序)。
|
||||
* · viewport = P3 钉死 390×844(GamePackage 不加 viewport 字段,全平台统一逻辑视口)。
|
||||
* · buildFactoryOpts = 经 factoryOpts.runtime 把「标准插件集 + bundle + 真实 AudioContext 取法」暴露给游戏,
|
||||
* 与 ref 同形(生成游戏想复用 collision/juice 等能力时经 opts.runtime.plugins 取,一职一路)。
|
||||
*
|
||||
* 【Q4 铁律 · 零引擎 import】本文件仅装配插件实例 + 视口 + 工厂 wiring;引擎名字空间由 bootGameHost 经
|
||||
* opts.engine 注入(real 通道由 entry 包装层动态 import 后传入)。本文件 node import-safe(不触 window/AudioContext)。
|
||||
*
|
||||
* 【与 ref-host-config.js 的区别】
|
||||
* ref-host-config = wanglanmei 专属装配(含 game-shop 工厂 + render 视口 + wanglanmei save namespace);
|
||||
* 本文件 = 零专属通用装配(不 import 任何游戏工厂——工厂由 entry-generic.js 经 import 生成工厂传入;
|
||||
* save namespace 用通用 'zaomeng-generic';视口硬编码 390×844)。两者正交,互不引用。
|
||||
* ════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// 能力库 7 件标准业务插件工厂(host 拥有,经工厂 opts.runtime 注入给游戏,一职一路)。
|
||||
// 与 ref-host-config 照搬同一组 import(同一份插件库实现),保 generic 与 ref 能力面一致。
|
||||
import { createCollisionPlugin } from '../plugins/collision/impl.js';
|
||||
import { createPhysicsLitePlugin } from '../plugins/physics-lite/impl.js';
|
||||
import { createGamefeelPlugin } from '../plugins/gamefeel/impl.js';
|
||||
import { createParticlesJuicePlugin } from '../plugins/particles-juice/impl.js';
|
||||
import { createPalettePostPlugin } from '../plugins/palette-post/impl.js';
|
||||
import { createAudioMusicPlugin } from '../plugins/audio-music/impl.js';
|
||||
import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../plugins/save-progress/impl.js';
|
||||
|
||||
/**
|
||||
* P3 generic 壳逻辑视口:390×844(P3 钉死)。
|
||||
* 全平台统一逻辑视口;GamePackage 不加 viewport 字段,故此处硬编码而非读包。
|
||||
*/
|
||||
export const GENERIC_VIEWPORT = { w: 390, h: 844 };
|
||||
|
||||
/**
|
||||
* 装配通用宿主调 bootGameHost 所需的标准入参(标准插件集 / 注册偏序 / 视口 / 工厂 wiring)。
|
||||
* entry-generic.js 补 canvas/statusEl/engine/seed/mode/engineMode/factory 后传 bootGameHost。
|
||||
* 注:本函数零wanglanmei专属——不含任何游戏工厂;工厂由调用方(entry-generic)另行 import 生成工厂后传入。
|
||||
*
|
||||
* @param {{seed:number}} args 装配入参(仅需 seed,用于粒子插件确定性)。
|
||||
* @returns {{plugins:object, registerOrder:Array<object>, viewport:{w:number,h:number}, buildFactoryOpts:Function}}
|
||||
* - plugins:标准能力插件实例集 { collision, physics, gamefeel, juice, palettePost, audioMusic, save };
|
||||
* - registerOrder:注册偏序(保六锚 canonical 顺序);
|
||||
* - viewport:{w:390,h:844}(P3 钉死);
|
||||
* - buildFactoryOpts:构造工厂 opts 回调(经 runtime 暴露标准插件集 + bundle + 真实 AudioContext 取法)。
|
||||
*/
|
||||
export function buildGenericHostConfig(args) {
|
||||
// seed 缺省 0(确定性兜底);规整为无符号 32 位(与 host/ref 同口径)。
|
||||
const seed = ((args && args.seed) != null ? args.seed : 0) >>> 0;
|
||||
|
||||
/* ── 能力库 7 件标准业务插件实例(host 拥有;游戏经工厂 opts.runtime.plugins 复用,一职一路)──
|
||||
* 参数与 ref-host-config 同口径(保 generic 与 ref 能力一致;这些参数是能力库默认面,非游戏玩法)。 */
|
||||
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 });
|
||||
// 存档后端:优先浏览器 localStorage;不可用环境(node/隐私模式)回退内存 adapter,错误路径留痕。
|
||||
let saveAdapter;
|
||||
try {
|
||||
saveAdapter = createLocalStorageAdapter();
|
||||
} catch (e) {
|
||||
// node 侧 import-safe 不会走到此(buildGenericHostConfig 仅在 bootGameHost 运行期调);浏览器隐私模式可能触发。
|
||||
console.warn('[generic] localStorage 不可用,存档退内存 adapter:' + (e && e.message));
|
||||
saveAdapter = createMemoryAdapter();
|
||||
}
|
||||
// 存档命名空间用通用 'zaomeng-generic'(非wanglanmei专属;P3 多游戏各自 namespace 由生成域决定,此处给通用默认)。
|
||||
const save = createSaveProgressPlugin({ namespace: 'zaomeng-generic', adapter: saveAdapter });
|
||||
|
||||
const plugins = { collision, physics, gamefeel, juice, palettePost, audioMusic, save };
|
||||
// 注册偏序(保六锚偏序:collision→physics→gamefeel→juice→palettePost→save→audioMusic,与 ref 同序)。
|
||||
const registerOrder = [collision, physics, gamefeel, juice, palettePost, save, audioMusic];
|
||||
|
||||
return {
|
||||
plugins,
|
||||
registerOrder,
|
||||
viewport: GENERIC_VIEWPORT,
|
||||
/**
|
||||
* 构造工厂 opts(host wiring):经 runtime 把标准插件集 + bundle + 真实 AudioContext 取法暴露给游戏。
|
||||
* 与 ref-host-config.buildFactoryOpts 同形——生成游戏若复用能力插件(collision/juice/save 等),
|
||||
* 经 opts.runtime.plugins 取(一职一路);纯净游戏不取 runtime 也可(仅吃 boot 脊柱)。
|
||||
* @param {import('../core/api.d.ts').PluginContext} _bootCtx 本帧受控面(本回调不直接用,由游戏经 boot 拿)。
|
||||
* @param {{bundle:object, plugins:object, getRealAudioCtx:Function}} hostInternals host 内部句柄。
|
||||
*/
|
||||
buildFactoryOpts: (_bootCtx, hostInternals) => ({
|
||||
runtime: {
|
||||
plugins: hostInternals.plugins,
|
||||
bundle: hostInternals.bundle,
|
||||
getRealAudioCtx: hostInternals.getRealAudioCtx,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user