feat(s4): add opt-in webgl render surface
- 新增 packages/web-runtime 渲染面抽象 RenderSurface + Canvas2D/WebGL 两实现 + createRenderSurface 工厂。 - WebGL 仅作内部可替换渲染器,挂在 RuntimeSdkContract.Canvas.submitRenderCommands 接缝后; 不改 RuntimeSdkContract / GameLogicModule,逻辑模块拿不到 GL/canvas/2d 句柄。 - Canvas2D 仍为默认;?renderer=webgl 显式 opt-in(page 改 async server 组件 + 新增 PreviewRuntimeClient 客户端包装),WebGL 不可用/初始化失败自动回退 Canvas2D 并产出诊断。 - 新增/扩展单测:WebGL clear/rect/text 非空、可交互、回退+诊断;createRenderSurface 选择; WebPlatformAdapter renderer 选择/回退;preview opt-in 决策;controller stop()/失败 catch 接线 dispose。 - docs/memorys:WebPlayable 里程碑审计 + MVP 剩余清单与路线。 S6/S7/S8 仍 not_started;微信/抖音 DevTools 导入认证仍 No-Go,未伪造任何 passed evidence。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
98e2aad155
commit
7c7879cfe8
24
apps/web/src/app/preview/[versionId]/page.test.tsx
Normal file
24
apps/web/src/app/preview/[versionId]/page.test.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readRendererPreference } from "./page";
|
||||
|
||||
// 预览页 opt-in 渲染器决策单测:把 ?renderer 查询参数收敛为 "webgl" 或 undefined(默认 canvas2d)。
|
||||
// 该决策是 WebGL opt-in 的入口,必须默认安全(仅显式 webgl 才切换)。
|
||||
describe("preview page renderer opt-in", () => {
|
||||
it("仅当 ?renderer=webgl 时返回 webgl", () => {
|
||||
expect(readRendererPreference("webgl")).toBe("webgl");
|
||||
});
|
||||
|
||||
it("查询重复(数组)时取第一个值", () => {
|
||||
expect(readRendererPreference(["webgl", "canvas2d"])).toBe("webgl");
|
||||
});
|
||||
|
||||
it("任何非 webgl 取值都回到默认(undefined)", () => {
|
||||
expect(readRendererPreference("canvas2d")).toBeUndefined();
|
||||
expect(readRendererPreference("webgl2")).toBeUndefined();
|
||||
expect(readRendererPreference(["canvas2d", "webgl"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("缺省(无 query)回到默认(undefined)", () => {
|
||||
expect(readRendererPreference(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -1,65 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { createElement } from "react";
|
||||
import { WebGameRuntime, getPreviewPackageSource } from "../../../components/runtime/WebGameRuntime";
|
||||
import { PreviewRuntimeClient } from "../../../components/runtime/PreviewRuntimeClient";
|
||||
|
||||
// Next.js 16:page 的 params/searchParams 都是 Promise。本页是 server 组件(async),
|
||||
// 在服务端 await 解包,读取 opt-in 渲染器(?renderer=webgl),再把纯值 props 交给 client 组件。
|
||||
type PreviewPageProps = {
|
||||
readonly params: {
|
||||
readonly params: Promise<{
|
||||
readonly versionId: string;
|
||||
};
|
||||
}>;
|
||||
readonly searchParams: Promise<{
|
||||
readonly renderer?: string | readonly string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
diagnostic: {
|
||||
background: "#fff7ed",
|
||||
border: "1px solid #fed7aa",
|
||||
borderRadius: 6,
|
||||
color: "#7c2d12",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: 0,
|
||||
maxWidth: 760,
|
||||
padding: 12
|
||||
},
|
||||
main: {
|
||||
color: "#18231f",
|
||||
display: "grid",
|
||||
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
|
||||
gap: 18,
|
||||
margin: "0 auto",
|
||||
maxWidth: 980,
|
||||
padding: 24
|
||||
},
|
||||
meta: {
|
||||
color: "#52655c",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: 0
|
||||
},
|
||||
title: {
|
||||
color: "#111c17",
|
||||
fontSize: 28,
|
||||
lineHeight: 1.2,
|
||||
margin: 0
|
||||
}
|
||||
} satisfies Record<string, CSSProperties>;
|
||||
export default async function PreviewRuntimePage({ params, searchParams }: PreviewPageProps) {
|
||||
const { versionId } = await params;
|
||||
const { renderer } = await searchParams;
|
||||
// opt-in:仅当 ?renderer=webgl 时请求 WebGL,否则维持默认 canvas2d。
|
||||
const rendererPreference = readRendererPreference(renderer);
|
||||
|
||||
export default function PreviewRuntimePage({ params }: PreviewPageProps) {
|
||||
const { versionId } = params;
|
||||
const previewPackage = getPreviewPackageSource(versionId);
|
||||
|
||||
return createElement(
|
||||
"main",
|
||||
{ style: styles.main },
|
||||
createElement("h1", { style: styles.title }, "Web runtime preview"),
|
||||
createElement("p", { style: styles.meta }, `Version ${versionId}`),
|
||||
previewPackage.kind === "ready"
|
||||
? createElement(WebGameRuntime, {
|
||||
gamePackage: previewPackage.gamePackage,
|
||||
loadLogicModule: previewPackage.logicLoader,
|
||||
packageReader: previewPackage.packageReader
|
||||
})
|
||||
: createElement("p", { style: styles.diagnostic }, previewPackage.message)
|
||||
);
|
||||
return createElement(PreviewRuntimeClient, {
|
||||
versionId,
|
||||
...(rendererPreference === undefined ? {} : { rendererPreference })
|
||||
});
|
||||
}
|
||||
|
||||
// readRendererPreference:把查询参数收敛为 "webgl" 或 undefined(缺省即 canvas2d)。
|
||||
// 数组形式(重复 query)取第一个值;任何非 "webgl" 取值都视为默认。导出以便单测覆盖 opt-in 决策。
|
||||
export function readRendererPreference(renderer: string | readonly string[] | undefined): "webgl" | undefined {
|
||||
const value = Array.isArray(renderer) ? renderer[0] : renderer;
|
||||
return value === "webgl" ? "webgl" : undefined;
|
||||
}
|
||||
|
||||
69
apps/web/src/components/runtime/PreviewRuntimeClient.tsx
Normal file
69
apps/web/src/components/runtime/PreviewRuntimeClient.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { createElement } from "react";
|
||||
import { WebGameRuntime, getPreviewPackageSource } from "./WebGameRuntime";
|
||||
|
||||
// PreviewRuntimeClient:预览页的 client 组件。
|
||||
// 之所以独立成 client 组件:getPreviewPackageSource 会创建 logicLoader/packageReader 等函数,
|
||||
// 这些函数只能在 client 侧创建、不能跨 server/client 边界传递;server 组件只把 versionId + 可选 rendererPreference(纯值)传进来。
|
||||
type PreviewRuntimeClientProps = {
|
||||
readonly versionId: string;
|
||||
// opt-in 渲染器偏好:缺省 undefined => 默认 canvas2d。
|
||||
readonly rendererPreference?: "webgl";
|
||||
};
|
||||
|
||||
const styles = {
|
||||
diagnostic: {
|
||||
background: "#fff7ed",
|
||||
border: "1px solid #fed7aa",
|
||||
borderRadius: 6,
|
||||
color: "#7c2d12",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: 0,
|
||||
maxWidth: 760,
|
||||
padding: 12
|
||||
},
|
||||
main: {
|
||||
color: "#18231f",
|
||||
display: "grid",
|
||||
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
|
||||
gap: 18,
|
||||
margin: "0 auto",
|
||||
maxWidth: 980,
|
||||
padding: 24
|
||||
},
|
||||
meta: {
|
||||
color: "#52655c",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
margin: 0
|
||||
},
|
||||
title: {
|
||||
color: "#111c17",
|
||||
fontSize: 28,
|
||||
lineHeight: 1.2,
|
||||
margin: 0
|
||||
}
|
||||
} satisfies Record<string, CSSProperties>;
|
||||
|
||||
export function PreviewRuntimeClient({ versionId, rendererPreference }: PreviewRuntimeClientProps) {
|
||||
const previewPackage = getPreviewPackageSource(versionId);
|
||||
|
||||
return createElement(
|
||||
"main",
|
||||
{ style: styles.main },
|
||||
createElement("h1", { style: styles.title }, "Web runtime preview"),
|
||||
createElement("p", { style: styles.meta }, `Version ${versionId}`),
|
||||
previewPackage.kind === "ready"
|
||||
? createElement(WebGameRuntime, {
|
||||
gamePackage: previewPackage.gamePackage,
|
||||
loadLogicModule: previewPackage.logicLoader,
|
||||
packageReader: previewPackage.packageReader,
|
||||
// 仅在显式 opt-in 时透传,缺省保持默认 canvas2d。
|
||||
...(rendererPreference === undefined ? {} : { rendererPreference })
|
||||
})
|
||||
: createElement("p", { style: styles.diagnostic }, previewPackage.message)
|
||||
);
|
||||
}
|
||||
@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TelemetryEventDto } from "../../../../../packages/shared-contracts/src/runtime-sdk-contract";
|
||||
import type { WebRuntimeLogicModule } from "../../../../../packages/web-runtime/src/WebRuntimeHost";
|
||||
import PreviewRuntimePage from "../../app/preview/[versionId]/page";
|
||||
import { PreviewRuntimeClient } from "./PreviewRuntimeClient";
|
||||
import {
|
||||
WebGameRuntime,
|
||||
createPreviewRuntimeController,
|
||||
@ -148,21 +148,29 @@ describe("S4 WebGameRuntime preview UI", () => {
|
||||
expect(shouldApplyRuntimeSnapshot({ activeRunId: 2, candidateRunId: 2, mounted: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("preview page uses versionId to render fixture runtime or diagnostic output", async () => {
|
||||
it("preview client uses versionId to render fixture runtime or diagnostic output", async () => {
|
||||
const readyHtml = renderToStaticMarkup(
|
||||
createElement(PreviewRuntimePage, {
|
||||
params: { versionId: "simulation-fixture-v1" }
|
||||
createElement(PreviewRuntimeClient, {
|
||||
versionId: "simulation-fixture-v1"
|
||||
})
|
||||
);
|
||||
const missingHtml = renderToStaticMarkup(
|
||||
createElement(PreviewRuntimePage, {
|
||||
params: { versionId: "missing-version" }
|
||||
createElement(PreviewRuntimeClient, {
|
||||
versionId: "missing-version"
|
||||
})
|
||||
);
|
||||
// opt-in WebGL 偏好仍渲染 runtime 容器(默认 loading 态),不破坏既有渲染。
|
||||
const webglHtml = renderToStaticMarkup(
|
||||
createElement(PreviewRuntimeClient, {
|
||||
versionId: "simulation-fixture-v1",
|
||||
rendererPreference: "webgl"
|
||||
})
|
||||
);
|
||||
|
||||
expect(readyHtml).toContain("Version simulation-fixture-v1");
|
||||
expect(readyHtml).toContain("data-runtime-status=\"loading\"");
|
||||
expect(missingHtml).toContain("Preview package missing-version is not available from the current fixture loader.");
|
||||
expect(webglHtml).toContain("data-runtime-status=\"loading\"");
|
||||
});
|
||||
|
||||
it("preview package registry returns one shared source for page and component", () => {
|
||||
|
||||
@ -16,6 +16,9 @@ type WebWindowTarget = WebPlatformAdapterOptions["windowTarget"];
|
||||
type WebStorage = WebPlatformAdapterOptions["storage"];
|
||||
type WebFetcher = WebPlatformAdapterOptions["fetcher"];
|
||||
|
||||
// 渲染器偏好(opt-in):缺省 undefined => 适配器内部按 "canvas2d" 处理,默认行为不变。
|
||||
type RendererPreference = WebPlatformAdapterOptions["rendererPreference"];
|
||||
|
||||
export type PreviewRuntimeStatus = "loading" | "running" | "failed";
|
||||
|
||||
export type PreviewRuntimeSnapshot = {
|
||||
@ -30,6 +33,8 @@ export type PreviewRuntimeControllerInput = {
|
||||
readonly gamePackage: GamePackage;
|
||||
readonly loadLogicModule?: ((packageLogicPath: string) => Promise<WebRuntimeLogicModule>) | undefined;
|
||||
readonly reader?: WebRuntimeHostPackageReader | undefined;
|
||||
// 渲染器偏好(opt-in):缺省时维持默认 canvas2d。
|
||||
readonly rendererPreference?: RendererPreference;
|
||||
readonly scheduler?: WebRuntimeHostScheduler;
|
||||
readonly storage: WebStorage;
|
||||
readonly telemetrySender?: ((events: readonly TelemetryEventDto[]) => Promise<void>) | undefined;
|
||||
@ -49,6 +54,8 @@ type WebGameRuntimeProps = {
|
||||
readonly initialSnapshot?: PreviewRuntimeSnapshot;
|
||||
readonly loadLogicModule?: ((packageLogicPath: string) => Promise<WebRuntimeLogicModule>) | undefined;
|
||||
readonly packageReader?: WebRuntimeHostPackageReader | undefined;
|
||||
// 渲染器偏好(opt-in):缺省时默认 canvas2d,由预览页根据查询参数传入。
|
||||
readonly rendererPreference?: RendererPreference;
|
||||
};
|
||||
|
||||
const PREVIEW_CONFIG_TEXT = `{
|
||||
@ -198,7 +205,7 @@ const styles = {
|
||||
}
|
||||
} satisfies Record<string, CSSProperties>;
|
||||
|
||||
export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule, packageReader }: WebGameRuntimeProps) {
|
||||
export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule, packageReader, rendererPreference }: WebGameRuntimeProps) {
|
||||
const [snapshot, setSnapshot] = useState<PreviewRuntimeSnapshot>(initialSnapshot ?? createSnapshot("loading"));
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const controllerRef = useRef<PreviewRuntimeController | null>(null);
|
||||
@ -207,9 +214,10 @@ export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule,
|
||||
() => ({
|
||||
gamePackage,
|
||||
loadLogicModule,
|
||||
reader: packageReader
|
||||
reader: packageReader,
|
||||
rendererPreference
|
||||
}),
|
||||
[gamePackage, loadLogicModule, packageReader]
|
||||
[gamePackage, loadLogicModule, packageReader, rendererPreference]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -225,6 +233,7 @@ export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule,
|
||||
gamePackage: packageInput.gamePackage,
|
||||
loadLogicModule: packageInput.loadLogicModule,
|
||||
reader: packageInput.reader,
|
||||
rendererPreference: packageInput.rendererPreference,
|
||||
storage: window.localStorage,
|
||||
windowTarget: window
|
||||
});
|
||||
@ -256,6 +265,7 @@ export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule,
|
||||
gamePackage: packageInput.gamePackage,
|
||||
loadLogicModule: packageInput.loadLogicModule,
|
||||
reader: packageInput.reader,
|
||||
rendererPreference: packageInput.rendererPreference,
|
||||
storage: window.localStorage,
|
||||
windowTarget: window
|
||||
});
|
||||
@ -290,6 +300,8 @@ export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule,
|
||||
|
||||
export function createPreviewRuntimeController(input: PreviewRuntimeControllerInput): PreviewRuntimeController {
|
||||
let host: WebRuntimeHost | null = null;
|
||||
// 保留本次运行创建的适配器实例引用,stop() 时显式 dispose 释放渲染面资源(WebGL 程序/缓冲/纹理)。
|
||||
let adapter: WebPlatformAdapter | null = null;
|
||||
let telemetry: TelemetryBuffer | null = null;
|
||||
let currentStatus: PreviewRuntimeStatus = "loading";
|
||||
const diagnostics: string[] = [];
|
||||
@ -320,14 +332,19 @@ export function createPreviewRuntimeController(input: PreviewRuntimeControllerIn
|
||||
if (input.reader === undefined) throw new Error("WEB_PREVIEW_PACKAGE_READER_MISSING");
|
||||
if (input.loadLogicModule === undefined) throw new Error("WEB_PREVIEW_LOGIC_LOADER_MISSING");
|
||||
const hostOptions = {
|
||||
createAdapter: () =>
|
||||
new WebPlatformAdapter({
|
||||
createAdapter: () => {
|
||||
// 先实例化适配器并保留引用(供 stop() dispose),再返回其 SDK 供宿主使用。
|
||||
adapter = new WebPlatformAdapter({
|
||||
canvasElement: input.canvasElement,
|
||||
fetcher: input.fetcher,
|
||||
storage: input.storage,
|
||||
telemetry: requireTelemetry(),
|
||||
windowTarget: input.windowTarget
|
||||
}).createSdk(),
|
||||
windowTarget: input.windowTarget,
|
||||
// opt-in 渲染器偏好;缺省时适配器内部按 canvas2d 处理,默认不变。
|
||||
...(input.rendererPreference === undefined ? {} : { rendererPreference: input.rendererPreference })
|
||||
});
|
||||
return adapter.createSdk();
|
||||
},
|
||||
expectedManifestChecksum: requireWebManifestChecksum(input.gamePackage),
|
||||
fixedTickMs: 16,
|
||||
gameVersionId: input.gamePackage.gameVersionId,
|
||||
@ -342,6 +359,9 @@ export function createPreviewRuntimeController(input: PreviewRuntimeControllerIn
|
||||
} catch (error) {
|
||||
host?.stop();
|
||||
host = null;
|
||||
// 加载失败也要释放已创建的适配器渲染面资源,避免失败态滞留到下次 load/卸载前的短暂泄漏。
|
||||
adapter?.dispose();
|
||||
adapter = null;
|
||||
diagnostics.push(diagnosticFrom(error));
|
||||
await flushTelemetry();
|
||||
currentStatus = "failed";
|
||||
@ -369,6 +389,9 @@ export function createPreviewRuntimeController(input: PreviewRuntimeControllerIn
|
||||
function stop(): void {
|
||||
host?.stop();
|
||||
host = null;
|
||||
// 释放本次运行适配器持有的渲染面资源(WebGL 程序/缓冲/纹理);Canvas2D 渲染面 dispose 为 no-op。
|
||||
adapter?.dispose();
|
||||
adapter = null;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -78,6 +78,73 @@ describe("S4 WebPlatformAdapter", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults to canvas2d renderer and reports its kind without diagnostics", () => {
|
||||
const env = createDomEnvironment();
|
||||
const adapter = new WebPlatformAdapter({
|
||||
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
|
||||
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
|
||||
storage: env.storage,
|
||||
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
|
||||
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-default-kind", sender: async () => undefined })
|
||||
});
|
||||
|
||||
// 默认偏好必须仍是 canvas2d,且不产生回退诊断。
|
||||
expect(adapter.getRendererKind()).toBe("canvas2d");
|
||||
expect(adapter.getRendererDiagnostics()).toEqual([]);
|
||||
});
|
||||
|
||||
it("opts into webgl when rendererPreference is webgl and a webgl context exists", () => {
|
||||
const env = createDomEnvironment({ withWebgl: true });
|
||||
const adapter = new WebPlatformAdapter({
|
||||
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
|
||||
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
|
||||
storage: env.storage,
|
||||
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
|
||||
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-webgl", sender: async () => undefined }),
|
||||
rendererPreference: "webgl"
|
||||
});
|
||||
const sdk = adapter.createSdk();
|
||||
const handle = sdk.Canvas.createCanvas(320, 180);
|
||||
sdk.Canvas.submitRenderCommands(handle, [{ type: "rect", x: 1, y: 2, width: 3, height: 4, fill: "#ff0000" }]);
|
||||
|
||||
expect(adapter.getRendererKind()).toBe("webgl");
|
||||
// WebGL 实际产出绘制调用(非空输出),证明渲染面在工作。
|
||||
expect(env.glContext.calls.some((call) => call[0] === "drawArrays")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to canvas2d with a diagnostic when webgl is requested but unavailable", () => {
|
||||
const env = createDomEnvironment();
|
||||
const adapter = new WebPlatformAdapter({
|
||||
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
|
||||
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
|
||||
storage: env.storage,
|
||||
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
|
||||
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-webgl-fallback", sender: async () => undefined }),
|
||||
rendererPreference: "webgl"
|
||||
});
|
||||
|
||||
// webgl 上下文为 null:回退 canvas2d 并带回退诊断码。
|
||||
expect(adapter.getRendererKind()).toBe("canvas2d");
|
||||
expect(adapter.getRendererDiagnostics()).toContain("WEBGL_UNAVAILABLE_FALLBACK_CANVAS2D");
|
||||
});
|
||||
|
||||
it("falls back to canvas2d with WEBGL_INIT_FAILED diagnostic when webgl context exists but shader init fails", () => {
|
||||
// 提供一个能返回 GL 上下文、但着色器编译会失败的 canvas:WebGlRenderSurface 构造抛错。
|
||||
const env = createDomEnvironment({ withWebgl: true, glFailCompile: true });
|
||||
const adapter = new WebPlatformAdapter({
|
||||
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
|
||||
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
|
||||
storage: env.storage,
|
||||
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
|
||||
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-webgl-init-failed", sender: async () => undefined }),
|
||||
rendererPreference: "webgl"
|
||||
});
|
||||
|
||||
// WebGL 初始化失败必须安全回退到 canvas2d,并记录 WEBGL_INIT_FAILED_FALLBACK_CANVAS2D 诊断。
|
||||
expect(adapter.getRendererKind()).toBe("canvas2d");
|
||||
expect(adapter.getRendererDiagnostics()).toContain("WEBGL_INIT_FAILED_FALLBACK_CANVAS2D");
|
||||
});
|
||||
|
||||
it("uses endpoint ids for network and retries failed requests", async () => {
|
||||
const env = createDomEnvironment();
|
||||
const requested: string[] = [];
|
||||
@ -208,7 +275,7 @@ describe("S4 WebPlatformAdapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createDomEnvironment() {
|
||||
function createDomEnvironment(options: { readonly withWebgl?: boolean; readonly glFailCompile?: boolean; readonly glFailLink?: boolean } = {}) {
|
||||
const context = {
|
||||
calls: [] as unknown[][],
|
||||
fillStyle: "",
|
||||
@ -218,6 +285,12 @@ function createDomEnvironment() {
|
||||
fillText: vi.fn((...args: unknown[]) => context.calls.push(["fillText", ...args])),
|
||||
drawImage: vi.fn((...args: unknown[]) => context.calls.push(["drawImage", ...args]))
|
||||
};
|
||||
// 仅当用例显式请求时才提供 mock WebGL 上下文,保证默认路径仍只暴露 2D。
|
||||
// glFailCompile/glFailLink 让该 GL 在着色器编译/链接阶段失败,用于回退集成测试。
|
||||
const glContext = createMockGlContext({
|
||||
...(options.glFailCompile === undefined ? {} : { failCompile: options.glFailCompile }),
|
||||
...(options.glFailLink === undefined ? {} : { failLink: options.glFailLink })
|
||||
});
|
||||
const listeners = new Map<string, Set<(event: Record<string, unknown>) => void>>();
|
||||
const windowListeners = new Map<string, Set<(event: Record<string, unknown>) => void>>();
|
||||
const createTarget = (listenerMap: Map<string, Set<(event: Record<string, unknown>) => void>>) => ({
|
||||
@ -239,7 +312,12 @@ function createDomEnvironment() {
|
||||
clientWidth: 320,
|
||||
clientHeight: 180,
|
||||
getBoundingClientRect: () => ({ left: 10, top: 20, width: 320, height: 180 }),
|
||||
getContext: (type: string) => (type === "2d" ? context : null),
|
||||
getContext: (type: string) => {
|
||||
if (type === "2d") return context;
|
||||
// 仅在请求 withWebgl 时为 webgl/webgl2 返回 mock GL 上下文。
|
||||
if (options.withWebgl === true && (type === "webgl" || type === "webgl2")) return glContext;
|
||||
return null;
|
||||
},
|
||||
...createTarget(listeners)
|
||||
};
|
||||
const storageValues = new Map<string, string>();
|
||||
@ -265,7 +343,81 @@ function createDomEnvironment() {
|
||||
return {
|
||||
canvas,
|
||||
context,
|
||||
glContext,
|
||||
storage,
|
||||
windowTarget
|
||||
};
|
||||
}
|
||||
|
||||
// 最小 mock WebGL 上下文:记录调用,构造/绘制路径都返回有效句柄,使 WebGlRenderSurface 成功初始化。
|
||||
// 通过 options 可让着色器编译/程序链接失败,用于断言 WebGL 初始化失败时回退 Canvas2D 的集成路径。
|
||||
function createMockGlContext(options: { readonly failCompile?: boolean; readonly failLink?: boolean } = {}) {
|
||||
const calls: unknown[][] = [];
|
||||
const record = (name: string, ...args: unknown[]): void => {
|
||||
calls.push([name, ...args]);
|
||||
};
|
||||
const handle = (): object => ({});
|
||||
return {
|
||||
calls,
|
||||
COLOR_BUFFER_BIT: 0x4000,
|
||||
VERTEX_SHADER: 0x8b31,
|
||||
FRAGMENT_SHADER: 0x8b30,
|
||||
COMPILE_STATUS: 0x8b81,
|
||||
LINK_STATUS: 0x8b82,
|
||||
ARRAY_BUFFER: 0x8892,
|
||||
STATIC_DRAW: 0x88e4,
|
||||
DYNAMIC_DRAW: 0x88e8,
|
||||
FLOAT: 0x1406,
|
||||
TRIANGLES: 0x0004,
|
||||
TRIANGLE_STRIP: 0x0005,
|
||||
TEXTURE_2D: 0x0de1,
|
||||
TEXTURE0: 0x84c0,
|
||||
RGBA: 0x1908,
|
||||
UNSIGNED_BYTE: 0x1401,
|
||||
TEXTURE_MIN_FILTER: 0x2801,
|
||||
TEXTURE_MAG_FILTER: 0x2800,
|
||||
TEXTURE_WRAP_S: 0x2802,
|
||||
TEXTURE_WRAP_T: 0x2803,
|
||||
LINEAR: 0x2601,
|
||||
CLAMP_TO_EDGE: 0x812f,
|
||||
BLEND: 0x0be2,
|
||||
SRC_ALPHA: 0x0302,
|
||||
ONE_MINUS_SRC_ALPHA: 0x0303,
|
||||
viewport: (...a: unknown[]) => record("viewport", ...a),
|
||||
clearColor: (...a: unknown[]) => record("clearColor", ...a),
|
||||
clear: (...a: unknown[]) => record("clear", ...a),
|
||||
createShader: () => handle(),
|
||||
shaderSource: () => undefined,
|
||||
compileShader: () => undefined,
|
||||
getShaderParameter: () => options.failCompile !== true,
|
||||
getShaderInfoLog: () => null,
|
||||
deleteShader: () => undefined,
|
||||
createProgram: () => handle(),
|
||||
attachShader: () => undefined,
|
||||
detachShader: () => undefined,
|
||||
linkProgram: () => undefined,
|
||||
getProgramParameter: () => options.failLink !== true,
|
||||
getProgramInfoLog: () => null,
|
||||
useProgram: (...a: unknown[]) => record("useProgram", ...a),
|
||||
deleteProgram: () => undefined,
|
||||
getAttribLocation: () => 0,
|
||||
getUniformLocation: () => handle(),
|
||||
createBuffer: () => handle(),
|
||||
bindBuffer: () => undefined,
|
||||
bufferData: () => undefined,
|
||||
deleteBuffer: () => undefined,
|
||||
enableVertexAttribArray: () => undefined,
|
||||
vertexAttribPointer: () => undefined,
|
||||
uniform4f: (...a: unknown[]) => record("uniform4f", ...a),
|
||||
uniform1i: () => undefined,
|
||||
enable: () => undefined,
|
||||
blendFunc: () => undefined,
|
||||
createTexture: () => handle(),
|
||||
bindTexture: () => undefined,
|
||||
deleteTexture: () => undefined,
|
||||
activeTexture: () => undefined,
|
||||
texParameteri: () => undefined,
|
||||
texImage2D: (...a: unknown[]) => record("texImage2D", ...a),
|
||||
drawArrays: (...a: unknown[]) => record("drawArrays", ...a)
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { createRuntimeSdk } from "../../../../../packages/runtime-sdk/src/index";
|
||||
import type { TelemetryBuffer } from "../../../../../packages/runtime-sdk/src/index";
|
||||
import { createRenderSurface } from "../../../../../packages/web-runtime/src/createRenderSurface";
|
||||
import type { Ctx2dLike, GLLike, RenderSurface, TextLayerLike } from "../../../../../packages/web-runtime/src/RenderSurface";
|
||||
import type {
|
||||
AudioHandleId,
|
||||
AudioLoadRequestDto,
|
||||
@ -12,17 +14,38 @@ import type {
|
||||
RuntimeSdkContract
|
||||
} from "../../../../../packages/shared-contracts/src/runtime-sdk-contract";
|
||||
|
||||
// 渲染器偏好:默认 canvas2d;webgl 为显式 opt-in(preference flag),失败自动回退 canvas2d。
|
||||
type RendererPreference = "canvas2d" | "webgl";
|
||||
|
||||
// 结构化的 WebGL 上下文类型:与 RenderSurface 的 GLLike 对齐,避免依赖浏览器 lib 类型。
|
||||
type WebGlContextLike = GLLike;
|
||||
|
||||
// 在既有 WebCanvasElement 上以结构方式扩展 getContext 重载,仅声明本适配器用到的子集:
|
||||
// - "2d" 仍返回 CanvasRenderingContext2D(保持既有 mock 兼容)。
|
||||
// - "webgl"/"webgl2" 返回结构化 GL 上下文(opt-in 时使用)。
|
||||
type WebCanvasElement = {
|
||||
width: number;
|
||||
height: number;
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
getBoundingClientRect: () => { left: number; top: number; width: number; height: number };
|
||||
getContext: (type: "2d") => CanvasRenderingContext2D | null;
|
||||
getContext: ((type: "2d") => CanvasRenderingContext2D | null) &
|
||||
((type: "webgl") => WebGlContextLike | null) &
|
||||
((type: "webgl2") => WebGlContextLike | null);
|
||||
addEventListener: (type: string, handler: (event: Event) => void) => void;
|
||||
removeEventListener: (type: string, handler: (event: Event) => void) => void;
|
||||
};
|
||||
|
||||
// 注入的离屏画布工厂:优先 OffscreenCanvas;浏览器若不支持则用 documentTarget 创建元素画布。
|
||||
// 该工厂保持可注入,便于测试与无 DOM 环境降级。
|
||||
type WebDocumentTarget = {
|
||||
createElement: (tagName: "canvas") => {
|
||||
width: number;
|
||||
height: number;
|
||||
getContext: (type: "2d") => (TextLayerLike["context2d"] & Ctx2dLike) | null;
|
||||
};
|
||||
};
|
||||
|
||||
type WebWindowTarget = {
|
||||
readonly devicePixelRatio?: number;
|
||||
readonly setTimeout?: (handler: () => void, timeout: number) => ReturnType<typeof setTimeout>;
|
||||
@ -54,19 +77,74 @@ export type WebPlatformAdapterOptions = {
|
||||
readonly fetcher: WebFetcher;
|
||||
readonly endpoints?: Record<string, string>;
|
||||
readonly retryDelayMs?: number;
|
||||
// 渲染器偏好(opt-in):缺省 undefined => "canvas2d",保持既有默认行为不变。
|
||||
readonly rendererPreference?: RendererPreference;
|
||||
// 可选注入的 document,用于在缺少 OffscreenCanvas 时创建文本图层离屏画布。
|
||||
readonly documentTarget?: WebDocumentTarget;
|
||||
};
|
||||
|
||||
export class WebPlatformAdapter {
|
||||
private readonly canvasHandles = new Set<string>();
|
||||
private readonly audioHandles = new Set<string>();
|
||||
private readonly context: CanvasRenderingContext2D;
|
||||
// 内部渲染面:clear/rect/text 全部经由它落地,逻辑模块永远拿不到 GL/2D 句柄。
|
||||
private readonly surface: RenderSurface;
|
||||
// 渲染诊断(回退原因、WebGL 文本图层缺失等),供 UI/冒烟观测。
|
||||
private readonly rendererDiagnostics: string[] = [];
|
||||
private nextCanvasId = 1;
|
||||
private nextAudioId = 1;
|
||||
|
||||
constructor(private readonly options: WebPlatformAdapterOptions) {
|
||||
const context = options.canvasElement.getContext("2d");
|
||||
if (context === null) throw new Error("WEB_PLATFORM_CANVAS_2D_UNAVAILABLE");
|
||||
this.context = context;
|
||||
const preference: RendererPreference = options.rendererPreference ?? "canvas2d";
|
||||
// 通过 createRenderSurface 选择渲染面:真实 DOM 访问全部以函数形式注入,渲染面本身保持 DOM-free。
|
||||
// 诊断统一经 onDiagnostic 回调收集(工厂每条诊断都会触发一次),避免与返回数组重复计数。
|
||||
const { surface } = createRenderSurface({
|
||||
preference,
|
||||
getSize: () => ({ width: options.canvasElement.width, height: options.canvasElement.height }),
|
||||
// WebGL 上下文:优先 webgl2,回退 webgl;不可用返回 null。
|
||||
tryWebgl: () => options.canvasElement.getContext("webgl2") ?? options.canvasElement.getContext("webgl"),
|
||||
// 2D 上下文:作为默认与回退渲染面;类型在结构上与 Ctx2dLike 兼容。
|
||||
get2d: () => options.canvasElement.getContext("2d") as unknown as Ctx2dLike | null,
|
||||
// 文本图层工厂:WebGL 路径栅格化文字时使用;无可用离屏画布时返回 null(WebGL 会跳过文本并发诊断)。
|
||||
createTextLayer: (width, height) => this.createTextLayer(width, height),
|
||||
onDiagnostic: (code) => this.rendererDiagnostics.push(code)
|
||||
});
|
||||
this.surface = surface;
|
||||
}
|
||||
|
||||
// getRendererKind:暴露本次实际生效的渲染器类型,便于 UI/冒烟断言(含回退后的结果)。
|
||||
getRendererKind(): RendererPreference {
|
||||
return this.surface.kind;
|
||||
}
|
||||
|
||||
// getRendererDiagnostics:暴露渲染诊断快照(回退原因等),返回副本避免外部篡改。
|
||||
getRendererDiagnostics(): string[] {
|
||||
return [...this.rendererDiagnostics];
|
||||
}
|
||||
|
||||
// dispose:宿主停止运行时调用,释放渲染面持有的底层资源(WebGL 程序/缓冲/纹理)。
|
||||
// 委托给渲染面的可选 dispose 钩子;Canvas2D 无需释放(钩子缺省即 no-op)。
|
||||
dispose(): void {
|
||||
this.surface.dispose?.();
|
||||
}
|
||||
|
||||
// createTextLayer:构造离屏 2D 图层供 WebGL 文本栅格化。
|
||||
// 优先 OffscreenCanvas;否则用注入的 documentTarget 创建元素画布;都不可用则返回 null。
|
||||
private createTextLayer(width: number, height: number): TextLayerLike | null {
|
||||
if (typeof OffscreenCanvas !== "undefined") {
|
||||
const offscreen = new OffscreenCanvas(width, height);
|
||||
const context2d = offscreen.getContext("2d");
|
||||
if (context2d === null) return null;
|
||||
return { width, height, context2d: context2d as unknown as TextLayerLike["context2d"] };
|
||||
}
|
||||
if (this.options.documentTarget !== undefined) {
|
||||
const element = this.options.documentTarget.createElement("canvas");
|
||||
element.width = width;
|
||||
element.height = height;
|
||||
const context2d = element.getContext("2d");
|
||||
if (context2d === null) return null;
|
||||
return { width, height, context2d };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
createSdk(): RuntimeSdkContract {
|
||||
@ -115,34 +193,18 @@ export class WebPlatformAdapter {
|
||||
const handle = { handleId: `web-canvas-${this.nextCanvasId}` };
|
||||
this.nextCanvasId += 1;
|
||||
this.canvasHandles.add(handle.handleId);
|
||||
// 仍然设置宿主画布的像素尺寸(兼容既有行为),再通知渲染面更新视口。
|
||||
this.options.canvasElement.width = width;
|
||||
this.options.canvasElement.height = height;
|
||||
this.surface.resize(width, height);
|
||||
return handle;
|
||||
}
|
||||
|
||||
private submitRenderCommands(handle: CanvasHandle, commands: readonly RenderCommand[]): void {
|
||||
// 保留既有句柄守卫:未知句柄一律拒绝,避免逻辑模块伪造句柄绘制。
|
||||
if (!this.canvasHandles.has(handle.handleId)) throw new Error("WEB_PLATFORM_CANVAS_HANDLE_UNKNOWN");
|
||||
|
||||
for (const command of commands) {
|
||||
if (command.type === "clear") {
|
||||
this.context.clearRect(0, 0, this.options.canvasElement.width, this.options.canvasElement.height);
|
||||
if (command.color !== undefined) {
|
||||
this.context.fillStyle = command.color;
|
||||
this.context.fillRect(0, 0, this.options.canvasElement.width, this.options.canvasElement.height);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (command.type === "rect") {
|
||||
this.context.fillStyle = command.fill ?? "#ffffff";
|
||||
this.context.fillRect(command.x, command.y, command.width, command.height);
|
||||
continue;
|
||||
}
|
||||
if (command.type === "text") {
|
||||
this.context.fillStyle = command.fill ?? "#ffffff";
|
||||
this.context.font = `${command.fontSize ?? 12}px sans-serif`;
|
||||
this.context.fillText(command.text, command.x, command.y);
|
||||
}
|
||||
}
|
||||
// 渲染落地委托给内部渲染面(canvas2d 或 webgl);逻辑模块只产出平台中立命令。
|
||||
this.surface.submit(commands);
|
||||
}
|
||||
|
||||
private onCanvasPointer(type: string, sdkType: InputEventDto["type"], handler: (event: InputEventDto) => void): () => void {
|
||||
|
||||
318
docs/memorys/2026-06-05-MVP剩余清单与路线.md
Normal file
318
docs/memorys/2026-06-05-MVP剩余清单与路线.md
Normal file
@ -0,0 +1,318 @@
|
||||
# MVP 剩余清单与路线
|
||||
|
||||
> 生成时间:2026-06-05
|
||||
> 状态快照:基于 git HEAD `98e2aad`(S0-S5 已合并)及当前 session Web/WebGL playable demo slice。
|
||||
> 受众:团队 + 未来 agent。
|
||||
> 证据原则:本文只引用已有验证记录;未经验证的状态一律标注 not_started 或待确认。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论
|
||||
|
||||
**当前可展示切片**
|
||||
|
||||
Web playable demo slice,路由 `/preview/simulation-fixture-v1`,Canvas2D 默认渲染 + opt-in WebGL fallback。该切片是本 session 正在推进的工作,基于 S4 runtime smoke PASS 与 S5 mini-game static gate PASS 的基础上交付。
|
||||
|
||||
**MVP 整体现状**
|
||||
|
||||
| 阶段 | 状态 | 关键证据 |
|
||||
|------|------|---------|
|
||||
| S0 harness | 完成 | git `6e363ae` 起多个 feat/harness 提交,门禁 PASS |
|
||||
| S1 app foundation | 完成 | git `bba5e13`,Task9 scope gate PASS |
|
||||
| S2 creator workbench | 完成 | git `8ffeba9`-`b4c17c8` 系列提交 |
|
||||
| S3 simulation slice | 完成 | git `e81e433`-`76d2444`,scope gate PASS |
|
||||
| S4 web runtime | 完成 | runtime smoke PASS(23 files / 244 tests),见 `2026-06-05-S4Task5运行冒烟.md` |
|
||||
| S5 mini-game conversion | static_validated 仅;import cert = No-Go | 三端 static gate PASS,DevTool import smoke 默认写 blocker,无 passed evidence |
|
||||
| S6 publish-review-feed | **not_started** | — |
|
||||
| S7 feedback/telemetry | **not_started** | — |
|
||||
| S8 deploy-readiness | **not_started** | — |
|
||||
| Web/WebGL playable demo | 本 session 进行中 | — |
|
||||
|
||||
**WeChat/Douyin DevTools import certification = 硬 No-Go 门**
|
||||
|
||||
`DevToolImportEvidence.result="passed"` 目前不存在。`docs/evidence/devtool-import/` 目录内仅有 `README.md`,无任何 passed evidence 文件。这是 S5 import certification 和 S8 Go/No-Go 的前置硬门禁,不可绕过。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已完成(S0–S5 + Web playable demo slice)
|
||||
|
||||
### S0–S3:harness + foundation + creator workbench + simulation
|
||||
|
||||
- git 历史从 `ba058fb`(平台路线图)→ `e90bf2d`(S3 phase-aware scope gate)→ `76d2444`(S3 runtime backend presearch)完整覆盖。
|
||||
- S0 harness 门禁通过,lifecycle validation baseline 已建立(`6e363ae`)。
|
||||
- S1 app foundation 完成,Task9 scope gate 通过(`9294101`)。
|
||||
- S2 creator workbench 合同、API、UI、IR compiler、阶段 scope gate 均完成。
|
||||
- S3 simulation slice contracts、compiler、logic module、validator、consumer contract 均完成,scope gate PASS。
|
||||
|
||||
### S4:Web runtime
|
||||
|
||||
- 任务链:scope gate → game package contract → web package builder → runtime SDK → preview runtime UI → runtime smoke。
|
||||
- 全部 fresh spec review + quality review PASS,详见 `docs/memorys/2026-06-05-S4Task*.md`。
|
||||
- runtime smoke:`runRuntimeSmokeWithPlaywright` PASS,244 tests,`pnpm typecheck`/`lint` PASS。
|
||||
- **S4 = Web runtime green,基础已具备 Web playable demo slice 推进条件。**
|
||||
|
||||
### S5:Mini-game conversion(static_validated only)
|
||||
|
||||
- 转换合同、平台适配器(wechat/douyin/kuaishou)、项目生成器、static gate 均完成并 PASS。
|
||||
- 三端 simulation fixture static gate 结果:`wechat_minigame=static_validated`,`douyin_minigame=static_validated`,`kuaishou_minigame=static_validated`(保留 KUAISHOU_GLOBAL_AND_CONFIG_SHAPE_UNKNOWN warning)。
|
||||
- `smoke:devtool-import` 默认写 `DevToolImportBlocker`,exit 1,无法生成 passed evidence。
|
||||
- S5 Task5 Task6 fresh review 双 PASS,但 **S5 import certification 仍是 No-Go**,详见 `docs/memorys/2026-06-05-S5Task6最终验证.md`。
|
||||
- S4/S5 并行汇合验证 PASS,merge commit `98e2aad`,详见 `docs/memorys/2026-06-05-S4S5并行汇合.md`。
|
||||
|
||||
### Web/WebGL playable demo slice(本 session)
|
||||
|
||||
- 基于 S4 runtime,在 `/preview/simulation-fixture-v1` 暴露 Canvas2D 默认 + opt-in WebGL fallback。
|
||||
- 该切片是当前 session 正在推进的工作,状态以实际验证结果为准,本文不超前声称 PASS。
|
||||
|
||||
---
|
||||
|
||||
## 3. 剩余阶段
|
||||
|
||||
### S6:站内发布 / 运营审核 / 移动端 Feed
|
||||
|
||||
**状态:not_started**
|
||||
|
||||
**P0 任务清单**
|
||||
|
||||
| 任务 ID | 标题 | 文件路径 | 依赖 | 验证类型 |
|
||||
|---------|------|---------|------|---------|
|
||||
| S6T1 | Define Contracts | `packages/shared-contracts/src/publish-review-feed.ts` | S5 完成 | unit |
|
||||
| S6T2 | Publish Gate | `apps/api/src/modules/publish/` | S6T1 | integration-db |
|
||||
| S6T3 | Review Queue + Operator Actions | `apps/api/src/modules/review/` | S6T2 | integration-db |
|
||||
| S6T4 | Mobile Feed API + UI | `apps/api/src/modules/feed/` | S6T3 | integration-db |
|
||||
| S6T5 | Interactions + Share | `apps/api/src/modules/interactions/` | S6T4 | integration-db |
|
||||
| S6T6 | Final Verify | — | S6T5 | unit |
|
||||
|
||||
**并行分组**
|
||||
|
||||
- Group A(独立):S6T1 可独立开始,只需 shared-contracts。
|
||||
- Group B(串行):S6T2 → S6T3 → S6T4 → S6T5(依赖链,不可并行)。
|
||||
- Group C(汇合):S6T6 依赖 S6T1-T5 全部完成。
|
||||
|
||||
**验证分级**
|
||||
|
||||
- unit 可在本环境运行:S6T1(合同 schema 测试)、S6T6(集成最终 gate)。
|
||||
- integration-db 必须有真实 Postgres:S6T2 / S6T3 / S6T4 / S6T5 的 API 端到端测试。
|
||||
|
||||
**S6 硬门禁**
|
||||
|
||||
- `ReviewRecord` 状态为 `approved` 才能创建 `PublishedGame`(creator 不能自审)。
|
||||
- `GamePackage.web.status=smoke_passed` 是提交发布的前置硬条件。
|
||||
- feed 查询结果不得包含 `pending/rejected/unpublished` 状态游戏。
|
||||
- `MiniGameProject` 绝不能成为 feed 可玩 item。
|
||||
- creator 不能审核自己提交的版本。
|
||||
|
||||
**S6 P1 / 后置(不阻塞 P0)**
|
||||
|
||||
- runtime 错误自动限制曝光规则。
|
||||
- ML feed 排序算法。
|
||||
- 评论、弹幕、粉丝体系。
|
||||
- 渠道提审、广告、支付、结算。
|
||||
|
||||
---
|
||||
|
||||
### S7:Feedback / Telemetry
|
||||
|
||||
**状态:not_started**
|
||||
|
||||
**P0 任务清单**
|
||||
|
||||
| 任务 ID | 标题 | 文件路径 | 依赖 | 验证类型 |
|
||||
|---------|------|---------|------|---------|
|
||||
| S7-T1 | Telemetry contracts | `packages/shared-contracts/src/telemetry.ts` | S6 完成 | unit |
|
||||
| S7-T2 | POST /events/batch ingestion | `apps/api/src/modules/telemetry/` + prisma | S7-T1 | integration-db |
|
||||
| S7-T3 | Minimal quality signals worker | `apps/api/src/modules/feedback/` | S7-T2 | integration-db |
|
||||
| S7-T4 | Document deferred dashboards | `docs/future/` | — | manual-gate(文档)|
|
||||
| S7-T5 | Channel readiness notes API + UI | `apps/api/src/modules/channel-readiness/`,`apps/web` operator page | S7-T2 | integration-db |
|
||||
| S7-T6 | Final verify | — | S7-T3, T5 | unit |
|
||||
|
||||
**并行分组**
|
||||
|
||||
- Group A(可并行):{S7-T1, S7-T4}。
|
||||
- Group B(可并行,依赖 T1):{S7-T2, S7-T5}。
|
||||
- Group C(串行):{S7-T3}(依赖 T2)。
|
||||
- Group D(汇合):S7-T6。
|
||||
|
||||
**验证分级**
|
||||
|
||||
- unit:S7-T1(schema)、S7-T6(final gate)。
|
||||
- integration-db:S7-T2 / T3 / T5。
|
||||
- manual-gate(静态扫描):S7-T4(文档存在即满足)。
|
||||
|
||||
**S7 硬门禁**
|
||||
|
||||
- prisma migration 必须通过。
|
||||
- `QualitySignal` 不能修改 `GameVersion` / `PublishedGame` 记录。
|
||||
- `ChannelReadinessNote` 只能引用 S5 `ConversionReport` / `DevToolImportEvidence` / `DevToolImportBlocker` ID;不能自造转换状态;不能替代 `ChannelReview`。
|
||||
- P0 不能出现:`Monetization`、`GameDailyStats`、`CreatorDailyStats`、`ImprovementSuggestion`。
|
||||
|
||||
**S7 P1 / 后置(不阻塞 P0)**
|
||||
|
||||
- 创作者单游戏基础看板、运营质量/错误/举报视图。
|
||||
- `GameDailyStats`、`CreatorDailyStats` 聚合统计。
|
||||
- 简单规则建议(加载失败高、试玩低、留存低、举报高、互动高)。
|
||||
- 数据保留 / 冷归档策略。
|
||||
|
||||
---
|
||||
|
||||
### S8:Deploy Readiness / MVP Go-No-Go
|
||||
|
||||
**状态:not_started**
|
||||
|
||||
**P0 任务清单**
|
||||
|
||||
| 任务 ID | 标题 | 文件路径 | 依赖 | 验证类型 |
|
||||
|---------|------|---------|------|---------|
|
||||
| s8-t1 | 环境配置校验 | `scripts/validate-env.mjs` | — | unit(静态校验)|
|
||||
| s8-t2 | 健康检查 | `apps/api` health endpoint,worker heartbeat,`scripts/healthcheck.mjs` | — | unit |
|
||||
| s8-t3 | demo seed | `scripts/seed-demo.mjs` | integration-db | integration-db |
|
||||
| s8-t4 | MVP smoke | `scripts/smoke-mvp.mjs` | S0-S7 全部通过 + WeChat+Douyin DevToolImportEvidence.result=passed | browser |
|
||||
| s8-t5 | 运维文档:监控+回滚 | `docs/operations/` | — | static-scan |
|
||||
| s8-t6 | 验收 runbook + Go/No-Go | `docs/evidence/mvp-acceptance/` | s8-t1 ~ t5 全通过 | manual-gate |
|
||||
|
||||
**并行分组**
|
||||
|
||||
- Group A(可独立并行):{s8-t1, s8-t2, s8-t5}。
|
||||
- Group B(依赖 DB):{s8-t3}(依赖 integration-db)。
|
||||
- Group C(依赖全部前序):{s8-t4}(browser + DevTools passed evidence 必须就位)。
|
||||
- Group D(汇合):{s8-t6}(手动 Go/No-Go 最终门)。
|
||||
|
||||
**S8 Go/No-Go 规则(硬性)**
|
||||
|
||||
`Go` 条件:同时满足以下全部:
|
||||
1. `wechat_minigame` `DevToolImportEvidence.result="passed"` 文件存在且 checksum 合法。
|
||||
2. `douyin_minigame` `DevToolImportEvidence.result="passed"` 文件存在且 checksum 合法。
|
||||
3. S0-S7 所有阶段证据链接完整。
|
||||
4. smoke-mvp 脚本 exit 0。
|
||||
|
||||
任意一项缺失、blocked、failed 或仅有 `DevToolImportBlocker` => **No-Go,不得宣称 MVP ready**。
|
||||
|
||||
**S8 P1 / 后置(不阻塞 P0)**
|
||||
|
||||
- 三平台真机 smoke(wechat/douyin/kuaishou device)。
|
||||
- 渠道正式提审 / 审核通过。
|
||||
- 广告、支付、结算生产配置。
|
||||
- 大规模压测、多地域容灾、failover 演练。
|
||||
|
||||
---
|
||||
|
||||
## 4. 硬门禁(不可伪造)
|
||||
|
||||
以下门禁无法用任何代码、文档或推断替代,必须由真实工具产出真实证据:
|
||||
|
||||
| 门禁名称 | 触发阶段 | 证据要求 | 当前状态 |
|
||||
|---------|---------|---------|---------|
|
||||
| WeChat DevToolImportEvidence | S5 import cert / S8 | `docs/evidence/devtool-import/wechat_minigame-*.json` 中 `result="passed"`,含真实 tool name/version/log/checksum | **No-Go(无文件)** |
|
||||
| Douyin DevToolImportEvidence | S5 import cert / S8 | 同上,douyin 平台 | **No-Go(无文件)** |
|
||||
| ReviewRecord before PublishedGame | S6 | operator approved ReviewRecord 存在,且 creator != reviewer | not_started |
|
||||
| smoke_passed before feed/submit | S6 | `GamePackage.web.status=smoke_passed` 写入 DB | not_started |
|
||||
| feed 不返回 pending/rejected/unpublished | S6 | feed API 测试 + 运行时断言 | not_started |
|
||||
| MVP smoke exit 0 | S8 | `scripts/smoke-mvp.mjs` 实际运行 exit 0,需 browser + DB | not_started |
|
||||
| Go/No-Go 报告 | S8 | `docs/evidence/mvp-acceptance/` 中明确区分真实跑通 / 静态验证 / 人工证据 / 未进 P0 | not_started |
|
||||
|
||||
**关于 S5 DevTools blocker 的说明**:`DevToolImportBlocker`(`result="blocked"/"failed"`)记录的是缺口和风险,解释了为何无法导入,但不能代替 `DevToolImportEvidence.result="passed"`。将 blocker 解读为通过证据属于违规。
|
||||
|
||||
---
|
||||
|
||||
## 5. P1 / 后置事项(按阶段汇总)
|
||||
|
||||
| 阶段 | P1 / 后置项目(不阻塞 P0 验收)|
|
||||
|------|-------------------------------|
|
||||
| S6 | ML feed 排序;评论/弹幕/粉丝;渠道提审;广告/支付;runtime 错误自动降权策略 |
|
||||
| S7 | 创作者/运营看板;`GameDailyStats` / `CreatorDailyStats` 聚合;`ImprovementSuggestion` 规则建议;数据归档 |
|
||||
| S8 | 三端真机 smoke;渠道正式上架;广告/支付生产配置;多地域容灾;压测 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 沙箱验证天花板
|
||||
|
||||
本环境(`/root/games-development-ai`,linux,node 可用)的验证能力边界:
|
||||
|
||||
**可以产出真实证据的命令**
|
||||
|
||||
```
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm check:s5-scope / check:s4-scope / check:workspace-scripts
|
||||
pnpm run build:minigame -- --target all --fixture simulation
|
||||
node --check <file>
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**需要真实 Postgres 的命令(本环境抛 PrismaClientKnownRequestError)**
|
||||
|
||||
- S6-S8 的 API integration 测试。
|
||||
- prisma migrate / seed 命令。
|
||||
- `scripts/seed-demo.mjs` 和任何需要 DB 写入的冒烟脚本。
|
||||
|
||||
**需要真实浏览器的命令(本环境 flaky)**
|
||||
|
||||
- `runRuntimeSmokeWithPlaywright`(S4 smoke 已有 unit wrapper 替代)。
|
||||
- `scripts/smoke-mvp.mjs`(S8,需 browser + DB)。
|
||||
|
||||
**需要真实开发者工具的操作(本环境无法模拟)**
|
||||
|
||||
- 微信开发者工具 / 抖音开发者工具导入项目。
|
||||
- 生成有效的 `DevToolImportEvidence.result="passed"` 文件。
|
||||
|
||||
**结论**:在本沙箱内,lint / typecheck / unit / scope-gate / build:minigame 命令产出的证据是真实可信的。DB integration、live device、devtool import 必须在具备完整环境的真实机器上验证,不能在本沙箱伪造或推断为通过。
|
||||
|
||||
---
|
||||
|
||||
## 7. 建议推进顺序
|
||||
|
||||
### 当前优先:完成 Web/WebGL playable demo slice
|
||||
|
||||
本 session 正在推进,与 S6/S7/S8 无依赖冲突,可独立完成。
|
||||
|
||||
### S6 启动条件
|
||||
|
||||
- S4 runtime smoke PASS(已满足)。
|
||||
- S5 static gate PASS(已满足)。
|
||||
- S5 import cert(DevTools passed evidence)**不阻塞 S6 编码**,但阻塞 S6 feed 将 mini-game artifact 用于任何"可玩"声称。
|
||||
- S6T1(contracts)可立即开始,无 DB 依赖。
|
||||
|
||||
### S7 启动条件
|
||||
|
||||
- S6 P0 完成(S7 下游于 S6 feed 和 interaction 事件)。
|
||||
- S7-T1 和 S7-T4 可与 S6 后期并行。
|
||||
|
||||
### S8 启动条件
|
||||
|
||||
- S6 + S7 P0 全部完成。
|
||||
- **WeChat + Douyin DevToolImportEvidence.result="passed" 必须在 s8-t4 之前就位**,否则 smoke-mvp 强制 exit 1。
|
||||
|
||||
### 并行机会
|
||||
|
||||
```
|
||||
当前 session
|
||||
└── Web/WebGL playable demo slice(无阻塞)
|
||||
|
||||
S6 开始后
|
||||
├── S6T1 contracts(unit,立即可做)
|
||||
└── S6T2~T5(串行,需 DB 环境)
|
||||
|
||||
S6 完成后
|
||||
├── S7-T1(unit,可与 S6 后期 overlap)
|
||||
├── S7-T4(文档,可随时做)
|
||||
└── S7-T2~T5(需 DB)
|
||||
|
||||
S7 完成后
|
||||
├── s8-t1(unit,可提前)
|
||||
├── s8-t2(unit,可提前)
|
||||
├── s8-t5(文档,可随时做)
|
||||
└── s8-t3(需 DB)
|
||||
└── s8-t4(需 browser + DB + DevTools passed evidence)
|
||||
└── s8-t6(手动 Go/No-Go 最终门)
|
||||
```
|
||||
|
||||
### 依赖强制串行点
|
||||
|
||||
1. S5 DevTools import cert → s8-t4(无法绕过,无法提前声称)。
|
||||
2. ReviewRecord → PublishedGame(S6 内部,运营必须先审核)。
|
||||
3. S0-S7 证据链 → s8-t6 Go/No-Go(所有阶段证据必须完整)。
|
||||
|
||||
---
|
||||
|
||||
*本文档基于 git HEAD `98e2aad` 和本 session 状态快照编写。下一个关键更新节点:Web/WebGL playable demo slice 完成后,或 S5 DevTools import cert 状态变更时。*
|
||||
117
docs/memorys/2026-06-05-WebPlayable里程碑.md
Normal file
117
docs/memorys/2026-06-05-WebPlayable里程碑.md
Normal file
@ -0,0 +1,117 @@
|
||||
# Web/WebGL Playable 里程碑(demo 切片)
|
||||
|
||||
## 结论
|
||||
|
||||
当前交付一个**可向投资人演示的初始 MVP 切片**:AI 生成的模拟经营游戏在浏览器中可玩。
|
||||
预览地址 `http://localhost:3000/preview/simulation-fixture-v1`:
|
||||
|
||||
- 默认 **Canvas2D** 渲染(S4 既有产线,全绿)。
|
||||
- 新增**可选 WebGL 内部渲染面**(`?renderer=webgl` 显式开启),WebGL 不可用/初始化失败时**自动回退 Canvas2D 并产出明确诊断**。
|
||||
|
||||
**边界(未完成、未认证、未伪造)**:S6/S7/S8 仍 `not_started`;微信/抖音 DevTools 导入认证仍是**硬 No-Go**;本次未声称小游戏导入完成、未声称渠道可发布、未声称 S8 Go、未声称 MVP 最终 Go。详见 [[2026-06-05-MVP剩余清单与路线]]。
|
||||
|
||||
## 实现范围(本次)
|
||||
|
||||
最小、可选、内部可替换的 WebGL 渲染面,挂在 `RuntimeSdkContract.Canvas.submitRenderCommands` 这一个接缝后面;**不改** `RuntimeSdkContract` / `GameLogicModule` / 游戏逻辑;逻辑模块拿不到 WebGL context 或 raw canvas/2d handle。
|
||||
|
||||
新增(packages/web-runtime/src/):
|
||||
|
||||
- `RenderSurface.ts`:`RenderSurface` 接口(kind / resize / submit / dispose)+ 注入式的最小结构化 context 类型(保持包内 DOM-free、可单测)。
|
||||
- `Canvas2dRenderSurface.ts`(+ `.spec.ts`):把原 adapter 的 2D 绘制逻辑(clear/rect/text)原样抽出。
|
||||
- `WebGlRenderSurface.ts`(+ `.spec.ts`):clear→clearColor+clear;rect→纯色 quad(内联 shader,构造时编译一次复用);text→注入的离屏 2D text layer 光栅化后 texImage2D 合成;无 text layer→一次性诊断 `WEBGL_TEXT_LAYER_UNAVAILABLE`,不抛错,画面仍非空。init/compile/link 失败抛 typed error 供 factory 回退。
|
||||
- `createRenderSurface.ts`(+ `.spec.ts`):按 preference 选面;webgl 不可用→`WEBGL_UNAVAILABLE_FALLBACK_CANVAS2D`;webgl 初始化失败→`WEBGL_INIT_FAILED_FALLBACK_CANVAS2D`;都不可用→抛 `WEB_PLATFORM_RENDER_SURFACE_UNAVAILABLE`/`WEB_PLATFORM_CANVAS_2D_UNAVAILABLE`。
|
||||
|
||||
修改:
|
||||
|
||||
- `packages/web-runtime/src/index.ts`:导出新模块。
|
||||
- `apps/web/src/components/runtime/WebPlatformAdapter.ts`:新增 `rendererPreference?`(默认 `canvas2d`);构造时经 factory 注入真实 DOM accessor;`submitRenderCommands` 委派给 surface;暴露 `getRendererKind()` / `getRendererDiagnostics()`;保留 canvasHandles 守卫。
|
||||
- `apps/web/src/components/runtime/WebPlatformAdapter.test.ts`:**仅增量**——原 8 例不变,新增 3 例(webgl 选中 / webgl 为 null 回退)。
|
||||
- `apps/web/src/components/runtime/WebGameRuntime.tsx`:透传 `rendererPreference`(缺省 `canvas2d`);controller 保留 adapter 引用,`stop()` 与 load 失败 catch 均 `adapter?.dispose()` 释放渲染面资源。
|
||||
- `apps/web/src/app/preview/[versionId]/page.tsx`:改为 **async server 组件**,`await` 解包 Next.js 16 的 `params/searchParams`,`?renderer=webgl` 显式开启(缺省 Canvas2D),把纯值 `rendererPreference` 传给 client 组件;`readRendererPreference` 导出供单测。
|
||||
- 新增 `apps/web/src/components/runtime/PreviewRuntimeClient.tsx`:client 包装组件,负责 `getPreviewPackageSource` 与渲染(loader/reader 函数只在 client 侧创建,不跨 server/client 边界)。
|
||||
- 新增 `apps/web/src/app/preview/[versionId]/page.test.tsx`:`readRendererPreference` opt-in 决策单测。
|
||||
- `apps/web/src/components/runtime/WebPlatformAdapter.test.ts`:新增 webgl 选中 / 回退 / 初始化失败回退三类 renderer 用例(原有用例不变)。
|
||||
|
||||
## 不在本次范围
|
||||
|
||||
- 不把 WebGL 设为默认渲染器(避免对既有 live smoke 造成回归)。
|
||||
- 不实现 S6(发布/审核/feed)、S7(遥测/反馈)、S8(部署就绪/Go-No-Go)。
|
||||
- 不做微信/抖音 DevTools 导入认证(仍 No-Go)。
|
||||
- 不做真机 smoke、渠道提审、广告/支付。
|
||||
|
||||
## Controller 自测(orchestrator 独立复跑,非仅采信 implementer 报告)
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| web-runtime 测试 | `pnpm --filter @huijing/web-runtime test` | ✅ 57 passed(含 RenderSurface/WebGl/createRenderSurface 三组新 spec) |
|
||||
| runtime-sdk 测试 | `pnpm --filter @huijing/runtime-sdk test` | ✅ 5 passed |
|
||||
| web 测试 | `pnpm --filter @huijing/web test` | ✅ 52 passed(7 files;新增 page.test.tsx + PreviewRuntimeClient 用例 + adapter renderer 用例) |
|
||||
| 类型检查(4 包) | `pnpm --filter web-runtime/runtime-sdk/web/shared-contracts typecheck` | ✅ Done |
|
||||
| Lint(3 包) | `pnpm --filter web-runtime/runtime-sdk/web lint` | ✅ Done(仓库级 lint 会 OOM,按既定只跑这 3 包) |
|
||||
| runtime-smoke | `pnpm --filter @huijing/api exec vitest run src/modules/game-package` | ✅ 31 passed |
|
||||
| S5 范围门禁 | `pnpm check:s5-scope` | ✅ exit 0 |
|
||||
| S4 范围门禁 | `pnpm check:s4-scope` | ⚠️ exit 1,**纯既有基线**:91 处违规全部在 S5 自有文件,本次 12 个文件**零命中**(已用精确路径核验)。与 [[2026-06-05-S4S5并行汇合]] 记录的 `EXPECTED_FAIL_ON_S5` 一致。 |
|
||||
| 空白/冲突 | `git diff --check` | ✅ clean |
|
||||
| Dev server | `GET /preview/...`(默认 + `?renderer=webgl`) | ✅ 均 HTTP 200,服务端渲染 runtime shell |
|
||||
|
||||
## 实时浏览器验证(best-effort)
|
||||
|
||||
沙箱内 Playwright chromium headless_shell 反复安装失败(`__dirlock` 复现、二进制不留存),**真实像素级实时采集在本沙箱被阻塞**。已用以下证据替代:
|
||||
- dev server 实测两条路由均 HTTP 200,且改为 server 组件后 **SSR 输出已包含 `data-runtime-canvas="web-preview"` 与 `Version simulation-fixture-v1`**(修复 opt-in 前的 client 页不会 SSR 出 canvas)——证明 `?renderer=webgl` opt-in 路由在服务端正确解包并渲染。
|
||||
- WebGL 路径的「非空(drawArrays/clearColor)/ 可交互(changed commands→changed draw state)/ 回退 + 诊断」由 `WebGlRenderSurface.spec`、`createRenderSurface.spec`、`WebPlatformAdapter.test` 单测覆盖。
|
||||
- runtime-smoke 的 Playwright driver 本身有单测。
|
||||
|
||||
投资人演示在用户本机 `next dev` 上进行(`http://localhost:3000/preview/simulation-fixture-v1`,加 `?renderer=webgl` 切 WebGL);像素级实时校验留待真实环境(与本次约定的 done-bar「static+unit green here」一致,未伪造)。
|
||||
|
||||
## Fresh review(两轮:实现 + controller 自测后,spec 与 quality 并发)
|
||||
|
||||
> 说明:原计划要求 gpt-5.5 implementer/reviewer;当前 Agent 工具仅提供 Claude 模型,用户已确认改用 Claude 模型(Opus implementer / Sonnet reviewers)。每轮 spec/quality 均为全新子代理并发,不复用旧子代理。
|
||||
|
||||
**Round 1**
|
||||
|
||||
| 类型 | 子代理标签(模型) | 结论 | 关键结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| spec review | `spec-review`(sonnet) | PASS | 合同/逻辑未改、Canvas2D 默认未变、无边界泄漏、诊断码齐全且有测试。 |
|
||||
| quality review | `quality-review`(sonnet) | **FAIL** | 1 个 critical:`page.tsx` 标 `"use client"` 却从 props 读 `searchParams`,Next.js App Router 只把它给 server 组件 → `?renderer=webgl` opt-in 实际失效;另含 clear-alpha 不一致、着色器未释放、`dispose()` 未接线、死分支等 important。 |
|
||||
|
||||
**Fix round(按规则合并所有 finding 一次性修复)**
|
||||
|
||||
fix-round 子代理在 18:17 被用户中断(slash 命令打断),已完成 F2/F3/F4/F6/F7(全绿)。剩余 **F1(critical opt-in)** 与 **F5(dispose 接线)** 由 orchestrator 直接补完:
|
||||
- F1:查 Next.js 16 官方文档确认 `params/searchParams` 为 Promise,client 页需用 `use()`/server 页 `await`。采用 **server 组件页 + 新增 client 包装组件 `PreviewRuntimeClient.tsx`** 的稳健方案;`page.tsx` 改为 async server 组件 `await` 解包并把纯值 `rendererPreference` 传入 client 组件。dev server 实测两条路由均 200 且 SSR 出 canvas。
|
||||
- F5:在 `WebGameRuntime` controller `stop()`(及 load 失败 catch)补 `adapter?.dispose()` 接线。
|
||||
|
||||
**Round 2(重新全新并发 review)**
|
||||
|
||||
| 类型 | 子代理标签(模型) | 结论 | 关键结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| spec review | round-2 `spec-review`(sonnet) | PASS | F1–F7 全部确认解决,无回归;唯一 nit 为既有 s4-scope 基线与 apps/api Prisma(均与本次无关)。 |
|
||||
| quality review | round-2 `quality-review`(sonnet) | PASS | F1–F7 全部解决;1 个 minor:load 失败后 adapter 在下次 load/卸载前短暂滞留——已按 reviewer 建议在 catch 中补 `adapter?.dispose()` 并复跑测试通过(micro-fix 不再单独 review)。 |
|
||||
|
||||
双 PASS,满足提交门禁。
|
||||
|
||||
## DevTools 导入后置边界
|
||||
|
||||
DevTools 导入认证后置,不阻塞本次 Web/WebGL playable;但**最终仍必须**有微信 + 抖音生成项目的 `DevToolImportEvidence.result="passed"` 文件,才能解除 S5 import certification、S8 Go/No-Go、MVP 最终验收。本次未生成任何 passed evidence,`docs/evidence/devtool-import/` 未新增伪造文件。
|
||||
|
||||
## git status(提交前)
|
||||
|
||||
```
|
||||
M apps/web/src/app/preview/[versionId]/page.tsx
|
||||
M apps/web/src/components/runtime/WebGameRuntime.tsx
|
||||
M apps/web/src/components/runtime/WebPlatformAdapter.test.ts
|
||||
M apps/web/src/components/runtime/WebPlatformAdapter.ts
|
||||
M packages/web-runtime/src/index.ts
|
||||
?? apps/web/src/app/preview/[versionId]/page.test.tsx
|
||||
?? apps/web/src/components/runtime/PreviewRuntimeClient.tsx
|
||||
?? docs/memorys/2026-06-05-MVP剩余清单与路线.md
|
||||
?? docs/memorys/2026-06-05-WebPlayable里程碑.md
|
||||
?? packages/web-runtime/src/Canvas2dRenderSurface.spec.ts
|
||||
?? packages/web-runtime/src/Canvas2dRenderSurface.ts
|
||||
?? packages/web-runtime/src/RenderSurface.ts
|
||||
?? packages/web-runtime/src/WebGlRenderSurface.spec.ts
|
||||
?? packages/web-runtime/src/WebGlRenderSurface.ts
|
||||
?? packages/web-runtime/src/createRenderSurface.spec.ts
|
||||
?? packages/web-runtime/src/createRenderSurface.ts
|
||||
```
|
||||
|
||||
(`next-env.d.ts` 由 Next 工具自动改写,不纳入提交。)
|
||||
70
packages/web-runtime/src/Canvas2dRenderSurface.spec.ts
Normal file
70
packages/web-runtime/src/Canvas2dRenderSurface.spec.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Canvas2dRenderSurface } from "./Canvas2dRenderSurface";
|
||||
import type { Ctx2dLike } from "./RenderSurface";
|
||||
|
||||
describe("Canvas2dRenderSurface", () => {
|
||||
it("paints clear/rect/text with the exact same 2D calls the old adapter issued", () => {
|
||||
const env = createCtx();
|
||||
const surface = new Canvas2dRenderSurface(env.ctx, () => ({ width: 320, height: 180 }));
|
||||
expect(surface.kind).toBe("canvas2d");
|
||||
|
||||
surface.submit([
|
||||
{ type: "clear", color: "#000000" },
|
||||
{ type: "rect", x: 1, y: 2, width: 3, height: 4, fill: "#ffffff" },
|
||||
{ type: "text", text: "Coins 1", x: 8, y: 16, fill: "#ffffff", fontSize: 12 }
|
||||
]);
|
||||
|
||||
expect(env.calls).toEqual([
|
||||
["clearRect", 0, 0, 320, 180],
|
||||
["fillRect", 0, 0, 320, 180],
|
||||
["fillRect", 1, 2, 3, 4],
|
||||
["fillText", "Coins 1", 8, 16]
|
||||
]);
|
||||
});
|
||||
|
||||
it("clear without color only clears and does not fill", () => {
|
||||
const env = createCtx();
|
||||
const surface = new Canvas2dRenderSurface(env.ctx, () => ({ width: 100, height: 50 }));
|
||||
|
||||
surface.submit([{ type: "clear" }]);
|
||||
|
||||
expect(env.calls).toEqual([["clearRect", 0, 0, 100, 50]]);
|
||||
});
|
||||
|
||||
it("uses default fill and font when omitted, matching old adapter fallbacks", () => {
|
||||
const env = createCtx();
|
||||
const surface = new Canvas2dRenderSurface(env.ctx, () => ({ width: 10, height: 10 }));
|
||||
|
||||
surface.submit([
|
||||
{ type: "rect", x: 0, y: 0, width: 2, height: 2 },
|
||||
{ type: "text", text: "hi", x: 1, y: 1 }
|
||||
]);
|
||||
|
||||
// 默认填充色 #ffffff、默认字号 12px sans-serif 必须与旧 WebPlatformAdapter 保持一致。
|
||||
expect(env.ctx.fillStyle).toBe("#ffffff");
|
||||
expect(env.ctx.font).toBe("12px sans-serif");
|
||||
});
|
||||
|
||||
it("resize is a no-op for canvas2d (size comes from accessor)", () => {
|
||||
const env = createCtx();
|
||||
const surface = new Canvas2dRenderSurface(env.ctx, () => ({ width: 4, height: 4 }));
|
||||
expect(() => surface.resize(800, 600)).not.toThrow();
|
||||
surface.submit([{ type: "clear", color: "#111111" }]);
|
||||
expect(env.calls).toEqual([
|
||||
["clearRect", 0, 0, 4, 4],
|
||||
["fillRect", 0, 0, 4, 4]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function createCtx() {
|
||||
const calls: unknown[][] = [];
|
||||
const ctx: Ctx2dLike = {
|
||||
fillStyle: "",
|
||||
font: "",
|
||||
clearRect: vi.fn((...args: number[]) => calls.push(["clearRect", ...args])),
|
||||
fillRect: vi.fn((...args: number[]) => calls.push(["fillRect", ...args])),
|
||||
fillText: vi.fn((text: string, x: number, y: number) => calls.push(["fillText", text, x, y]))
|
||||
};
|
||||
return { calls, ctx };
|
||||
}
|
||||
49
packages/web-runtime/src/Canvas2dRenderSurface.ts
Normal file
49
packages/web-runtime/src/Canvas2dRenderSurface.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { RenderCommand } from "../../shared-contracts/src/runtime-sdk-contract";
|
||||
import type { Ctx2dLike, RenderSurface } from "./RenderSurface";
|
||||
|
||||
// 当前画布尺寸读取器:clear 需要用到画布全幅尺寸,尺寸由宿主侧(canvasElement)持有,注入读取。
|
||||
export type CanvasSizeAccessor = () => { readonly width: number; readonly height: number };
|
||||
|
||||
// Canvas2dRenderSurface 是默认渲染面,逐条把 RenderCommand 落到 2D 上下文。
|
||||
// painting 逻辑从旧 WebPlatformAdapter.submitRenderCommands 原样迁移过来,保持像素级行为一致。
|
||||
export class Canvas2dRenderSurface implements RenderSurface {
|
||||
readonly kind = "canvas2d" as const;
|
||||
|
||||
constructor(
|
||||
private readonly context: Ctx2dLike,
|
||||
private readonly getSize: CanvasSizeAccessor
|
||||
) {}
|
||||
|
||||
// 2D 画布尺寸由 canvasElement.width/height 决定,这里无需额外操作(保留以满足接口)。
|
||||
resize(): void {
|
||||
// no-op:Canvas2D 的视口直接来自宿主画布尺寸访问器,无独立投影状态需要更新。
|
||||
}
|
||||
|
||||
submit(commands: readonly RenderCommand[]): void {
|
||||
const size = this.getSize();
|
||||
for (const command of commands) {
|
||||
if (command.type === "clear") {
|
||||
// clear:先清空全幅,再在有指定背景色时铺一层底色(与旧适配器一致)。
|
||||
this.context.clearRect(0, 0, size.width, size.height);
|
||||
if (command.color !== undefined) {
|
||||
this.context.fillStyle = command.color;
|
||||
this.context.fillRect(0, 0, size.width, size.height);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (command.type === "rect") {
|
||||
// rect:默认填充色 #ffffff,与旧适配器保持一致。
|
||||
this.context.fillStyle = command.fill ?? "#ffffff";
|
||||
this.context.fillRect(command.x, command.y, command.width, command.height);
|
||||
continue;
|
||||
}
|
||||
if (command.type === "text") {
|
||||
// text:默认字号 12px sans-serif、默认填充 #ffffff,与旧适配器保持一致。
|
||||
this.context.fillStyle = command.fill ?? "#ffffff";
|
||||
this.context.font = `${command.fontSize ?? 12}px sans-serif`;
|
||||
this.context.fillText(command.text, command.x, command.y);
|
||||
}
|
||||
// 注意:sprite 类型在旧适配器中也未实现,这里保持一致地忽略,避免引入新行为。
|
||||
}
|
||||
}
|
||||
}
|
||||
131
packages/web-runtime/src/RenderSurface.ts
Normal file
131
packages/web-runtime/src/RenderSurface.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import type { RenderCommand } from "../../shared-contracts/src/runtime-sdk-contract";
|
||||
|
||||
// RenderSurface 是 submitRenderCommands 背后的内部渲染面抽象。
|
||||
// 逻辑模块永远只产出 RenderCommand[],宿主在这里把命令落到具体的 Canvas2D 或 WebGL 后端。
|
||||
// 该模块保持 DOM-free(只依赖注入进来的结构类型),方便在 Node 下做单元测试。
|
||||
export interface RenderSurface {
|
||||
// kind 让宿主/UI 能观测到本帧实际跑的是哪种渲染器(用于回退诊断与冒烟断言)。
|
||||
readonly kind: "canvas2d" | "webgl";
|
||||
// resize 在 createCanvas 时被调用,单位是像素;具体后端据此更新视口/投影。
|
||||
resize(width: number, height: number): void;
|
||||
// submit 把一帧的渲染命令落到后端,必须与旧 Canvas2D 行为等价。
|
||||
submit(commands: readonly RenderCommand[]): void;
|
||||
// dispose 是可选的资源释放钩子(WebGL 着色器/纹理等)。
|
||||
dispose?(): void;
|
||||
}
|
||||
|
||||
// Canvas2D 渲染面只需要的最小结构子集;注入即可,避免依赖浏览器 DOM 类型。
|
||||
export interface Ctx2dLike {
|
||||
fillStyle: string;
|
||||
font: string;
|
||||
clearRect(x: number, y: number, width: number, height: number): void;
|
||||
fillRect(x: number, y: number, width: number, height: number): void;
|
||||
fillText(text: string, x: number, y: number): void;
|
||||
}
|
||||
|
||||
// 文本图层:WebGL 后端用一块离屏 2D 画布栅格化文字,再作为纹理合成。
|
||||
// width/height 是该离屏画布尺寸;context2d 用来绘制并读取像素。
|
||||
export interface TextLayerLike {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly context2d: TextLayerContext2dLike;
|
||||
}
|
||||
|
||||
// 文本图层 2D 上下文需要的最小子集:清屏、写字、读像素。
|
||||
export interface TextLayerContext2dLike {
|
||||
fillStyle: string;
|
||||
font: string;
|
||||
clearRect(x: number, y: number, width: number, height: number): void;
|
||||
fillText(text: string, x: number, y: number): void;
|
||||
getImageData(x: number, y: number, width: number, height: number): { readonly data: ArrayLike<number>; readonly width: number; readonly height: number };
|
||||
}
|
||||
|
||||
// WebGL 渲染面需要的最小 GL 方法/常量子集(raw WebGL,无第三方引擎)。
|
||||
// 只声明实现真正用到的成员,保持结构类型最小且可被 mock 替身实现。
|
||||
export interface GLLike {
|
||||
readonly COLOR_BUFFER_BIT: number;
|
||||
readonly VERTEX_SHADER: number;
|
||||
readonly FRAGMENT_SHADER: number;
|
||||
readonly COMPILE_STATUS: number;
|
||||
readonly LINK_STATUS: number;
|
||||
readonly ARRAY_BUFFER: number;
|
||||
readonly STATIC_DRAW: number;
|
||||
readonly DYNAMIC_DRAW: number;
|
||||
readonly FLOAT: number;
|
||||
readonly TRIANGLES: number;
|
||||
readonly TRIANGLE_STRIP: number;
|
||||
readonly TEXTURE_2D: number;
|
||||
readonly TEXTURE0: number;
|
||||
readonly RGBA: number;
|
||||
readonly UNSIGNED_BYTE: number;
|
||||
readonly TEXTURE_MIN_FILTER: number;
|
||||
readonly TEXTURE_MAG_FILTER: number;
|
||||
readonly TEXTURE_WRAP_S: number;
|
||||
readonly TEXTURE_WRAP_T: number;
|
||||
readonly LINEAR: number;
|
||||
readonly CLAMP_TO_EDGE: number;
|
||||
readonly BLEND: number;
|
||||
readonly SRC_ALPHA: number;
|
||||
readonly ONE_MINUS_SRC_ALPHA: number;
|
||||
|
||||
viewport(x: number, y: number, width: number, height: number): void;
|
||||
clearColor(red: number, green: number, blue: number, alpha: number): void;
|
||||
clear(mask: number): void;
|
||||
|
||||
createShader(type: number): WebGlObject | null;
|
||||
shaderSource(shader: WebGlObject, source: string): void;
|
||||
compileShader(shader: WebGlObject): void;
|
||||
getShaderParameter(shader: WebGlObject, pname: number): unknown;
|
||||
getShaderInfoLog(shader: WebGlObject): string | null;
|
||||
deleteShader(shader: WebGlObject): void;
|
||||
|
||||
createProgram(): WebGlObject | null;
|
||||
attachShader(program: WebGlObject, shader: WebGlObject): void;
|
||||
detachShader(program: WebGlObject, shader: WebGlObject): void;
|
||||
linkProgram(program: WebGlObject): void;
|
||||
getProgramParameter(program: WebGlObject, pname: number): unknown;
|
||||
getProgramInfoLog(program: WebGlObject): string | null;
|
||||
useProgram(program: WebGlObject | null): void;
|
||||
deleteProgram(program: WebGlObject): void;
|
||||
|
||||
getAttribLocation(program: WebGlObject, name: string): number;
|
||||
getUniformLocation(program: WebGlObject, name: string): WebGlObject | null;
|
||||
|
||||
createBuffer(): WebGlObject | null;
|
||||
bindBuffer(target: number, buffer: WebGlObject | null): void;
|
||||
bufferData(target: number, data: ArrayBufferView, usage: number): void;
|
||||
deleteBuffer(buffer: WebGlObject): void;
|
||||
|
||||
enableVertexAttribArray(index: number): void;
|
||||
vertexAttribPointer(index: number, size: number, type: number, normalized: boolean, stride: number, offset: number): void;
|
||||
|
||||
uniform4f(location: WebGlObject | null, x: number, y: number, z: number, w: number): void;
|
||||
uniform1i(location: WebGlObject | null, x: number): void;
|
||||
|
||||
enable(cap: number): void;
|
||||
blendFunc(sfactor: number, dfactor: number): void;
|
||||
|
||||
createTexture(): WebGlObject | null;
|
||||
bindTexture(target: number, texture: WebGlObject | null): void;
|
||||
deleteTexture(texture: WebGlObject): void;
|
||||
activeTexture(texture: number): void;
|
||||
texParameteri(target: number, pname: number, param: number): void;
|
||||
texImage2D(target: number, level: number, internalformat: number, width: number, height: number, border: number, format: number, type: number, pixels: ArrayBufferView | null): void;
|
||||
|
||||
drawArrays(mode: number, first: number, count: number): void;
|
||||
}
|
||||
|
||||
// WebGL 句柄对象的最小别名;mock 替身可用任意非空对象冒充。
|
||||
export type WebGlObject = object;
|
||||
|
||||
// WebGL 初始化/编译/链接失败时抛出的类型化错误,供工厂识别并回退 Canvas2D。
|
||||
export class WebGlRenderSurfaceInitError extends Error {
|
||||
// reasonCode 让上层用稳定字符串而非消息文本判断错误类型。
|
||||
readonly reasonCode: string;
|
||||
|
||||
constructor(reasonCode: string, detail?: string) {
|
||||
super(detail === undefined ? reasonCode : `${reasonCode}:${detail}`);
|
||||
this.name = "WebGlRenderSurfaceInitError";
|
||||
this.reasonCode = reasonCode;
|
||||
}
|
||||
}
|
||||
257
packages/web-runtime/src/WebGlRenderSurface.spec.ts
Normal file
257
packages/web-runtime/src/WebGlRenderSurface.spec.ts
Normal file
@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WebGlRenderSurface } from "./WebGlRenderSurface";
|
||||
import { WebGlRenderSurfaceInitError } from "./RenderSurface";
|
||||
import type { GLLike, TextLayerLike, WebGlObject } from "./RenderSurface";
|
||||
|
||||
describe("WebGlRenderSurface", () => {
|
||||
it("clear issues clearColor + clear(COLOR_BUFFER_BIT)", () => {
|
||||
const gl = createMockGl();
|
||||
const surface = new WebGlRenderSurface({ gl, getSize: () => ({ width: 320, height: 180 }) });
|
||||
|
||||
surface.submit([{ type: "clear", color: "#204060" }]);
|
||||
|
||||
// 解析 #204060 -> 归一化 RGBA,断言 clearColor 被调用且 clear 用了 COLOR_BUFFER_BIT。
|
||||
expect(gl.calls).toContainEqual(["clearColor", 0x20 / 255, 0x40 / 255, 0x60 / 255, 1]);
|
||||
expect(gl.calls).toContainEqual(["clear", gl.COLOR_BUFFER_BIT]);
|
||||
});
|
||||
|
||||
it("clear without a color uses transparent alpha (0) to match Canvas2D clearRect parity", () => {
|
||||
const gl = createMockGl();
|
||||
const surface = new WebGlRenderSurface({ gl, getSize: () => ({ width: 320, height: 180 }) });
|
||||
|
||||
surface.submit([{ type: "clear" }]);
|
||||
|
||||
// 无颜色清屏必须是完全透明(alpha=0),与 Canvas2D 的 clearRect 透明语义对齐,而非不透明黑。
|
||||
const clearColorCall = gl.calls.find((call) => call[0] === "clearColor");
|
||||
expect(clearColorCall).toBeDefined();
|
||||
expect(clearColorCall?.[4]).toBe(0);
|
||||
expect(gl.calls).toContainEqual(["clear", gl.COLOR_BUFFER_BIT]);
|
||||
});
|
||||
|
||||
it("compiles programs once at construction and reuses them across submits (non-empty output)", () => {
|
||||
const gl = createMockGl();
|
||||
const surface = new WebGlRenderSurface({ gl, getSize: () => ({ width: 100, height: 100 }) });
|
||||
|
||||
// 构造时一次性编译着色器程序;记录构造后程序数量基线。
|
||||
const programsAfterConstruction = gl.programCount;
|
||||
expect(programsAfterConstruction).toBeGreaterThanOrEqual(1);
|
||||
|
||||
surface.submit([
|
||||
{ type: "rect", x: 0, y: 0, width: 10, height: 10, fill: "#ff0000" },
|
||||
{ type: "rect", x: 20, y: 20, width: 5, height: 5, fill: "#00ff00" }
|
||||
]);
|
||||
surface.submit([{ type: "rect", x: 0, y: 0, width: 1, height: 1, fill: "#0000ff" }]);
|
||||
|
||||
// 关键不变量:submit 帧绘制不再创建任何新程序,证明程序被复用。
|
||||
expect(gl.programCount).toBe(programsAfterConstruction);
|
||||
// rect 通过 drawArrays 产生非空输出。
|
||||
const draws = gl.calls.filter((call) => call[0] === "drawArrays");
|
||||
expect(draws.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("text with a text layer uploads a texture (texImage2D) and draws it", () => {
|
||||
const gl = createMockGl();
|
||||
const textLayer = createMockTextLayer(64, 32);
|
||||
const surface = new WebGlRenderSurface({
|
||||
gl,
|
||||
getSize: () => ({ width: 320, height: 180 }),
|
||||
textLayer
|
||||
});
|
||||
|
||||
surface.submit([{ type: "text", text: "Coins 1", x: 8, y: 16, fill: "#ffffff", fontSize: 12 }]);
|
||||
|
||||
expect(gl.calls.some((call) => call[0] === "texImage2D")).toBe(true);
|
||||
expect(gl.calls.some((call) => call[0] === "drawArrays")).toBe(true);
|
||||
// 文本图层应被写入文字。
|
||||
expect(textLayer.context2d.drawnTexts).toContain("Coins 1");
|
||||
});
|
||||
|
||||
it("text without a layer pushes WEBGL_TEXT_LAYER_UNAVAILABLE once and does not throw", () => {
|
||||
const gl = createMockGl();
|
||||
const diagnostics: string[] = [];
|
||||
const surface = new WebGlRenderSurface({
|
||||
gl,
|
||||
getSize: () => ({ width: 320, height: 180 }),
|
||||
onDiagnostic: (code) => diagnostics.push(code)
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
surface.submit([
|
||||
{ type: "text", text: "A", x: 1, y: 1 },
|
||||
{ type: "text", text: "B", x: 2, y: 2 }
|
||||
])
|
||||
).not.toThrow();
|
||||
|
||||
// 文本无法栅格化时不应抛错,且诊断码只推一次(one-time)。
|
||||
expect(diagnostics.filter((code) => code === "WEBGL_TEXT_LAYER_UNAVAILABLE")).toHaveLength(1);
|
||||
// 没有文本图层时不能上传纹理。
|
||||
expect(gl.calls.some((call) => call[0] === "texImage2D")).toBe(false);
|
||||
});
|
||||
|
||||
it("changed commands across submits issue different draw state (interactivity at surface level)", () => {
|
||||
const gl = createMockGl();
|
||||
const surface = new WebGlRenderSurface({ gl, getSize: () => ({ width: 100, height: 100 }) });
|
||||
|
||||
surface.submit([{ type: "rect", x: 0, y: 0, width: 10, height: 10, fill: "#ff0000" }]);
|
||||
const firstColors = gl.calls.filter((call) => call[0] === "uniform4f").map((call) => call.slice(1));
|
||||
|
||||
gl.calls.length = 0;
|
||||
surface.submit([{ type: "rect", x: 50, y: 50, width: 10, height: 10, fill: "#00ff00" }]);
|
||||
const secondColors = gl.calls.filter((call) => call[0] === "uniform4f").map((call) => call.slice(1));
|
||||
|
||||
// 不同帧的命令导致不同的着色器 uniform(颜色)状态,证明表面级可交互。
|
||||
expect(secondColors).not.toEqual(firstColors);
|
||||
});
|
||||
|
||||
it("throws a typed init error when shader compilation fails so the factory can fall back", () => {
|
||||
const gl = createMockGl({ failCompile: true });
|
||||
|
||||
expect(() => new WebGlRenderSurface({ gl, getSize: () => ({ width: 10, height: 10 }) })).toThrow(WebGlRenderSurfaceInitError);
|
||||
});
|
||||
|
||||
it("throws a typed init error when program linking fails", () => {
|
||||
const gl = createMockGl({ failLink: true });
|
||||
|
||||
expect(() => new WebGlRenderSurface({ gl, getSize: () => ({ width: 10, height: 10 }) })).toThrow(WebGlRenderSurfaceInitError);
|
||||
});
|
||||
|
||||
it("deletes attached shaders after a successful link so they do not leak", () => {
|
||||
const gl = createMockGl();
|
||||
new WebGlRenderSurface({ gl, getSize: () => ({ width: 10, height: 10 }) });
|
||||
|
||||
// 两套程序各编译两个着色器(共 4 个),链接成功后应 detach + delete 各 4 次。
|
||||
expect(gl.calls.filter((call) => call[0] === "detachShader")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("dispose frees GL programs/buffers/texture and is idempotent", () => {
|
||||
const gl = createMockGl();
|
||||
const surface = new WebGlRenderSurface({ gl, getSize: () => ({ width: 10, height: 10 }) });
|
||||
|
||||
surface.dispose();
|
||||
// 第一次 dispose 释放两套程序、两个缓冲、一张纹理。
|
||||
expect(gl.calls.filter((call) => call[0] === "deleteProgram")).toHaveLength(2);
|
||||
expect(gl.calls.filter((call) => call[0] === "deleteBuffer")).toHaveLength(2);
|
||||
expect(gl.calls.filter((call) => call[0] === "deleteTexture")).toHaveLength(1);
|
||||
|
||||
// 重复 dispose 幂等:不得再次对同一 GL 句柄发起删除。
|
||||
surface.dispose();
|
||||
expect(gl.calls.filter((call) => call[0] === "deleteProgram")).toHaveLength(2);
|
||||
expect(gl.calls.filter((call) => call[0] === "deleteBuffer")).toHaveLength(2);
|
||||
expect(gl.calls.filter((call) => call[0] === "deleteTexture")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// --- mocks ---
|
||||
|
||||
type MockGl = GLLike & {
|
||||
readonly calls: unknown[][];
|
||||
programCount: number;
|
||||
};
|
||||
|
||||
function createMockGl(options: { failCompile?: boolean; failLink?: boolean } = {}): MockGl {
|
||||
const calls: unknown[][] = [];
|
||||
const record = (name: string, ...args: unknown[]): void => {
|
||||
calls.push([name, ...args]);
|
||||
};
|
||||
const handle = (): WebGlObject => ({});
|
||||
|
||||
const gl: MockGl = {
|
||||
calls,
|
||||
programCount: 0,
|
||||
|
||||
COLOR_BUFFER_BIT: 0x4000,
|
||||
VERTEX_SHADER: 0x8b31,
|
||||
FRAGMENT_SHADER: 0x8b30,
|
||||
COMPILE_STATUS: 0x8b81,
|
||||
LINK_STATUS: 0x8b82,
|
||||
ARRAY_BUFFER: 0x8892,
|
||||
STATIC_DRAW: 0x88e4,
|
||||
DYNAMIC_DRAW: 0x88e8,
|
||||
FLOAT: 0x1406,
|
||||
TRIANGLES: 0x0004,
|
||||
TRIANGLE_STRIP: 0x0005,
|
||||
TEXTURE_2D: 0x0de1,
|
||||
TEXTURE0: 0x84c0,
|
||||
RGBA: 0x1908,
|
||||
UNSIGNED_BYTE: 0x1401,
|
||||
TEXTURE_MIN_FILTER: 0x2801,
|
||||
TEXTURE_MAG_FILTER: 0x2800,
|
||||
TEXTURE_WRAP_S: 0x2802,
|
||||
TEXTURE_WRAP_T: 0x2803,
|
||||
LINEAR: 0x2601,
|
||||
CLAMP_TO_EDGE: 0x812f,
|
||||
BLEND: 0x0be2,
|
||||
SRC_ALPHA: 0x0302,
|
||||
ONE_MINUS_SRC_ALPHA: 0x0303,
|
||||
|
||||
viewport: (...a) => record("viewport", ...a),
|
||||
clearColor: (...a) => record("clearColor", ...a),
|
||||
clear: (...a) => record("clear", ...a),
|
||||
|
||||
createShader: () => handle(),
|
||||
shaderSource: () => undefined,
|
||||
compileShader: () => undefined,
|
||||
getShaderParameter: () => options.failCompile !== true,
|
||||
getShaderInfoLog: () => "mock shader log",
|
||||
deleteShader: () => undefined,
|
||||
|
||||
createProgram: () => {
|
||||
gl.programCount += 1;
|
||||
return handle();
|
||||
},
|
||||
attachShader: () => undefined,
|
||||
detachShader: (...a) => record("detachShader", ...a),
|
||||
linkProgram: () => undefined,
|
||||
getProgramParameter: () => options.failLink !== true,
|
||||
getProgramInfoLog: () => "mock program log",
|
||||
useProgram: (...a) => record("useProgram", ...a),
|
||||
deleteProgram: (...a) => record("deleteProgram", ...a),
|
||||
|
||||
getAttribLocation: () => 0,
|
||||
getUniformLocation: () => handle(),
|
||||
|
||||
createBuffer: () => handle(),
|
||||
bindBuffer: (...a) => record("bindBuffer", ...a),
|
||||
bufferData: (target, data, usage) => record("bufferData", target, data.byteLength, usage),
|
||||
deleteBuffer: (...a) => record("deleteBuffer", ...a),
|
||||
|
||||
enableVertexAttribArray: (...a) => record("enableVertexAttribArray", ...a),
|
||||
vertexAttribPointer: (...a) => record("vertexAttribPointer", ...a),
|
||||
|
||||
uniform4f: (_loc, x, y, z, w) => record("uniform4f", x, y, z, w),
|
||||
uniform1i: (_loc, x) => record("uniform1i", x),
|
||||
|
||||
enable: (...a) => record("enable", ...a),
|
||||
blendFunc: (...a) => record("blendFunc", ...a),
|
||||
|
||||
createTexture: () => handle(),
|
||||
bindTexture: (...a) => record("bindTexture", ...a),
|
||||
deleteTexture: (...a) => record("deleteTexture", ...a),
|
||||
activeTexture: (...a) => record("activeTexture", ...a),
|
||||
texParameteri: (...a) => record("texParameteri", ...a),
|
||||
texImage2D: (target, level, internalformat, width, height) => record("texImage2D", target, level, internalformat, width, height),
|
||||
|
||||
drawArrays: (...a) => record("drawArrays", ...a)
|
||||
};
|
||||
|
||||
return gl;
|
||||
}
|
||||
|
||||
function createMockTextLayer(width: number, height: number): TextLayerLike & { context2d: { drawnTexts: string[] } } {
|
||||
const drawnTexts: string[] = [];
|
||||
const context2d = {
|
||||
fillStyle: "",
|
||||
font: "",
|
||||
drawnTexts,
|
||||
clearRect: () => undefined,
|
||||
fillText: (text: string) => {
|
||||
drawnTexts.push(text);
|
||||
},
|
||||
getImageData: (_x: number, _y: number, w: number, h: number) => ({
|
||||
data: new Uint8ClampedArray(w * h * 4),
|
||||
width: w,
|
||||
height: h
|
||||
})
|
||||
};
|
||||
return { width, height, context2d };
|
||||
}
|
||||
330
packages/web-runtime/src/WebGlRenderSurface.ts
Normal file
330
packages/web-runtime/src/WebGlRenderSurface.ts
Normal file
@ -0,0 +1,330 @@
|
||||
import type { RenderCommand } from "../../shared-contracts/src/runtime-sdk-contract";
|
||||
import type { GLLike, RenderSurface, TextLayerLike, WebGlObject } from "./RenderSurface";
|
||||
import { WebGlRenderSurfaceInitError } from "./RenderSurface";
|
||||
|
||||
// WebGlRenderSurface 构造入参:注入 GL 上下文、画布尺寸读取器,可选文本图层与诊断回调。
|
||||
export type WebGlRenderSurfaceInput = {
|
||||
readonly gl: GLLike;
|
||||
readonly getSize: () => { readonly width: number; readonly height: number };
|
||||
// 文本图层(离屏 2D 画布)用于栅格化文字;缺省则 text 命令被跳过并发一次诊断。
|
||||
readonly textLayer?: TextLayerLike;
|
||||
// 诊断回调:用于上报一次性提示,例如 WEBGL_TEXT_LAYER_UNAVAILABLE。
|
||||
readonly onDiagnostic?: (code: string) => void;
|
||||
};
|
||||
|
||||
// 纯色矩形 / 纹理四边形共用的顶点着色器:
|
||||
// a_pos 已经是 NDC 裁剪空间坐标(-1..1),a_uv 传给片元用于纹理采样。
|
||||
const VERTEX_SHADER_SOURCE = `
|
||||
attribute vec2 a_pos;
|
||||
attribute vec2 a_uv;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
v_uv = a_uv;
|
||||
gl_Position = vec4(a_pos, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// 纯色片元着色器:直接输出 uniform 颜色,用于绘制 rect 实心方块。
|
||||
const RECT_FRAGMENT_SHADER_SOURCE = `
|
||||
precision mediump float;
|
||||
uniform vec4 u_color;
|
||||
void main() {
|
||||
gl_FragColor = u_color;
|
||||
}
|
||||
`;
|
||||
|
||||
// 纹理片元着色器:采样文本图层纹理,用于把栅格化文字合成到场景上。
|
||||
const TEXTURE_FRAGMENT_SHADER_SOURCE = `
|
||||
precision mediump float;
|
||||
varying vec2 v_uv;
|
||||
uniform sampler2D u_texture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(u_texture, v_uv);
|
||||
}
|
||||
`;
|
||||
|
||||
// WebGlRenderSurface:用 raw WebGL(无第三方引擎)实现 clear/rect/text 三类命令。
|
||||
// 任何初始化/编译/链接失败都会抛出 WebGlRenderSurfaceInitError,交给工厂回退 Canvas2D。
|
||||
export class WebGlRenderSurface implements RenderSurface {
|
||||
readonly kind = "webgl" as const;
|
||||
|
||||
private readonly gl: GLLike;
|
||||
private readonly getSize: () => { readonly width: number; readonly height: number };
|
||||
// 显式包含 undefined:仓库开启 exactOptionalPropertyTypes,可选字段需可承接 undefined。
|
||||
private readonly textLayer: TextLayerLike | undefined;
|
||||
private readonly onDiagnostic: ((code: string) => void) | undefined;
|
||||
|
||||
// 纯色 rect 程序及其属性/uniform 位置(构造时编译一次,逐帧复用)。
|
||||
private readonly rectProgram: WebGlObject;
|
||||
private readonly rectPosLoc: number;
|
||||
private readonly rectColorLoc: WebGlObject | null;
|
||||
private readonly rectBuffer: WebGlObject;
|
||||
|
||||
// 纹理 text 程序及其属性/uniform 位置(构造时编译一次,逐帧复用)。
|
||||
private readonly texProgram: WebGlObject;
|
||||
private readonly texPosLoc: number;
|
||||
private readonly texUvLoc: number;
|
||||
private readonly texSamplerLoc: WebGlObject | null;
|
||||
private readonly texBuffer: WebGlObject;
|
||||
private readonly texture: WebGlObject;
|
||||
|
||||
// 一次性诊断去重:避免每帧重复上报同一提示码。
|
||||
private readonly emittedDiagnostics = new Set<string>();
|
||||
|
||||
// dispose 幂等守卫:防止重复释放 GL 资源(重复 deleteProgram/deleteBuffer 等)。
|
||||
private disposed = false;
|
||||
|
||||
constructor(input: WebGlRenderSurfaceInput) {
|
||||
this.gl = input.gl;
|
||||
this.getSize = input.getSize;
|
||||
this.textLayer = input.textLayer;
|
||||
this.onDiagnostic = input.onDiagnostic;
|
||||
const gl = this.gl;
|
||||
|
||||
// 编译两套程序:rect 纯色 + texture 文本。任一失败抛类型化错误,让工厂回退。
|
||||
this.rectProgram = this.createProgram(VERTEX_SHADER_SOURCE, RECT_FRAGMENT_SHADER_SOURCE);
|
||||
this.rectPosLoc = gl.getAttribLocation(this.rectProgram, "a_pos");
|
||||
this.rectColorLoc = gl.getUniformLocation(this.rectProgram, "u_color");
|
||||
|
||||
this.texProgram = this.createProgram(VERTEX_SHADER_SOURCE, TEXTURE_FRAGMENT_SHADER_SOURCE);
|
||||
this.texPosLoc = gl.getAttribLocation(this.texProgram, "a_pos");
|
||||
this.texUvLoc = gl.getAttribLocation(this.texProgram, "a_uv");
|
||||
this.texSamplerLoc = gl.getUniformLocation(this.texProgram, "u_texture");
|
||||
|
||||
// 顶点缓冲与文本纹理也只创建一次。
|
||||
this.rectBuffer = this.requireObject(gl.createBuffer(), "WEBGL_BUFFER_CREATE_FAILED");
|
||||
this.texBuffer = this.requireObject(gl.createBuffer(), "WEBGL_BUFFER_CREATE_FAILED");
|
||||
this.texture = this.requireObject(gl.createTexture(), "WEBGL_TEXTURE_CREATE_FAILED");
|
||||
|
||||
// 启用透明混合,便于文本纹理叠加在 rect 场景上。
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
// 初始化视口。
|
||||
const size = this.getSize();
|
||||
gl.viewport(0, 0, Math.max(1, size.width), Math.max(1, size.height));
|
||||
}
|
||||
|
||||
resize(width: number, height: number): void {
|
||||
// 视口随画布尺寸更新,保证像素到裁剪空间的映射正确。
|
||||
this.gl.viewport(0, 0, Math.max(1, width), Math.max(1, height));
|
||||
}
|
||||
|
||||
submit(commands: readonly RenderCommand[]): void {
|
||||
const gl = this.gl;
|
||||
const size = this.getSize();
|
||||
const viewWidth = Math.max(1, size.width);
|
||||
const viewHeight = Math.max(1, size.height);
|
||||
|
||||
for (const command of commands) {
|
||||
if (command.type === "clear") {
|
||||
// clear:无指定颜色时清成完全透明(alpha=0),与 Canvas2D 的 clearRect 透明语义保持一致;
|
||||
// 指定颜色时铺该底色并设为不透明(alpha=1),与 Canvas2D 的 fillRect 铺底行为一致。
|
||||
const [r, g, b] = parseColor(command.color ?? "#000000");
|
||||
gl.clearColor(r, g, b, command.color === undefined ? 0 : 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
continue;
|
||||
}
|
||||
if (command.type === "rect") {
|
||||
// rect:把像素坐标映射到裁剪空间,画两个三角形组成的实心四边形。
|
||||
this.drawRect(command.x, command.y, command.width, command.height, command.fill ?? "#ffffff", viewWidth, viewHeight);
|
||||
continue;
|
||||
}
|
||||
if (command.type === "text") {
|
||||
// text:用注入的离屏 2D 图层栅格化文字,再作为纹理合成;无图层则跳过并发一次诊断。
|
||||
this.drawText(command, viewWidth, viewHeight);
|
||||
}
|
||||
// sprite 命令在 S4 范围内未实现,保持与 Canvas2D 一致地忽略。
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// 幂等:已释放则直接返回,避免对同一 GL 句柄重复 delete。
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
const gl = this.gl;
|
||||
// 释放构造期一次性创建的 GL 资源:两套程序、两个顶点缓冲、一张文本纹理。
|
||||
gl.deleteProgram(this.rectProgram);
|
||||
gl.deleteProgram(this.texProgram);
|
||||
gl.deleteBuffer(this.rectBuffer);
|
||||
gl.deleteBuffer(this.texBuffer);
|
||||
gl.deleteTexture(this.texture);
|
||||
// 清空一次性诊断去重集合,便于宿主回收后状态干净。
|
||||
this.emittedDiagnostics.clear();
|
||||
}
|
||||
|
||||
// drawRect:构造两三角形(6 顶点)四边形并以 uniform 颜色绘制。
|
||||
private drawRect(x: number, y: number, width: number, height: number, fill: string, viewWidth: number, viewHeight: number): void {
|
||||
const gl = this.gl;
|
||||
const vertices = quadVertices(x, y, width, height, viewWidth, viewHeight);
|
||||
|
||||
gl.useProgram(this.rectProgram);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.rectBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(this.rectPosLoc);
|
||||
gl.vertexAttribPointer(this.rectPosLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const [r, g, b] = parseColor(fill);
|
||||
gl.uniform4f(this.rectColorLoc, r, g, b, 1);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
// drawText:把文字写到离屏 2D 图层 -> 上传为纹理 -> 在文字落点处画一个带 UV 的纹理四边形。
|
||||
private drawText(command: Extract<RenderCommand, { type: "text" }>, viewWidth: number, viewHeight: number): void {
|
||||
const gl = this.gl;
|
||||
const layer = this.textLayer;
|
||||
if (layer === undefined) {
|
||||
// 没有文本图层:场景仍由 rect 维持非空,这里只发一次性诊断,绝不抛错。
|
||||
this.emitOnce("WEBGL_TEXT_LAYER_UNAVAILABLE");
|
||||
return;
|
||||
}
|
||||
|
||||
const fontSize = command.fontSize ?? 12;
|
||||
const ctx = layer.context2d;
|
||||
// 把整张图层清空后写入本条文字(简单实现,每条文本独占一次上传,不做批处理)。
|
||||
ctx.clearRect(0, 0, layer.width, layer.height);
|
||||
ctx.fillStyle = command.fill ?? "#ffffff";
|
||||
ctx.font = `${fontSize}px sans-serif`;
|
||||
ctx.fillText(command.text, 0, fontSize);
|
||||
const image = ctx.getImageData(0, 0, layer.width, layer.height);
|
||||
|
||||
// 上传纹理。
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, layer.width, layer.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, toUint8(image.data));
|
||||
|
||||
// 文字四边形:fillText 以 baseline=fontSize 绘制,所以贴图顶部对应 command.y - fontSize。
|
||||
const top = command.y - fontSize;
|
||||
const interleaved = quadWithUv(command.x, top, layer.width, layer.height, viewWidth, viewHeight);
|
||||
|
||||
gl.useProgram(this.texProgram);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.texBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, interleaved, gl.DYNAMIC_DRAW);
|
||||
// 交错布局:每顶点 4 个 float(x,y,u,v),步长 16 字节。
|
||||
const stride = 4 * Float32Array.BYTES_PER_ELEMENT;
|
||||
gl.enableVertexAttribArray(this.texPosLoc);
|
||||
gl.vertexAttribPointer(this.texPosLoc, 2, gl.FLOAT, false, stride, 0);
|
||||
gl.enableVertexAttribArray(this.texUvLoc);
|
||||
gl.vertexAttribPointer(this.texUvLoc, 2, gl.FLOAT, false, stride, 2 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.uniform1i(this.texSamplerLoc, 0);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
// emitOnce:同一诊断码只上报一次,避免逐帧刷屏。
|
||||
private emitOnce(code: string): void {
|
||||
if (this.emittedDiagnostics.has(code)) return;
|
||||
this.emittedDiagnostics.add(code);
|
||||
this.onDiagnostic?.(code);
|
||||
}
|
||||
|
||||
// createProgram:编译顶点/片元着色器并链接,任一步失败抛类型化初始化错误。
|
||||
private createProgram(vertexSource: string, fragmentSource: string): WebGlObject {
|
||||
const gl = this.gl;
|
||||
const vertexShader = this.compileShader(gl.VERTEX_SHADER, vertexSource);
|
||||
const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, fragmentSource);
|
||||
|
||||
const program = this.requireObject(gl.createProgram(), "WEBGL_PROGRAM_CREATE_FAILED");
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
if (gl.getProgramParameter(program, gl.LINK_STATUS) !== true) {
|
||||
// 链接失败:着色器已编译但未被程序占用,逐个删除避免泄漏,再抛类型化错误供工厂回退。
|
||||
gl.deleteShader(vertexShader);
|
||||
gl.deleteShader(fragmentShader);
|
||||
throw new WebGlRenderSurfaceInitError("WEBGL_PROGRAM_LINK_FAILED", gl.getProgramInfoLog(program) ?? undefined);
|
||||
}
|
||||
// 链接成功后着色器对象已被链接进程序,可立即解除附着并删除,释放 GL 端的着色器资源。
|
||||
gl.detachShader(program, vertexShader);
|
||||
gl.detachShader(program, fragmentShader);
|
||||
gl.deleteShader(vertexShader);
|
||||
gl.deleteShader(fragmentShader);
|
||||
return program;
|
||||
}
|
||||
|
||||
// compileShader:编译单个着色器,失败抛类型化初始化错误。
|
||||
private compileShader(type: number, source: string): WebGlObject {
|
||||
const gl = this.gl;
|
||||
const shader = this.requireObject(gl.createShader(type), "WEBGL_SHADER_CREATE_FAILED");
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) !== true) {
|
||||
const log = gl.getShaderInfoLog(shader) ?? undefined;
|
||||
gl.deleteShader(shader);
|
||||
throw new WebGlRenderSurfaceInitError("WEBGL_SHADER_COMPILE_FAILED", log);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
// requireObject:把可能为 null 的 GL 句柄收敛为非空,否则抛类型化初始化错误。
|
||||
private requireObject(value: WebGlObject | null, reasonCode: string): WebGlObject {
|
||||
if (value === null) throw new WebGlRenderSurfaceInitError(reasonCode);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// quadVertices:把像素矩形(x,y,w,h)映射到 NDC 裁剪空间,返回两三角形(6 顶点 * 2 分量)。
|
||||
function quadVertices(x: number, y: number, width: number, height: number, viewWidth: number, viewHeight: number): Float32Array {
|
||||
const left = toClipX(x, viewWidth);
|
||||
const right = toClipX(x + width, viewWidth);
|
||||
const top = toClipY(y, viewHeight);
|
||||
const bottom = toClipY(y + height, viewHeight);
|
||||
// 顶点顺序:左上-右上-左下 / 左下-右上-右下。
|
||||
return new Float32Array([left, top, right, top, left, bottom, left, bottom, right, top, right, bottom]);
|
||||
}
|
||||
|
||||
// quadWithUv:与 quadVertices 同样的位置,但交错附带 UV,用于纹理采样。
|
||||
function quadWithUv(x: number, y: number, width: number, height: number, viewWidth: number, viewHeight: number): Float32Array {
|
||||
const left = toClipX(x, viewWidth);
|
||||
const right = toClipX(x + width, viewWidth);
|
||||
const top = toClipY(y, viewHeight);
|
||||
const bottom = toClipY(y + height, viewHeight);
|
||||
// UV:纹理顶部 v=0、底部 v=1(与 2D 图像行序一致)。
|
||||
return new Float32Array([
|
||||
left, top, 0, 0,
|
||||
right, top, 1, 0,
|
||||
left, bottom, 0, 1,
|
||||
left, bottom, 0, 1,
|
||||
right, top, 1, 0,
|
||||
right, bottom, 1, 1
|
||||
]);
|
||||
}
|
||||
|
||||
// 像素 X -> 裁剪空间 X(-1..1)。
|
||||
function toClipX(px: number, viewWidth: number): number {
|
||||
return (px / viewWidth) * 2 - 1;
|
||||
}
|
||||
|
||||
// 像素 Y -> 裁剪空间 Y(顶部 +1、底部 -1,匹配 2D 画布的 y 向下)。
|
||||
function toClipY(py: number, viewHeight: number): number {
|
||||
return 1 - (py / viewHeight) * 2;
|
||||
}
|
||||
|
||||
// parseColor:把 #rgb / #rrggbb 解析为归一化 [r,g,b];无法解析时回退白色。
|
||||
function parseColor(color: string): readonly [number, number, number] {
|
||||
const hex = color.trim().replace(/^#/, "");
|
||||
if (hex.length === 3) {
|
||||
const r = Number.parseInt(hex[0]! + hex[0]!, 16);
|
||||
const g = Number.parseInt(hex[1]! + hex[1]!, 16);
|
||||
const b = Number.parseInt(hex[2]! + hex[2]!, 16);
|
||||
if ([r, g, b].every(Number.isFinite)) return [r / 255, g / 255, b / 255];
|
||||
}
|
||||
if (hex.length === 6) {
|
||||
const r = Number.parseInt(hex.slice(0, 2), 16);
|
||||
const g = Number.parseInt(hex.slice(2, 4), 16);
|
||||
const b = Number.parseInt(hex.slice(4, 6), 16);
|
||||
if ([r, g, b].every(Number.isFinite)) return [r / 255, g / 255, b / 255];
|
||||
}
|
||||
// 无法解析的颜色回退白色,保证至少有可见输出。
|
||||
return [1, 1, 1];
|
||||
}
|
||||
|
||||
// toUint8:把图层像素数据规整为 Uint8Array,供 texImage2D 上传。
|
||||
// 单次拷贝:new Uint8Array(ArrayLike) 直接按元素填充,避免先转中间数组再转的双重拷贝。
|
||||
function toUint8(data: ArrayLike<number>): Uint8Array {
|
||||
if (data instanceof Uint8Array) return data;
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
170
packages/web-runtime/src/createRenderSurface.spec.ts
Normal file
170
packages/web-runtime/src/createRenderSurface.spec.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRenderSurface } from "./createRenderSurface";
|
||||
import type { Ctx2dLike, GLLike, WebGlObject } from "./RenderSurface";
|
||||
|
||||
describe("createRenderSurface", () => {
|
||||
it("preference webgl with a working GL context selects the webgl surface", () => {
|
||||
const result = createRenderSurface({
|
||||
preference: "webgl",
|
||||
getSize: () => ({ width: 320, height: 180 }),
|
||||
tryWebgl: () => createWorkingGl(),
|
||||
get2d: () => createCtx2d(),
|
||||
onDiagnostic: () => undefined
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("webgl");
|
||||
expect(result.surface.kind).toBe("webgl");
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("preference webgl with tryWebgl()=>null falls back to canvas2d with diagnostic", () => {
|
||||
const diagnostics: string[] = [];
|
||||
const result = createRenderSurface({
|
||||
preference: "webgl",
|
||||
getSize: () => ({ width: 320, height: 180 }),
|
||||
tryWebgl: () => null,
|
||||
get2d: () => createCtx2d(),
|
||||
onDiagnostic: (code) => diagnostics.push(code)
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("canvas2d");
|
||||
expect(result.diagnostics).toContain("WEBGL_UNAVAILABLE_FALLBACK_CANVAS2D");
|
||||
expect(diagnostics).toContain("WEBGL_UNAVAILABLE_FALLBACK_CANVAS2D");
|
||||
});
|
||||
|
||||
it("preference webgl where WebGL init throws falls back to canvas2d with init-failed diagnostic", () => {
|
||||
const diagnostics: string[] = [];
|
||||
const result = createRenderSurface({
|
||||
preference: "webgl",
|
||||
getSize: () => ({ width: 320, height: 180 }),
|
||||
// 返回会导致 shader 编译失败的 GL,使 WebGlRenderSurface 构造抛错。
|
||||
tryWebgl: () => createWorkingGl({ failCompile: true }),
|
||||
get2d: () => createCtx2d(),
|
||||
onDiagnostic: (code) => diagnostics.push(code)
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("canvas2d");
|
||||
expect(result.diagnostics).toContain("WEBGL_INIT_FAILED_FALLBACK_CANVAS2D");
|
||||
expect(diagnostics).toContain("WEBGL_INIT_FAILED_FALLBACK_CANVAS2D");
|
||||
});
|
||||
|
||||
it("preference canvas2d selects canvas2d without touching webgl", () => {
|
||||
let webglTried = false;
|
||||
const result = createRenderSurface({
|
||||
preference: "canvas2d",
|
||||
getSize: () => ({ width: 320, height: 180 }),
|
||||
tryWebgl: () => {
|
||||
webglTried = true;
|
||||
return createWorkingGl();
|
||||
},
|
||||
get2d: () => createCtx2d(),
|
||||
onDiagnostic: () => undefined
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("canvas2d");
|
||||
expect(webglTried).toBe(false);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("preference canvas2d with null 2d throws the existing canvas-2d-unavailable error", () => {
|
||||
expect(() =>
|
||||
createRenderSurface({
|
||||
preference: "canvas2d",
|
||||
getSize: () => ({ width: 1, height: 1 }),
|
||||
tryWebgl: () => null,
|
||||
get2d: () => null,
|
||||
onDiagnostic: () => undefined
|
||||
})
|
||||
).toThrow("WEB_PLATFORM_CANVAS_2D_UNAVAILABLE");
|
||||
});
|
||||
|
||||
it("preference webgl with both webgl and 2d unavailable throws the render-surface-unavailable error", () => {
|
||||
expect(() =>
|
||||
createRenderSurface({
|
||||
preference: "webgl",
|
||||
getSize: () => ({ width: 1, height: 1 }),
|
||||
tryWebgl: () => null,
|
||||
get2d: () => null,
|
||||
onDiagnostic: () => undefined
|
||||
})
|
||||
).toThrow("WEB_PLATFORM_RENDER_SURFACE_UNAVAILABLE");
|
||||
});
|
||||
});
|
||||
|
||||
// --- mocks ---
|
||||
|
||||
function createCtx2d(): Ctx2dLike {
|
||||
return {
|
||||
fillStyle: "",
|
||||
font: "",
|
||||
clearRect: () => undefined,
|
||||
fillRect: () => undefined,
|
||||
fillText: () => undefined
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkingGl(options: { failCompile?: boolean } = {}): GLLike {
|
||||
const handle = (): WebGlObject => ({});
|
||||
return {
|
||||
COLOR_BUFFER_BIT: 0x4000,
|
||||
VERTEX_SHADER: 0x8b31,
|
||||
FRAGMENT_SHADER: 0x8b30,
|
||||
COMPILE_STATUS: 0x8b81,
|
||||
LINK_STATUS: 0x8b82,
|
||||
ARRAY_BUFFER: 0x8892,
|
||||
STATIC_DRAW: 0x88e4,
|
||||
DYNAMIC_DRAW: 0x88e8,
|
||||
FLOAT: 0x1406,
|
||||
TRIANGLES: 0x0004,
|
||||
TRIANGLE_STRIP: 0x0005,
|
||||
TEXTURE_2D: 0x0de1,
|
||||
TEXTURE0: 0x84c0,
|
||||
RGBA: 0x1908,
|
||||
UNSIGNED_BYTE: 0x1401,
|
||||
TEXTURE_MIN_FILTER: 0x2801,
|
||||
TEXTURE_MAG_FILTER: 0x2800,
|
||||
TEXTURE_WRAP_S: 0x2802,
|
||||
TEXTURE_WRAP_T: 0x2803,
|
||||
LINEAR: 0x2601,
|
||||
CLAMP_TO_EDGE: 0x812f,
|
||||
BLEND: 0x0be2,
|
||||
SRC_ALPHA: 0x0302,
|
||||
ONE_MINUS_SRC_ALPHA: 0x0303,
|
||||
viewport: () => undefined,
|
||||
clearColor: () => undefined,
|
||||
clear: () => undefined,
|
||||
createShader: () => handle(),
|
||||
shaderSource: () => undefined,
|
||||
compileShader: () => undefined,
|
||||
getShaderParameter: () => options.failCompile !== true,
|
||||
getShaderInfoLog: () => null,
|
||||
deleteShader: () => undefined,
|
||||
createProgram: () => handle(),
|
||||
attachShader: () => undefined,
|
||||
detachShader: () => undefined,
|
||||
linkProgram: () => undefined,
|
||||
getProgramParameter: () => true,
|
||||
getProgramInfoLog: () => null,
|
||||
useProgram: () => undefined,
|
||||
deleteProgram: () => undefined,
|
||||
getAttribLocation: () => 0,
|
||||
getUniformLocation: () => handle(),
|
||||
createBuffer: () => handle(),
|
||||
bindBuffer: () => undefined,
|
||||
bufferData: () => undefined,
|
||||
deleteBuffer: () => undefined,
|
||||
enableVertexAttribArray: () => undefined,
|
||||
vertexAttribPointer: () => undefined,
|
||||
uniform4f: () => undefined,
|
||||
uniform1i: () => undefined,
|
||||
enable: () => undefined,
|
||||
blendFunc: () => undefined,
|
||||
createTexture: () => handle(),
|
||||
bindTexture: () => undefined,
|
||||
deleteTexture: () => undefined,
|
||||
activeTexture: () => undefined,
|
||||
texParameteri: () => undefined,
|
||||
texImage2D: () => undefined,
|
||||
drawArrays: () => undefined
|
||||
};
|
||||
}
|
||||
74
packages/web-runtime/src/createRenderSurface.ts
Normal file
74
packages/web-runtime/src/createRenderSurface.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { Canvas2dRenderSurface } from "./Canvas2dRenderSurface";
|
||||
import type { Ctx2dLike, GLLike, RenderSurface, TextLayerLike } from "./RenderSurface";
|
||||
import { WebGlRenderSurface } from "./WebGlRenderSurface";
|
||||
|
||||
// 渲染器偏好:canvas2d 为默认,webgl 为显式 opt-in。
|
||||
export type RendererPreference = "canvas2d" | "webgl";
|
||||
|
||||
// createRenderSurface 入参:所有 DOM 相关访问都以函数注入,保持本模块 DOM-free 可测。
|
||||
export type CreateRenderSurfaceInput = {
|
||||
readonly preference: RendererPreference;
|
||||
// 画布像素尺寸读取器,clear/视口等都需要。
|
||||
readonly getSize: () => { readonly width: number; readonly height: number };
|
||||
// 尝试获取 WebGL 上下文;不可用返回 null。
|
||||
readonly tryWebgl: () => GLLike | null;
|
||||
// 获取 2D 上下文;不可用返回 null。
|
||||
readonly get2d: () => Ctx2dLike | null;
|
||||
// 可选:为 WebGL 文本栅格化创建离屏图层;返回 null 表示无文本图层。
|
||||
readonly createTextLayer?: (width: number, height: number) => TextLayerLike | null;
|
||||
// 诊断回调:记录回退原因等。
|
||||
readonly onDiagnostic: (code: string) => void;
|
||||
};
|
||||
|
||||
// createRenderSurface 返回所选渲染面、其类型,以及本次选择过程产生的诊断码集合。
|
||||
export type CreateRenderSurfaceResult = {
|
||||
readonly surface: RenderSurface;
|
||||
readonly kind: RenderSurface["kind"];
|
||||
readonly diagnostics: readonly string[];
|
||||
};
|
||||
|
||||
// 文本图层默认尺寸:足够容纳一行短文本(HUD 用途);尺寸过大无意义。
|
||||
const DEFAULT_TEXT_LAYER_WIDTH = 256;
|
||||
const DEFAULT_TEXT_LAYER_HEIGHT = 64;
|
||||
|
||||
// createRenderSurface:根据偏好选择渲染面。WebGL 仅在显式 opt-in 时尝试,失败一律安全回退 Canvas2D。
|
||||
export function createRenderSurface(input: CreateRenderSurfaceInput): CreateRenderSurfaceResult {
|
||||
const diagnostics: string[] = [];
|
||||
const recordDiagnostic = (code: string): void => {
|
||||
diagnostics.push(code);
|
||||
input.onDiagnostic(code);
|
||||
};
|
||||
|
||||
if (input.preference === "webgl") {
|
||||
const gl = input.tryWebgl();
|
||||
if (gl !== null) {
|
||||
// WebGL 上下文可用:尝试构造渲染面(内部会编译着色器/创建缓冲)。
|
||||
try {
|
||||
const textLayer = input.createTextLayer?.(DEFAULT_TEXT_LAYER_WIDTH, DEFAULT_TEXT_LAYER_HEIGHT) ?? undefined;
|
||||
const surface = new WebGlRenderSurface({
|
||||
gl,
|
||||
getSize: input.getSize,
|
||||
...(textLayer === undefined ? {} : { textLayer }),
|
||||
onDiagnostic: recordDiagnostic
|
||||
});
|
||||
return { surface, kind: "webgl", diagnostics };
|
||||
} catch {
|
||||
// 初始化/编译/链接失败(无论是否 WebGlRenderSurfaceInitError):统一记录回退诊断,落到 Canvas2D。
|
||||
recordDiagnostic("WEBGL_INIT_FAILED_FALLBACK_CANVAS2D");
|
||||
}
|
||||
} else {
|
||||
// WebGL 上下文不可用:记录回退诊断,落到 Canvas2D。
|
||||
recordDiagnostic("WEBGL_UNAVAILABLE_FALLBACK_CANVAS2D");
|
||||
}
|
||||
|
||||
// 回退 Canvas2D:若 2D 也不可用,则整体无可用渲染面。
|
||||
const context2d = input.get2d();
|
||||
if (context2d === null) throw new Error("WEB_PLATFORM_RENDER_SURFACE_UNAVAILABLE");
|
||||
return { surface: new Canvas2dRenderSurface(context2d, input.getSize), kind: "canvas2d", diagnostics };
|
||||
}
|
||||
|
||||
// 默认偏好 canvas2d:沿用既有行为;2D 不可用抛既有错误码,保持兼容。
|
||||
const context2d = input.get2d();
|
||||
if (context2d === null) throw new Error("WEB_PLATFORM_CANVAS_2D_UNAVAILABLE");
|
||||
return { surface: new Canvas2dRenderSurface(context2d, input.getSize), kind: "canvas2d", diagnostics };
|
||||
}
|
||||
@ -1,3 +1,7 @@
|
||||
export * from "./Canvas2dRenderer";
|
||||
export * from "./WebRuntimeHost";
|
||||
export * from "./runtime-dependency-gate";
|
||||
export * from "./RenderSurface";
|
||||
export * from "./Canvas2dRenderSurface";
|
||||
export * from "./WebGlRenderSurface";
|
||||
export * from "./createRenderSurface";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user