/* ============================================================================ * pbl_overlay_play.js —— M9 覆盖层装配 + 事件处理器(scense / scense_game 侧) * * 铁律: * 1. 不修改 scense/scense_game 渲染主循环 —— 只做「外挂」:探测宿主只读钩子 * (window.scenseGame / CustomEvent),钩子缺失时降级为契约轮询,绝不 * 包裹/注入宿主帧函数(requestAnimationFrame 链不劫持)。 * 2. 广播到达 → 覆盖层 DOM 更新完成 ≤ 500ms(BUDGETS.broadcast_render_ms), * 超预算打 RENDER_DEGRADED 横幅并自适应节流合并补丁,不丢数据、不静默。 * 3. 首屏 ≤ 1500ms(BUDGETS.first_screen_ms),超时走 LOAD_FAIL 骨架 + 局部重试。 * 4. 网络交互只走 pbl_overlay_transport.js 契约端点(pbl_* 模块 .dspy)。 * * 依赖加载顺序:pbl_overlay_core.js → pbl_overlay_transport.js → 本文件 * API 对齐:Core.Bus(全局单例,on=window 监听 / emit=document CustomEvent)、 * Core.Chrono(label,budget,onOver).stop()、 * Core.DegradeManager(root).show(key,{detail})/.fromError(err)、 * Tpt.Transport({session_id,blueprint_id,onEvent,onDegrade,onPerf}) * ==========================================================================*/ (function (global) { 'use strict'; var Core = global.PblOverlayCore; var Tpt = global.PblOverlayTransport; if (!Core || !Tpt) { if (global.console) { console.error('[pbl_overlay_play] core/transport 未加载'); } return; } var BUDGETS = Core.BUDGETS; var esc = Core.esc; var now = Core.now; /* ───────────────────────── 1. 页面路由解析(注册表 route → 实际 URL) ───────────────────────── */ // 设计注册表 route 形如 /pbl_overlay/mission_board.ui; // 模块自动路由实际为 /pbl_scense_ext/pbl_overlay/mission_board.ui var MODULE_PREFIX = '/pbl_scense_ext'; function pageUrl(page) { var route = (page && page.route) || ''; if (!route) { return ''; } if (route.indexOf(MODULE_PREFIX + '/') === 0) { return Tpt.apiBase() + route; } if (route.charAt(0) === '/') { return Tpt.apiBase() + MODULE_PREFIX + route; } return Tpt.apiBase() + MODULE_PREFIX + '/pbl_overlay/' + route; } function readQuery() { var out = {}; var qs = String(location.search || '').replace(/^\?/, ''); if (!qs) { return out; } qs.split('&').forEach(function (kv) { if (!kv) { return; } var i = kv.indexOf('='); var k = i < 0 ? kv : kv.slice(0, i); var v = i < 0 ? '' : kv.slice(i + 1); try { out[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, ' ')); } catch (e) { out[k] = v; } }); return out; } function hostPageKey() { var el = document.querySelector('[data-pbl-page]'); if (el && el.getAttribute('data-pbl-page')) { return el.getAttribute('data-pbl-page'); } var m = String(location.pathname).match(/\/pbl_overlay\/([a-z_]+)\.ui/); if (m) { return m[1]; } return readQuery().page || 'play_preview'; } function readContext() { // 上下文优先级:宿主注入 __PBL_OVERLAY_CTX__ > URL query > 覆盖层本地草稿 var inj = global.__PBL_OVERLAY_CTX__ || {}; var q = readQuery(); var draft = {}; try { draft = JSON.parse(global.localStorage.getItem('pbl.overlay.ctx') || '{}') || {}; } catch (e) { draft = {}; } var ctx = { page: q.page || inj.page || null, session_id: q.session_id || inj.session_id || draft.session_id || null, blueprint_id: q.blueprint_id || inj.blueprint_id || draft.blueprint_id || null, world_id: q.world_id || inj.world_id || draft.world_id || null, role: inj.role || q.role || null }; try { global.localStorage.setItem('pbl.overlay.ctx', JSON.stringify({ session_id: ctx.session_id, blueprint_id: ctx.blueprint_id, world_id: ctx.world_id })); } catch (e) { /* 隐私模式忽略 */ } return ctx; } /* ───────────────────────── 2. scense / scense_game 只读桥(不改渲染主循环) ───────────────────────── */ var ScenseBridge = { renderer: function () { return global.scenseGame || global.SCENSE_GAME || global.__SCENSE_RUNTIME__ || null; }, // 仅探测宿主状态,不写回 hasRenderer: function () { return !!this.renderer(); }, rendererStatus: function () { var g = this.renderer(); if (!g) { return { available: false, paused: null, entities: 0 }; } var n = 0; if (typeof g.entityCount === 'number') { n = g.entityCount; } else if (g.entities && g.entities.length !== undefined) { n = g.entities.length; } return { available: true, paused: (typeof g.paused === 'boolean') ? g.paused : null, entities: n }; }, // 订阅宿主广播的 CustomEvent(宿主没有该事件时零副作用,靠 transport 通道兜底) onHostEvent: function (handler) { var names = ['scense:entity_updated', 'scense:broadcast', 'scense:state-applied', 'pbl:broadcast']; names.forEach(function (n) { try { global.addEventListener(n, function (ev) { handler((ev && ev.detail) || {}); }, false); } catch (e) { /* 忽略 */ } }); }, // Play 控制:优先宿主 API(只调用不修改),否则回落 scense_game 契约端点 control: function (action, ctx) { var g = this.renderer(); if (g && typeof g[action] === 'function') { try { var r = g[action](); if (r && typeof r.then === 'function') { return r; } return Promise.resolve({ status: 'ok', via: 'host', action: action }); } catch (e) { /* 继续走契约 */ } } var map = { start: 'game_start', pause: 'game_pause', resume: 'game_resume', restart: 'game_restart', end: 'game_end' }; var ep = map[action]; if (!ep) { return Promise.reject({ code: 'PBL_E_NOT_FOUND', message: '未知控制 ' + action }); } return fetch(Tpt.apiBase() + '/scense_game/game/api/' + ep + '.dspy', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json;charset=UTF-8' }, body: JSON.stringify({ session_id: (ctx && ctx.session_id) || '', world_id: (ctx && ctx.world_id) || '' }) }).then(function (r) { if (!r.ok) { return { status: 'fail', code: 'PBL_E_NETWORK', message: 'HTTP ' + r.status }; } return r.json(); }); } }; /* ───────────────────────── 3. 覆盖层实例 ───────────────────────── */ function Overlay(opts) { opts = opts || {}; this.pageKey = opts.page || hostPageKey() || 'play_preview'; this.page = Core.byKey(this.pageKey) || Core.byKey('play_preview'); this.ctx = opts.context || readContext(); this.chrono = new Core.Chrono('first_screen', BUDGETS.first_screen_ms, function (ms) { if (global.__PBL_OVERLAY__) { global.__PBL_OVERLAY__.degrade.show('RENDER_DEGRADED', { detail: '首屏 ' + Math.round(ms) + 'ms 超预算 ' + BUDGETS.first_screen_ms + 'ms' }); } }); this.degrade = new Core.DegradeManager(opts.root || this.pageRoot()); this.components = new Core.Components(); this.transport = null; this.model = {}; this.patchQueue = []; this.patchScheduled = false; this.lastPatchCost = 0; this.throttleMs = 0; // 超预算后自适应节流 this.mounted = false; this.destroyed = false; this.perf = { first_screen_ms: null, broadcast_render_ms: null }; this.emitter = document; // 局部事件走 document CustomEvent(pbl-ov:* 前缀) this._unsubs = []; this.registerBuiltinComponents(); this.registerEventHandlers(); } Overlay.prototype.pageRoot = function () { return document.querySelector('[data-pbl-overlay-root]') || document.body; }; /* ── 局部事件总线(不占用全局 Bus 命名空间,避免与宿主事件混淆) ── */ Overlay.prototype.on = function (name, fn) { var h = function (ev) { fn((ev && ev.detail) || {}); }; this.emitter.addEventListener(name, h, false); this._unsubs.push([name, h]); return this; }; Overlay.prototype.emit = function (name, detail) { return Core.Bus.emit('pbl-ov:' + name, detail); }; Overlay.prototype.mount = function () { if (this.mounted) { return Promise.resolve(this); } this.mounted = true; var self = this; this.transport = new Tpt.Transport({ session_id: this.ctx.session_id, blueprint_id: this.ctx.blueprint_id, since_seq: 0, onEvent: function (item) { self.ingestTransport(item); }, onDegrade: function (code, msg) { self.degrade.show(self.degrade.mapCode(code), { detail: msg || '' }); }, onPerf: function (name, ms) { self.perf[name] = ms; } }); // S1:bootstrap → 首屏渲染(≤1500ms 门禁由 Chrono 自动判定) return this.transport.bootstrap({ world_id: this.ctx.world_id, page: this.pageKey }) .then(function (d) { self.model = normalizeBootstrap(d); self.degrade.hide('LOAD_FAIL'); self.renderAll(); self.chrono.stop(); self.startStreams(); self.writePerfBadge(); return self; }) .catch(function (err) { self.degrade.fromError(err); // 断网/超时:用本地缓存模型渲染骨架,保证首屏不完全空白 + 局部重试按钮 self.model = cachedModel(self.pageKey); self.renderAll(true); self.chrono.stop(); self.startStreams(); self.writePerfBadge(); return self; }); }; // 事件流:transport(SSE→长轮询自动降级)+ 宿主 CustomEvent(只读旁路) Overlay.prototype.startStreams = function () { var self = this; this.transport.subscribe(); ScenseBridge.onHostEvent(function (detail) { self.ingestHost(detail); }); // 重试按钮(core DegradeManager banner 事件委托:data-act=retry 会派发 pbl:degrade-retry) Core.Bus.on('pbl:degrade-retry', function () { self.retryLast(); }); this.on('navigate', function (p) { self.navigate(p && p.page); }); this.on('control', function (p) { ScenseBridge.control(p && p.action, self.ctx).then(function (res) { if (res && res.status === 'fail') { self.degrade.show(self.degrade.mapCode(res.code || 'PBL_E_NETWORK'), { detail: res.message || '' }); } else { self.degrade.show('SESSION_ENDED', { detail: '控制指令已下发:' + (p && p.action), transient: true }); } }).catch(function (e) { self.degrade.fromError(e); }); }); }; Overlay.prototype.retryLast = function () { var self = this; return this.transport.bootstrap({ world_id: this.ctx.world_id, page: this.pageKey }) .then(function (d) { self.model = normalizeBootstrap(d); self.degrade.hide('LOAD_FAIL'); self.degrade.hide('NET_FAIL'); self.renderAll(); return d; }) .catch(function (e) { self.degrade.fromError(e); }); }; /* ───────────────────────── 4. 广播 → 覆盖层补丁(≤500ms 门禁 + 自适应节流) ───────────────────────── */ // transport 通道:item = {seq,event_type,entity_id,payload,received_at} Overlay.prototype.ingestTransport = function (item) { var patch = normalizeTransportPatch(item); if (!patch) { return; } this.patchQueue.push(patch); this.schedulePatch(); }; // 宿主 CustomEvent 旁路(无 seq,仅做展示层增量,不参与权威状态) Overlay.prototype.ingestHost = function (detail) { if (!detail || (detail.seq !== undefined && detail.seq !== null)) { return; } // 带 seq 的走 transport this.patchQueue.push(normalizeHostPatch(detail)); this.schedulePatch(); }; Overlay.prototype.schedulePatch = function () { var self = this; if (this.patchScheduled) { return; } this.patchScheduled = true; var wait = this.throttleMs; var raf = global.requestAnimationFrame || function (fn) { return global.setTimeout(fn, 16); }; global.setTimeout(function () { raf(function () { self.flushPatches(); }); }, wait); }; Overlay.prototype.flushPatches = function () { this.patchScheduled = false; if (this.destroyed || !this.patchQueue.length) { return; } var batch = this.patchQueue.splice(0, this.patchQueue.length); var t0 = now(); for (var i = 0; i < batch.length; i++) { this.applyPatch(batch[i]); } this.renderDynamic(); persistModel(this.pageKey, this.model); var cost = Math.round(now() - t0); this.lastPatchCost = cost; this.perf.broadcast_render_ms = cost; this.writePerfBadge(); if (cost > BUDGETS.broadcast_render_ms) { // 超预算:横幅 + 自适应节流(合并更多补丁),不丢数据 this.throttleMs = Math.min(2000, Math.max(200, this.throttleMs + 200)); this.degrade.show('RENDER_DEGRADED', { detail: '广播渲染 ' + cost + 'ms 超预算 ' + BUDGETS.broadcast_render_ms + 'ms,已合并补丁(节流 ' + this.throttleMs + 'ms)' }); } else if (this.throttleMs > 0) { this.throttleMs = Math.max(0, this.throttleMs - 100); } }; Overlay.prototype.applyPatch = function (patch) { var m = this.model; if (!m || !patch) { return; } if (patch.entity_id) { if (!m.entities) { m.entities = {}; } var prev = m.entities[patch.entity_id] || {}; m.entities[patch.entity_id] = Object.assign({}, prev, patch.state || {}); m.entities[patch.entity_id].__rev = (prev.__rev || 0) + 1; m.entity_count = Object.keys(m.entities).length; } if (patch.event_type) { if (!m.events) { m.events = []; } m.events.unshift({ type: patch.event_type, text: patch.text || patch.event_type, at: patch.at }); if (m.events.length > 50) { m.events.length = 50; } } if (patch.progress !== undefined && patch.progress !== null) { m.progress = Number(patch.progress); } if (patch.quality_state) { m.quality_state = patch.quality_state; } if (patch.artifact) { (m.artifacts = m.artifacts || []).unshift(patch.artifact); } m.lastEvent = patch; this.emit('model-patched', patch); }; function normalizeTransportPatch(item) { if (!item) { return null; } var p = item.payload || {}; return { source: 'broadcast', seq: item.seq, event_type: item.event_type || 'patch', entity_id: item.entity_id || p.entity_id || null, state: p.state || p.patch || null, text: p.text || p.message || item.event_type || '', progress: (p.progress !== undefined) ? p.progress : undefined, quality_state: p.quality_state || null, artifact: p.artifact || null, at: item.received_at || now() }; } function normalizeHostPatch(detail) { detail = detail || {}; return { source: 'host', seq: null, event_type: detail.event_type || detail.type || 'host_patch', entity_id: detail.entity_id || null, state: detail.state || detail.patch || null, text: detail.text || detail.message || '', progress: detail.progress, quality_state: detail.quality_state || null, artifact: detail.artifact || null, at: detail.at || now() }; } /* ───────────────────────── 5. 11 组件注册 + 渲染(覆盖层内 DOM,不触碰渲染层 canvas) ───────────────────────── */ Overlay.prototype.registerBuiltinComponents = function () { var self = this; var C = this.components; C.register('TopBar', function (host, m) { host.innerHTML = '
'; }); C.register('NavRail', function (host, m) { var pages = Core.pagesOfRole(m.role || 'logined'); var html = ''; }); C.register('GoalCard', function (host, m) { var g = m.goal || {}; host.insertAdjacentHTML('beforeend', '' + esc(g.question || '暂无目标(等待蓝图编译)') + '
' + '' + esc(g.milestone || '') + '
' + esc(s.name || '-') + ' ' + esc(s.kind || '') + '
' + '实体 ' + Number(m.entity_count || 0) + ' · 观察者 ' + Number(m.observer_count || 0) + '
| 维度 | 权重 | 得分 |
|---|---|---|
| ' + esc(rows[i].name || rows[i].dimension || '-') + ' | ' + esc(String(rows[i].weight === undefined ? '' : rows[i].weight)) + ' | ' + esc(String(rows[i].score === undefined ? '-' : rows[i].score)) + ' |