From dfd24cfe2aa222ccf7e04bcb6b5df32794749df3 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Fri, 23 Oct 2026 20:47:00 +0800 Subject: [PATCH] docs(09-25): add translucent overlay and postEffect plan --- .../09-minimum-browser-runnable/09-25-PLAN.md | 584 ++++++++++++++++++ 1 file changed, 584 insertions(+) create mode 100644 .planning/phases/09-minimum-browser-runnable/09-25-PLAN.md diff --git a/.planning/phases/09-minimum-browser-runnable/09-25-PLAN.md b/.planning/phases/09-minimum-browser-runnable/09-25-PLAN.md new file mode 100644 index 0000000..891caca --- /dev/null +++ b/.planning/phases/09-minimum-browser-runnable/09-25-PLAN.md @@ -0,0 +1,584 @@ +--- +phase: 09-minimum-browser-runnable +plan: 25 +type: execute +# 20:沿用本阶段既有计划的波次编号,使本计划排期在 09-01..09-24 之后(D-01 增量派发,计划累积) +# depends_on 为空:本计划改动的 8 个文件没有任何其它计划触碰,不存在同波次并行写冲突,故结构上独立; +# 运行在最后的波次只是「排期在后」,不是「依赖前序计划」(见 must_haves.assumptions 的波次说明) +wave: 20 +depends_on: [] +files_modified: + - packages-user/client-base/src/map/overlay.ts + - packages-user/client-base/src/map/__test__/overlay.test.ts + - packages-user/client-base/src/map/__test__/rendererGate.test.ts + - packages-user/client-base/src/map/vertex.ts + - packages-user/client-base/src/map/renderer.ts + - packages-user/client-base/src/map/types.ts + - packages-user/client-base/src/map/shader/map.frag + - .planning/phases/09-minimum-browser-runnable/09-25-SUMMARY.md +autonomous: false +requirements: [] +estimate: + # 未校准估算(confidence: low),SUMMARY 须声明这一点 + # 单计划例外(用户批准):本增量由用户明确要求「新增一个计划」,即本派发单元只允许一个计划,故不拆分; + # 160000 tokens / 7 tasks 超出 100000 的单计划预算(ratio 1.6),属用户批准的超预算例外, + # tokens 按真实估算保留,不为通过预算而调低 + tokens: 160000 + raw_tokens: 160000 + tasks: 7 + confidence: low +must_haves: + truths: + - "**本计划第一个 block 点是 Task 0 的 `blocking-human` 汇报关卡(D-03 / D-04)**:先汇报「修正地图渲染的半透明排序缺陷 + 修好后处理 FBO 的深度与 ping/pong 配对」的要做什么 / 为什么 / 大致怎么做 / 影响范围,逐条列明两遍式渲染的落点、静态半透明提取机制、动态块并入覆盖层、后处理修正项、验证方式、已知边界与风险、全部待批准命名(OI-2)与疑似问题(OI-1,只提问、不判定、不擅自修复),然后停下等待「可以执行」。" + - "**用户可观察结果(浏览器 UAT 的判据,缺一不可)**:①半透明图块压在不透明图块上时能透出下方内容,不出现黑洞;②两个相互重叠的半透明图块(同一行 / 不同行各一组)后到前混合正确,刷新或交换绘制来源后不闪错序;③半透明的移动块经过半透明与不透明图块时遮挡与混合正确、移动中不闪烁;④26×13 跨分块地图上的半透明图块位置正确、跨块部分不被裁掉(OI-1 选 (B) 时降级为「不崩溃 / 不新增错误」);⑤淡出到极低 alpha 时不因 0.1 阈值突然消失或跳变;⑥0 / 1 / 2+ 个后处理效果下画面都可见、不黑、深度遮挡正确;⑦改变画布尺寸(含效果开启)无 GL 报错且颜色 / 深度都正确缩放;⑧动态增删移动块后覆盖层成员正确、不残留残影;⑨在有半透明移动块时调整图层列表 / `zIndex`,动态块的遮挡与混合顺序随新图层顺序正确变化,不残留旧排序。" + - "**两遍式渲染(锁定设计)**:不透明遍保持现有分块批量实例化绘制与 `depthMask(true)`(显式设置),分块张量 `[chunk][layer][row][col]` 零改动、不排序;透明覆盖层最后绘制,`depthMask(false)`、深度测试仍开、混合开;覆盖层与不透明层之间的遮挡由不透明遍写入的深度决定,覆盖层之间由提交顺序决定(不写深度)。渲染状态收尾必须显式恢复 `depthMask(true)`;混合改为分离式函数(`blendFuncSeparate`),使 alpha 通道正确累积。" + - "**静态半透明提取(锁定设计)**:静态图块的有效 alpha 变为 `< 1` 时,从索引池取到该实例后把该实例的 `x/y/w/h` 置 0(复用「空图块」退化四边形机制),保留其槽位与其余数据,并登记一条覆盖层记录(记录 layer / 地图坐标 / 原始 rect);alpha 回到 1 时把 rect 写回并注销记录;**不压缩、不重排分块数组**。成员图块在 `updateTileVertex` 重新写入后被再次置零并刷新记录,避免同一图块同时被不透明遍与覆盖层绘制。地图重建(`checkRebuild` / `setBlockSize`)会重新分配数组并丢失 alpha 状态,必须同时清空静态覆盖层成员并标记排序脏。" + - "**覆盖层存储与绘制(锁定设计)**:使用独立的、体量很小的暂存实例数组(不是分块数组、不是动态数组),按缓存顺序把静态半透明图块与全部动态图块组装成实例数据,脏时上传到独立 GPU 缓冲区,用 `drawArraysInstanced` 绘制(WebGL2 无 baseInstance,按现有做法重新绑定属性指针)。**不重排动态数组**,`MovingBlock.index` / `movingIndexMap` / 扩缩容逻辑逐字保留。" + - "**排序键与惰性排序(锁定设计)**:覆盖层按 `(layerIndex, y, x)` 升序绘制,动态块使用其小数 y;x 仅作为同一行超大地图的稳定次级键,且在同一键下静态先于动态;x 排序只在 CPU 侧,**绝不编码进 z 或深度**,不使用半精度/浮点深度编码,不引入 OIT / 深度剥离。排序在 `render()` 内惰性执行:仅排序脏时排序一次,仅值脏时复用缓存顺序。**条目身份唯一**:`key` 为唯一身份——静态为「种类 + 层索引 + 地图 x + 地图 y」,动态为「种类 + 移动索引」;因此同一格在不同图层的两个半透明图块是两条独立条目,不会互相覆盖。" + - "**两个独立脏标记(锁定设计)**:值脏(alpha / frame / 纹理等**不改变排序键**的值变化)只刷新暂存缓冲区,不重排;顺序脏(成员增删、alpha 跨阈值、动态块增删、地图重建、图层列表 / zIndex 变化、动态块位置或图层变化)才重排。`setStaticAlpha` 在覆盖层内部的纯 alpha 变化不得触发重排(只有跨阈值改变成员时才重排)。**图层列表 / zIndex 变化后不得残留旧的动态排序键**:动态条目的 `layerIndex` 在每次组装时按当前 `renderer.getLayerIndex(block.layer)`(以及 `block.x` / `block.y`)重新推导并 `update` 到排序模块,不依赖 `updateMoving` 被再次调用;静态条目在该路径上随地图重建清空。任一脏标记都必须让 `needUpdate()` 返回 true,从而触发暂存缓冲区重新上传与重绘。" + - "**map.frag 的 discard 阈值**:由 `0.1` 下调为 `0.004`(或等价极小值),使淡出不再跳变;保留 discard 仅作为填充率优化,**不作为排序机制**。既有注释不得修改。" + - "**后处理修正(锁定设计,在范围内)**:ping/pong 两个 FBO 各自绑定自己的颜色纹理(ping←pingTexture、pong←pongTexture),并各自挂接一个 `DEPTH_COMPONENT24` 深度 renderbuffer(`framebufferRenderbuffer`),使场景渲染进 FBO 时深度测试仍有效;ping/pong 纹理设置 `NEAREST` 过滤与 `CLAMP_TO_EDGE` 包裹,避免纹理不完整导致采样为黑。链路修正为:0 个启用效果 → 渲染到默认帧缓冲;至少 1 个 → 场景渲染进 ping,效果 i 读取上一个 FBO 的颜色纹理、写入另一个 FBO,最后一个效果写 `null`(屏幕);单效果路径必须正确。`setCanvasSize` 同时重设两个深度 renderbuffer 的存储尺寸,`destroy` 删除两个 FBO、两个纹理、两个深度 renderbuffer(以及覆盖层缓冲区)。新增字段必须进入 `IContextData`。" + - "**验证策略(无 RESEARCH / 无 VALIDATION,Nyquist 不适用)**:把排序与成员状态机抽成 DOM-free 的纯模块并用 vitest 单测(排序键、x 稳定次级键、静态先于动态、成员增删、值脏 vs 顺序脏、惰性排序、阈值边界);用静态源码闸门覆盖 `depthMask(false)` / `depthMask(true)` / 分离式混合 / `DEPTH_COMPONENT24` + `framebufferRenderbuffer` + 缩放 + 删除 / 无半精度顺序编码 / 分块布局未被重排 / 动态索引身份未变 / `map.frag` 阈值;跑 `vitest`、`eslint`、按文件过滤的 `vue-tsc --noEmit`(沿用 09-24 的基线纪律:只精确豁免用户裁定的 `getLayerByAlias` 基线一条并打印证据,不忽略 `renderer.ts` 的其它诊断)与范围闸门。最终必须由用户在浏览器做人工 UAT(`autonomous: false`),并诚实声明没有 WebGL E2E 自动化。" + - "**命名尚未获批(D-04),且 OI-2 是完整清单**:本计划新增的**全部**标识符——文件名、常量、函数、枚举及其成员、接口及其**每个字段**、类及其**每个字段**(含两个内部 `Map` 与缓存数组)、方法(含接口方法与私有方法)、私有接口及其**每个字段**、键构造方法、`IContextData` 新字段——都只以「提议」形式出现,逐条集中在 `must_haves.open_items` 的 OI-2(每项含 1-2 个备选名);`renderer.ts` 不新增任何类成员(只使用 `IContextData` 字段与局部变量)。Task 0 必须逐条报备并等待批准,未获批前不得落码。既有名称可自由使用。" + - "**范围(只动 8 个文件)**:`packages-user/client-base/src/map/overlay.ts`(新建)、`map/__test__/overlay.test.ts`(新建)、`map/__test__/rendererGate.test.ts`(新建)、`map/vertex.ts`、`map/renderer.ts`、`map/types.ts`、`map/shader/map.frag`、`09-25-SUMMARY.md`。`moving.ts` / `viewport.ts` / `element.ts` / `block.ts` / `constant.ts` / `status.ts` / `map/index.ts` / `extension/**` / `shader/*.vert` / 依赖与配置文件 / 09-01..09-24 计划与 CONTEXT 一律零改动。" + artifacts: + - path: packages-user/client-base/src/map/overlay.ts + provides: "DOM-free 的覆盖层排序与成员状态机:alpha 阈值常量与判定函数、条目种类枚举、条目接口(含唯一身份键与数值数据索引)、排序接口与实现(加入 / 移除 / 更新 / 清空静态 / 清空 / 列动态条目 / 标记顺序脏 / 标记值脏 / 清除脏标记 / 取缓存顺序);身份键 `static:${layerIndex}:${mapX}:${mapY}` 与 `dynamic:${movingIndex}`;排序键 `(layerIndex, y, x, kind)`,静态先于动态;不依赖 WebGL / DOM / 数据端" + - path: packages-user/client-base/src/map/__test__/overlay.test.ts + provides: "纯单测:排序键与稳定次级键、静态先于动态、成员增删、重复加入幂等、同键只值脏 / 换坐标顺序脏、身份唯一性(同格不同图层共存、静态与动态同键位共存)、惰性排序与缓存复用、`clearStatic` 只清静态、阈值边界(1 为不透明、0.999 与 0 为覆盖层)、值脏 / 顺序脏语义;每个 `it` 前一行注释" + - path: packages-user/client-base/src/map/__test__/rendererGate.test.ts + provides: "只读源码闸门(用 `node:fs` 读取本计划改动的源码,不导入 WebGL 模块):两遍式状态设置、分离式混合、ping/pong 配对、`DEPTH_COMPONENT24` + `framebufferRenderbuffer` + `renderbufferStorage` + `deleteRenderbuffer`、覆盖层独立缓冲区与 `drawArraysInstanced`、无 `drawElementsInstanced`、分块布局标记仍在且分块数组未被排序、动态索引身份仍在、组装前用 `getMovingBlockByIndex` + `getLayerIndex` 重新推导动态排序键、`map.frag` 阈值、无半精度 / OIT / 深度剥离标记;每个 `it` 前一行注释" + - path: packages-user/client-base/src/map/vertex.ts + provides: "覆盖层成员与暂存组装:`setStaticAlpha` 的阈值成员增删与退化 rect / 恢复(静态身份键 = 种类 + 层索引 + 地图坐标,跨图层同格不互相覆盖)、`updateTileVertex` 对已入覆盖层的静态图块再次置零、`setStaticFrame` 的覆盖层值脏、`checkRebuild` / `setBlockSize` 清空静态成员、动态条目在 `updateMoving` 登记与更新 / `deleteMoving` 注销、`isOverlayDirty()` / `getOverlayArray()` / `getOverlayCount()` 与惰性排序组装(组装前按活动 `IMovingBlock` 重新推导动态条目的坐标与 `layerIndex`,图层列表 / zIndex 变化不残留旧排序键);分块张量与动态索引身份零改动。若 Task 0 裁定 (A),另含 `getIndexInBlock` 的块内偏移改用数据坐标(`dataX` / `dataY`)" + - path: packages-user/client-base/src/map/renderer.ts + provides: "两遍式渲染与后处理修正:不透明遍显式 `depthMask(true)` 且只绘制静态分块(把动态尾部裁剪掉)、覆盖层最后绘制(`depthMask(false)`、独立缓冲区、`drawArraysInstanced`)、收尾恢复 `depthMask(true)`、分离式混合、`needUpdate()` 纳入覆盖层脏、`initContext` 创建覆盖层缓冲区与 ping/pong 深度 renderbuffer + 正确配对 + 纹理参数、`setCanvasSize` 重设深度 renderbuffer、`destroy` 删除全部新增对象、0/1/2+ 效果链路修正" + - path: packages-user/client-base/src/map/types.ts + provides: "`IContextData` 新增 `overlayBuffer` / `pingDepthBuffer` / `pongDepthBuffer`;`IMapVertexGenerator` 新增 `isOverlayDirty()` / `getOverlayArray()` / `getOverlayCount()`;既有接口成员一律不动" + - path: packages-user/client-base/src/map/shader/map.frag + provides: "discard 阈值由 `0.1` 下调为 `0.004`;着色器其余行与既有注释不改" + - path: .planning/phases/09-minimum-browser-runnable/09-25-SUMMARY.md + provides: "执行摘要:两遍式的最终落点、静态提取与恢复机制、条目身份键、排序键与两个脏标记的语义、动态块并入覆盖层的路径(含组装前重新推导 `layerIndex`)、后处理修正的最终写法(含字面量 `DEPTH_COMPONENT24`)、四类门禁结果、浏览器人工 UAT 的逐项结果与「无 WebGL E2E 自动化」声明、OI-1 的裁定与限制、OI-2 最终采用的命名、`estimate` 未校准声明与单计划超预算例外、以及未做全量构建 / 未改其它包的边界" + key_links: + - "`setStaticAlpha` 的 alpha 阈值 ↔ 分块实例 rect 置零 ↔ 覆盖层静态记录(rect)↔ 覆盖层暂存组装 —— 三者必须指向**同一个实例槽**;若查找与写入用了不同槽位,会出现「该透明的没透明、不该透明的被挖空」" + - "不透明遍写入的深度 ↔ 覆盖层的深度测试(开)+ 深度写入(关) —— 覆盖层与不透明层的遮挡靠深度测试,覆盖层彼此的遮挡靠提交顺序;两者缺一不可" + - "排序键 `(layerIndex, y, x, kind)` ↔ 暂存数组的装入顺序 ↔ `drawArraysInstanced` 的实例顺序 —— WebGL2 的实例顺序只由缓冲区顺序决定,CPU 排序必须与装入顺序严格一致" + - "值脏 / 顺序脏 ↔ 暂存组装 ↔ GPU 上传 ↔ `needUpdate()` —— 仅值脏时复用缓存顺序、只重传;顺序脏才排序;任一脏都必须让 `needUpdate()` 为真,否则帧循环不会重绘" + - "`updateMoving` / `deleteMoving` ↔ 动态覆盖层成员 ↔ `MovingBlock.index` / `movingIndexMap` —— 条目身份是 `dynamic:${movingIndex}`,`dataIndex` 只用于读取 CPU 动态数组;不得重排该数组或改变索引身份" + - "活动 `IMovingBlock`(`layer` / `x` / `y`)↔ 组装前 `overlay.update` ↔ 动态条目的 `layerIndex` / 坐标 —— 图层列表 / `zIndex` 变化只改 `layerIndexMap` 与分块数组,不会回调 `updateMoving`;只有在组装时重新推导,才不会用旧排序键画错遮挡" + - "`checkRebuild` / `setBlockSize` ↔ 静态覆盖层清空 —— 重建会重新分配分块数组并把 alpha 复位为 1,旧记录与旧退化 rect 都会失效;不清空会留下永远不可见或重复绘制的图块" + - "ping/pong FBO ↔ 各自的颜色纹理与深度 renderbuffer ↔ 效果链的输入/输出配对 —— 配对写反会形成「读自己正在写的纹理」的反馈环;缺深度附件会让场景在 FBO 中失去深度测试" + - "`IMapRenderData.render` 的动态尾部(`viewport.checkDynamic`)↔ 不透明遍的绘制裁剪(`dynamicStart`)—— 动态块改由覆盖层绘制后,不透明遍必须把这一截裁掉,否则动态块会被画两遍" + assumptions: + - "**工作树基线(规划日实测,HEAD `9327254`)**:工作树已包含用户并发的未提交改动(规划日 porcelain 实测)——`packages-user/client-base/src/hotkey/hotkey.ts`、`packages-user/client-base/src/hotkey/types.ts`、`packages/common/src/logger.json`、`packages/system/src/hotkey/{command.ts,system.ts,type.ts}`、`packages/system/src/hotkey/__test__/{command,system}.test.ts`、`src/client/misc/hotkey.ts`。本计划一律不回滚 / 不暂存 / 不提交 / 不修改这些文件;范围闸门把它们列为允许的既有脏文件,并把 `map/` 下的非白名单文件与依赖 / 配置文件列为受保护项。" + - "**疑似问题 OI-1(只提问,未确认为问题,默认不擅自修复)**:`vertex.ts` 的 `getIndexInBlock` 用 `block.x` / `block.y`(分块坐标)计算块内偏移(规划日实测 `const dx = x - block.x; const dy = y - block.y;`),而同一文件的 `updateBlockVertex` / `updateBlockCache` 用 `block.dataX` / `block.dataY`(数据坐标)。单分块地图(默认 13×13,`block.x === dataX === 0`)看不出差异;26×13 地图的第二列分块会定位到错误实例(例如地图 x=13 会落到块内 12 号槽)。本计划不擅自判定为缺陷、不擅自修复;Task 0 需用户裁定 (A) 授权在 Task 1 内做一行修正(`dataX` / `dataY`),或 (B) 不修并接受跨分块地图上的定位限制。" + - "**单计划例外(用户批准,estimate 超预算但不拆分)**:本增量由用户明确要求「新增一个计划」,即本派发单元只允许一个计划;`estimate` 的 160000 tokens / 7 tasks 超出 100000 的单计划预算(ratio 1.6),按该用户指令**保持单计划、不拆分**,`tokens` 按真实估算保留、不为通过预算而调低。" + - "**波次与独立性(wave 20 / `depends_on: []`)**:本计划按 D-01 增量派发追加在 09-01..09-24 之后,沿用波次 20;它改动的 8 个文件没有任何其它计划触碰(同波次不存在并行写冲突),因此 `depends_on: []` 表示结构独立,「排期在最后」只是排期,不是依赖前序计划。" + - "**已知类型基线(用户裁定,不在修复范围)**:HEAD 的 `vue-tsc --noEmit` 报 `renderer.ts(463,25): error TS2339: Property 'getLayerByAlias' does not exist on type 'IGameMap'.`(`MapRenderer.getLayer` 调用了 `IGameMap` 上不存在的方法)。用户明确要求**不要修复**它、不要动数据端接口。本计划的三个类型闸门(Task 1 / 4 / 5)以「文件 = `renderer.ts`、代码 = `TS2339`、消息 = `Property 'getLayerByAlias' does not exist on type 'IGameMap'.`」**精确豁免恰好一条**,并要求闸门打印被豁免的原文作为证据;任何其它 `map/` 诊断(含重复的同消息条目)仍然失败。豁免按「去掉 `(行,列)`」归一化(Task 4 在 `setCanvasSize` 的插入会让该行下移),且**不是**「忽略 `renderer.ts` 的全部错误」。" + - "**条目身份与图层变化(已按代码核对)**:静态条目键为 `static:${layerIndex}:${mapX}:${mapY}`,同一格在不同图层的半透明图块因此互不覆盖;`updateLayerList` / `setLayerState` 都会调用 `vertex.updateLayerArray()`(置 `needRebuild`),下一次 `render()` 的 `checkRebuild()` 会重新分配分块数组并清空静态成员,故以 `layerIndex` 入键不会残留陈旧静态键。动态条目键为 `dynamic:${movingIndex}`,不含 `layerIndex`,图层顺序变化不改变身份;组装时用 `renderer.getMovingBlockByIndex(index)` + `renderer.getLayerIndex(block.layer)`(两者都在 `IMapRenderer` 上,`MapVertexGenerator` 持有该接口)就地重算排序键,不依赖 `updateMoving` 再次被调用。" + - "**动态条目不再另存记录表**:动态条目的坐标与 `layerIndex` 的唯一来源是活动 `IMovingBlock`(`layer` / `x` / `y` / `index` 均为只读),因此不引入 `IOverlayDynamicRecord` / `overlayDynamic`,避免维护一份可能过期的副本;`getOverlayArray()` 内先按 `getDynamic()` 的快照逐条刷新,再 `getOrdered()` 排序。" + - "**`area.render` 含动态尾部(已按代码核对)**:`viewport.getRenderArea` 的 `checkDynamic` 会把 `dynamicStart..dynamicStart+dynamicCount` 追加或合并进 `render`。本计划**不改 viewport**,而是在不透明遍把每个绘制区间的终点裁剪到 `dynamicStart`,因此动态块只由覆盖层绘制一次。" + - "**地图重建会丢失静态 alpha(既有行为,非本计划引入)**:`checkRebuild` / `setBlockSize` 重新 `mallocVertexArray` + `splitBlock` 后,实例 alpha 复位为 1、rect 由地图数据重建;本计划不改变该既有语义,只清空静态覆盖层记录(动态条目仍在,其坐标与 `layerIndex` 在组装时按活动块重新推导)。" + - "**排序稳定性**:依赖 ES2019+ 的稳定 `Array.prototype.sort`;比较器在 `(layerIndex, y, x, kind)` 全等时保持插入顺序。静态先于动态由 `kind` 末位比较保证。" + - "**同深度重叠的既定限制**:不透明图块与覆盖层图块若落在同一 layer 且同一行(z 相同),深度测试的严格小于比较会让先绘制的不透明图块胜出;这是深度式排序的固有边界,现有不透明渲染对同 z 重叠同样是「先画者胜」,本计划不新增 float 深度编码去消解它,只在 UAT 中记录现象。" + - "**空图块的半透明允许存在**:对 rect 全 0 的静态图块设置 alpha < 1 会登记一条退化覆盖层记录(不产生像素);待该格后续被写入真实图块时由 `updateTileVertex` 的再提取逻辑刷新 rect。" + - "**`IContextData` 的唯一构造点**:规划日核对 `initContext` 是唯一创建 `IContextData` 对象字面量的位置,新增字段只需在该处补齐;`IMapVertexGenerator` 的唯一实现是 `MapVertexGenerator`。" + - "**测试收集**:根 `vite.config.ts` 的 `test` 段未设 `include`,vitest 默认收集 `**/*.test.ts`;`packages-user/client-base` 目前没有任何测试文件,新增 `map/__test__/*.test.ts` 会被自动收集,无需改配置。测试在仓库根目录运行,`node:fs` 的相对路径以仓库根为基准。" + - "**无 RESEARCH / 无 VALIDATION**:本 run 不存在 `09-25-RESEARCH.md` 与 `09-25-VALIDATION.md`,Nyquist 验证不适用(本阶段无 requirement ID);不创建这两类文件。spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)。" + - "**`addPostEffect` 当前无调用方(已核对)**:后处理链路的浏览器可见性验证需要一个临时调用方;本计划不新增调用方、不改入口,因此该路径以源码闸门 + 人工临时挂载验证(由用户决定是否在本地临时加一行调用,不提交)。" + prohibitions: + - "不得在用户回复通过 Task 0 关卡之前执行任何任务或派发执行子代理。" + - "不得改分块张量布局 `[chunk][layer][row][col]`;不得对整个静态实例数组排序;不得用 `drawElementsInstanced` + 排序索引作为实例顺序机制(WebGL2 的实例顺序只由缓冲区顺序决定)。" + - "不得重排动态 / 移动数组;不得改变 `MovingBlock.index`、`movingIndexMap`、`expandMoving` / `reduceMoving` / `requireMovingIndex` / `returnMovingIndex` 的身份与扩缩容语义;不得改 `moving.ts`。" + - "不得把顺序编码进深度(半精度 / 浮点深度编码),不得引入 OIT 或深度剥离;x 只允许作为 CPU 侧排序键。" + - "不得修改任何既有注释或 jsDoc(AGENTS.md);只允许新增注释,新增注释遵循 dev.md(单行、`//` 后留空格、不解释下一行代码)。" + - "不得在未获批准前使用任何新命名(OI-2 全部条目);不得把提议名当成已定名写进代码或 SUMMARY。" + - "不得修复 OI-1 之外的疑似问题;不得把 OI-1 直接写成「已确认缺陷」;未获 (A) 授权时不得改 `getIndexInBlock`。" + - "**不得修改 `MapRenderer.getLayer`;不得给 `IGameMap` / `GameMap` 增加 `getLayerByAlias`;不得以任何方式「顺手修复」`renderer.ts(463,25)` 的既有 `TS2339`。** 该条只作为用户裁定的已知基线被类型闸门精确豁免并打印;不得把豁免扩大成「忽略 `renderer.ts` 的全部诊断」。" + - "不得改 `moving.ts` / `viewport.ts` / `element.ts` / `block.ts` / `constant.ts` / `status.ts` / `map/index.ts` / `extension/**` / `shader/*.vert`;不得改 09-01..09-24 计划、`09-CONTEXT.md`、其它包、`package.json` / lockfile / `vite.config.ts` / `tsconfig.json` / eslint 配置。" + - "不得新增依赖;不得执行安装命令。" + - "不得跑 `build:game` / `build:packages` / `build:lib`,不做全仓类型修复;类型闸门只对本计划文件负责。" + - "不得伪造验证:无 WebGL E2E 就不得写成「已自动化验证」;浏览器 UAT 未做或未通过必须如实记录;不得删减用例、放宽断言或把未覆盖的形态写成已覆盖。" + - "不得改 `map.frag` 的既有注释;只改 discard 的数值。" + open_items: + - "**OI-1(疑似问题,只提问、未确认为问题)**:`getIndexInBlock` 用 `block.x` / `block.y` 而非 `block.dataX` / `block.dataY` 计算块内偏移。单分块地图无影响;26×13 地图第二列会定位到错误实例,直接威胁静态半透明提取与 26×13 UAT。裁定选项:(A) 授权 Task 1 做一行修正(`dataX` / `dataY`)并加源码闸门;(B) 不修,本计划沿用现有查找(内部一致但跨分块会定位错),UAT 的 26×13 项降级为「不崩溃 / 不新增错误」并在 SUMMARY 记录限制。**未裁定前不得修复。**" + - "**OI-2(命名待批准,未获批前不得落码;本清单是本计划新增标识符的完整清单,含文件、常量、函数、枚举及成员、接口及字段、类及字段、方法、私有接口及字段、内部 Map 与键构造方法)——文件 / 导出与方法:** ① 文件 `packages-user/client-base/src/map/overlay.ts`(备选 `renderOrder.ts` / `overlayOrder.ts`);② 测试文件 `packages-user/client-base/src/map/__test__/overlay.test.ts`(备选 `overlayOrder.test.ts`)、`packages-user/client-base/src/map/__test__/rendererGate.test.ts`(备选 `renderSourceGate.test.ts`);③ 常量 `OVERLAY_ALPHA_THRESHOLD`(备选 `TRANSPARENT_ALPHA_LIMIT` / `ALPHA_OPAQUE`);④ 函数 `isOverlayAlpha`(备选 `isTransparentAlpha`);⑤ 枚举 `OverlayEntryKind`(备选 `OverlayKind`),成员 `Static`(备选 `Tile`)/ `Dynamic`(备选 `Moving`);⑥ 接口 `IMapOverlayOrder`(备选 `IOverlayOrder`),方法 `add` / `remove` / `update` / `clearStatic` / `clear` / `getOrdered` / `getDynamic` / `markOrderDirty` / `markValueDirty` / `clearDirty`(备选 `addEntry` / `removeEntry` / `updateEntry` / `resetStatic` / `reset` / `getOrder` / `getDynamicEntries` / `markOrderChanged` / `markValueChanged` / `resetDirty`);⑦ 类 `MapOverlayOrder`(备选 `OverlayOrder`),字段 `orderDirty`(备选 `needSort`)/ `valueDirty`(备选 `needUpload`)、内部 `staticEntries`(备选 `staticEntryMap`,`Map`)/ `dynamicEntries`(备选 `dynamicEntryMap`,`Map`)/ `ordered`(备选 `orderCache` / `sortedEntries`,`IOverlayEntry[]`)。" + - "**OI-2(续,接口字段 / vertex / types 命名待批准)——字段清单:** ⑧ 接口 `IOverlayEntry`(备选 `IMapOverlayEntry`)的字段 `kind`(备选 `type`)/ `key`(备选 `id`;**唯一身份键**,静态 `static:${layerIndex}:${mapX}:${mapY}`、动态 `dynamic:${movingIndex}`)/ `dataIndex`(备选 `payloadIndex` / `entryIndex`;静态为地图索引、动态为移动索引,仅供组装定位实例数据)/ `layerIndex`(备选 `depthIndex`)/ `x`(备选 `mapX`)/ `y`(备选 `mapY`);⑨ `IMapVertexGenerator` 新增方法 `isOverlayDirty()` / `getOverlayArray()` / `getOverlayCount()`(备选 `overlayNeedUpdate()` / `buildOverlayArray()` / `overlaySize()`);⑩ `MapVertexGenerator` 私有字段 `overlay`(备选 `overlayOrder`,类型 `IMapOverlayOrder`)/ `overlayStatic`(备选 `staticOverlay` / `overlayStaticMap`,类型 `Map`)/ `overlayInstancedArray`(备选 `overlayStage`)/ `overlayCount`(备选 `overlayLength`);⑪ 私有方法 `assembleOverlay()`(备选 `buildOverlay()` / `stageOverlay()`)、`getStaticOverlayKey(layerIndex, mapX, mapY)`(备选 `staticOverlayKey`)、`getDynamicOverlayKey(index)`(备选 `dynamicOverlayKey`);⑫ 私有接口 `IOverlayStaticRecord`(备选 `IStaticOverlayRecord`)的字段 `layer`(备选 `mapLayer`)/ `mapIndex`(备选 `dataIndex`)/ `key`(备选 `id`)/ `rectX`(备选 `tileX`)/ `rectY`(备选 `tileY`)/ `rectWidth`(备选 `tileWidth`)/ `rectHeight`(备选 `tileHeight`);⑬ `IContextData` 新字段 `overlayBuffer`(备选 `overlayInstanceBuffer`)/ `pingDepthBuffer`(备选 `pingDepthRenderbuffer`)/ `pongDepthBuffer`(备选 `pongDepthRenderbuffer`)。**不再引入** `IOverlayDynamicRecord` / `overlayDynamic`(动态条目只用种类 + 移动索引派生键,坐标与 `layerIndex` 在组装时从活动 `IMovingBlock` 重新推导);`renderer.ts` 不新增类成员(仅使用 `IContextData` 字段与局部变量)。" +--- + + +Phase 9 · 第 25 个计划(新增的独立增量,**不是** 09-01..09-24 的 UI 迁移任务)—— **修正地图渲染的半透明图块排序缺陷(两遍式渲染 + 覆盖层),并修好后处理 FBO 的深度附件与 ping/pong 配对**。 + +**要解决的问题:** 地图渲染目前是「绘制顺序 + 深度缓冲兜底」的混合方案:`gl.enable(DEPTH_TEST)`、`depthFunc(LESS)`、深度写入保持默认 `true`、alpha 混合开启(`renderer.ts` 规划日实测约 788–796 行);绘制顺序是实例缓冲区的自然顺序(`[chunk][layer][row][col]`,见 `MapVertexBlock.rebuild`),动态块追加在静态数组末尾并最后绘制(`vertex.ts` 的 malloc 与 `viewport.ts` 的 `checkDynamic`)。这对不透明图块可以接受,但对半透明图块是错的:半透明面要么把后提交的更远表面挖掉(洞),要么以错误顺序混合。 + +**做法(锁定设计):** +- **两遍式**:不透明遍保持现有分块批量实例化绘制与 `depthMask(true)`,分块张量不重排;透明覆盖层最后绘制,`depthMask(false)`、深度测试仍开、混合开。覆盖层含「有效 alpha `< 1` 的静态图块」+「全部动态块」,按 `(layerIndex, y, x, kind)` 后到前排序;x 只做 CPU 侧稳定次级键,绝不进深度。**条目身份唯一**:静态键 = 种类 + 层索引 + 地图 x + 地图 y,动态键 = 种类 + 移动索引;同一格在不同图层的半透明图块因此是两条独立条目。 +- **静态提取**:alpha 跨到 `< 1` 时把该实例 `x/y/w/h` 置 0(复用空图块机制)并登记覆盖层记录(rect 等);回到 1 时写回并注销;不压缩、不重排分块数组。 +- **覆盖层存储**:独立的小型暂存实例数组 + 独立 GPU 缓冲区,按缓存顺序组装后用 `drawArraysInstanced` 绘制;不重排动态数组,保留 `MovingBlock.index` 身份。 +- **脏标记**:值脏(alpha / frame / 纹理等不改排序键的值)只重传不重排;顺序脏(成员变化、跨阈值、动态增删 / 移动、重建、图层变化)才重排;排序在 `render()` 内惰性执行;任一脏都让 `needUpdate()` 为真。动态条目的 `layerIndex` 在组装时按当前图层顺序重新推导,图层列表 / zIndex 变化不会留下旧排序键。 +- **状态卫生**:显式 `depthMask(true)` / `depthMask(false)` 与收尾恢复;混合改 `blendFuncSeparate`;`map.frag` 的 discard 阈值 `0.1` → `0.004`。 +- **后处理**:ping/pong 各自配对颜色纹理并挂 `DEPTH_COMPONENT24` 深度 renderbuffer;修正 0 / 1 / 2+ 效果链路;`setCanvasSize` 重设深度缓冲;`destroy` 删除新增对象;`IContextData` 补字段。 +- **验证**:纯 DOM-free 排序 / 成员状态机单测 + 静态源码闸门 + `vitest` / `eslint` / 按文件过滤的 `vue-tsc` + 范围闸门;最后必须由用户做浏览器人工 UAT(`autonomous: false`),并诚实声明没有 WebGL E2E。 + +**约束:** 只动 `files_modified` 的 8 个文件;不改分块张量、不重排动态数组、不把顺序编码进深度、不改既有注释与 jsDoc、不新增依赖、不改其它计划与包。**所有新增命名待批准(OI-2)**;**OI-1 的跨分块查找疑似问题只提问、不擅自修复**。 + +**修改方式声明(AGENTS.md):** 本计划的文件写入(新增 3 个文件、改写 4 个源码 / 着色器文件、写 1 个 SUMMARY)由**派发子代理(`gsd-executor`)执行**,非内联执行;除 `files_modified` 外无任何写入。 + +**可见跳过:** spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)。本阶段无 requirement ID,不创建 RESEARCH.md / VALIDATION.md,Nyquist 验证不适用。 + + +## 本计划产出物(Artifacts this phase produces) + +| 类型 | 名称 / 路径 | 说明 | +|------|-------------|------| +| 新文件 | `packages-user/client-base/src/map/overlay.ts` | 纯排序 / 成员状态机(无 DOM / WebGL / 数据端依赖) | +| 新文件 | `packages-user/client-base/src/map/__test__/overlay.test.ts` | 排序与成员状态机单测 | +| 新文件 | `packages-user/client-base/src/map/__test__/rendererGate.test.ts` | 源码静态闸门(读文件,不导入 WebGL) | +| 新符号 | `OVERLAY_ALPHA_THRESHOLD`、`isOverlayAlpha`、`OverlayEntryKind`(`Static` / `Dynamic`)、`IOverlayEntry`(字段 `kind` / `key` / `dataIndex` / `layerIndex` / `x` / `y`)、`IMapOverlayOrder`、`MapOverlayOrder` | 均在 `overlay.ts`,命名待批准(OI-2) | +| 新符号 | `MapOverlayOrder.add / remove / update / clearStatic / clear / getOrdered / getDynamic / markOrderDirty / markValueDirty / clearDirty`、字段 `orderDirty` / `valueDirty`、内部 `staticEntries` / `dynamicEntries`(`Map`)与 `ordered`(缓存数组) | 命名待批准(OI-2) | +| 新符号 | `MapVertexGenerator` 的 `isOverlayDirty() / getOverlayArray() / getOverlayCount()`、私有字段 `overlay` / `overlayStatic` / `overlayInstancedArray` / `overlayCount`、私有方法 `assembleOverlay()` / `getStaticOverlayKey()` / `getDynamicOverlayKey()`、私有接口 `IOverlayStaticRecord`(字段 `layer` / `mapIndex` / `key` / `rectX` / `rectY` / `rectWidth` / `rectHeight`) | 命名待批准(OI-2);**不再引入** `IOverlayDynamicRecord` / `overlayDynamic`(动态条目的坐标与 `layerIndex` 在组装时从活动 `IMovingBlock` 重新推导) | +| 新字段 | `IContextData.overlayBuffer` / `pingDepthBuffer` / `pongDepthBuffer` | 命名待批准(OI-2) | +| 修改 | `vertex.ts`(成员登记 / 静态提取 / 暂存组装)、`renderer.ts`(两遍式 + 后处理)、`types.ts`(新增成员声明)、`map.frag`(阈值) | 不新增其它公开面 | +| 交付 | `.planning/phases/09-minimum-browser-runnable/09-25-SUMMARY.md` | 执行摘要与 UAT 结果 | + + +@C:/Users/13194/.config/opencode/gsd-core/workflows/execute-plan.md +@C:/Users/13194/.config/opencode/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/09-minimum-browser-runnable/09-CONTEXT.md +@.planning/phases/09-minimum-browser-runnable/09-PATTERNS.md +@AGENTS.md +@dev.md +@packages-user/client-base/src/map/renderer.ts +@packages-user/client-base/src/map/vertex.ts +@packages-user/client-base/src/map/types.ts +@packages-user/client-base/src/map/status.ts +@packages-user/client-base/src/map/moving.ts +@packages-user/client-base/src/map/viewport.ts +@packages-user/client-base/src/map/block.ts +@packages-user/client-base/src/map/element.ts +@packages-user/client-base/src/map/constant.ts +@packages-user/client-base/src/map/shader/map.vert +@packages-user/client-base/src/map/shader/map.frag +@packages-user/client-base/src/map/shader/back.vert +@packages-user/client-base/src/map/shader/back.frag +@packages-user/client-base/src/shared.ts +@packages-user/client-base/src/map/extension/text.ts + + + + + Task 0: 预执行汇报关卡 —— 汇报两遍式半透明渲染、覆盖层状态机、后处理修正、待批准命名与 OI-1 疑似问题,等待「可以执行」 + `renderer.ts` 仍含 `gl.enable(gl.DEPTH_TEST)`、`gl.depthFunc(gl.LESS)`、`gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)` 与 `postEffects.length > 1`,且**不含** `depthMask` / `blendFuncSeparate` / `DEPTH_COMPONENT24`;`vertex.ts` 仍含 `this.dynamicStart = staticCount;` 与 `setStaticAlpha(`,且**不含**任何覆盖层实现;`map.frag` 仍为 `alpha < 0.1`;`moving.ts` 仍含 `this.index = index;`;`types.ts` 的 `IContextData` **不含** `overlayBuffer` / `pingDepthBuffer` / `pongDepthBuffer`;`map/__test__/` 目录尚不存在。**已知类型基线(用户已裁定,不在本计划修复范围)**:`vue-tsc --noEmit` 在 HEAD 对 `renderer.ts(463,25)` 报 `error TS2339: Property 'getLayerByAlias' does not exist on type 'IGameMap'.`(`MapRenderer.getLayer` 调用了 `IGameMap` 上不存在的方法)。本计划所有类型闸门按 Task 0 ⑫ 精确豁免该条,且不得改动 `getLayer` / `IGameMap` / `GameMap`。 + (不改仓库文件) + + - packages-user/client-base/src/map/renderer.ts(`initContext` 的上下文创建 / FBO 初始化 / 状态设置、`setCanvasSize`、`destroy`、`render` 的绘制与后处理链) + - packages-user/client-base/src/map/vertex.ts(`mallocVertexArray`、`splitBlock`、`expandMoving`、`checkRebuild`、`updateTileVertex`、`setStaticAlpha` / `setStaticFrame` / `getIndexInBlock`、`updateMoving` / `deleteMoving`、`getVertexArray`) + - packages-user/client-base/src/map/types.ts(`IContextData`、`IMapVertexGenerator`、`IMapRenderData`、`IMovingBlock`) + - packages-user/client-base/src/map/viewport.ts(`checkDynamic` 把动态尾部并入 `render` / `dirty`) + - packages-user/client-base/src/map/moving.ts(`MovingBlock.index` 身份、`setPos` / `stepMoving`) + - packages-user/client-base/src/map/block.ts(`IBlockData` 的 `x` / `y` 与 `dataX` / `dataY`) + - packages-user/client-base/src/map/status.ts(`StaticBlockStatus.setAlpha` → `vertex.setStaticAlpha`) + - packages-user/client-base/src/map/shader/map.frag(discard 阈值与既有注释) + - .planning/phases/09-minimum-browser-runnable/09-24-PLAN.md(Task 0 的汇报样式、门禁样式、`autonomous: false` 与命名报备格式) + - AGENTS.md(执行前须获批、命名先反馈、既有注释不得修改、修改方式须预先声明、疑似问题须先确认)、dev.md(命名、jsDoc、无副作用、类先接口后 implements、CRLF) + + + **停下并向用户汇报,等待回复「可以执行」后才继续;本任务不修改任何仓库文件、不派发执行子代理。** + + ① **要解决的问题:** 地图渲染当前把半透明图块和不透明图块混在同一个实例缓冲里、按缓冲区顺序(`[chunk][layer][row][col]`)+ 深度缓冲兜底绘制。半透明面因此会出现两类错误:更近的半透明面先画、把之后提交的更远表面挖出黑洞;或两个半透明面以错误顺序混合。另外 `postEffect` 的 ping/pong FBO 配对写反、只有一个启用效果时根本不会把场景渲染进 FBO、FBO 也没有深度附件,一旦启用后处理就会黑屏或丢失深度测试。 + + ② **两遍式渲染(本计划的做法):** 不透明遍保持现有分块批量实例化绘制、显式 `depthMask(true)`;动态尾部从这一遍裁掉。透明覆盖层最后绘制:`depthMask(false)`、深度测试仍开、混合开。覆盖层 = 有效 alpha `< 1` 的静态图块 + 全部动态块,按 `(layerIndex, y, x, kind)` 后到前排序(动态块用小数 y;x 只做同键稳定次级键且静态先于动态;x 绝不编码进 z / 深度)。覆盖层与不透明层靠不透明遍写入的深度做遮挡,覆盖层彼此靠提交顺序。收尾显式恢复 `depthMask(true)`;混合改分离式,保证 alpha 通道正确累积。 + + ③ **静态半透明提取:** `setStaticAlpha` 发现 alpha `< 1` 且尚未登记时,取到该实例(layer 子数组 + 块内偏移),把 `x/y/w/h` 置 0(复用空图块退化机制),保留槽位与其余数据,登记覆盖层记录(layer、地图坐标、原始 rect);alpha 回到 1 时写回 rect 并注销。**不压缩、不重排分块数组。** 若成员图块随后被 `updateTileVertex` 重新写入(地图更新 / 自动元件刷新),必须再次置零并刷新记录,避免它同时被不透明遍和覆盖层绘制。地图重建(`checkRebuild` / `setBlockSize`)会重新分配数组并把 alpha 复位为 1,必须同时清空静态成员并标记顺序脏。 + + ④ **覆盖层存储与绘制:** 独立的小型暂存实例数组(不是分块数组、不是动态数组),按缓存顺序把静态记录与动态块组装成 16 float/实例的数据,脏时上传到独立 GPU 缓冲区,用 `drawArraysInstanced` 绘制(WebGL2 没有 baseInstance,按现有做法重新绑定 4 个实例属性指针)。**不重排动态数组**,`MovingBlock.index` / `movingIndexMap` / 扩缩容逻辑逐字保留。 + + ⑤ **两个独立脏标记与惰性排序:** 值脏(alpha / frame / 纹理等**不改排序键**的值)只刷新暂存缓冲区、不重排;顺序脏(成员增删、alpha 跨阈值、动态块增删、地图重建、图层列表 / zIndex 变化、动态块位置 / 图层变化)才重排。排序在 `render()` 内惰性执行(顺序脏时排一次,仅值脏时复用缓存顺序);`setStaticAlpha` 的纯 alpha 变化不得触发重排;任一脏都必须让 `needUpdate()` 返回 true。本计划会显式说明「排序在 render 内、捕获脏标记在组装前」的顺序要求。**条目身份唯一**:静态键 = 种类 + 层索引 + 地图 x + 地图 y(同一格不同图层不互相覆盖),动态键 = 种类 + 移动索引;图层列表 / zIndex 变化后,动态条目的 `layerIndex` 在组装时按当前图层顺序重新推导,不残留旧排序键。 + + ⑥ **后处理修正(在范围内):** ping/pong 各自绑定自己的颜色纹理(ping←pingTexture、pong←pongTexture),各自挂 `DEPTH_COMPONENT24` 深度 renderbuffer(`framebufferRenderbuffer`),并设置 `NEAREST` 过滤 / `CLAMP_TO_EDGE` 包裹;把渲染做成「0 个启用效果 → 默认帧缓冲;至少 1 个 → 场景进 ping,效果 i 读上一个 FBO 的颜色纹理、写另一个 FBO,最后一个写 `null`(屏幕)」;单效果路径必须正确。`setCanvasSize` 同时重设两个深度 renderbuffer 的存储尺寸;`destroy` 删除两个 FBO、两个纹理、两个深度 renderbuffer 与覆盖层缓冲区;新字段进入 `IContextData`。 + + ⑦ **`map.frag`:** discard 阈值 `0.1` → `0.004`,保留 discard 仅作填充率优化;既有注释一字不改。 + + ⑧ **验证方式:** 纯 DOM-free 排序 / 成员状态机单测(vitest);静态源码闸门(两遍式的状态设置、分离式混合、深度 renderbuffer 的创建 / 附着 / 缩放 / 删除、ping/pong 配对、覆盖层独立缓冲区、分块布局未被重排、动态索引身份未变、`map.frag` 阈值、无半精度 / 顺序剥离类标记);`cmd /c "pnpm exec vitest run ..."`、`cmd /c "pnpm exec eslint ..."`、按文件过滤的 `vue-tsc --noEmit`(沿用 09-24 的基线纪律;**仅精确豁免用户裁定的 `renderer.ts(463,25) TS2339 getLayerByAlias / IGameMap` 一条并打印该行作为证据,不忽略 `renderer.ts` 的其它诊断**);范围闸门(白名单由 `files_modified` 派生,用户既有脏文件列为允许项)。**最终用浏览器做人工 UAT,本计划不提供也不宣称 WebGL E2E 自动化。** + + ⑨ **已知风险与边界:** ①同 layer 同行的不透明与覆盖层图块 z 相同,严格的深度比较会让先绘制的不透明块胜出——这是深度式排序的固有边界,本计划不引入浮点深度编码去消解,只在 UAT 里记录;②地图重建会丢失静态 alpha(既有行为),本计划只清空成员、不改变该语义;③`addPostEffect` 当前无调用方,后处理的浏览器可见性验证需要一个临时调用方(用户可本地临时加、不提交);④不跑全量构建、不做全仓类型修复、不改其它包与 09-01..09-24 计划;⑤工作树已有用户并发改动(hotkey / logger),一律不碰。 + + ⑩ **待批准命名(OI-2,全部只以提议形式出现,未获批前不得落码;以下为**完整清单**,每项含 1-2 个备选):** 文件 `overlay.ts`(`renderOrder.ts` / `overlayOrder.ts`);测试文件 `__test__/overlay.test.ts`(`overlayOrder.test.ts`)、`__test__/rendererGate.test.ts`(`renderSourceGate.test.ts`);常量 `OVERLAY_ALPHA_THRESHOLD`(`TRANSPARENT_ALPHA_LIMIT` / `ALPHA_OPAQUE`);函数 `isOverlayAlpha`(`isTransparentAlpha`);枚举 `OverlayEntryKind`(`OverlayKind`),成员 `Static`(`Tile`)/ `Dynamic`(`Moving`);接口 `IMapOverlayOrder`(`IOverlayOrder`)与方法 `add` / `remove` / `update` / `clearStatic` / `clear` / `getOrdered` / `getDynamic` / `markOrderDirty` / `markValueDirty` / `clearDirty`(`addEntry` / `removeEntry` / `updateEntry` / `resetStatic` / `reset` / `getOrder` / `getDynamicEntries` / `markOrderChanged` / `markValueChanged` / `resetDirty`);类 `MapOverlayOrder`(`OverlayOrder`)与字段 `orderDirty`(`needSort`)/ `valueDirty`(`needUpload`)、内部 `staticEntries`(`staticEntryMap`)/ `dynamicEntries`(`dynamicEntryMap`)/ `ordered`(`orderCache` / `sortedEntries`);接口 `IOverlayEntry`(`IMapOverlayEntry`)的字段 `kind`(`type`)/ `key`(`id`,唯一身份键)/ `dataIndex`(`payloadIndex` / `entryIndex`)/ `layerIndex`(`depthIndex`)/ `x`(`mapX`)/ `y`(`mapY`);`IMapVertexGenerator` 的 `isOverlayDirty()` / `getOverlayArray()` / `getOverlayCount()`(`overlayNeedUpdate()` / `buildOverlayArray()` / `overlaySize()`);`MapVertexGenerator` 私有字段 `overlay`(`overlayOrder`)/ `overlayStatic`(`staticOverlay` / `overlayStaticMap`)/ `overlayInstancedArray`(`overlayStage`)/ `overlayCount`(`overlayLength`);私有方法 `assembleOverlay()`(`buildOverlay()` / `stageOverlay()`)/ `getStaticOverlayKey()`(`staticOverlayKey`)/ `getDynamicOverlayKey()`(`dynamicOverlayKey`);私有接口 `IOverlayStaticRecord`(`IStaticOverlayRecord`)的字段 `layer`(`mapLayer`)/ `mapIndex`(`dataIndex`)/ `key`(`id`)/ `rectX`(`tileX`)/ `rectY`(`tileY`)/ `rectWidth`(`tileWidth`)/ `rectHeight`(`tileHeight`);`IContextData` 字段 `overlayBuffer`(`overlayInstanceBuffer`)/ `pingDepthBuffer`(`pingDepthRenderbuffer`)/ `pongDepthBuffer`(`pongDepthRenderbuffer`)。**不再引入** `IOverlayDynamicRecord` / `overlayDynamic`;`renderer.ts` 不新增类成员(仅使用 `IContextData` 字段与局部变量)。 + + ⑪ **疑似问题 OI-1(只提问、未确认为问题、本计划不擅自修复):** `vertex.ts` 的 `getIndexInBlock` 用 `block.x` / `block.y`(分块坐标)算块内偏移,而 `updateBlockVertex` / `updateBlockCache` 用 `block.dataX` / `block.dataY`(数据坐标)。单分块地图(默认 13×13,两者都为 0)看不出差异;26×13 地图第二列会定位到错误实例(如地图 x=13 落到块内 12 号槽)。静态半透明提取依赖这个查找,所以 26×13 UAT 会暴露它。请裁定:**(A)** 授权本计划做一行修正(`dataX` / `dataY`),并加源码闸门;**(B)** 不修,本计划沿用现有查找(内部一致但跨分块会定位错),UAT 的 26×13 项降级为「不崩溃 / 不新增错误」并在 SUMMARY 记录限制。 + + ⑫ **已知类型基线(用户已裁定:不修复):** HEAD 的 `vue-tsc --noEmit` 报 `renderer.ts(463,25): error TS2339: Property 'getLayerByAlias' does not exist on type 'IGameMap'.`——`MapRenderer.getLayer` 调用了 `IGameMap` 上不存在的方法。用户明确要求:**不要修复它、不要改动数据端接口、本计划只做半透明渲染。** 因此本计划把这一条当作**已知基线**(不是待修的疑似问题、不是待确认问题),三个类型闸门(Task 1 / 4 / 5)以「文件 = `renderer.ts`、代码 = `TS2339`、消息 = `Property 'getLayerByAlias' does not exist on type 'IGameMap'.`」**精确豁免恰好一条**,并要求闸门在通过时**打印被豁免的那一行原文**作为证据;除它之外的任何 `map/` 诊断(包括第二条同消息)一律失败。豁免按「去掉 `(行,列)`」归一化,因为 Task 4 在 `setCanvasSize`(第 463 行之前)插入代码会让该诊断的行号下移。豁免**不是**「忽略 `renderer.ts` 的所有错误」。 + + ⑬ **修改方式:** 由 `gsd-executor` 子代理执行(非内联),只写 `files_modified` 的 8 个文件;不改依赖与配置。 + + 是否按本计划(09-25)执行:在 `packages-user/client-base/src/map/` 内新增 DOM-free 的覆盖层排序 / 成员状态机与其单测、源码闸门;把 `MapRenderer.render()` 改为「不透明遍(显式 depthMask(true)、裁掉动态尾部)+ 透明覆盖层遍(depthMask(false)、独立暂存缓冲区、drawArraysInstanced)」;在 `MapVertexGenerator` 内实现静态半透明提取 / 恢复、动态块登记、两个脏标记与惰性组装;修正 ping/pong FBO 的颜色纹理配对与 `DEPTH_COMPONENT24` 深度附件、0/1/2+ 效果链路、`setCanvasSize` 与 `destroy`;把 `map.frag` 的 discard 阈值下调到 `0.004`;并请用户同时裁定 OI-1(是否授权 `getIndexInBlock` 的一行修正)与 OI-2(全部新增命名)。 + + AGENTS.md 要求执行前先汇报并等待「可以执行」、命名先反馈、既有注释不得修改、疑似问题须先确认、修改方式须预先声明;`autonomous: false`。规划日实测(HEAD `9327254`,工作树含用户并发改动):`renderer.ts` 的状态设置与 `postEffects.length > 1` 如 `` 所述;`vertex.ts` 的 `dynamicStart = staticCount`、`setStaticAlpha`、`getIndexInBlock`(`block.x` / `block.y`)如述;`map.frag` 为 `alpha < 0.1`;`moving.ts` 为 `this.index = index;`;`types.ts` 无覆盖层 / 深度字段;`map/__test__/` 不存在。执行期含新增文件与改写源码,但无删除动作、无依赖 / lockfile 变更、无 `moving.ts` / `viewport.ts` 改动。**已知类型基线(用户裁定,不修):** HEAD 的 `vue-tsc --noEmit` 报 `renderer.ts(463,25): error TS2339: Property 'getLayerByAlias' does not exist on type 'IGameMap'.`——本计划把它当作基线豁免,禁止改 `MapRenderer.getLayer` 或在 `IGameMap` / `GameMap` 上补方法。 + + + + + + + 回复「可以执行」即可;如需改名请在回复中给出最终命名,并对 OI-1 明确选择 (A) 或 (B)。未收到「可以执行」前不会执行任何任务。 + + node -e "const fs=require('fs');const bad=[];const need=['packages-user/client-base/src/map/renderer.ts','packages-user/client-base/src/map/vertex.ts','packages-user/client-base/src/map/types.ts','packages-user/client-base/src/map/moving.ts','packages-user/client-base/src/map/viewport.ts','packages-user/client-base/src/map/block.ts','packages-user/client-base/src/map/shader/map.frag'];for(const p of need){if(!fs.existsSync(p))bad.push('missing '+p)}const r=fs.readFileSync('packages-user/client-base/src/map/renderer.ts','utf8');for(const s of ['gl.enable(gl.DEPTH_TEST)','gl.depthFunc(gl.LESS)','gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)','postEffects.length > 1']){if(r.indexOf(s)<0)bad.push('prestate changed: '+s)}if(r.indexOf('depthMask')>=0)bad.push('prestate changed: depthMask already present');if(r.indexOf('blendFuncSeparate')>=0)bad.push('prestate changed: blendFuncSeparate already present');if(r.indexOf('DEPTH_COMPONENT24')>=0)bad.push('prestate changed: depth renderbuffer already present');const v=fs.readFileSync('packages-user/client-base/src/map/vertex.ts','utf8');if(v.indexOf('this.dynamicStart = staticCount;')<0)bad.push('prestate changed: dynamicStart layout');if(v.indexOf('setStaticAlpha(')<0)bad.push('prestate changed: setStaticAlpha missing');if(v.indexOf('const dx = x - block.x;')<0)bad.push('prestate changed: getIndexInBlock no longer uses block.x');if(v.indexOf('overlay')>=0)bad.push('prestate changed: overlay already present in vertex.ts');const f=fs.readFileSync('packages-user/client-base/src/map/shader/map.frag','utf8');if(f.indexOf('alpha < 0.1')<0)bad.push('prestate changed: discard threshold is not 0.1');const m=fs.readFileSync('packages-user/client-base/src/map/moving.ts','utf8');if(m.indexOf('this.index = index;')<0)bad.push('prestate changed: moving index identity');const t=fs.readFileSync('packages-user/client-base/src/map/types.ts','utf8');if(t.indexOf('overlayBuffer')>=0||t.indexOf('pingDepthBuffer')>=0||t.indexOf('pongDepthBuffer')>=0)bad.push('prestate changed: IContextData already has overlay/depth fields');if(fs.existsSync('packages-user/client-base/src/map/__test__'))bad.push('prestate changed: map/__test__ already exists');if(bad.length){console.error('PRESTATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK prestate: two-pass/post-effect work not started; map tensor and moving index intact')" + non-zero exit,或 stderr 含 "PRESTATE FAILED":必需文件缺失 / 两遍式的起点标记已变(说明实现已被提前修改)/ `depthMask` / `blendFuncSeparate` / 深度 renderbuffer 已存在 / `getIndexInBlock` 已不是 `block.x` 写法(OI-1 的基线已变)/ `overlay` 已出现在 `vertex.ts` / `map.frag` 阈值已变 / `moving.ts` 索引身份已变 / `IContextData` 已有新字段 / `map/__test__` 已存在 + 用户明确回复「可以执行」;若对 OI-1 选择 (B) 或对 OI-2 的命名给出不同意见,须先按新决定修订本计划(命名、结构闸门字符串、`must_haves`、SUMMARY 关键字同步)并重新获批,未修订前不得执行 + + + - 用户回复中明确包含「可以执行」 + - 汇报涵盖:要解决的问题(半透明排序缺陷 + 后处理 FBO 深度 / 配对缺陷)、两遍式落点、静态提取与恢复机制、覆盖层存储与绘制、排序键与两个脏标记、后处理修正项、验证方式、已知风险与边界 + - 已逐条列出 OI-2 的**完整**待批准命名与备选名——含每个接口 / 私有接口的字段、内部 `Map` 与缓存数组、键构造方法、`IContextData` 字段——并明确「未获批前不得落码」 + - 已说明条目身份规则(静态键含层索引 + 地图坐标、动态键为移动索引)与「图层列表 / zIndex 变化后动态 `layerIndex` 在组装时重新推导」 + - 已以提问方式列出 OI-1(`getIndexInBlock` 的分块坐标 vs 数据坐标),明确「未确认为问题、未裁定前不擅自修复」,并给出 (A) / (B) 两种后果 + - 已说明 `map.frag` 只改 discard 数值、既有注释不动;已说明不重排分块张量与动态数组、不把顺序编码进深度 + - 已说明修改由子代理执行、只写 `files_modified` 的 8 个文件;已说明浏览器 UAT 由用户执行、本计划无 WebGL E2E 自动化 + - 已把 `renderer.ts(463,25) error TS2339: Property 'getLayerByAlias' does not exist on type 'IGameMap'.` 作为**用户裁定的已知基线**报备(不是疑似问题、不是待修项),并说明:三个类型闸门会精确豁免恰好一条并打印该行作为证据;不修改 `MapRenderer.getLayer`,不给 `IGameMap` / `GameMap` 加 `getLayerByAlias` + - 已运行 `` 前置校验并通过(`OK prestate: ...`) + + 用户明确回复「可以执行」;在此之前未执行任何任务、未修改任何仓库文件、未派发任何执行子代理 + + + + Task 1: 端到端打通「一个静态半透明图块走覆盖层」—— 新建排序模块、静态提取 / 恢复、独立暂存缓冲区与覆盖层绘制、正确的深度掩码与分离式混合 + Task 0 已闭环(用户回复「可以执行」;OI-1 与 OI-2 已裁定 / 已按提议默认执行)。`renderer.ts` / `vertex.ts` / `map.frag` 仍是 `` 所述的起点;`map/__test__/` 不存在。 + packages-user/client-base/src/map/overlay.ts, packages-user/client-base/src/map/__test__/overlay.test.ts, packages-user/client-base/src/map/vertex.ts, packages-user/client-base/src/map/renderer.ts, packages-user/client-base/src/map/types.ts, packages-user/client-base/src/map/shader/map.frag + + - packages-user/client-base/src/map/renderer.ts(`initContext` 的 788–840 行状态与 `IContextData` 字面量、`render` 的 1360–1529 行绘制与后处理、`setCanvasSize`、`destroy`、`needUpdate`) + - packages-user/client-base/src/map/vertex.ts(`mallocVertexArray` / `splitBlock` / `checkRebuild`、`updateTileVertex`、`getIndexInBlock`、`setStaticAlpha` / `setStaticFrame`、`getVertexArray`、`MapVertexBlock.rebuild`) + - packages-user/client-base/src/map/types.ts(`IContextData`、`IMapVertexGenerator`、`IMapRenderData`) + - packages-user/client-base/src/map/viewport.ts(`checkDynamic` 的合并语义,确认绘制裁剪的边界) + - packages-user/client-base/src/map/shader/map.frag(阈值与既有注释) + - packages-user/client-base/src/map/block.ts(`block.x` / `block.y` 与 `block.dataX` / `block.dataY`) + - AGENTS.md(测试 `it` 前一行注释、既有注释不得修改、命名须先获批)、dev.md(CRLF、import 连续、类先接口后 implements、无 `import type`) + + + **本任务只交付「一个静态半透明图块从 alpha 阈值进入覆盖层、被正确排序并绘制、alpha 回到 1 时恢复」的端到端路径,以及覆盖层绘制所需的最小状态设置。完整的成员状态机、动态块、后处理修正留给后续任务。** + + (1) **新建 `packages-user/client-base/src/map/overlay.ts`(纯模块,无任何 import):** 定义 alpha 阈值常量(提议 `OVERLAY_ALPHA_THRESHOLD = 1`)、判定函数(提议 `isOverlayAlpha(alpha)`,返回 `alpha < 阈值`)、条目种类枚举(提议 `OverlayEntryKind`,成员 `Static` / `Dynamic`)、条目接口(提议 `IOverlayEntry`,字段 `kind` / `key` / `dataIndex` / `layerIndex` / `x` / `y`)、排序接口(提议 `IMapOverlayOrder`)与实现类(提议 `MapOverlayOrder`)。**身份规则(本任务的核心约定):`key` 是条目的唯一身份,由调用方按「种类 + 层身份 + 地图坐标」构造——静态 `static:${layerIndex}:${mapX}:${mapY}`,动态 `dynamic:${movingIndex}`;排序模块只把 `key` 当不透明字符串,不自行解析。** 因此同一格在不同图层的两个半透明图块是**两条独立条目**(不会互相覆盖),而动态条目在图层顺序变化时身份不变。`dataIndex` 只是组装时定位实例数据的数值载荷(静态为地图索引、动态为移动索引),**不参与身份判定**。本任务实现:`add(kind, key, dataIndex, layerIndex, x, y)`(同 `key` 已存在时按更新处理,不重复;有实际新增时同时置顺序脏与值脏)、`remove(kind, key)`(不存在时无副作用;有实际移除时同时置顺序脏与值脏)、`getOrdered()`(顺序脏时按 `layerIndex` 升序 → `y` 升序 → `x` 升序 → `kind` 升序排序一次,排序后把顺序脏置为假但**不清除值脏**;静态先于动态;返回缓存数组)、`getDynamic()`(返回动态条目快照数组,供组装前逐条刷新;可在遍历中安全 `remove`)、`markOrderDirty()`、`markValueDirty()`、`clearDirty()`、`clear()`,以及只读的 `orderDirty` / `valueDirty` 字段。内部用两个以 `key` 为键的 `Map`(提议 `staticEntries` / `dynamicEntries`)加一个合并的缓存数组(提议 `ordered`);`getOrdered()` 只在 `orderDirty` 时排序。这样「成员变化」既要求重排也要求重传,而 `getOverlayArray()` 可以在组装前用 `orderDirty || valueDirty` 判断是否需要工作。**不要在本任务实现 `update` / `clearStatic`**(属 Task 2)。禁止任何 WebGL / DOM / 数据端依赖。 + + (2) **`vertex.ts` 的覆盖层成员与暂存组装(静态部分):** 新增私有字段:排序器(提议 `overlay`,类型用接口 `IMapOverlayOrder`)、静态记录表(提议 `overlayStatic`,`Map`,键为静态条目身份键)、暂存数组(提议 `overlayInstancedArray`)、当前数量(提议 `overlayCount`),以及两个私有键构造方法(提议 `getStaticOverlayKey(layerIndex, mapX, mapY)` / `getDynamicOverlayKey(index)`)与私有接口(提议 `IOverlayStaticRecord`,字段 `layer` / `mapIndex` / `key` / `rectX` / `rectY` / `rectWidth` / `rectHeight`)。**静态身份键一律经 `getStaticOverlayKey(this.renderer.getLayerIndex(layer), x, y)` 构造,不得只用 `mapIndex`**:同一格在不同图层的两个半透明图块必须落在两条独立记录上,否则后登记的会覆盖先登记的、只剩一条被排序。实现 `setStaticAlpha` 的完整阈值逻辑:找到该实例(若 Task 0 裁定 (A),把 `getIndexInBlock` 的 `dx` / `dy` 改为 `x - block.dataX` / `y - block.dataY`;若 (B) 则保持原样并在 SUMMARY 记录限制);写入 alpha;当 alpha 低于阈值且该键未登记时,读取该实例当前 `x/y/w/h` 存入记录、把实例 `x/y/w/h` 置 0、登记记录与排序条目(`add(Static, key, mapIndex, layerIndex, x, y)`)、标记顺序脏与值脏;当 alpha 回到 1 且该键已登记时,把记录的 rect 写回实例、注销记录与排序条目(`remove(Static, key)`)、标记顺序脏与值脏;alpha 仍低于阈值且已登记时只更新实例 alpha 与值脏。`setStaticFrame` 对已登记图块额外标记值脏。`updateTileVertex` 的静态分支在写完实例后按 `(layer, mapX, mapY)` 构造键并检查是否已登记:**仅当本次更新包含位置(`VertexUpdate.Position`)时**才用刚写入的 `x/y/w/h` 刷新记录并再次置零并标记值脏;只更新贴图(`VertexUpdate.Texture`,如自动元件的邻格刷新)时不得读取已被置零的 rect 覆盖记录,否则会丢失恢复数据。另在 `updateVertexArray` 的空图块分支(rect 置 0 后)按同一键检查是否已登记:若是,把记录的 rect 同步为 0 并标记值脏,避免地图格子变空后覆盖层仍用旧 rect 画出残影;待该格重新有内容时由上述位置更新路径刷新 rect。新增公开方法(提议 `isOverlayDirty()` 返回两个脏标记的或、`getOverlayArray()`、`getOverlayCount()`)与私有组装方法(提议 `assembleOverlay()`;`getOverlayArray()` 在脏时调用它,组装后清除脏标记):`assembleOverlay()` 按 `getOrdered()` 的顺序组装 16 float/实例——**本任务只有静态条目**:按记录里的 `layer` / `mapIndex` 重新解析分块与层子数组,拷贝 16 个 float,再用记录的 rect 覆盖第 0..3 个 float;容量不足时按 2 倍扩容;组装完成后设置数量并清除脏标记。**不要在本任务接入动态块。** + + (3) **`types.ts`:** 给 `IContextData` 增加只读字段 `overlayBuffer: WebGLBuffer`;给 `IMapVertexGenerator` 增加 `isOverlayDirty(): boolean`、`getOverlayArray(): Float32Array`、`getOverlayCount(): number`(命名按 Task 0 裁定)。**每个新增的公开成员 / 字段都必须在声明处补一行 jsDoc(`/** ... */`,风格对齐同文件既有字段),且不得修改任何既有 jsDoc / 注释**;`overlay.ts` 中新增的接口(提议 `IMapOverlayOrder` / `IOverlayEntry`)及其每个字段、每个方法同样必须在源头 `interface` 处带 jsDoc。 + + (4) **`renderer.ts` 的两遍式最小闭环:** `initContext` 中创建覆盖层缓冲区(提议 `overlayBuffer`)并放入上下文数据;把现有的混合设置替换为分离式混合(源 alpha / 一减源 alpha;alpha 通道用 1 / 一减源 alpha);在 `render()` 开始、清屏之前显式 `gl.depthMask(true)`,背景与不透明遍都保持深度写入;不透明遍遍历 `area.render` 时把每个区间的终点裁剪到 `this.vertex.dynamicStart`(静态部分可能与该尾部合并,必须按终点裁剪后再计算数量,数量不大于 0 时跳过);不透明遍结束后,若 `this.vertex.isOverlayDirty()` 为真,先取数组与数量、把数量大于 0 的数组前段上传到覆盖层缓冲区;随后若数量大于 0,设置 `gl.depthMask(false)`、绑定覆盖层缓冲区、把 4 个实例属性指针的偏移设为 0 / 16 / 32 / 48 字节、用 `drawArraysInstanced(TRIANGLE_STRIP, 0, 4, 数量)` 绘制,再把 `gl.depthMask(true)` 恢复;`tileVAO` 的解绑移到覆盖层绘制之后;`destroy` 删除覆盖层缓冲区;`needUpdate()` 增加 `this.vertex.isOverlayDirty()`。**不要在本任务改 `initContext` 的 ping/pong FBO**(属 Task 4)。 + + (5) **`map.frag`:** 只把 `0.1` 改为 `0.004`;文件其余内容与既有注释逐字不动。 + + (6) **`__test__/overlay.test.ts`(本任务先建立最小单测,Task 2 扩展):** 覆盖阈值判定(`1` 为不透明;`0.999` / `0` 为覆盖层)、`add` 后 `getOrdered()` 的 `layerIndex → y → x → kind` 顺序、同位置静态先于动态、**身份唯一性(`key` 不同即条目不同:同一 `(x, y)` 但 `layerIndex` 不同的两个静态条目共存且按 `layerIndex` 排序;同一 `(layerIndex, x, y)` 的静态与动态条目共存)**、`remove` 移除后不再出现、`getDynamic()` 只返回动态条目、`getOrdered()` 在 `orderDirty` 时排序且 `clearDirty()` 后两个脏标记为 false、`clear()` 清空。每个 `it` 前一行 `//` 注释说明覆盖内容;不导入 WebGL / DOM。 + + (7) 所有改动文件以 CRLF 保存;写完后运行 `cmd /c "pnpm exec prettier --write packages-user/client-base/src/map/overlay.ts packages-user/client-base/src/map/__test__/overlay.test.ts packages-user/client-base/src/map/vertex.ts packages-user/client-base/src/map/renderer.ts packages-user/client-base/src/map/types.ts"`(**不要**把 `map.frag` 传给 prettier)。 + + + cmd /c "pnpm exec vitest run packages-user/client-base/src/map/__test__/overlay.test.ts" + non-zero exit,或任一用例失败:阈值边界、排序键、静态先于动态、移除、脏标记清除、`clear()` 与设计不符;或文件未被 vitest 收集(0 test) + cmd /c "pnpm exec eslint packages-user/client-base/src/map/overlay.ts packages-user/client-base/src/map/__test__/overlay.test.ts packages-user/client-base/src/map/vertex.ts packages-user/client-base/src/map/renderer.ts packages-user/client-base/src/map/types.ts" + non-zero exit:存在 eslint 报告(未使用变量、格式、`no-console` 等) + node -e "const fs=require('fs');const bad=[];const r=fs.readFileSync('packages-user/client-base/src/map/renderer.ts','utf8');if(r.indexOf('gl.depthMask(true)')<0)bad.push('opaque/restore depthMask(true) missing');if(r.indexOf('gl.depthMask(false)')<0)bad.push('overlay depthMask(false) missing');if(r.indexOf('blendFuncSeparate(')<0)bad.push('separate blend function missing');if(r.indexOf('overlayBuffer')<0)bad.push('overlay buffer missing from renderer');const v=fs.readFileSync('packages-user/client-base/src/map/vertex.ts','utf8');if(v.indexOf('this.dynamicStart = staticCount;')<0)bad.push('chunk layout marker missing');if(v.indexOf('isOverlayDirty')<0||v.indexOf('getOverlayArray')<0)bad.push('overlay staging API missing');if(/(instancedArray|tileInstanced)\s*\.sort\(/.test(v))bad.push('chunk array must not be sorted');const f=fs.readFileSync('packages-user/client-base/src/map/shader/map.frag','utf8');if(f.indexOf('0.004')<0)bad.push('map.frag threshold not lowered');if(f.indexOf('alpha < 0.1')>=0)bad.push('old 0.1 threshold still present');const t=fs.readFileSync('packages-user/client-base/src/map/types.ts','utf8');const ti=t.indexOf('overlayBuffer:');if(ti<0)bad.push('IContextData.overlayBuffer missing');else{const semi=t.lastIndexOf(';',ti);if(t.slice(semi+1,ti).indexOf('/**')<0)bad.push('overlayBuffer missing jsDoc at declaration')}if(bad.length){console.error('TRACER GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK tracer gate: one static translucent tile path wired')" + non-zero exit,或 stderr 含 "TRACER GATE FAILED":缺少显式的两种 `depthMask` 设置、缺少分离式混合、缺少覆盖层缓冲区 / 组装 API、分块数组被排序、阈值未改或旧值残留、`IContextData.overlayBuffer` 缺失或声明处缺 jsDoc + node -e "const cp=require('child_process');const r=cp.spawnSync('cmd',['/c','pnpm exec vue-tsc --noEmit'],{encoding:'utf8',timeout:240000});const text=(r.stdout||'')+(r.stderr||'');const bad=[];if(r.error||r.status===null||r.signal)bad.push('vue-tsc did not complete: '+((r.error&&r.error.code)||r.signal||'status=null'));if(r.status!==0&&text.trim()==='')bad.push('vue-tsc produced no output');const all=text.split(/\r?\n/).filter(l=>/error TS\d+/.test(l));const scoped=all.filter(l=>/packages-user[\\/]client-base[\\/]src[\\/]map[\\/](__test__[\\/])?(overlay|vertex|renderer|types)[^\\/]*\.ts/.test(l));const norm=l=>l.replace(/\(\d+,\d+\):/,'').replace(/^.*[\\/]/,'');const base='renderer.ts error TS2339: Property \'getLayerByAlias\' does not exist on type \'IGameMap\'.';const hit=scoped.filter(l=>norm(l)===base);const fresh=scoped.filter(l=>norm(l)!==base);if(hit.length!==1)bad.push('known baseline expected exactly once, got '+hit.length+' :: '+base);for(const e of fresh)bad.push('new scoped diagnostic :: '+e);if(bad.length){console.error('TYPECHECK FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK map files type-check clean (new diagnostics: 0); total diagnostics: '+all.length+' ; exempted baseline: '+(hit[0]||'none'))" + non-zero exit,或 stderr 含 "TYPECHECK FAILED":`vue-tsc` 没能正常跑完、非零退出且无输出;本计划的 `map/` 文件存在**新的**类型错误;已知基线 `getLayerByAlias` 诊断没有恰好出现 1 次(0 次 = 豁免失去证据,多于 1 次 = 出现重复诊断)。豁免只覆盖那一条,不覆盖 `renderer.ts` 的其它诊断 + + 存在纯排序 / 阈值模块与最小单测;静态条目以「种类 + 层索引 + 地图坐标」为唯一身份(同格不同图层不互相覆盖);`setStaticAlpha` 能把一个静态图块置为退化 rect 并登记覆盖层记录,alpha 回到 1 时恢复 rect 并注销;`updateTileVertex` 对被登记的图块会再次置零;`getOverlayArray()` / `getOverlayCount()` / `isOverlayDirty()` 能组装并暴露缓存顺序;`renderer.ts` 以 `depthMask(true)` 绘制不透明遍(裁掉动态尾部)、以 `depthMask(false)` 最后绘制覆盖层并恢复 `depthMask(true)`;分离式混合生效;`map.frag` 阈值为 `0.004`;单测、eslint、TRACER GATE、类型闸门全部通过;新增的公开字段 / 接口成员均在声明处带 jsDoc(既有注释零修改);未接入动态块与后处理修正(留给后续任务) + + + + Task 2: 完成静态覆盖层成员 / 顺序状态机 —— 阈值双向迁移、重建清空、图层变化处理、值脏 vs 顺序脏与惰性排序 + Task 1 已通过(单测、eslint、TRACER GATE、类型闸门全绿)。`overlay.ts` 已有阈值 / 排序 / 增删 / 脏标记的最小实现;`vertex.ts` 已能提取与恢复单个静态图块。 + packages-user/client-base/src/map/overlay.ts, packages-user/client-base/src/map/__test__/overlay.test.ts, packages-user/client-base/src/map/vertex.ts + + - packages-user/client-base/src/map/overlay.ts(Task 1 的版本) + - packages-user/client-base/src/map/__test__/overlay.test.ts(Task 1 的用例) + - packages-user/client-base/src/map/vertex.ts(`setStaticAlpha` / `setStaticFrame` / `updateTileVertex` / `checkRebuild` / `setBlockSize` / `updateLayerArray` / `resizeMap` / `updateBlockVertex` / `updateBlockCache` 的调用链) + - packages-user/client-base/src/map/renderer.ts(`sortLayer` / `updateLayerList` / `setLayerState` 与 `vertex.updateLayerArray()` / `resizeMap()` 的关系) + - packages-user/client-base/src/map/block.ts(`getBlockByDataLoc` / `getLayerInstanced` 的定位方式) + - dev.md(CRLF、命名、jsDoc、私有方法放在调用者之前)、AGENTS.md(测试 `it` 前注释、既有注释不得修改) + + + **本任务只补全覆盖层的成员 / 顺序状态机与静态链路;不接入动态块(Task 3)、不动后处理(Task 4)。** + + (1) **`overlay.ts` 补全:** 实现 `update(kind, key, dataIndex, layerIndex, x, y)`(条目不存在时等价于 `add`;存在时总是标记值脏,且仅当 `(layerIndex, x, y)` 任一变化时标记顺序脏;`key` 是唯一身份,坐标变化不改身份、不新增条目)、`clearStatic()`(只移除静态条目;有移除时同时置顺序脏与值脏)、`clear()`(清空两类条目;有移除时同时置顺序脏与值脏)。`getOrdered()` 仍只在顺序脏时排序;`clearDirty()` 同时清除两个标记;`getOrdered()` 不得在未组装前偷偷清除值脏(值脏的清除只由 `clearDirty()` 完成)。排序实现必须是稳定且确定性的:`layerIndex` → `y` → `x` → `kind`(静态 0 / 动态 1),全等时保持插入顺序。**`getDynamic()` 必须返回新数组快照**,使组装阶段的逐条刷新 / 移除不影响遍历。 + + (2) **`vertex.ts` 的静态成员补充:** + - `setStaticAlpha` 的重复调用必须幂等:对已登记图块再次设置 `< 1` 的 alpha 只更新 alpha 与值脏,不重复置零、不重复登记;连续把 alpha 从 `< 1` 改到 `1` 再改回 `< 1` 必须正确注销 / 重新登记。 + - `checkRebuild()` 在 `mallocVertexArray()` + `splitBlock()` 之后清空所有静态记录(`overlayStatic.clear()`)与对应的排序条目(提议调用 `clearStatic()`),并标记顺序脏与值脏;`setBlockSize()` 同样处理。理由:重建会重新分配数组并把实例 alpha 复位为 1、rect 由地图数据重建,旧的退化 rect 与记录全部失效。 + - `updateLayerList` / `setLayerState` / `resizeMap` 引起重建后,静态记录已被清空;**以 `layerIndex` 入静态键不会残留陈旧键**(重建路径保证清空)。动态条目的 `layerIndex` 不在本任务处理:Task 3 在组装时按当前 `renderer.getLayerIndex(block.layer)` 重新推导,本任务不新增 renderer 侧刷新调用、也不假设 `updateMoving` 会被再次调用。 + - 若 Task 0 裁定 (A),`getIndexInBlock` 使用 `block.dataX` / `block.dataY`;否则保持原样。静态记录的 `mapIndex` 在 `mapWidth` 变化后失效,但重建路径已清空记录,故不需要额外迁移。 + - `setStaticFrame` 对已登记图块标记值脏(Task 1 已加则确认其覆盖所有分支)。 + - `setStaticAlpha` 在写 alpha 后仍调用 `index.block.data.markRenderDirty()`(保持既有行为)。 + + (3) **扩展 `overlay.test.ts`:** 增加「重复 `add` 同键不重复、`update` 同键只值脏、`update` 换 x / y / layerIndex 置顺序脏但不新增条目(身份仍由 `key` 决定)、同 `(layerIndex, x, y)` 的静态与动态条目并存且静态先提交、`clearStatic` 只清静态且置顺序脏、`clear` 清空两类、`clearDirty` 同时清两个标记、顺序脏时 `getOrdered()` 排序一次后缓存复用、值脏不改变顺序、`y` 相同时按 `x`、`y` 有小数时按小数、`getDynamic()` 返回快照(遍历中 `remove` 不影响本次遍历)」等用例。每个 `it` 前一行注释。**不要导入 `vertex.ts` / `renderer.ts`**。 + + (4) 所有改动文件以 CRLF 保存;写完后运行 `cmd /c "pnpm exec prettier --write packages-user/client-base/src/map/overlay.ts packages-user/client-base/src/map/__test__/overlay.test.ts packages-user/client-base/src/map/vertex.ts"`。 + + + - 阈值:`alpha === 1` 不属于覆盖层;`0 <= alpha < 1` 属于覆盖层。 + - 顺序脏(成员变化时同时置值脏):成员增删、alpha 跨阈值、`update` 的 `(layerIndex, x, y)` 变化、`clearStatic` / `clear` 有实际移除、重建。 + - 值脏:alpha / frame / 纹理等**不改变排序键**的值变化;不触发重排。 + - 身份:`key` 不变即身份不变;`update` 改坐标只置顺序脏,不新增、不残留条目。 + - `getOrdered()`:顺序脏时按 `layerIndex → y → x → kind` 排序一次,之后复用数组;值脏不改变顺序。 + - 静态先于动态:同 `(layerIndex, y, x)` 时静态条目先提交;同 `(layerIndex, y, x)` 的静态与动态条目因 `key` 不同而共存。 + - 重建:静态成员与退化 rect 记录被清空,不留下不可见或重复绘制的图块。 + - 幂等:重复设置同一 alpha 不重复登记;`1 → < 1` 的往返能正确注销与重新登记。 + - 图层变化:静态条目随重建清空;动态条目的 `layerIndex` 由 Task 3 在组装阶段重新推导,本任务不依赖 `updateMoving` 被再次调用。 + + + cmd /c "pnpm exec vitest run packages-user/client-base/src/map/__test__/overlay.test.ts" + non-zero exit,或任一用例失败:`update` 的同键 / 换键脏标记、`clearStatic` / `clear`、缓存复用、值脏不重排、静态先于动态、小数 y、阈值边界与设计不符 + cmd /c "pnpm exec eslint packages-user/client-base/src/map/overlay.ts packages-user/client-base/src/map/__test__/overlay.test.ts packages-user/client-base/src/map/vertex.ts" + non-zero exit:存在 eslint 报告 + node -e "const fs=require('fs');const bad=[];const o=fs.readFileSync('packages-user/client-base/src/map/overlay.ts','utf8');for(const s of ['update(','clearStatic(','clear(','markOrderDirty(','markValueDirty(','clearDirty(','getOrdered(']){if(o.indexOf(s)<0)bad.push('overlay.ts missing '+s)}const v=fs.readFileSync('packages-user/client-base/src/map/vertex.ts','utf8');if(!/checkRebuild\(\)\s*\{[\s\S]{0,400}(clearStatic|overlay)/.test(v))bad.push('checkRebuild must reset static overlay membership');if(!/setBlockSize\([\s\S]{0,400}(clearStatic|overlay)/.test(v))bad.push('setBlockSize must reset static overlay membership');if(v.indexOf('this.dynamicStart = staticCount;')<0)bad.push('chunk layout marker missing');const t=fs.readFileSync('packages-user/client-base/src/map/__test__/overlay.test.ts','utf8');const lines=t.split(/\r?\n/);let its=0;lines.forEach((l,i)=>{if(/(^|[^A-Za-z.])it\(/.test(l)){its++;let j=i-1;while(j>=0&&lines[j].trim()==='')j--;if(j<0||!lines[j].trim().startsWith('//'))bad.push('overlay.test.ts:'+(i+1)+' it() without a preceding comment')}});if(its<16)bad.push('overlay.test.ts has only '+its+' it() cases, expected at least 16');if(bad.length){console.error('STATE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK state gate: static membership/state machine complete, '+its+' cases')" + non-zero exit,或 stderr 含 "STATE GATE FAILED":`overlay.ts` 缺少状态机方法、`checkRebuild` / `setBlockSize` 未清空静态成员、分块布局标记丢失、某 `it` 前无注释或用例少于 16 条 + + 覆盖层排序模块具备完整的成员 / 顺序 / 值状态机与惰性排序;静态图块的 alpha 双向迁移、重复设置幂等、地图重建与分块尺寸变化时清空静态成员均正确;单测(≥16 条,每 `it` 前注释)、eslint、STATE GATE 全部通过;未接入动态块与后处理 + + + + Task 3: 把全部动态块并入覆盖层 —— 登记 / 注销 / 位置变化重排 / 组装时重新推导图层键 / alpha·frame 只重传,保留索引身份 + Task 2 已通过(覆盖层状态机与静态链路全绿)。`updateMoving` / `deleteMoving` 仍是原实现;`MovingBlock.index` 与动态数组未改。 + packages-user/client-base/src/map/vertex.ts, packages-user/client-base/src/map/types.ts, packages-user/client-base/src/map/__test__/overlay.test.ts + + - packages-user/client-base/src/map/vertex.ts(`updateMoving` / `updateMovingList` / `deleteMoving`、`setDynamicAlpha` / `setDynamicFrame`、`dynamicInstancedArray`、`dynamicStart` / `dynamicCount`) + - packages-user/client-base/src/map/moving.ts(`MovingBlock.index` / `x` / `y` / `layer` 只读字段;`setPos` / `stepMoving` 只改 x / y) + - packages-user/client-base/src/map/renderer.ts(`addMovingBlock` / `deleteMoving` / `tick` 的调用顺序;`expandMoving` / `reduceMoving`) + - packages-user/client-base/src/map/status.ts(`DynamicBlockStatus.setAlpha` / `setFrame` → `setDynamicAlpha` / `setDynamicFrame`) + - packages-user/client-base/src/map/types.ts(`IMovingBlock` 的 `readonly index` / `x` / `y` / `layer`) + - dev.md(CRLF、私有方法位置)、AGENTS.md(测试 `it` 前注释) + + + **本任务只把动态块接入覆盖层,并保持动态数组与索引身份逐字不变;不动静态链路与后处理。** + + (1) **登记与更新(不新增动态记录表):** 在 `vertex.updateMoving(block, updateTexture)` 中,先保持现有的实例写入与 `dynamicRenderDirty = true`,随后调用 `overlay.update(Dynamic, this.getDynamicOverlayKey(block.index), block.index, this.renderer.getLayerIndex(block.layer), block.x, block.y)`(条目不存在时 `update` 等价于新增)。位置 / `layerIndex` 变化 → 顺序脏;仅贴图 / 其它值变化 → 值脏。动态块**无论 alpha 是否为 1 都在覆盖层**,因此不要按 alpha 做成员判断。**不引入 `IOverlayDynamicRecord` / `overlayDynamic`**:动态条目的唯一身份是 `dynamic:${movingIndex}`,坐标与 `layerIndex` 的来源是活动 `IMovingBlock` 本身,不需要另存一份可能过期的副本。 + + (2) **注销:** 在 `vertex.deleteMoving(moving)` 中,先调用 `this.overlay.remove(Dynamic, this.getDynamicOverlayKey(moving.index))`,再执行现有的 `EMPTY_VETREX` 清空与 `dynamicRenderDirty = true`。不得改变 `renderer.deleteMoving` 中的 `returnMovingIndex` / `movingBlock.delete` / `movingIndexMap.delete` 顺序。 + + (3) **值脏:** `setDynamicAlpha(index, alpha)` / `setDynamicFrame(index, frame)` 在写入 `dynamicInstancedArray` 后调用 `this.overlay.markValueDirty()`(它们只可能来自已在覆盖层中的移动块);不得标记顺序脏。 + + (4) **组装(含图层变化后的重新推导):** `assembleOverlay()`(由 `getOverlayArray()` 在脏时调用)在调用 `getOrdered()` **之前**先刷新所有动态条目:遍历 `this.overlay.getDynamic()` 的快照,用 `this.renderer.getMovingBlockByIndex(entry.dataIndex)` 取活动块——取到则用 `this.renderer.getLayerIndex(block.layer)` / `block.x` / `block.y` 调 `overlay.update(Dynamic, entry.key, entry.dataIndex, ...)`,因此**图层列表 / zIndex 变化后旧的排序键会被就地纠正,不需要 `updateMoving` 被再次调用**;取不到(已被删除但条目残留)则 `overlay.remove(Dynamic, entry.key)`。刷新后再 `getOrdered()`,按条目顺序组装:动态条目从 `dynamicInstancedArray` 拷贝 16 个 float(`entry.dataIndex * INSTANCED_COUNT` 起),不做 rect 覆盖;静态分支保持 Task 1 的行为。**动态数组不重排**,`MovingBlock.index`、`movingIndexMap`、`expandMoving` / `reduceMoving` 一律不动。 + + (5) **扩展 `overlay.test.ts`:** 增加「动态条目与静态条目混合排序」「同键位静态先于动态」「动态位置变化置顺序脏」「动态图层 / 位置经 `update` 刷新后重排且身份不变」「动态仅 alpha / 值变化只值脏」「动态移除后不再出现」等用例(纯排序模块层,不触碰 vertex / WebGL)。每个 `it` 前一行注释。 + + (6) 所有改动文件以 CRLF 保存;写完后运行 `cmd /c "pnpm exec prettier --write packages-user/client-base/src/map/vertex.ts packages-user/client-base/src/map/types.ts packages-user/client-base/src/map/__test__/overlay.test.ts"`。 + + + cmd /c "pnpm exec vitest run packages-user/client-base/src/map/__test__/overlay.test.ts" + non-zero exit,或任一用例失败:动态条目的排序 / 同键次序 / 位置变化重排 / 仅值变化不重排 / 移除与设计不符 + cmd /c "pnpm exec eslint packages-user/client-base/src/map/vertex.ts packages-user/client-base/src/map/types.ts packages-user/client-base/src/map/__test__/overlay.test.ts" + non-zero exit:存在 eslint 报告 + node -e "const fs=require('fs');const bad=[];const v=fs.readFileSync('packages-user/client-base/src/map/vertex.ts','utf8');const body=(src,sig)=>{const i=src.indexOf(sig);if(i<0)return '';const j=src.indexOf('\n }',i);return j<0?src.slice(i):src.slice(i,j)};const um=body(v,'updateMoving(block: IMovingBlock');if(um.indexOf('overlay.update(')<0)bad.push('updateMoving body must call overlay.update (inside the method)');if(!/\.Dynamic\b/.test(um))bad.push('updateMoving body must register a Dynamic overlay entry');const dm=body(v,'deleteMoving(moving: IMovingBlock');if(dm.indexOf('overlay.remove(')<0)bad.push('deleteMoving body must call overlay.remove');if(!/\.Dynamic\b/.test(dm))bad.push('deleteMoving body must unregister a Dynamic overlay entry');const ao=body(v,'private assembleOverlay(');if(ao==='')bad.push('private assembleOverlay method missing');if(ao.indexOf('getDynamic()')<0)bad.push('assembleOverlay must iterate dynamic entries via getDynamic()');if(ao.indexOf('getMovingBlockByIndex(')<0)bad.push('assembleOverlay must re-derive positions from live moving blocks');if(ao.indexOf('getLayerIndex(')<0)bad.push('assembleOverlay must re-derive layerIndex via renderer.getLayerIndex');if(v.indexOf('this.dynamicStart = staticCount;')<0)bad.push('chunk layout marker missing');if(!/dynamicInstancedArray[\s\S]{0,200}INSTANCED_COUNT/.test(v))bad.push('dynamic instance storage marker missing');const m=fs.readFileSync('packages-user/client-base/src/map/moving.ts','utf8');if(m.indexOf('this.index = index;')<0)bad.push('moving index identity changed');if(m.indexOf('readonly index: number;')>=0||m.indexOf('index: number;')<0)bad.push('unexpected moving.ts shape');const r=fs.readFileSync('packages-user/client-base/src/map/renderer.ts','utf8');for(const s of ['movingIndexMap.set(index, moving)','this.movingBlock.delete(block)','returnMovingIndex(block.index)']){if(r.indexOf(s)<0)bad.push('renderer moving identity marker missing: '+s)}if(bad.length){console.error('DYNAMIC GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK dynamic gate: dynamic blocks join the overlay without reordering')" + non-zero exit,或 stderr 含 "DYNAMIC GATE FAILED":`updateMoving` 的**方法体**未调用 `overlay.update` / 未登记动态条目、`deleteMoving` 的方法体未注销、`private assembleOverlay` 方法缺失或其方法体内未用 `getDynamic()` + `getMovingBlockByIndex` + `getLayerIndex` 重新推导动态排序键、分块或动态实例存储标记丢失、`moving.ts` 索引身份被改、`renderer.ts` 的索引映射 / 删除 / 归还逻辑被改。**门禁按方法体切片判定(签名到该方法的 4 空格闭合括号),不以字符窗口判定**,因此不受语句长度影响 + + 全部动态块在 `updateMoving` 登记、在 `deleteMoving` 注销;位置变化触发顺序脏,alpha / frame 只触发值脏;覆盖层组装前按活动 `IMovingBlock` 重新推导坐标与 `layerIndex`(图层列表 / zIndex 变化不残留旧排序键),再按缓存顺序混排静态与动态;`MovingBlock.index` / `movingIndexMap` / 扩缩容与 `moving.ts` 零改动;单测、eslint、DYNAMIC GATE 全部通过 + + + + Task 4: 修正后处理 —— ping/pong 颜色纹理配对、`DEPTH_COMPONENT24` 深度附件、0/1/2+ 效果链路、缩放与销毁 + Task 1..3 已通过(两遍式与覆盖层全绿)。`initContext` 仍把 pongTexture 附到 pingFramebuffer、pingTexture 附到 pongFramebuffer,FBO 无深度附件;`render()` 仍以 `postEffects.length > 1` 决定是否进 FBO;`setCanvasSize` 只重设纹理;`destroy` 未删除 ping/pong 对象。 + packages-user/client-base/src/map/renderer.ts, packages-user/client-base/src/map/types.ts + + - packages-user/client-base/src/map/renderer.ts(`initContext` 的 FBO / 纹理创建、`setCanvasSize`、`destroy`、`render` 的 1436–1518 行后处理链) + - packages-user/client-base/src/map/types.ts(`IContextData`、`IMapRendererPostEffect.render` 的输入 / 输出契约) + - packages-user/client-base/src/map/extension/types.ts(`IMapRendererPostEffect` 的消费者面) + - dev.md(CRLF、注释规范)、AGENTS.md(既有注释不得修改) + + + **本任务只修后处理链路与 FBO 深度;不动两遍式与覆盖层的排序逻辑。** + + (1) **`initContext` 的 FBO / 纹理:** 创建 ping / pong 两个深度 renderbuffer(提议 `pingDepthBuffer` / `pongDepthBuffer`),各自绑定后用 `renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, canvas.width, canvas.height)` 分配存储;pingFramebuffer 的颜色附件改为 `pingTexture2D`(必须与名字一致),pongFramebuffer 改为 `pongTexture2D`;两个 FBO 都通过 `framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, 对应深度缓冲)` 挂上深度;两个颜色纹理设置 `TEXTURE_MIN_FILTER = NEAREST`、`TEXTURE_MAG_FILTER = NEAREST`、`TEXTURE_WRAP_S = CLAMP_TO_EDGE`、`TEXTURE_WRAP_T = CLAMP_TO_EDGE`;完成初始化后解绑 framebuffer / renderbuffer / texture。`IContextData` 补上两个深度字段(只读),**两个新字段都必须在声明处补一行 jsDoc**(风格对齐同文件既有字段),不得修改任何既有 jsDoc / 注释。 + + (2) **`render()` 的链路:** 先按启用效果数量决定目标:0 个 → 默认帧缓冲(`null`);**至少 1 个 → `pingFramebuffer`**(门控条件必须是 `postEffects.length > 0` 或等价的 `postEffects.length === 0 ? null : pingFramebuffer`,**不得保留 `postEffects.length > 1`**)。场景(背景 + 不透明遍 + 覆盖层遍)按 Task 1 的顺序绘制。随后把效果链的**初始输入纹理显式设为 `pingTexture2D`**(场景写入 ping 的颜色纹理),再遍历启用效果:在循环体内用 `i === a.length - 1`(或 `i === postEffects.length - 1`)判定「最后一个」,最后一个效果写 `null`(屏幕),其余写「上一个写入 FBO 之外的另一个 FBO」;**不得再使用 `a.length - 2` 这种「倒数第二个就切屏幕」的写法**(那是基线缺陷)。必须覆盖 0 / 1 / 2 / 3+ 的正确配对(单效果时读 `pingTexture2D`、写屏幕,不得读自己正在写的纹理)。不得改变 `IMapRendererPostEffect.render` 的签名或每个效果自绑 FBO 的契约。 + + (3) **`setCanvasSize`:** 在重设两个颜色纹理之后,绑定并重设两个深度 renderbuffer 的存储尺寸(与纹理同宽高),再解绑;保持 `updateRequired = true`。 + + (4) **`destroy`:** 删除 `pingFramebuffer` / `pongFramebuffer`、`pingTexture2D` / `pongTexture2D`、两个深度 renderbuffer,以及 Task 1 新增的覆盖层缓冲区;保持现有其它删除顺序。 + + (5) 所有改动文件以 CRLF 保存;写完后运行 `cmd /c "pnpm exec prettier --write packages-user/client-base/src/map/renderer.ts packages-user/client-base/src/map/types.ts"`。 + + + cmd /c "pnpm exec eslint packages-user/client-base/src/map/renderer.ts packages-user/client-base/src/map/types.ts" + non-zero exit:存在 eslint 报告 + node -e "const fs=require('fs');const bad=[];const r=fs.readFileSync('packages-user/client-base/src/map/renderer.ts','utf8');for(const s of ['DEPTH_COMPONENT24','framebufferRenderbuffer','renderbufferStorage','deleteRenderbuffer','deleteFramebuffer','deleteTexture']){if(r.indexOf(s)<0)bad.push('renderer.ts missing '+s)}const init=r.slice(r.indexOf('private initContext()'),r.indexOf('destroy(): void'));const attach={};const re=/framebufferTexture2D\(\s*gl\.FRAMEBUFFER\s*,\s*gl\.COLOR_ATTACHMENT0\s*,\s*gl\.TEXTURE_2D\s*,\s*(\w+)/g;let mm;while((mm=re.exec(init))){const before=init.slice(0,mm.index);const b=before.lastIndexOf('bindFramebuffer(gl.FRAMEBUFFER, ');const fm=b<0?null:/bindFramebuffer\(gl\.FRAMEBUFFER, (\w+)\)/.exec(before.slice(b));attach[fm?fm[1]:'?']=mm[1]}if(attach.pingFramebuffer!=='pingTexture2D')bad.push('pingFramebuffer color attachment must be pingTexture2D, got '+(attach.pingFramebuffer||'none'));if(attach.pongFramebuffer!=='pongTexture2D')bad.push('pongFramebuffer color attachment must be pongTexture2D, got '+(attach.pongFramebuffer||'none'));if(r.indexOf('postEffects.length > 1')>=0)bad.push('old post-effect gating still present');if(!/(postEffects\.length\s*>\s*0[\s\S]{0,240}pingFramebuffer)|(postEffects\.length\s*===\s*0[\s\S]{0,240}pingFramebuffer)/.test(r))bad.push('0 effects must target the default framebuffer and 1+ must target ping');const cs=r.indexOf('const postEffects');const ce=r.indexOf('postEffects.forEach(');if(cs<0||ce<0)bad.push('post-effect chain missing');else if(r.slice(cs,ce).indexOf('pingTexture2D')<0)bad.push('first effect must read pingTexture2D (the scene target)');const ls=r.indexOf('postEffects.forEach(');if(ls<0)bad.push('post-effect loop missing');else{const le=r.indexOf('});',ls);const lb=r.slice(ls,le<0?ls+1200:le);if(lb.indexOf('.length - 2')>=0)bad.push('effect chain still switches to the screen at the second-to-last effect');if(!/\.length\s*-\s*1/.test(lb))bad.push('last effect must target the screen (i === a.length - 1)');if(lb.indexOf('null')<0)bad.push('effect chain never writes to the screen')}if(!/setCanvasSize\([\s\S]{0,2000}renderbufferStorage/.test(r))bad.push('setCanvasSize does not resize depth renderbuffers');const t=fs.readFileSync('packages-user/client-base/src/map/types.ts','utf8');for(const n of ['pingDepthBuffer','pongDepthBuffer','overlayBuffer']){const i=t.indexOf(n+':');if(i<0)bad.push('IContextData missing '+n);else{const semi=t.lastIndexOf(';',i);if(t.slice(semi+1,i).indexOf('/**')<0)bad.push(n+' missing jsDoc at declaration')}}if(bad.length){console.error('POST GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK post gate: ping/pong attachment pairing, depth attachment, 0/1/2+ chain target, resize, delete and jsDoc wired')" + non-zero exit,或 stderr 含 "POST GATE FAILED":缺少深度 renderbuffer 的创建 / 附着 / 缩放 / 删除;**实际 `framebufferTexture2D` 调用的配对**不是 ping←`pingTexture2D` / pong←`pongTexture2D`(门禁按「每个颜色附着的最近一次 `bindFramebuffer`」映射判定,不按声明相邻字符判定);旧门控 `postEffects.length > 1` 残留;0 / 1+ 的效果目标不是在 `null` 与 `pingFramebuffer` 之间切换;效果链初始输入不是 `pingTexture2D`;循环仍用 `.length - 2` 切屏幕、或没有 `i === a.length - 1` 的末项判定;`IContextData` 缺字段或新字段声明处缺 jsDoc + node -e "const cp=require('child_process');const r=cp.spawnSync('cmd',['/c','pnpm exec vue-tsc --noEmit'],{encoding:'utf8',timeout:240000});const text=(r.stdout||'')+(r.stderr||'');const bad=[];if(r.error||r.status===null||r.signal)bad.push('vue-tsc did not complete: '+((r.error&&r.error.code)||r.signal||'status=null'));if(r.status!==0&&text.trim()==='')bad.push('vue-tsc produced no output');const all=text.split(/\r?\n/).filter(l=>/error TS\d+/.test(l));const scoped=all.filter(l=>/packages-user[\\/]client-base[\\/]src[\\/]map[\\/](__test__[\\/])?(overlay|vertex|renderer|types)[^\\/]*\.ts/.test(l));const norm=l=>l.replace(/\(\d+,\d+\):/,'').replace(/^.*[\\/]/,'');const base='renderer.ts error TS2339: Property \'getLayerByAlias\' does not exist on type \'IGameMap\'.';const hit=scoped.filter(l=>norm(l)===base);const fresh=scoped.filter(l=>norm(l)!==base);if(hit.length!==1)bad.push('known baseline expected exactly once, got '+hit.length+' :: '+base);for(const e of fresh)bad.push('new scoped diagnostic :: '+e);if(bad.length){console.error('TYPECHECK FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK map files type-check clean (new diagnostics: 0); total diagnostics: '+all.length+' ; exempted baseline: '+(hit[0]||'none'))" + non-zero exit,或 stderr 含 "TYPECHECK FAILED":`vue-tsc` 未正常完成;本计划 `map/` 文件存在**新的**类型错误;已知基线 `getLayerByAlias` 诊断未恰好出现 1 次(0 次 = 豁免失去证据,多于 1 次 = 重复诊断);或豁免被当成「忽略 `renderer.ts` 全部错误」 + + ping/pong FBO 各自配对同名颜色纹理(以**实际 `framebufferTexture2D` 调用**的配对为准)并挂 `DEPTH_COMPONENT24` 深度 renderbuffer;两个颜色纹理有完整采样参数;0 个效果走默认帧缓冲、至少 1 个先渲染进 ping 且链路初始输入为 `pingTexture2D`、效果链交替读写且**最后一项(`i === a.length - 1`)**写屏幕(单效果正确,不残留 `.length - 2` 写法);`setCanvasSize` 同步缩放两个深度缓冲;`destroy` 删除 FBO / 纹理 / 深度缓冲 / 覆盖层缓冲;两个新增 `IContextData` 字段在声明处带 jsDoc(既有注释零修改);eslint、POST GATE、类型闸门(含已知基线精确豁免)全部通过 + + + + Task 5: 源码静态闸门 + 全量验证(vitest / eslint / 按文件过滤的 vue-tsc / 范围闸门) + Task 1..4 已通过;`map.frag` 为 `0.004`;两遍式、覆盖层、后处理均已落地。 + packages-user/client-base/src/map/__test__/rendererGate.test.ts + + - packages-user/client-base/src/map/renderer.ts / vertex.ts / types.ts / shader/map.frag(Task 1..4 之后的版本) + - packages-user/client-base/src/map/moving.ts / viewport.ts / block.ts(保护项,只读) + - packages-user/client-base/src/map/__test__/overlay.test.ts(测试风格与 `it` 注释) + - .planning/phases/09-minimum-browser-runnable/09-24-PLAN.md(静态闸门与范围闸门的写法) + - dev.md(CRLF、import 连续、测试 `it` 前注释)、AGENTS.md(不得改既有注释、不得跑全量构建) + + + **本任务只新增源码静态闸门测试并把所有门禁跑齐;不改生产逻辑。** + + (1) **新建 `packages-user/client-base/src/map/__test__/rendererGate.test.ts`:** 用 `node:fs` 的 `readFileSync` 读取 `renderer.ts` / `vertex.ts` / `types.ts` / `moving.ts` / `viewport.ts` / `shader/map.frag`(路径以仓库根为基准),用 `node:path` 的 `resolve` 组合路径;**不导入任何 WebGL / DOM 模块**。逐项 `it`(每个前一行 `//` 注释): + - 两遍式状态:`renderer.ts` 同时含 `gl.depthMask(true)` 与 `gl.depthMask(false)`。 + - 混合:`renderer.ts` 含 `blendFuncSeparate(`。 + - 深度附件:`renderer.ts` 含 `DEPTH_COMPONENT24`、`framebufferRenderbuffer`、`renderbufferStorage`、`deleteRenderbuffer`。 + - ping/pong 配对(**必须锚定实际附着调用,不得只扩大窗口**):在 `initContext` 片段内收集每个 `framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, <纹理>, 0)` 调用,取该调用之前**最近一次** `bindFramebuffer(gl.FRAMEBUFFER, )` 的 ``,断言映射为 `pingFramebuffer → pingTexture2D` 且 `pongFramebuffer → pongTexture2D`;该断言在修复前的基线(ping←pongTexture、pong←pingTexture)上必须失败。 + - 后处理链路的最终目标:`postEffects.forEach` 的循环体内必须出现「最后一项」(`i === a.length - 1` 或 `i === postEffects.length - 1`)写 `null`(屏幕),且**全文件不得出现 `.length - 2`**;链路的初始输入必须是 `pingTexture2D`;0 个效果 → `null`、至少 1 个 → `pingFramebuffer` 的门控(`postEffects.length > 0` 或 `postEffects.length === 0 ? null : pingFramebuffer`)存在;全文件不含旧的门控 `postEffects.length > 1`。这些断言在修复前的基线上必须失败(基线用 `inputTextrue = pongTexture2D` 与 `i === a.length - 2`)。 + - 覆盖层绘制:`renderer.ts` 含覆盖层缓冲区标识符与 `drawArraysInstanced`,且不使用按排序索引绘制实例的另一入口(具体串只在验收闸门与测试文件中出现)。 + - 分块布局:`vertex.ts` 含 `this.dynamicStart = staticCount;`、`instancedStart`、`i * count * INSTANCED_COUNT`;且不对 `instancedArray` / `tileInstanced` 做任何排序(比较器写法见验收闸门)。 + - 动态索引身份:`moving.ts` 含 `this.index = index;` 且不对数组排序;`types.ts` 的 `IMovingBlock` 仍声明 `readonly index: number;`;`renderer.ts` 仍含 `movingIndexMap.set(index, moving)` 与 `returnMovingIndex(block.index)`。 + - 动态排序键刷新:`vertex.ts` 的组装方法内(`assembleOverlay()` → `getOverlayArray()` 链)含 `getDynamic()`、`getMovingBlockByIndex(`、`getLayerIndex(`,即图层列表 / zIndex 变化后在组装时重新推导动态条目的坐标与 `layerIndex`。 + - 阈值:`map.frag` 含 `0.004` 且旧阈值不残留(旧值见 Task 0 的 ``)。 + - 禁止的半精度 / 顺序剥离类标记:`renderer.ts` 与 `vertex.ts` 均不含(具体正则只在验收闸门与测试文件中出现)。 + - `IContextData` / `IMapVertexGenerator` 的新成员声明存在,且**每个新增公开成员 / 字段在其声明处带 jsDoc**(判定:该成员名之前的「上一个 `;` 到成员名之间」含 `/**`);不得因新增注释而修改任何既有 jsDoc / 注释。 + (2) 写完后运行完整验证:`vitest` 两个测试文件、`eslint` 全部改动 TS 文件、按文件过滤的 `vue-tsc --noEmit`、范围闸门(白名单由 `files_modified` 派生;用户既有脏文件允许;`map/` 下非白名单文件与依赖 / 配置文件受保护)。 + (3) 测试文件以 CRLF 保存;写完后运行 `cmd /c "pnpm exec prettier --write packages-user/client-base/src/map/__test__/rendererGate.test.ts"`。 + + + cmd /c "pnpm exec vitest run packages-user/client-base/src/map/__test__/overlay.test.ts packages-user/client-base/src/map/__test__/rendererGate.test.ts" + non-zero exit,或任一用例失败:任一静态闸门命中了与设计不符的源码形态 + cmd /c "pnpm exec eslint packages-user/client-base/src/map/overlay.ts packages-user/client-base/src/map/vertex.ts packages-user/client-base/src/map/renderer.ts packages-user/client-base/src/map/types.ts packages-user/client-base/src/map/__test__/overlay.test.ts packages-user/client-base/src/map/__test__/rendererGate.test.ts" + non-zero exit:存在 eslint 报告 + node -e "const cp=require('child_process');const r=cp.spawnSync('cmd',['/c','pnpm exec vue-tsc --noEmit'],{encoding:'utf8',timeout:240000});const text=(r.stdout||'')+(r.stderr||'');const bad=[];if(r.error||r.status===null||r.signal)bad.push('vue-tsc did not complete: '+((r.error&&r.error.code)||r.signal||'status=null'));if(r.status!==0&&text.trim()==='')bad.push('vue-tsc produced no output');const all=text.split(/\r?\n/).filter(l=>/error TS\d+/.test(l));const scoped=all.filter(l=>/packages-user[\\/]client-base[\\/]src[\\/]map[\\/](__test__[\\/])?(overlay|vertex|renderer|types)[^\\/]*\.ts/.test(l));const norm=l=>l.replace(/\(\d+,\d+\):/,'').replace(/^.*[\\/]/,'');const base='renderer.ts error TS2339: Property \'getLayerByAlias\' does not exist on type \'IGameMap\'.';const hit=scoped.filter(l=>norm(l)===base);const fresh=scoped.filter(l=>norm(l)!==base);if(hit.length!==1)bad.push('known baseline expected exactly once, got '+hit.length+' :: '+base);for(const e of fresh)bad.push('new scoped diagnostic :: '+e);if(bad.length){console.error('TYPECHECK FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK map files type-check clean (new diagnostics: 0); total diagnostics: '+all.length+' ; exempted baseline: '+(hit[0]||'none'))" + non-zero exit,或 stderr 含 "TYPECHECK FAILED":`vue-tsc` 未正常完成;本计划 `map/` 文件存在**新的**类型错误;已知基线 `getLayerByAlias` 诊断未恰好出现 1 次(0 次 = 豁免失去证据,多于 1 次 = 重复诊断);或豁免被当成「忽略 `renderer.ts` 全部错误」 + node -e "const fs=require('fs'),cp=require('child_process');const plan=fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-25-PLAN.md','utf8');const s=plan.indexOf('files_modified:');const e=plan.indexOf('autonomous:');const allow=plan.slice(s,e).split(/\r?\n/).slice(1).map(x=>x.replace(/^\s*-\s*/,'').trim()).filter(Boolean);const userBaseline=['packages-user/client-base/src/hotkey/hotkey.ts','packages-user/client-base/src/hotkey/types.ts','packages/common/src/logger.json','packages/system/src/hotkey/__test__/command.test.ts','packages/system/src/hotkey/__test__/system.test.ts','packages/system/src/hotkey/command.ts','packages/system/src/hotkey/system.ts','packages/system/src/hotkey/type.ts','src/client/misc/hotkey.ts'];const protectedPaths=['packages-user/client-base/src/map/moving.ts','packages-user/client-base/src/map/viewport.ts','packages-user/client-base/src/map/element.ts','packages-user/client-base/src/map/block.ts','packages-user/client-base/src/map/constant.ts','packages-user/client-base/src/map/status.ts','packages-user/client-base/src/map/shader/map.vert','packages-user/client-base/src/map/shader/back.vert','packages-user/client-base/src/map/shader/back.frag','packages-user/client-base/src/map/index.ts','packages-user/client-base/package.json','package.json','pnpm-lock.yaml'];const g=cp.spawnSync('git',['status','--porcelain'],{encoding:'utf8'});const bad=[];if(g.status!==0)bad.push('git status failed');const lines=(g.stdout||'').split(/\r?\n/).filter(Boolean);if(allow.length!==8)bad.push('allowlist should have 8 entries, got '+allow.length);for(const l of lines){const p=l.slice(3).trim();if(allow.includes(p)||p.startsWith('.planning/')||p.startsWith('docs/')||userBaseline.includes(p))continue;bad.push('out-of-scope change: '+p)}for(const p of protectedPaths){if(lines.some(l=>l.slice(3).trim()===p))bad.push('protected file modified: '+p)}if(bad.length){console.error('SCOPE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK scope gate: only the 8 plan files changed; user baseline untouched')" + non-zero exit,或 stderr 含 "SCOPE GATE FAILED":出现白名单外的受控改动、`map/` 下受保护文件被改、或依赖 / 配置文件被改 + + `rendererGate.test.ts` 覆盖全部静态闸门并全绿(含按实际附着调用判定的 ping/pong 配对、效果链末项写屏幕、新增成员 jsDoc);两个测试文件、eslint、按文件过滤的 `vue-tsc`(仅精确豁免用户裁定的 `getLayerByAlias` 基线一条并打印证据)、范围闸门全部通过;未新增生产逻辑、未改受保护文件 + + + + Task 6: 浏览器人工 UAT 清单 + `09-25-SUMMARY.md`(诚实声明无 WebGL E2E 自动化) + Task 1..5 全部通过;工作树中只应有本计划的 8 个文件与用户既有的并发改动。 + .planning/phases/09-minimum-browser-runnable/09-25-SUMMARY.md + + - .planning/phases/09-minimum-browser-runnable/09-24-SUMMARY.md(SUMMARY 的写法、边界声明与未校准估算声明) + - 本计划的 `must_haves` 与 Task 1..5 的实际改动 + - AGENTS.md(问题须经确认才能登记、不得伪造验证)、dev.md(文档格式) + + + **先写 `.planning/phases/09-minimum-browser-runnable/09-25-SUMMARY.md`,然后向用户呈交下面的浏览器 UAT 清单并等待逐项结果;用户报告后把结果补进 SUMMARY(通过 / 失败 / 环境),失败的项如实记录、不在本计划内顺手修(另派计划)。** + + SUMMARY 必须记录:①两遍式的最终落点(文件 + 位置 + 关键写法);②静态提取 / 恢复 / 再提取的机制与重建清空;③条目身份键(静态 / 动态)、排序键与两个脏标记的语义、惰性排序的调用顺序(先刷新 / 捕获脏再组装);④动态块并入覆盖层的路径、「组装前用 `getMovingBlockByIndex` + `getLayerIndex` 重新推导排序键」与「未重排动态数组 / 索引身份保留」的证据;⑤后处理修正的最终写法(配对、`DEPTH_COMPONENT24` 深度附件、0/1/2+ 链路、缩放、销毁)——**必须出现字面量 `DEPTH_COMPONENT24`**(SUMMARY 闸门按该字面量校验,不要只写「深度附件」);⑥四类门禁的实际结果;⑦OI-1 的裁定((A) 已修 / (B) 未修与限制);⑧OI-2 最终采用的命名;⑨边界(未做 WebGL E2E、未跑全量构建、未改其它包 / 09-01..09-24、工作树既有用户改动未触碰);⑩`estimate.tokens=160000` / 7 tasks 为**未校准估算**(`confidence: low`)的声明,并声明它超出 100000 的单计划预算(ratio 1.6)是**用户明确要求「本增量只新增一个计划」而批准的单计划例外**(未拆分、未调低 tokens);⑪无 RESEARCH / VALIDATION 与 spec-less probe 可见跳过。 + + 浏览器 UAT 清单(由用户执行,逐项记录): + 1. 静态半透明图块压在不透明图块上:能透出下方图块,不再出现黑洞。 + 2. 两个相互重叠的半透明图块(同一行与不同行各一组):后到前混合正确,交换绘制来源 / 刷新后不闪错序。 + 3. 半透明移动的勇士 / 跟随者经过半透明与不透明图块:遮挡与混合正确,移动过程中不出现闪烁或错误穿插。 + 4. 26×13 地图上跨越分块边界的超大地图(半透明):位置正确、跨块部分不被裁掉、不出现洞;若 OI-1 选 (B),本项只验证「不崩溃 / 不新增错误」并记录定位限制。 + 5. 淡出到极低 alpha(接近 0):不出现 0.1 阈值导致的突然消失 / 跳变。 + 6. 后处理:0 个效果时画面正常;用临时本地调用(不提交)加 1 个效果时效果可见、画面不黑、深度遮挡正确;再加到 2 个效果时链路交替正确。 + 7. 改变画布尺寸(含效果开启时):无 GL 报错,深度与颜色都正确缩放。 + 8. 动态增删移动块(如 NPC 出现 / 消失):覆盖层成员正确增删,不残留残影。 + 9. 图层顺序变化:在有半透明移动块时调整图层列表 / `zIndex`(或触发 `updateLayerList` / `setLayerState`),动态块的遮挡与混合顺序随新图层顺序正确变化,不残留旧排序(这是「动态 `layerIndex` 在组装时重新推导」的人工判据)。 + 另需声明:本计划**没有** WebGL E2E 自动化;上述清单是唯一的人工验证;未通过项不写成通过。 + + + - 地图渲染改为两遍式:不透明遍(显式 `depthMask(true)`、裁掉动态尾部)+ 透明覆盖层遍(`depthMask(false)`、深度测试开、独立暂存缓冲区、`drawArraysInstanced`),收尾恢复 `depthMask(true)`;混合改为分离式。 + - 静态半透明图块在 alpha `< 1` 时以退化 rect 进入覆盖层,alpha 回到 1 时恢复;成员图块被重新写入后会再次置零;地图重建 / 分块尺寸变化会清空静态成员;分块张量与动态数组均未重排。 + - 全部动态块并入覆盖层,按 `(layerIndex, y, x, kind)` 后到前排序;条目身份键唯一(静态含层索引 + 地图坐标、动态为移动索引);组装前用 `getMovingBlockByIndex` + `getLayerIndex` 重新推导动态条目的坐标与 `layerIndex`(图层列表 / zIndex 变化不残留旧排序键);值脏 / 顺序脏分离,纯 alpha / frame 不重排;`MovingBlock.index` 身份保留。 + - ping/pong FBO 颜色纹理配对修正、`DEPTH_COMPONENT24` 深度附件、0/1/2+ 效果链路、`setCanvasSize` 缩放、`destroy` 全量删除、`IContextData` 补字段;`map.frag` discard 阈值 `0.1` → `0.004`。 + - 纯排序 / 成员状态机单测与源码静态闸门、eslint、按文件过滤的 `vue-tsc`、范围闸门全部通过。 + + + 1. 运行 `cmd /c "pnpm dev"`(或等价开发服务器),打开包含地图的场景。 + 2. 逐项执行 `` 中的 9 条浏览器 UAT 清单,把每项结果(通过 / 失败 / 环境说明)记下来。 + 3. 重点确认:半透明不挖洞、两层半透明混合顺序正确、半透明移动块遮挡正确、26×13 跨分块位置正确、极低 alpha 不跳变、0/1/2+ 效果链路可见且不黑、改变画布尺寸无 GL 报错、动态增删无残影、图层顺序变化后动态块遮挡顺序正确。 + 4. 把逐项结果回复给执行者;执行者据此补全 `09-25-SUMMARY.md`。失败项不写成通过、不在本计划内顺手修。 + + 逐项回复 UAT 结果(每项写「通过」或「失败 + 现象 + 环境」);无需回复「可以执行」(该关卡已在 Task 0 通过)。如有失败项,确认是否另派修复计划。 + + 用户在浏览器中逐项执行 UAT 清单并把每项结果(通过 / 失败 / 环境说明)回复;`09-25-SUMMARY.md` 已存在且记录了 ①..⑪、UAT 各项结果与「无 WebGL E2E 自动化」声明;任何失败项如实记录为未通过 + node -e "const fs=require('fs');const p='.planning/phases/09-minimum-browser-runnable/09-25-SUMMARY.md';const bad=[];if(!fs.existsSync(p))bad.push('SUMMARY missing');else{const s=fs.readFileSync(p,'utf8');for(const k of ['两遍','覆盖层','DEPTH_COMPONENT24','排序','脏','UAT','未校准','confidence: low','无 WebGL E2E']){if(s.indexOf(k)<0)bad.push('SUMMARY missing keyword: '+k)}}if(bad.length){console.error('SUMMARY GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK summary gate')" + non-zero exit,或 stderr 含 "SUMMARY GATE FAILED":SUMMARY 缺失或缺少关键内容(两遍式 / 覆盖层 / 深度附件 / 排序 / 脏标记 / UAT / 未校准声明 / 无 E2E 声明) + + + - `09-25-SUMMARY.md` 已生成,包含 ①..⑪ 全部内容、字面量 `DEPTH_COMPONENT24` 与 UAT 逐项结果(9 条) + - 明确写出「没有 WebGL E2E 自动化」,不把人工 UAT 表述为自动化验证 + - `estimate.tokens=160000` / 7 tasks 被声明为未校准估算(`confidence: low`),并声明超预算未拆分是用户批准的单计划例外 + - OI-1 的裁定与后果、OI-2 的最终命名被如实记录 + - 失败项(若有)记录为未通过,未在本计划内顺手修复、未写成通过 + + 用户在浏览器完成 UAT 清单并给出逐项结果;`09-25-SUMMARY.md` 已按上述 ①..⑪ 与 UAT 结果写好;未通过项如实记录;未新增任何生产改动 + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| GPU 状态 → 后续帧 / 后处理 | `depthMask` / 混合 / 绑定 FBO 是全局状态,任一泄漏都会污染后续帧与效果 | +| 起始数据(地图 alpha / 覆盖层成员)→ 渲染 | 用户通过 `setAlpha` / `addMovingBlock` / 地图重建改变覆盖层成员与顺序 | +| CPU 排序 → GPU 实例顺序 | 暂存数组的装入顺序就是最终绘制顺序,排序键与装入必须一致 | +| FBO ↔ 颜色纹理 / 深度附件 | ping/pong 的配对与深度附件决定场景在离屏渲染时是否可见、深度是否正确 | +| 写入边界 | 本增量是写操作,只允许写 `files_modified` 白名单(8 个文件),无依赖 / lockfile 变更 | + +**威胁级别说明(security_enforcement = ASVS L1,block on high):** 本计划是渲染装配与排序修正,不执行第三方代码、不引入网络面、不新增依赖、不改序列化与存档。**不存在未缓解的 `high` 级威胁**;`T-09-25-SC` 标注为「无安装动作」。 + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-09-25-01 | Tampering | `renderer.ts` 的 `depthMask` / 混合 / FBO 绑定 | high | mitigate | 不透明遍显式 `depthMask(true)`、覆盖层遍 `depthMask(false)`、收尾恢复 `depthMask(true)`;清屏前确保深度写入开启;分离式混合在 `initContext` 设置一次;FBO 在渲染结束后回到 `null`;静态闸门断言两种 `depthMask` 与分离式混合存在 | +| T-09-25-02 | Tampering | 覆盖层顺序 / 成员状态机(`overlay.ts` + `vertex.ts`) | high | mitigate | 两个独立脏标记 + 惰性排序;alpha 跨阈值、动态增删 / 移动、重建、图层变化都置顺序脏;纯 alpha / frame 只置值脏;单测覆盖排序键、稳定次级键、静态先于动态、幂等、重建清空;`needUpdate()` 纳入脏标记 | +| T-09-25-03 | Denial of service | ping/pong FBO 完整性(深度附件 / 纹理参数 / 配对) | high | mitigate | `DEPTH_COMPONENT24` renderbuffer + `framebufferRenderbuffer`;`NEAREST` + `CLAMP_TO_EDGE`;0/1/2+ 链路显式配对;`setCanvasSize` 同步缩放深度缓冲;`destroy` 全量删除;静态闸门断言配对与参数 | +| T-09-25-04 | Tampering | 分块张量 / 动态索引身份被误改 | high | mitigate | 分块布局标记与「不排序」闸门;`moving.ts` 零改动 + `MovingBlock.index` / `movingIndexMap` / 归还逻辑标记闸门;范围闸门把 `moving.ts` / `viewport.ts` / `block.ts` 等列为受保护文件 | +| T-09-25-05 | Repudiation | 验证被夸大(把人工 UAT 写成自动化 / 把未覆盖写成已覆盖) | medium | mitigate | `autonomous: false` + Task 6 人工 UAT 清单;SUMMARY 必须声明「无 WebGL E2E 自动化」;门禁只做结构断言,不冒充行为验证 | +| T-09-25-06 | Information disclosure | 无网络 / 无存储 / 无敏感日志 | low | accept | 渲染器只操作 GPU 资源与内存数组,不读取用户数据、不落盘 | +| T-09-25-SC | Tampering | 依赖安装(npm/pip/cargo) | high | accept | 本计划不执行任何安装、不改 `package.json` / lockfile;范围闸门对两者做零改动断言,故无供应链面 | + + + +- **Task 0**:`PRESTATE` 闸门通过(两遍式起点、`map.frag` 0.1、`moving.ts` 索引身份、`IContextData` 缺字段、`map/__test__` 不存在);用户明确回复「可以执行」并裁定 OI-1 / OI-2。 +- **Task 1**:`overlay.test.ts` 全绿;eslint;`TRACER GATE`(两种 `depthMask`、分离式混合、覆盖层缓冲区 / 组装 API、分块数组未被排序、阈值 0.004、`IContextData.overlayBuffer` 及其 jsDoc)。**Task 1 不得移除 `postEffects.length > 1` 旧门控**(它是 Task 4 的 ``,旧门控的移除属 Task 4);按文件过滤的 `vue-tsc`(精确豁免用户裁定的 `getLayerByAlias` 基线一条并打印证据,其余诊断必须为 0)。 +- **Task 2**:`overlay.test.ts` ≥16 条全绿;eslint;`STATE GATE`(`overlay.ts` 状态机方法齐全、`checkRebuild` / `setBlockSize` 清空静态成员、分块布局标记仍在、`it` 前注释)。 +- **Task 3**:`overlay.test.ts` 全绿;eslint;`DYNAMIC GATE`(**按方法体切片**判定 `updateMoving` 登记 / 更新、`deleteMoving` 注销、`private assembleOverlay` 内用 `getDynamic()` + `getMovingBlockByIndex` + `getLayerIndex` 重新推导动态排序键;分块与动态存储标记;`moving.ts` 索引身份与 `renderer.ts` 映射 / 删除 / 归还逻辑未改)。 +- **Task 4**:eslint;`POST GATE`(深度 renderbuffer 的创建 / 附着 / 缩放 / 删除;**按实际 `framebufferTexture2D` 调用 + 最近一次 `bindFramebuffer` 映射**判定 ping←pingTexture / pong←pongTexture;**旧后处理门控 `postEffects.length > 1` 消失**;0 / 1+ 的目标切换;链路初始输入 `pingTexture2D`;最后一项 `i === a.length - 1` 写 `null` 且不残留 `.length - 2`;`IContextData` 三个新字段均带 jsDoc);按文件过滤的 `vue-tsc`(含已知基线精确豁免)。 +- **Task 5**:两个测试文件全绿;eslint;按文件过滤的 `vue-tsc`(**除用户裁定的 `getLayerByAlias` 基线一条外,本计划 `map/` 文件 0 条新诊断**;闸门打印被豁免的那一行原文作为证据,基线未恰好出现一次即失败);`SCOPE GATE`(白名单由 `files_modified` 派生且恰 8 条;用户既有脏文件允许;`map/` 下受保护文件与依赖 / 配置文件零改动;用户文件未被暂存)。 +- **Task 6**:用户逐项完成 9 条浏览器 UAT;`SUMMARY GATE`(关键内容齐全,含字面量 `DEPTH_COMPONENT24`)。 +- **范围诚实**:不跑 `build:game` / `build:packages` / `build:lib`,不做全仓类型修复,不做 WebGL E2E;SUMMARY 必须如实记录这些边界与 UAT 的失败项(若有);`estimate.tokens=160000` / 7 tasks 是未校准估算(`confidence: low`),其超出单计划预算(ratio 1.6)属用户明确要求「只新增一个计划」而批准的单计划例外(不拆分、不调低 tokens),SUMMARY 须显式声明。 +- **既有注释**:`renderer.ts` / `vertex.ts` / `types.ts` / `map.frag` / `moving.ts` 的既有注释与 jsDoc 零修改;`map.frag` 只改 discard 数值。 +- **可见跳过**:spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip);不创建 RESEARCH.md / VALIDATION.md。 + + + +- 两遍式渲染落地:不透明遍保持分块批量实例化绘制与显式 `depthMask(true)`,透明覆盖层最后绘制、`depthMask(false)`、深度测试开、混合开;收尾恢复 `depthMask(true)`;使用分离式混合。 +- 静态半透明图块在 alpha `< 1` 时被置为退化 rect 并进入覆盖层,alpha 回到 1 时恢复并注销;成员图块被重新写入后会再次置零;地图重建与分块尺寸变化会清空静态成员;分块张量未被压缩或排序。 +- 覆盖层使用独立小型暂存数组与独立 GPU 缓冲区,按缓存顺序组装并用 `drawArraysInstanced` 绘制;动态数组未被重排,`MovingBlock.index` / `movingIndexMap` / 扩缩容与 `moving.ts` 零改动。 +- 排序键为 `(layerIndex, y, x, kind)`,动态块使用小数 y,x 仅作 CPU 侧稳定次级键、静态先于动态;顺序未编码进深度,无半精度 / OIT / 深度剥离。 +- 条目身份唯一:静态键 = 种类 + 层索引 + 地图坐标、动态键 = 种类 + 移动索引;同一格在不同图层的半透明图块是两条独立条目(互不覆盖、各自参与排序)。 +- 值脏 / 顺序脏独立生效:纯 alpha / frame / 纹理等不改排序键的变化不重排;成员变化、重建、动态位置变化、图层变化重排;动态条目的坐标与 `layerIndex` 在组装前按活动 `IMovingBlock` 重新推导(图层列表 / zIndex 变化不残留旧排序键);排序在 `render()` 内惰性执行;任一脏都让 `needUpdate()` 为真。 +- `map.frag` 的 discard 阈值改为 `0.004`,既有注释不动。 +- ping/pong FBO **按实际 `framebufferTexture2D` 调用**各自配对同名颜色纹理(ping←`pingTexture2D`、pong←`pongTexture2D`)并挂 `DEPTH_COMPONENT24` 深度 renderbuffer;颜色纹理有完整的 `NEAREST` / `CLAMP_TO_EDGE` 参数;0 个效果走默认帧缓冲,至少 1 个先渲染进 ping 且链路初始输入为 `pingTexture2D`,效果链交替读写、**最后一项(`i === a.length - 1`)写屏幕**(单效果正确,不残留 `.length - 2` 写法);`setCanvasSize` 缩放深度缓冲;`destroy` 删除 FBO / 纹理 / 深度缓冲 / 覆盖层缓冲;`IContextData` 补齐字段且每个新字段带 jsDoc。 +- 纯排序 / 成员状态机单测(`overlay.test.ts`,每 `it` 前注释)与源码闸门(`rendererGate.test.ts`,每 `it` 前注释)全绿;`eslint`、按文件过滤的 `vue-tsc`、范围闸门全部通过。**`vue-tsc` 只精确豁免用户裁定的 `renderer.ts(463,25) TS2339 getLayerByAlias / IGameMap` 一条,并在通过时打印该行原文作为证据;基线未恰好出现一次或出现任何其它 `map/` 诊断都失败。** +- `MapRenderer.getLayer`、`IGameMap` / `GameMap` 零改动:不新增 `getLayerByAlias`、不以任何方式「顺手修复」既有基线诊断。 +- 所有新增的公开接口成员 / 字段(`IContextData.overlayBuffer` / `pingDepthBuffer` / `pongDepthBuffer`、`IMapVertexGenerator` 的三个新方法、`overlay.ts` 的接口与其字段 / 方法)都在声明处带 jsDoc,且既有 jsDoc / 注释零修改。 +- OI-1 按用户裁定执行((A) 修 `getIndexInBlock` 并加闸门,或 (B) 不修并记录跨分块限制);OI-2 的最终命名已获批准并一致用于代码 / 闸门 / SUMMARY。 +- 用户在浏览器完成 9 条 UAT 清单;`09-25-SUMMARY.md` 记录 ①..⑪(含字面量 `DEPTH_COMPONENT24`)、逐项 UAT 结果、失败项(若有)与「无 WebGL E2E 自动化」声明,并声明 `estimate` 为未校准估算、超预算未拆分是用户批准的单计划例外。 +- 受保护文件(`moving.ts` / `viewport.ts` / `element.ts` / `block.ts` / `constant.ts` / `status.ts` / `map/index.ts` / `extension/**` / `shader/*.vert` / 依赖与配置 / 09-01..09-24 计划 / 其它包)零改动;用户既有脏文件未被回滚、暂存、提交或修改。 + + + +Create `.planning/phases/09-minimum-browser-runnable/09-25-SUMMARY.md` when done +