diff --git a/game-runtime/games/shanhai-xingji/assets/bg/bg-map1-arena-01.jpg b/game-runtime/games/shanhai-xingji/assets/bg/bg-map1-arena-01.jpg new file mode 100644 index 00000000..9c392880 Binary files /dev/null and b/game-runtime/games/shanhai-xingji/assets/bg/bg-map1-arena-01.jpg differ diff --git a/game-runtime/games/shanhai-xingji/evidence/round-11/00-arena-bg-result.json b/game-runtime/games/shanhai-xingji/evidence/round-11/00-arena-bg-result.json new file mode 100644 index 00000000..9a20df49 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/evidence/round-11/00-arena-bg-result.json @@ -0,0 +1,224 @@ +{ + "checkpoint": "round-11 竞技场边界 + 接大图 bg-map1-arena-01 + CDP 截实际效果", + "utc": "2026-07-25T06:40:50.500Z", + "machine": "lili-mac.local(开发机,非 mini-desktop)", + "node": "v22.22.3", + "url": "http://127.0.0.1:4517/?engine=stub&mode=evidence&seed=777", + "dpr": 2, + "viewport": { + "w": 390, + "h": 844 + }, + "arena": { + "w": 1560, + "h": 2960 + }, + "collector": "/tmp/arena-e2e.mjs(临时脚本,置 /tmp 不入仓)", + "shots": [ + { + "name": "01-arena-center-panorama.png", + "desc": "全景:玩家场心(0,0),镜头居中,竞技场整幅背景中心区", + "sha256": "8d3f74d92ff0161167ad2d2e5dc51d41c50024028001edd4bfd5e0082b943f21", + "bytes": 1717505, + "utc": "2026-07-25T06:41:00.803Z", + "phase": "play", + "pos": { + "x": 0, + "y": 0 + }, + "timeS": 1.6, + "score": 0, + "enemies": 1, + "expectedScreen": { + "camX": -195, + "camY": -422, + "sx": 195, + "sy": 422 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 27467, + "total": 47190, + "ratio": 0.582 + } + }, + { + "name": "02-arena-edge-right.png", + "desc": "右边缘:玩家贴右界(x≈768),镜头clamp,玩家屏内偏右+右岩壁压边", + "sha256": "448552645aaeb73ee98db14c0fb9d2dd35c0a13155c517380502a423d1c4e4ce", + "bytes": 1343707, + "utc": "2026-07-25T06:41:01.213Z", + "phase": "play", + "pos": { + "x": 768, + "y": 0 + }, + "timeS": 8.7, + "score": 4, + "hp": 100, + "enemies": 3, + "level": 1, + "expectedScreen": { + "camX": 390, + "camY": -422, + "sx": 378, + "sy": 422 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 11469, + "total": 47190, + "ratio": 0.243 + } + }, + { + "name": "03-arena-edge-top.png", + "desc": "上边缘:玩家贴上界(y≈-1468),上岩壁压边", + "sha256": "0a5299271de8c2c6c4039d023b56c1956130f00332812054950f7a62674f97a1", + "bytes": 1122250, + "utc": "2026-07-25T06:41:01.760Z", + "phase": "play", + "pos": { + "x": 768, + "y": -1468 + }, + "timeS": 21.7, + "score": 14, + "hp": 100, + "enemies": 5, + "level": 2, + "expectedScreen": { + "camX": 390, + "camY": -1480, + "sx": 378, + "sy": 12 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 2525, + "total": 47190, + "ratio": 0.054 + } + }, + { + "name": "04-arena-edge-left.png", + "desc": "左边缘:玩家贴左界(x≈-768),左岩壁压边", + "sha256": "5401057127be6519615fbea21f6f8258865f15783783782a1e153081920eb7ef", + "bytes": 1066428, + "utc": "2026-07-25T06:41:02.307Z", + "phase": "play", + "pos": { + "x": -768, + "y": -1468 + }, + "timeS": 34.8, + "score": 24, + "hp": 100, + "enemies": 7, + "level": 2, + "expectedScreen": { + "camX": -780, + "camY": -1480, + "sx": 12, + "sy": 12 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 2663, + "total": 47190, + "ratio": 0.056 + } + }, + { + "name": "05-arena-edge-bottom.png", + "desc": "下边缘:玩家贴下界(y≈1468),下岩壁压边", + "sha256": "5a4a97f58def0e323cbf0aecad2a56fa97b962cbe1a52acde5bc05032a9b99bd", + "bytes": 1127024, + "utc": "2026-07-25T06:41:02.852Z", + "phase": "play", + "pos": { + "x": -768, + "y": 1468 + }, + "timeS": 59.9, + "score": 42, + "hp": 100, + "enemies": 20, + "level": 2, + "expectedScreen": { + "camX": -780, + "camY": 636, + "sx": 12, + "sy": 832 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 2412, + "total": 47190, + "ratio": 0.051 + } + }, + { + "name": "06-arena-corner-br.png", + "desc": "右下角:同时贴右+下界,两面岩壁 + 角落暗角收口", + "sha256": "2cb36e2838df4953f6e4fbd9d2a2c783a79ad32e013db31c1b47aafdbe681d51", + "bytes": 1365807, + "utc": "2026-07-25T06:41:03.415Z", + "phase": "play", + "pos": { + "x": 768, + "y": 1468 + }, + "timeS": 78.4, + "score": 56, + "hp": 64, + "enemies": 52, + "level": 3, + "expectedScreen": { + "camX": 390, + "camY": 636, + "sx": 378, + "sy": 832 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 5774, + "total": 47190, + "ratio": 0.122 + } + } + ], + "boot": { + "ok": true + }, + "arenaBgAssetProbe": "ready 1560x2960", + "startState": { + "phase": "play", + "pos": { + "x": 0, + "y": 0 + } + }, + "pageErrors": [], + "consoleErrors": [ + "2026-07-25T06:40:58.330Z [error] Failed to load resource: the server responded with a status of 404 (File not found)", + "2026-07-25T06:41:00.436Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently", + "2026-07-25T06:41:00.897Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently", + "2026-07-25T06:41:01.371Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently", + "2026-07-25T06:41:02.006Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently", + "2026-07-25T06:41:02.549Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently", + "2026-07-25T06:41:03.087Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently" + ] +} diff --git a/game-runtime/games/shanhai-xingji/evidence/round-11/00-arena-bg-summary.md b/game-runtime/games/shanhai-xingji/evidence/round-11/00-arena-bg-summary.md new file mode 100644 index 00000000..c26abc1b --- /dev/null +++ b/game-runtime/games/shanhai-xingji/evidence/round-11/00-arena-bg-summary.md @@ -0,0 +1,83 @@ +# round-11 — 竞技场边界 + 接大图 bg-map1-arena-01 + CDP 截实际效果 + +检查点目标:缩放定稿草图 → 放 assets → 加竞技场边界(玩家+镜头 clamp)→ 接整幅战场背景图 → 真浏览器 CDP 截接图后战场背景。本检查点**不重跑⑧win**(留下检查点,避免超载)。 + +- 机器:`lili-mac.local`(开发机本地,非 mini-desktop;未 ssh)。 +- 采集时间:2026-07-25T06:40–06:41Z。视口 390×844 @ DPR2(截图 780×1688)。seed=777。 +- 采集器:`/tmp/arena-e2e.mjs`(临时脚本,置 /tmp 不入仓;node 子进程拉起 http.server + headless Chrome,CDP 前台跑到返回,finally 杀进程删 profile)。 +- 诚实红线:背景效果主观,本文只记代码 + 截图事实,美丑交创始人眼验。 + +## 一、图处理(任务一) + +| 项 | 值 | +|---|---| +| 源 | `/tmp/bg-draft/arena-bg_001.jpg`(1080×2048,创始人审"还行") | +| 缩放 | `sips -z 2960 1560` → **1560×2960**(竖屏,与 ARENA 同尺寸) | +| 格式 | **jpg**(webp 写工具全缺:cwebp 无、ffmpeg 无 libwebp、sips 可读不可写 webp;按任务回退规则用 jpg,quality 82) | +| 产物 | `assets/bg/bg-map1-arena-01.jpg`,`1,352,878` 字节(1.29 MB) | +| 解码内存 | 1560×2960×4 = `18,470,400` 字节 = **17.61 MB**(≤20MB 优 / 硬顶 33MB ✓) | +| 浏览器解码探针 | CDP 内 `` 同 url → `ready 1560x2960`(确认资产可解码,与 art.js 拉同一资源) | + +## 二、加边界 + 接图(任务二,逐处落地) + +`src/balance.js` — `PLAYER` 后新增 `export const ARENA = { w: 1560, h: 2960 }`(中心对称原点,玩家 (0,0)=场心;sim 与 render 共用)。 + +`src/sim.js` — import 加 `ARENA`;主循环玩家移动(`player.y += ...`)后加竞技场 clamp,纯函数不破确定性: +`player.x = Math.max(-ARENA.w/2 + player.r, Math.min(ARENA.w/2 - player.r, player.x))`(y 同理),留 `player.r`=12 边距。 + +`src/render.js` — +- import 加 `ARENA`;新增 `clampCam(cam,world,view)`;`drawGame` 镜头由 `player - view/2` 改为 `clampCam(...)`,下界 `-world/2`、上界 `world/2 - view`(竞技场≥视口,镜头不出界)。 +- 耦合修复:`drawPlayer` 调用点改传 `camX,camY`;签名同步;函数体 `x,y` 由恒屏心 `viewport.w/2,h/2` 改为 `v.player.x - camX, v.player.y - camY`(否则近边缘玩家脱出精灵位)。 +- `drawGround` 改三档:一档 = `art.hasBg('bg-map1-arena-01')` → `art.drawBg(..., -ARENA.w/2-camX, -ARENA.h/2-camY, ARENA.w, ARENA.h, 1)` + 新增 `drawArenaBounds`(四边 96px 向内渐隐暗带=岩壁/暗角压边)+ `drawDeco(...,ARENA)`(装饰裁到场内);二档 = 原视差+羽化瓦片+明暗(大图未载兜底);三档 = 无图哈希撒点。装饰循环抽成 `drawDeco(g,...,arena)`,无界时哈希口径逐字沿用旧版(零回归)。 + +`src/art.js` — +- `BG_ASSET` 登记 `bg-map1-arena-01`(url `./assets/bg/bg-map1-arena-01.jpg`)。 +- 预载策略(决策点 A):`preloadFirstScreen()` 后台拉起 arena 大图(`lazyImage`,菜单驻留期异步解码;未载完 `hasBg=false` → 渲染自动落二档,不闪白)。 +- `tiles-map1` 降级:移出首屏预载,改 `ensureTile()` 首次 `drawTile` 需要时懒拉(大图接起后 tiles 仅作二档兜底)。 + +## 三、首屏字节(如实,三口径) + +| 口径 | 字节 | 是否 ≤2MB | 说明 | +|---|---|---|---| +| **build-report firstScreenBytes**(代码 bundle+html,任务指定测此) | `379,191`(0.362 MB) | ✓ PASS | 此脚本只测代码,不含图像;接图前后不变 | +| **运行时首屏图像**(host `loadHostAssets` 读 `manifest.json` assets[] 预载) | `1,799,464` 图像 + 379KB 代码 ≈ **2.078 MB** | ✗(预我改前已 >2MB) | **由 manifest.json 决定,本检查点超纲未改**;见下"边界说明" | +| arena 大图(经 art.js `lazyImage` 异步拉,**未**进 manifest) | `1,352,878`(1.29 MB) | 不计首屏 | 故意走后台异步,不顶首屏门 | + +**边界说明(诚实)**:真实运行时首屏图像预载由 `manifest.json` 的 `assets[]` 经 `boot-game-host.loadHostAssets()` 在 boot 时全量 `new Image()` 拉起,与 art.js 的 `FIRST_SCREEN_PAGES` 无关;该 manifest 合计已 ≈2.08MB(本检查点前即超 2MB)。我在 art.js 把 tiles 降为懒载只影响 art.js 的自载兜底路径,**不改变** host 网络首屏——要让 tiles 真退出首屏网络、或把 arena 图纳入首屏,须改 `manifest.json`,而它不在本检查点工作目录白名单(只许 src×4 + assets/bg + dist + evidence/round-11)。我的 arena 图改走 art.js 异步后台拉取,正好满足"init 后台拉起、菜单驻留期解码、不顶首屏门",故接图本身不增加首屏网络字节。 + +## 四、加边界正确性(CDP 实测,seed=777) + +玩家世界坐标被 clamp 到 ±(world/2 − r)= ±768(x)/±1468(y);镜头 clamp 到 [−world/2, world/2−view];玩家屏幕位 = pos − cam,贴边时恰距屏边 12px(=player.r),**证明 drawPlayer 的 camX/camY 耦合生效**(镜头 clamp 后玩家不再恒在屏心): + +| 截图 | 世界 pos | 镜头 cam | 屏幕 sx,sy | 亮度比 | 看到的 | +|---|---|---|---|---|---| +| 01 全景 | (0,0) | (−195,−422) | (195,422) 屏心 | 0.582 | 整幅彩色战场中心,玩家居中 | +| 02 右缘 | (768,0) | (390,−422) | (378,422) | 0.243 | 右岩壁压边,玩家贴右 | +| 03 上缘 | (768,−1468) | (390,−1480) | (378,12) | 0.054 | 上+右岩壁,玩家贴右上角 | +| 04 左缘 | (−768,−1468) | (−780,−1480) | (12,12) | 0.056 | 左+上岩壁,玩家贴左上角 | +| 05 下缘 | (−768,1468) | (−780,636) | (12,832) | 0.051 | 左+下岩壁,玩家贴左下角 | +| 06 右下角 | (768,1468) | (390,636) | (378,832) | 0.122 | 右+下岩壁 + 角落暗角收口 | + +`pageErrors=0`(新 render/sim 代码无崩溃)。console 7 条均为良性:1 条 favicon 类 404(非 page error;arena 图本身探针 200 ready)+ 6 条 `willReadFrequently`(我亮度探针 getImageData 自产,非游戏代码)。 + +## 五、需创始人定夺的观察(已眼验截图,非阻断) + +1. **玩家走进岩壁暗带**:clamp 边距 = `player.r`=12px,而岩壁暗带 T=96px,玩家贴边时其碰撞中心距世界边 12px = 落在 96px 暗带内约 84px,视觉上玩家"站进黑墙里"(见 02/06)。三选项交决:① 收薄暗带 T(墙感变弱);② 把 clamp 边距提到≈T(玩家停在墙内沿、不入围合带,但可达区每边缩≈96px,改手感);③ 维持现状。任务原文指定 player.r 边距,故未擅改。 +2. **上缘精灵裁切**:玩家贴顶边时,高个主角 sprite 以底部锚定在 ~12px 处、躯体向上延伸出视口,顶边近乎"潜入墙下"(见 03)。根因同①小边距 + 高 sprite。 +3. **背景颗粒/暗边**:1080→1560 上采样 + DPR2 二倍绘 → 颗粒感;AI 草图本身顶/底偏暗,叠加岩壁带后边缘偏暗(亮度 0.05)。美丑归创始人。 +4. **有界竞技场的玩法含义**:玩家现可被逼到墙角(06 角落 HP=64、敌群贴墙成排)——以前无界世界可无限风筝,加边界后存在"被墙困住"的死角,难度/手感随之变。此为加边界的固有后果,记录供创始人评估是否调整边距或留逃生缓冲。 + +## 六、产物 sha256 + +源码 + 图: +- `src/balance.js` 9094f814b1987c4456d45391052d083061a5ce342a2c5a122a90a6f13342086d +- `src/sim.js` ed07425784ccd3a1cbfb1c0f1eb7a1b2a6d1fb716ce3f5dccefbe1ef325b45da +- `src/render.js` fa04a02e067752507b02c2a188a2f70810ddc3864aae340d4a410afe46488726 +- `src/art.js` 224e8d09da63a7f65a2e2be69bb42856b9557088cde0ff472a4712694adbfc0e +- `assets/bg/bg-map1-arena-01.jpg` aaa42f3a2f2a47e35f2d7f68cf7e64e7a53726983f68081c0d9f86daadec1956 + +六张截图 sha256 见同目录 `00-arena-bg-result.json` 各 `shots[].sha256`。 + +## 七、外部状态变更 + +无 git add/commit/push;无 staging;未碰 mini-infra;未 ssh mini-desktop;**未碰 plugins / game-runtime/src / contracts**(本会话零编辑越出 shanhai-xingji;working tree 中 `game-runtime/src/*`、`plugins/match3-*` 的 M/?? 为既有在飞 WIP,与本检查点无关);本地 serve + headless Chrome 已杀、端口释放、/tmp profile 已删,ps/lsof 复核无孤儿。 diff --git a/game-runtime/games/shanhai-xingji/evidence/round-11/arena-boundary.diff b/game-runtime/games/shanhai-xingji/evidence/round-11/arena-boundary.diff new file mode 100644 index 00000000..4e1d8bd3 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/evidence/round-11/arena-boundary.diff @@ -0,0 +1,473 @@ +diff --git a/game-runtime/games/shanhai-xingji/src/art.js b/game-runtime/games/shanhai-xingji/src/art.js +index 0811e61d..534b8bae 100644 +--- a/game-runtime/games/shanhai-xingji/src/art.js ++++ b/game-runtime/games/shanhai-xingji/src/art.js +@@ -32,10 +32,13 @@ const PAGE_ASSET = { + }; + /** 地表 tile 表(8 列 × 6 行,128px;index=row×8+col)。 */ + const TILE_ASSET = { ref: 'tiles/tiles-map1', url: './assets/tiles/tiles-map1.webp', size: 128, cols: 8 }; +-/** 背景/底图(远景 vista + 出击面板底纹)。 */ ++/** 背景/底图(远景 vista + 出击面板底纹 + 竞技场整幅战场背景)。 */ + const BG_ASSET = { + 'bg-map1-vista-01': { ref: 'bg/bg-map1-vista-01', url: './assets/bg/bg-map1-vista-01.webp' }, + 'ui-panel-sortie-01': { ref: 'bg/ui-panel-sortie-01', url: './assets/bg/ui-panel-sortie-01.webp' }, ++ // 竞技场整幅战场背景(1560×2960,render 一档地纹)。本机无 webp 写工具(cwebp/ffmpeg-libwebp/sips 均不可写) ++ // → 落 jpg(drawBg 走浏览器 解码,jpg/png/webp 皆可,url 后缀对应即可)。 ++ 'bg-map1-arena-01': { ref: 'bg/bg-map1-arena-01', url: './assets/bg/bg-map1-arena-01.jpg' }, + }; + /** 首屏预载页(boss 走懒加载,不进首屏)。 */ + const FIRST_SCREEN_PAGES = ['actors', 'beasts', 'ui', 'map1']; +@@ -68,8 +71,9 @@ export function createArt(boot) { + const canLoad = typeof Image === 'function'; // 浏览器/真机才能自载;node headless 为 false + /** @type {Record} 页 group → 图对象(优先宿主预载,否则自载)。 */ + const pageImg = {}; +- /** @type {any} tile 表图 / bg 图。 */ ++ /** @type {any} tile 表图 / bg 图。tileImg 改懒载(ensureTile):null=未就绪,tileTried 防重复拉。 */ + let tileImg = null; ++ let tileTried = false; + const bgImg = {}; + /** 取证/日志。 */ + const stats = { drawImage: 0, drawn: 0, fellBack: 0 }; +@@ -98,14 +102,30 @@ export function createArt(boot) { + pageImg[group] = fromHost(spec.ref) || lazyImage(spec.url); + } + +- /** init 预载:首屏页 + tile + bg vista(boss 懒加载不在此)。 */ ++ /** ++ * 懒载地表 tile 表(大图接起后 tiles 仅作二档兜底,降为非首屏):首次 drawTile 需要时才拉, ++ * 不占首屏字节余量。tileTried 防重复拉(失败则恒 null → drawTile 回退程序色块)。 ++ */ ++ function ensureTile() { ++ if (tileTried) return; ++ tileTried = true; ++ tileImg = fromHost(TILE_ASSET.ref) || lazyImage(TILE_ASSET.url); ++ } ++ ++ /** ++ * init 预载:首屏页 + bg(vista/panel/arena)。boss 页走懒加载不在此。 ++ * 竞技场大图 bg-map1-arena-01:init 后台拉起、菜单驻留期异步解码(非阻塞);未载完 hasBg=false, ++ * 渲染层 drawGround 自动落二档(视差瓦片)兜底,载完切入一档,不闪白。 ++ * tiles-map1 不在此预载(降为非首屏,ensureTile 首次需要时懒拉)。 ++ */ + function preloadFirstScreen() { + for (const g of FIRST_SCREEN_PAGES) ensurePage(g); +- tileImg = fromHost(TILE_ASSET.ref) || lazyImage(TILE_ASSET.url); + const vista = BG_ASSET['bg-map1-vista-01']; + bgImg['bg-map1-vista-01'] = fromHost(vista.ref) || lazyImage(vista.url); + const panel = BG_ASSET['ui-panel-sortie-01']; + bgImg['ui-panel-sortie-01'] = fromHost(panel.ref) || lazyImage(panel.url); ++ const arena = BG_ASSET['bg-map1-arena-01']; ++ bgImg['bg-map1-arena-01'] = fromHost(arena.ref) || lazyImage(arena.url); // 后台拉起,菜单期解码 + } + + /** 取某帧的页图(未 ensure 过则顺带 ensure=容错自愈)。 */ +@@ -231,8 +251,9 @@ export function createArt(boot) { + /** 独立 bg 图是否就绪。 */ + function hasBg(id) { return imgReady(bgImg[id]); } + +- /** 地表 tile:index → 8 列网格源块 → 目标 (dx,dy,size)。 */ ++ /** 地表 tile:index → 8 列网格源块 → 目标 (dx,dy,size)。tiles 懒载(ensureTile):首次需要才拉。 */ + function drawTile(g, index, dx, dy, size) { ++ ensureTile(); + if (!imgReady(tileImg)) return false; + const col = index % TILE_ASSET.cols, row = Math.floor(index / TILE_ASSET.cols); + const s = TILE_ASSET.size; +diff --git a/game-runtime/games/shanhai-xingji/src/balance.js b/game-runtime/games/shanhai-xingji/src/balance.js +index ca176175..ef97335d 100644 +--- a/game-runtime/games/shanhai-xingji/src/balance.js ++++ b/game-runtime/games/shanhai-xingji/src/balance.js +@@ -35,6 +35,15 @@ export const PLAYER = { + hurtIframe: 0.25, // 受击后短无敌窗(秒)——防同帧多怪叠伤秒杀 + }; + ++/** ++ * 竞技场边界(图 1 战场世界尺寸)。中心对称原点:玩家出生点 (0,0) = 场心, ++ * 世界坐标范围 x∈[-w/2, w/2]、y∈[-h/2, h/2]。 ++ * - sim 用它给玩家走位 clamp(留 player.r 边距,贴边不穿墙); ++ * - render 用它做镜头 clamp(竞技场≥视口,镜头不出世界边)+ 整幅战场背景图铺设范围。 ++ * 尺寸 1560×2960 = 竖屏战场(约 4 视口宽 × 3.5 视口高),与背景图 bg-map1-arena-01 同尺寸。 ++ */ ++export const ARENA = { w: 1560, h: 2960 }; ++ + /** 虚拟摇杆(手 1–3 断言的参数面;UI 层消费,sim 只收归一化方向)。 */ + export const JOYSTICK = { + radius: 64, // 摇杆逻辑半径(px) +diff --git a/game-runtime/games/shanhai-xingji/src/render.js b/game-runtime/games/shanhai-xingji/src/render.js +index 8f37b825..0ec800b9 100644 +--- a/game-runtime/games/shanhai-xingji/src/render.js ++++ b/game-runtime/games/shanhai-xingji/src/render.js +@@ -12,7 +12,7 @@ + + 'use strict'; + +-import { JOYSTICK, ROUND_S, DIFFICULTY, SLOTS, PASSIVES } from './balance.js'; ++import { JOYSTICK, ROUND_S, DIFFICULTY, SLOTS, PASSIVES, ARENA } from './balance.js'; + + /** 山海矿彩色板(程序回退唯一取色处;真图接入后仅缺图兜底与 HUD 文字用)。 */ + const C = { +@@ -113,6 +113,14 @@ export function menuLayout(viewport) { + }; + } + ++/** ++ * 镜头单轴 clamp(竞技场有界)。竞技场≥视口,镜头不出世界边: ++ * 下界 = -world/2(镜头左/上缘贴世界边),上界 = world/2 - view(镜头右/下缘贴世界边)。 ++ */ ++function clampCam(cam, world, view) { ++ return Math.max(-world / 2, Math.min(world / 2 - view, cam)); ++} ++ + /** + * 主绘制入口。 + * @param {CanvasRenderingContext2D} g +@@ -134,8 +142,10 @@ export function drawGame(g, s) { + if (shake && (shake.x || shake.y)) g.translate(shake.x, shake.y); + + const v = s.sim.view(); +- const camX = v.player.x - viewport.w / 2; +- const camY = v.player.y - viewport.h / 2; ++ // 镜头 clamp(竞技场有界):竞技场≥视口,镜头不出世界边(下界=-world/2,上界=world/2-view)。 ++ // 玩家居中镜头随玩家;玩家近边镜头停世界边、玩家在屏内偏移(drawPlayer 用 camX/camY 还原其屏幕位)。 ++ const camX = clampCam(v.player.x - viewport.w / 2, ARENA.w, viewport.w); ++ const camY = clampCam(v.player.y - viewport.h / 2, ARENA.h, viewport.h); + + drawGround(g, camX, camY, viewport, art); + drawEventOverlays(g, v, camX, camY, art); +@@ -144,7 +154,7 @@ export function drawGame(g, s) { + drawDrops(g, v, camX, camY, art); + drawEnemies(g, v, camX, camY, art); + drawProjectiles(g, v, camX, camY, art); +- drawPlayer(g, v, viewport, art); ++ drawPlayer(g, v, viewport, art, camX, camY); + drawFx(g, s.fx, camX, camY, viewport, art); + drawBanners(g, s.fx, viewport); + if (s.juice && s.juice.render) s.juice.render(g); +@@ -194,56 +204,39 @@ function drawLevelupTitle(g, v, picker, viewport) { + g.textAlign = 'left'; + } + +-/** 地纹:真 tile 铺地(8 变奏按世界哈希选)+ 稀疏装饰物;缺图回退哈希撒点。 */ ++/** ++ * 地纹三档(2026-07-24 接大图后分层): ++ * 一档 = 竞技场整幅背景图 bg-map1-arena-01(1560×2960 战场整图)+ 四边岩壁压边 + 场内装饰; ++ * 二档 = 视差远景 + 羽化瓦片 + 明暗(金标阶段二 A 缓解方案,大图未载完时的兜底); ++ * 三档 = 无图哈希撒点(占位期原路)。 ++ */ + function drawGround(g, camX, camY, viewport, art) { ++ // ── 一档:竞技场整幅背景图(2026-07-24 接大图 bg-map1-arena-01)────────────── ++ // 战场整图铺进世界矩形[-w/2,w/2]×[-h/2,h/2](玩家 (0,0)=场心,与 sim 出生点/clamp 同源)。 ++ // 图未载完(imgReady 未就绪)→ hasBg=false,自动落二档兜底;菜单驻留期后台解码完成后切入,不闪白。 ++ if (art && art.hasBg('bg-map1-arena-01')) { ++ art.drawBg(g, 'bg-map1-arena-01', -ARENA.w / 2 - camX, -ARENA.h / 2 - camY, ARENA.w, ARENA.h, 1); ++ drawArenaBounds(g, camX, camY, viewport); // 四边岩壁/暗角压边(收口,遮背景图硬边) ++ drawDeco(g, camX, camY, viewport, art, ARENA); // 稀疏装饰物(裁到场内) ++ return; ++ } ++ // ── 二档:背景拼接缓解(金标阶段二 A,2026-07-24;大图未载完的兜底)────────── + if (art && art.has('deco-rock-01')) { +- // 真 tile 铺地:128px 世界格。选帧口径 2026-07-08 修——原以 base 段(0..15)为主铺, +- // 但该段美术母题是「水面小岛」,满铺=规则重复小岛(真机眼验「同一水岛贴图网格平铺」的假)。 +- // 改大尺度地貌分区:成片深岩(16..31) / 成片青绿矿岩(32..47)穿插,一暗一亮互为层次 +- //(深岩沉稳、青绿提亮兼解深岩单铺过暗),读作「深岩地里的青绿矿脉/湿岩带」。水岸段(0..15)弃。 +- const T = 128; +- const OVER = 8; // 外扩重叠(原 2→8):加大压住 tile 非无缝边界的接缝 +- const REGION = 384; // 地貌分区尺度(3×3 tile 一片):同一片连成深岩 or 青绿矿岩 +- const x0 = Math.floor(camX / T) * T, y0 = Math.floor(camY / T) * T; +- for (let wy = y0; wy < camY + viewport.h + T; wy += T) { +- for (let wx = x0; wx < camX + viewport.w + T; wx += T) { +- const h = ((wx * 374761393 + wy * 668265263) ^ 0x5bf03635) >>> 0; +- // 大尺度分区哈希决定这一片是深岩还是青绿矿岩(而非每格胡椒面撒→避免噪点/打架) +- const rgx = Math.floor(wx / REGION), rgy = Math.floor(wy / REGION); +- const rh = ((rgx * 2654435761 + rgy * 40503) ^ 0x9e3779b1) >>> 0; +- const idx = ((rh % 5) < 2 ? 32 : 16) + (h % 16); // ~40% 片区青绿矿岩,余深岩 +- // 确定性 4 向翻转:同段 16 帧再乘 4 朝向,打散岩纹方向对齐的机械网格感(位提取只用 >>>) +- const fx = (h >>> 16) & 1, fy = (h >>> 17) & 1; +- const dx = wx - camX - OVER, dy = wy - camY - OVER, size = T + OVER * 2; +- if (fx || fy) { +- g.save(); +- const cxp = wx - camX + T / 2, cyp = wy - camY + T / 2; // 以 tile 显示中心为翻转轴 +- g.translate(cxp, cyp); g.scale(fx ? -1 : 1, fy ? -1 : 1); g.translate(-cxp, -cyp); +- art.drawTile(g, idx, dx, dy, size); +- g.restore(); +- } else { +- art.drawTile(g, idx, dx, dy, size); +- } +- } +- } +- // 大尺度地貌明暗:极轻径向光斑柔化(分区已提供主明暗,这里只轻抹起伏,治残余均匀感) +- drawGroundShade(g, camX, camY, viewport); +- // 稀疏装饰物(岩/图腾/骨/苔/芦苇/符石;补第 8 种 deco-totem-02;加密到约 2/5;世界哈希定位) +- const DECO = ['deco-rock-01', 'deco-rock-02', 'deco-totem-01', 'deco-totem-02', 'deco-moss-01', 'deco-bone-01', 'deco-reed-01', 'deco-runestone-01']; +- const D = 288; // 装饰格(原 384→288 加密,配合遮挡打断网格) +- const dx0 = Math.floor(camX / D) * D, dy0 = Math.floor(camY / D) * D; +- for (let wy = dy0; wy < camY + viewport.h + D; wy += D) { +- for (let wx = dx0; wx < camX + viewport.w + D; wx += D) { +- const h = ((wx * 2246822519 + wy * 3266489917) ^ 0x9e3779b1) >>> 0; +- if (h % 5 < 3) continue; // 约 2/5 格有装饰(原 1/3→加密) +- const name = DECO[h % DECO.length]; +- const px = wx + (h % 200) - camX, py = wy + ((h >>> 10) % 200) - camY; +- art.draw(g, name, px, py, 46 + (h >>> 4) % 26, { anchor: 'bottom', alpha: 0.92 }); +- } +- } ++ // 病根:tiles-map1 的 128px 瓦片本身是「方块母题」非无缝纹理,满屏平铺 → 明显 tiling ++ // 接缝 + 满屏壁纸网格感(创始人④眼验「很多方块图拼接、接缝明显、很丑」)。原 OVER=8 外扩 ++ // + 144 上采样只是创可贴(上采样反把 128→144 模糊/闪烁,外扩压不住方块边界)。 ++ // 纯代码缓解(用现有资产,不新增美术),两步叠加: ++ // 方案③ 去上采样 + 边界羽化:瓦片改原尺寸 128 不放大(去模糊/闪烁);整层 destination-in ++ // 重复羽化 mask,瓦片四边 alpha 渐隐 → 相邻瓦片柔和过渡、硬接缝消失。 ++ // 方案② 视差底层:已入库的 bg-map1-vista-01 远景整图作慢速视差底层先铺,瓦片层降 alpha ++ // 且羽化边缘透出远景 → 破「整片死均匀壁纸网格」,给地面纵深。 ++ drawGroundParallax(g, camX, camY, viewport, art); // 方案②:远景视差底层(先画,垫底) ++ drawGroundTiles(g, camX, camY, viewport, art); // 方案③:原尺寸 + 羽化瓦片层(叠上) ++ drawGroundShade(g, camX, camY, viewport); // 大尺度明暗:极轻径向光斑柔化 ++ drawDeco(g, camX, camY, viewport, art, null); // 稀疏装饰物(无界,仅可见区) + return; + } +- // 回退:整数哈希撒石点(占位期原路) ++ // ── 三档:无图哈希撒石点(占位期原路)────────────────────────────────────── + const cell = 96; + g.fillStyle = C.ground; + const x0 = Math.floor(camX / cell) * cell, y0 = Math.floor(camY / cell) * cell; +@@ -257,6 +250,55 @@ function drawGround(g, camX, camY, viewport, art) { + } + } + ++/** ++ * 稀疏装饰物(岩/图腾/骨/苔/芦苇/符石 8 种;世界哈希定位,约 2/5 格)。arena 传 ARENA 时迭代范围 ++ * 夹到场内 [-w/2,w/2]×[-h/2,h/2](装饰不出世界边);传 null 则无界(仅遍历可见区,二档兜底用)。 ++ * 哈希口径与定位逐字沿用旧版,无界时行为与接线前等价(零回归)。 ++ */ ++function drawDeco(g, camX, camY, viewport, art, arena) { ++ const DECO = ['deco-rock-01', 'deco-rock-02', 'deco-totem-01', 'deco-totem-02', 'deco-moss-01', 'deco-bone-01', 'deco-reed-01', 'deco-runestone-01']; ++ const D = 288; // 装饰格(原 384→288 加密,配合遮挡打断网格) ++ let wxMin = Math.floor(camX / D) * D, wyMin = Math.floor(camY / D) * D; ++ let wxMax = camX + viewport.w + D, wyMax = camY + viewport.h + D; ++ if (arena) { // 裁到场内(竞技场有界) ++ wxMin = Math.max(wxMin, -arena.w / 2); wyMin = Math.max(wyMin, -arena.h / 2); ++ wxMax = Math.min(wxMax, arena.w / 2); wyMax = Math.min(wyMax, arena.h / 2); ++ } ++ for (let wy = wyMin; wy < wyMax; wy += D) { ++ for (let wx = wxMin; wx < wxMax; wx += D) { ++ const h = ((wx * 2246822519 + wy * 3266489917) ^ 0x9e3779b1) >>> 0; ++ if (h % 5 < 3) continue; // 约 2/5 格有装饰 ++ const name = DECO[h % DECO.length]; ++ const px = wx + (h % 200) - camX, py = wy + ((h >>> 10) % 200) - camY; ++ art.draw(g, name, px, py, 46 + (h >>> 4) % 26, { anchor: 'bottom', alpha: 0.92 }); ++ } ++ } ++} ++ ++/** ++ * 竞技场四边岩壁压边(收口语义):沿世界矩形四边各画一条向内渐隐的暗带,遮住整幅背景图的硬边、 ++ * 给战场「四面岩壁围合」的收口;四角暗带叠加成暗角。只叠 4 条线性渐变 fillRect,廉价。 ++ * 玩家被 sim clamp 在场内、镜头被 clampCam 钳住,暗带恒落世界边(不压屏心玩家)。 ++ */ ++function drawArenaBounds(g, camX, camY, viewport) { ++ const T = 96; // 岩壁暗带厚度(世界 px) ++ const L = -ARENA.w / 2, R = ARENA.w / 2, Tp = -ARENA.h / 2, Bt = ARENA.h / 2; ++ const DARK = 'rgba(5,3,1,0.85)', TRAN = 'rgba(5,3,1,0)'; ++ let lg; ++ lg = g.createLinearGradient(L - camX, 0, L + T - camX, 0); // 左壁(向内渐隐) ++ lg.addColorStop(0, DARK); lg.addColorStop(1, TRAN); ++ g.fillStyle = lg; g.fillRect(L - camX, Tp - camY, T, ARENA.h); ++ lg = g.createLinearGradient(R - camX, 0, R - T - camX, 0); // 右壁 ++ lg.addColorStop(0, DARK); lg.addColorStop(1, TRAN); ++ g.fillStyle = lg; g.fillRect(R - T - camX, Tp - camY, T, ARENA.h); ++ lg = g.createLinearGradient(0, Tp - camY, 0, Tp + T - camY); // 上壁 ++ lg.addColorStop(0, DARK); lg.addColorStop(1, TRAN); ++ g.fillStyle = lg; g.fillRect(L - camX, Tp - camY, ARENA.w, T); ++ lg = g.createLinearGradient(0, Bt - camY, 0, Bt - T - camY); // 下壁 ++ lg.addColorStop(0, DARK); lg.addColorStop(1, TRAN); ++ g.fillStyle = lg; g.fillRect(L - camX, Bt - T - camY, ARENA.w, T); ++} ++ + /** + * 大尺度地貌明暗:确定性径向光斑铺屏(约半数暗坡 + 半数受光金斑),世界坐标定位随镜头滚动。 + * 大尺度格(512)远大于 128 tile、且不与格线对齐;平滑径向渐变叠在 tile 之上,给地面明暗 +@@ -283,8 +325,160 @@ function drawGroundShade(g, camX, camY, viewport) { + } + } + +-function drawPlayer(g, v, viewport, art) { +- const x = viewport.w / 2, y = viewport.h / 2; ++// ── 背景拼接处理:方案②③ 参数与离屏状态(2026-07-24)───────────────────── ++// 全部为可调旋钮,美丑交创始人眼验;此处只给合理初值。 ++const TILE_T = 128; // 瓦片原尺寸(不再上采样:原 OVER=8 外扩 + size=144 放大已去除) ++const FEATHER = 16; // 瓦片边界羽化带宽(px):四边各 16px alpha 渐隐,中心不透 ++const TILE_ALPHA = 0.9; // 瓦片层整体透明度:略透,让视差底层微微渗出破「死均匀壁纸」 ++const VISTA_ALPHA = 0.5; // 远景视差底层透明度(与出击页 0.55 同量级,垫底不抢) ++const PAR_X = 0.12; // 视差系数 X:远景随镜头慢移(12% 速度= distant backdrop 语义) ++const PAR_Y = 0.06; // 视差系数 Y:纵向更慢(竖屏战场横向滚动为主) ++const BG_RGB = '20,16,9'; // C.bg(#141009)的 rgb 分量,羽化渐变透明端用,保证融进暗底不留硬边 ++ ++// 离屏瓦片层 + 重复羽化 mask(懒建;非 DOM 环境(如 sim 扫描)不建,走直绘兜底) ++let groundLayer = null, glx = null, featherPattern = null; ++ ++/** ++ * 方案③ 瓦片层:原尺寸 128 平铺(选帧/分区/翻转口径与旧版一致)→ 整层 destination-in ++ * 重复羽化 mask(边缘 alpha 渐隐)→ 降 alpha 合成回主画布。 ++ * 关键:羽化让相邻瓦片硬接缝变成「柔和过渡带」,带内透出方案②的视差底层,网格感随破。 ++ */ ++function drawGroundTiles(g, camX, camY, viewport, art) { ++ const T = TILE_T, REGION = 384; // 地貌分区尺度(3×3 tile 一片) ++ // 非 DOM 或离屏建不起来 → 原尺寸直绘兜底(不羽化,但不崩溃) ++ if (!ensureGroundLayer(g.canvas, viewport)) { ++ const x0 = Math.floor(camX / T) * T, y0 = Math.floor(camY / T) * T; ++ for (let wy = y0; wy < camY + viewport.h + T; wy += T) { ++ for (let wx = x0; wx < camX + viewport.w + T; wx += T) { ++ art.drawTile(g, tileIdx(wx, wy, REGION), wx - camX, wy - camY, T); ++ } ++ } ++ return; ++ } ++ glx.clearRect(0, 0, viewport.w, viewport.h); ++ const x0 = Math.floor(camX / T) * T, y0 = Math.floor(camY / T) * T; ++ for (let wy = y0; wy < camY + viewport.h + T; wy += T) { ++ for (let wx = x0; wx < camX + viewport.w + T; wx += T) { ++ const h = ((wx * 374761393 + wy * 668265263) ^ 0x5bf03635) >>> 0; ++ const idx = tileIdx(wx, wy, REGION); ++ // 确定性 4 向翻转:同段 16 帧再乘 4 朝向,打散岩纹方向对齐的机械网格感(位提取只用 >>>) ++ const fx = (h >>> 16) & 1, fy = (h >>> 17) & 1; ++ const dx = wx - camX, dy = wy - camY; // 原尺寸 128:无 OVER 外扩、无 144 上采样 ++ if (fx || fy) { ++ glx.save(); ++ const cxp = dx + T / 2, cyp = dy + T / 2; // 以 tile 显示中心为翻转轴 ++ glx.translate(cxp, cyp); glx.scale(fx ? -1 : 1, fy ? -1 : 1); glx.translate(-cxp, -cyp); ++ art.drawTile(glx, idx, dx, dy, T); ++ glx.restore(); ++ } else { ++ art.drawTile(glx, idx, dx, dy, T); ++ } ++ } ++ } ++ // 边界羽化:重复羽化 mask 周期 = T,相位对齐瓦片格线(gridOff∈(-T,0]),对整层 destination-in, ++ // 只在瓦片四边削 alpha(中心保留)→ 硬接缝柔化、边缘透出底层。 ++ const gridOffX = x0 - camX, gridOffY = y0 - camY; ++ glx.save(); ++ glx.globalCompositeOperation = 'destination-in'; ++ glx.translate(gridOffX, gridOffY); ++ glx.fillStyle = featherPattern; ++ glx.fillRect(-gridOffX - T, -gridOffY - T, viewport.w + 2 * T, viewport.h + 2 * T); ++ glx.restore(); ++ // 合成回主画布:降 alpha(TILE_ALPHA)让视差底层渗出;drawImage 至 viewport 逻辑矩形, ++ // 由主 ctx 的 DPR 变换映回设备像素(离屏已按同 DPR 建,1:1 清晰),且继承震屏 translate。 ++ g.save(); ++ g.globalAlpha = TILE_ALPHA; ++ g.drawImage(groundLayer, 0, 0, viewport.w, viewport.h); ++ g.restore(); ++} ++ ++/** 选帧口径(与旧版一致):大尺度分区哈希定深岩(16..31)/青绿矿岩(32..47),格内哈希选 16 帧之一。 */ ++function tileIdx(wx, wy, REGION) { ++ const h = ((wx * 374761393 + wy * 668265263) ^ 0x5bf03635) >>> 0; ++ const rgx = Math.floor(wx / REGION), rgy = Math.floor(wy / REGION); ++ const rh = ((rgx * 2654435761 + rgy * 40503) ^ 0x9e3779b1) >>> 0; ++ return ((rh % 5) < 2 ? 32 : 16) + (h % 16); // ~40% 片区青绿矿岩,余深岩;水岸段(0..15)弃 ++} ++ ++/** ++ * 方案② 视差底层:把已入库远景整图 bg-map1-vista-01 作慢速视差垫底。 ++ * 世界无界(玩家自由移动、相机可无限增长),故用「取模环绕 + 多副本铺满」,每副本四边羽化到 ++ * C.bg → 非无缝远景图环绕相接处柔化成暗底雾带,绝无硬边/破图。视差随相机慢移(PAR_X/Y)。 ++ */ ++function drawGroundParallax(g, camX, camY, viewport, art) { ++ if (!art || !art.hasBg('bg-map1-vista-01')) return; // 远景未就绪 → 跳过(C.bg 暗底兜底) ++ const mod = (a, n) => ((a % n) + n) % n; ++ const VW = viewport.w * 1.4, VH = viewport.h * 1.15; // 单幅显示尺寸(>viewport,留环绕余量) ++ const ox = -mod(camX * PAR_X, VW), oy = -mod(camY * PAR_Y, VH); // 视差偏移(慢于镜头) ++ const f = Math.round(Math.min(VW, VH) * 0.16); // 副本四边羽化带宽(柔化环绕接缝) ++ for (let ty = oy - VH; ty < viewport.h; ty += VH) { ++ for (let tx = ox - VW; tx < viewport.w; tx += VW) { ++ if (!art.drawBg(g, 'bg-map1-vista-01', tx, ty, VW, VH, VISTA_ALPHA)) continue; ++ featherRectEdges(g, tx, ty, VW, VH, f); ++ } ++ } ++} ++ ++/** 矩形四边羽化到 C.bg(线性渐变 opaque→transparent):用于视差副本接缝柔化,融进暗底不留硬线。 */ ++function featherRectEdges(g, x, y, w, h, f) { ++ const TRAN = `rgba(${BG_RGB},0)`; // C.bg 的透明端 ++ let lg = g.createLinearGradient(0, y, 0, y + f); // 上边 ++ lg.addColorStop(0, C.bg); lg.addColorStop(1, TRAN); ++ g.fillStyle = lg; g.fillRect(x, y, w, f); ++ lg = g.createLinearGradient(0, y + h - f, 0, y + h); // 下边 ++ lg.addColorStop(0, TRAN); lg.addColorStop(1, C.bg); ++ g.fillStyle = lg; g.fillRect(x, y + h - f, w, f); ++ lg = g.createLinearGradient(x, 0, x + f, 0); // 左边 ++ lg.addColorStop(0, C.bg); lg.addColorStop(1, TRAN); ++ g.fillStyle = lg; g.fillRect(x, y, f, h); ++ lg = g.createLinearGradient(x + w - f, 0, x + w, 0); // 右边 ++ lg.addColorStop(0, TRAN); lg.addColorStop(1, C.bg); ++ g.fillStyle = lg; g.fillRect(x + w - f, y, f, h); ++} ++ ++/** ++ * 建/对齐离屏瓦片层(与主 canvas 同 backing 分辨率 + 同 DPR 变换,用户坐标=viewport 逻辑坐标), ++ * 并懒建重复羽化 mask 图案。@returns {boolean} 是否可用(非 DOM 环境返回 false)。 ++ */ ++function ensureGroundLayer(canvas, viewport) { ++ if (typeof document === 'undefined' || !canvas) return false; ++ const bw = canvas.width, bh = canvas.height, s = bw / viewport.w; // 整数 DPR 倍(生产 DPR2) ++ if (!groundLayer) { ++ groundLayer = document.createElement('canvas'); ++ glx = groundLayer.getContext('2d'); ++ } ++ if (groundLayer.width !== bw || groundLayer.height !== bh) { ++ groundLayer.width = bw; groundLayer.height = bh; ++ featherPattern = null; // backing 变了重建图案(图案本身与尺寸无关,仅随 ctx 重建) ++ } ++ glx.setTransform(s, 0, 0, s, 0, 0); // 同主 ctx DPR:离屏画的内容 1:1 贴回设备像素 ++ if (!featherPattern) featherPattern = buildFeatherPattern(glx); ++ return true; ++} ++ ++/** ++ * 建 T×T 重复羽化 mask:四边各 FEATHER px 由透明渐变到不透(中心保留),可分离两次渐变叠乘。 ++ * destination-in 只取 alpha → 白色仅承载 alpha 曲线。图案 repeat 后周期=T,对齐瓦片格线即成接缝羽化。 ++ */ ++function buildFeatherPattern(ctx) { ++ const c = document.createElement('canvas'); c.width = TILE_T; c.height = TILE_T; ++ const f = c.getContext('2d'); ++ const T = TILE_T, F = FEATHER, OPAQUE = 'rgba(255,255,255,1)', TRAN = 'rgba(255,255,255,0)'; ++ let lg = f.createLinearGradient(0, 0, T, 0); // 水平:左右各 F 渐隐 ++ lg.addColorStop(0, TRAN); lg.addColorStop(F / T, OPAQUE); ++ lg.addColorStop(1 - F / T, OPAQUE); lg.addColorStop(1, TRAN); ++ f.fillStyle = lg; f.fillRect(0, 0, T, T); ++ f.globalCompositeOperation = 'destination-in'; // 垂直叠乘:上下各 F 渐隐 → 四边+四角柔和 ++ lg = f.createLinearGradient(0, 0, 0, T); ++ lg.addColorStop(0, TRAN); lg.addColorStop(F / T, OPAQUE); ++ lg.addColorStop(1 - F / T, OPAQUE); lg.addColorStop(1, TRAN); ++ f.fillStyle = lg; f.fillRect(0, 0, T, T); ++ return ctx.createPattern(c, 'repeat'); ++} ++ ++function drawPlayer(g, v, viewport, art, camX, camY) { ++ // 镜头有界 clamp 后玩家不再恒在屏心:用世界坐标减镜头还原屏幕位(近边缘时贴边,不脱出精灵位) ++ const x = v.player.x - camX, y = v.player.y - camY; + const blink = v.player.iframe > 0 && ((v.frame >> 2) & 1) === 0; + const flip = v.player.dirX < -0.05; // 朝向:向左走翻转(art 默认朝右) + const moving = Math.abs(v.player.dirX) > 0.05 || Math.abs(v.player.dirY) > 0.05; +diff --git a/game-runtime/games/shanhai-xingji/src/sim.js b/game-runtime/games/shanhai-xingji/src/sim.js +index 6500cec9..ea97d58b 100644 +--- a/game-runtime/games/shanhai-xingji/src/sim.js ++++ b/game-runtime/games/shanhai-xingji/src/sim.js +@@ -30,7 +30,7 @@ import { createUniformGrid, separateOne, seekStep } from '../../../src/plugins/s + import { nearestViaGrid } from '../../../src/plugins/auto-targeting/impl.js'; + import { deriveWeapon, derivePlayer, WEAPON_BY_ID, PASSIVE_BY_ID } from './build-model.js'; + import { +- PLAYER, WEAPONS, PASSIVES, XP, ENEMIES, WAVES, ++ PLAYER, WEAPONS, PASSIVES, XP, ENEMIES, WAVES, ARENA, + MAX_ALIVE, MAX_ZONES, SPAWN_R, GRID_CELL, SEPARATION, + CRIT, SLOTS, OFFER, EVO_PASSIVE_MIN, CHAIN_R, HARVEST, DIFFICULTY, + TIDE, ELITE, AFFIXES, BOSSES, EVENTS, +@@ -643,6 +643,10 @@ export function createSim(opts) { + const speed = PLAYER.speed * pd.speedMul; + player.x += player.dirX * speed * dt; + player.y += player.dirY * speed * dt; ++ // 竞技场边界 clamp(图 1 战场有界):把玩家钳在 [-w/2+r, w/2-r] × [-h/2+r, h/2-r] 内, ++ // 留 player.r 边距(贴边不穿墙)。纯函数(只读当前位置),同输入同输出 → 不破确定性。 ++ player.x = Math.max(-ARENA.w / 2 + player.r, Math.min(ARENA.w / 2 - player.r, player.x)); ++ player.y = Math.max(-ARENA.h / 2 + player.r, Math.min(ARENA.h / 2 - player.r, player.y)); + if (player.iframe > 0) player.iframe -= dt; + if (pd.regen > 0 && player.hp < player.maxHp) player.hp = Math.min(player.maxHp, player.hp + pd.regen * dt); + diff --git a/game-runtime/games/shanhai-xingji/evidence/round-12/00-arena-bg-result.json b/game-runtime/games/shanhai-xingji/evidence/round-12/00-arena-bg-result.json new file mode 100644 index 00000000..214ae1cd --- /dev/null +++ b/game-runtime/games/shanhai-xingji/evidence/round-12/00-arena-bg-result.json @@ -0,0 +1,232 @@ +{ + "checkpoint": "round-12 微调 3 个 CONCERNS(clamp 边距→岩壁内沿 / 四边自然暗角 / 背景降颗粒)+ CDP 复核", + "utc": "2026-07-25T07:59:09.540Z", + "machine": "lili-mac.local(开发机,非 mini-desktop)", + "node": "v22.22.3", + "url": "http://127.0.0.1:4518/?engine=stub&mode=evidence&seed=777", + "dpr": 2, + "viewport": { + "w": 390, + "h": 844 + }, + "arena": { + "w": 1560, + "h": 2960 + }, + "clampMargin": "player.r(12)+ARENA_WALL_INSET(84)=96", + "clampX": 684, + "clampY": 1384, + "microadjust": { + "c12": "sim.js clamp 边距 player.r→player.r+84=96,玩家贴边停在岩壁内沿,不站黑墙/不出屏", + "c3a": "render.js drawArenaBounds 纯黑硬带 rgba(5,3,1,0.85)→四边自然暗角 rgba(16,11,6,·) 峰值 0.5、外半段集中渐透", + "c3b": "render.js drawGround 一档 drawBg 前 imageSmoothingEnabled=true + quality=high,缓解上采样+DPR2 颗粒" + }, + "collector": "/tmp/arena-e2e-r12.mjs(临时脚本,置 /tmp 不入仓)", + "shots": [ + { + "name": "01-arena-center-panorama.png", + "desc": "全景:玩家场心(0,0),镜头居中,竞技场整幅背景中心区", + "sha256": "8d3f74d92ff0161167ad2d2e5dc51d41c50024028001edd4bfd5e0082b943f21", + "bytes": 1717505, + "utc": "2026-07-25T07:59:15.507Z", + "phase": "play", + "pos": { + "x": 0, + "y": 0 + }, + "timeS": 1.6, + "score": 0, + "enemies": 1, + "expectedScreen": { + "camX": -195, + "camY": -422, + "sx": 195, + "sy": 422 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 27467, + "total": 47190, + "ratio": 0.582 + } + }, + { + "name": "02-arena-edge-right.png", + "desc": "右边缘:玩家贴右界(x≈684,岩壁内沿),应停在暗角内沿清亮区、不站黑墙", + "sha256": "a3ca2290e0da3056a38a89f2584b059b381af97cdfa0a367f9b33ea1f97c6c12", + "bytes": 1363380, + "utc": "2026-07-25T07:59:15.916Z", + "phase": "play", + "pos": { + "x": 684, + "y": 0 + }, + "timeS": 7.7, + "score": 3, + "hp": 100, + "enemies": 3, + "level": 1, + "expectedScreen": { + "camX": 390, + "camY": -422, + "sx": 294, + "sy": 422 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 11560, + "total": 47190, + "ratio": 0.245 + } + }, + { + "name": "03-arena-edge-top.png", + "desc": "上边缘:玩家贴上界(y≈-1384),躯体不出视口、不潜墙下", + "sha256": "ed6b29c9eab72ce7bb101a8ade15091c4d3a2ffa99af91f9cd6b070fcc11c98a", + "bytes": 1170387, + "utc": "2026-07-25T07:59:16.482Z", + "phase": "play", + "pos": { + "x": 684, + "y": -1384 + }, + "timeS": 19.7, + "score": 12, + "hp": 100, + "enemies": 5, + "level": 1, + "expectedScreen": { + "camX": 390, + "camY": -1480, + "sx": 294, + "sy": 96 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 2570, + "total": 47190, + "ratio": 0.054 + } + }, + { + "name": "04-arena-edge-left.png", + "desc": "左边缘:玩家贴左界(x≈-684),左岩壁自然暗角", + "sha256": "c6c73e06a3e86a0a4d344c366dd6c443a94f84a72990cb0be2e74c10534f2a8d", + "bytes": 1106646, + "utc": "2026-07-25T07:59:17.005Z", + "phase": "play", + "pos": { + "x": -684, + "y": -1384 + }, + "timeS": 31.8, + "score": 21, + "hp": 100, + "enemies": 7, + "level": 1, + "expectedScreen": { + "camX": -780, + "camY": -1480, + "sx": 96, + "sy": 96 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 2866, + "total": 47190, + "ratio": 0.061 + } + }, + { + "name": "05-arena-edge-bottom.png", + "desc": "下边缘:玩家贴下界(y≈1384),下岩壁自然暗角", + "sha256": "8a6c88785457b2412ec70a29f819b063c4c74bace764fd811c9ce4e1bae07936", + "bytes": 1181703, + "utc": "2026-07-25T07:59:17.578Z", + "phase": "play", + "pos": { + "x": -684, + "y": 1384 + }, + "timeS": 55, + "score": 39, + "hp": 100, + "enemies": 16, + "level": 2, + "expectedScreen": { + "camX": -780, + "camY": 636, + "sx": 96, + "sy": 748 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 2323, + "total": 47190, + "ratio": 0.049 + } + }, + { + "name": "06-arena-corner-br.png", + "desc": "右下角:同时贴右+下界,两面自然暗角 + 角落收口", + "sha256": "0235e318787b20ceb1d04dbaa3b83e21c8f18c511f898fd44d8b016872329f7c", + "bytes": 1349184, + "utc": "2026-07-25T07:59:18.118Z", + "phase": "play", + "pos": { + "x": 684, + "y": 1384 + }, + "timeS": 72, + "score": 63, + "hp": 96, + "enemies": 36, + "level": 2, + "expectedScreen": { + "camX": 390, + "camY": 636, + "sx": 294, + "sy": 748 + }, + "brightness": { + "ok": true, + "width": 780, + "height": 1688, + "bright": 4973, + "total": 47190, + "ratio": 0.105 + } + } + ], + "boot": { + "ok": true + }, + "arenaBgAssetProbe": "ready 1560x2960", + "startState": { + "phase": "play", + "pos": { + "x": 0, + "y": 0 + } + }, + "pageErrors": [], + "consoleErrors": [ + "2026-07-25T07:59:13.060Z [error] Failed to load resource: the server responded with a status of 404 (File not found)", + "2026-07-25T07:59:15.171Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently", + "2026-07-25T07:59:15.592Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently", + "2026-07-25T07:59:16.072Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently", + "2026-07-25T07:59:16.705Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently", + "2026-07-25T07:59:17.269Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently", + "2026-07-25T07:59:17.778Z [warning] Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently" + ] +} diff --git a/game-runtime/games/shanhai-xingji/evidence/round-12/microadjust-diff-summary.md b/game-runtime/games/shanhai-xingji/evidence/round-12/microadjust-diff-summary.md new file mode 100644 index 00000000..3c625ba6 --- /dev/null +++ b/game-runtime/games/shanhai-xingji/evidence/round-12/microadjust-diff-summary.md @@ -0,0 +1,116 @@ +# round-12 — 微调 3 个 CONCERNS(clamp 边距 / 自然暗角 / 背景降颗粒)+ CDP 复核 + +检查点目标:在 round-11「接大图 + 加边界」基础上,**只微调** CONCERN 1/2/3(墙角死角 CONCERN 4 作为有界竞技场设计保留),重建 dist,真浏览器 CDP 复核贴边/边界/颗粒,交创始人眼验。**不重跑⑧win**(留下检查点)。 + +- 机器:`lili-mac.local`(开发机本地,非 mini-desktop;未 ssh)。 +- 采集时间:2026-07-25T07:59Z。视口 390×844 @ DPR2(截图 780×1688)。seed=777。 +- 采集器:`/tmp/arena-e2e-r12.mjs`(临时脚本,置 /tmp 不入仓;node 子进程拉 http.server:4518 + headless Chrome:9318,CDP 前台跑到返回,finally 杀进程删 profile)。 +- 诚实红线:效果主观,本文只记代码 + 截图 + 像素采样事实,**美丑交创始人眼验**;对 CONCERN 3a 的可见残留给出已验证根因(见 §四)。 + +## 一、微调 diff(round-12 在 round-11 之上的增量,逐处) + +**sim.js — CONCERN 1+2(clamp 边距提到岩壁内沿)**:新增模块常量,clamp 边距由 `player.r`(12)改为 `player.r + ARENA_WALL_INSET`(=12+84=96=render 岩壁带厚 T),玩家中心停在暗带内沿。 + +```diff ++/** ++ * 竞技场岩壁内沿留白(世界 px)。clamp 玩家时除碰撞半径 player.r 外再留这段,使玩家贴边时 ++ * 停在「岩壁暗带内沿」而非站进暗带里:render 四边暗角厚 T=96px,这里取 84 → 总边距 ++ * player.r(12)+ 84 = 96 = T,玩家中心恰落在暗带内沿;其碰撞圆再向外延 player.r 也仅触及 ++ * 暗带最内一圈(该处渐变已近全透),不再「站进黑墙」、高个主角贴顶边时躯体也不出视口。 ++ * 代价:可达区每边缩 ~84px —— 有界竞技场本就有墙,此为设计内取舍(CONCERN 4 墙角死角保留)。 ++ */ ++const ARENA_WALL_INSET = 84; + ... +- // 留 player.r 边距(贴边不穿墙)。 +- player.x = Math.max(-ARENA.w / 2 + player.r, Math.min(ARENA.w / 2 - player.r, player.x)); +- player.y = Math.max(-ARENA.h / 2 + player.r, Math.min(ARENA.h / 2 - player.r, player.y)); ++ // 边距 = player.r + ARENA_WALL_INSET(=96=岩壁带厚 T),玩家中心停暗带内沿:不站黑墙、贴顶边不出视口。 ++ const WALL = player.r + ARENA_WALL_INSET; ++ player.x = Math.max(-ARENA.w / 2 + WALL, Math.min(ARENA.w / 2 - WALL, player.x)); ++ player.y = Math.max(-ARENA.h / 2 + WALL, Math.min(ARENA.h / 2 - WALL, player.y)); +``` + +**render.js — CONCERN 3b(背景降颗粒)**:一档 drawBg 前开高质量重采样,save/restore 隔离只作用于背景这一档。 + +```diff + if (art && art.hasBg('bg-map1-arena-01')) { ++ // 背景降颗粒:源图 1080×2048 上采样到 1560×2960 + 生产 DPR2 → 设备级放大~2.9× 出颗粒; ++ // 绘前开 bicubic 重采样让上采样平滑,save/restore 隔离不影响精灵/tile 路。 ++ g.save(); ++ g.imageSmoothingEnabled = true; ++ g.imageSmoothingQuality = 'high'; + art.drawBg(g, 'bg-map1-arena-01', -ARENA.w/2 - camX, -ARENA.h/2 - camY, ARENA.w, ARENA.h, 1); ++ g.restore(); + drawArenaBounds(g, camX, camY, viewport); +``` + +**render.js — CONCERN 3a(去纯黑硬框→自然暗角)**:`drawArenaBounds` 颜色由近纯黑 `rgba(5,3,1,0.85)` 改暖暗 `rgba(16,11,6,·)`,峰值 alpha 0.85→0.5,并加中段 stop 使变暗集中在带的外半段(贴世界边),内半段(玩家 clamp 所在的岩壁内沿)近全透。 + +```diff +- const DARK = 'rgba(5,3,1,0.85)', TRAN = 'rgba(5,3,1,0)'; +- // 每条壁两段 addColorStop(0,DARK)(1,TRAN) ++ const EDGE = 'rgba(16,11,6,0.50)', MID = 'rgba(16,11,6,0.16)', TRAN = 'rgba(16,11,6,0)'; ++ const shade = (lg) => { lg.addColorStop(0, EDGE); lg.addColorStop(0.5, MID); lg.addColorStop(1, TRAN); }; ++ // 四条壁各 createLinearGradient(世界边→内沿) + shade(lg);0.5 处加 MID 使变暗收在外半段 +``` + +dist 重建:`node scripts/build-report.mjs` → `bundleBytes=375998`、`firstScreenBytes=379258`、`pass.firstScreen=true`、`pass.engineCodeBudget=true`(接图前后首屏代码账不变)。bundle 内已确认 `imageSmoothingQuality="high"`、`rgba(16,11,6,0|0.16|0.50)` 存在,旧 `rgba(5,3,1,0.85)` 计数=0(已移除)。 + +## 二、CONCERN 1+2 客观成立(CDP 实测屏幕位) + +玩家屏幕位 = pos − cam(同 render.js clampCam 复算)。round-12 玩家 clamp 到 ±684(x)/±1384(y)(round-11 为 ±768/±1468),贴边时屏幕位由「距屏边 12px」变为「距屏边 96px」——玩家脱离极边黑墙、高个主角贴顶边时躯体留出 96px 不出视口: + +| 截图 | 世界 pos | 屏幕 sx,sy(逻辑px) | round-11 屏幕位 | 看到的 | +|---|---|---|---|---| +| 01 全景 | (0,0) | (195,422) 屏心 | (195,422) | 整幅彩色战场中心,玩家居中 | +| 02 右缘 | (684,0) | (**294**,422) | (378,422) | 玩家**完整可见**、立在彩色岩面;round-11 此位玩家被右黑墙切掉半身 | +| 03 上缘 | (684,−1384) | (294,**96**) | (378,12) | 玩家距顶 96px,头部不再潜入墙下/出视口 | +| 04 左缘 | (−684,−1384) | (**96**,**96**) | (12,12) | 玩家距左/顶各 96px,完整可见 | +| 05 下缘 | (−684,1384) | (96,**748**) | (12,832) | 玩家距左/底各 96px | +| 06 右下角 | (684,1384) | (**294**,**748**) | (378,832) | 玩家完整、距右/底 96px,不再被两面墙夹切 | + +`pageErrors=0`(新 render/sim 无崩溃)。console 7 条均良性:1 条 favicon 类 404(非 page error;arena 图探针 200 ready 1560×2960)+ 6 条 `willReadFrequently`(亮度探针 getImageData 自产,非游戏代码)。 + +## 三、CONCERN 3b(降颗粒) + +代码已落并在 bundle。该改动把上采样重采样由默认改 bicubic(`imageSmoothingQuality='high'`),缓解「块状像素感」;但暗岩区的细颗粒主要来自 AI 草图自身纹理 + JPEG 压缩噪点,重采样对此改善有限——**颗粒是否可接受归创始人眼验**(对比 01/02 与 round-11 同名图)。 + +## 四、CONCERN 3a 的可见残留:已验证根因 = bg 图自身纯黑边框(非暗角) + +微调把 `drawArenaBounds` 的纯黑硬带(0.85)改成了 0.5 峰值的柔和暗角(§一),代码正确且在 bundle。**但**贴边截图(02/03/04/06)在左/右/顶边仍见一条纯黑带,且其位置/宽度与 round-11 几乎一致——说明该黑带**主因不是暗角**,而是 bg 图自身的黑边。已用 PIL 直接采样 `assets/bg/bg-map1-arena-01.jpg` 像素(已验证事实,非推断): + +| 区域 | 平均 RGB | 判读 | +|---|---|---| +| 中心 100×100 | (106,96,58) | 彩色战场 | +| **左缘 0–40px** | **(0,0,0)** | **纯黑(烤进 jpg)** | +| **右缘 W−40–W** | **(0,0,0)** | **纯黑(烤进 jpg)** | +| 顶缘 0–40px | (16,21,21) | 近黑 | +| 底缘 H−40–H | (27,34,31) | 暗 | +| 左缘 40–120px | (11,13,14) | 仍近黑 | +| 右缘 W−120–W−40 | (10,15,17) | 仍近黑 | + +结论:round-11 入库的 bg 整图,左/右各有一条**纯黑(0,0,0)烤边**(顶也近黑),向内延到约 120px 仍近黑。叠加任何半透明暗角在 (0,0,0) 上结果仍是 (0,0,0)——所以「纯黑硬框」的可见症状**靠 render 的暗角无法消除**,其真根在 jpg 自身。这正与 round-11 §五-③「AI 草图本身顶/底偏暗」同根,且左右更甚。 + +**本检查点为何没顺手修**:工作目录白名单只许 `src/{sim,render}.js` 微调 + 重建 dist + evidence/round-12,**不含 bg jpg**;render 侧若要遮黑边只有两条路且都属材料级、不该擅自做——① 把 bg 放大约 1.3× 让黑边落到镜头可达区外(会改变整图取景/构图,且使 bg 与竞技场坐标解耦);② 在黑边处叠一条不透明岩色渐变带(本身又是一条「框」,且左右底色不同难匹配)。**二者交创始人决**(见 §六)。暗角按规约已正确软化,不是问题所在。 + +## 五、产物 sha256 + +- `src/sim.js` 92d0342abe62cf2cd8f898a664f90a1407eb87a71defaf0d519fc696b1aa768b +- `src/render.js` 08e8dad25773a893d667a79ee9d75463225aa92f8c9118d713b13892724b0ead +- `src/balance.js` 9094f814b1987c4456d45391052d083061a5ce342a2c5a122a90a6f13342086d(本轮未改,沿 round-11) +- `src/art.js` 224e8d09da63a7f65a2e2be69bb42856b9557088cde0ff472a4712694adbfc0e(本轮未改,沿 round-11) +- `assets/bg/bg-map1-arena-01.jpg` aaa42f3a2f2a47e35f2d7f68cf7e64e7a53726983f68081c0d9f86daadec1956(本轮未改) +- `dist/shanhai-bundle.js` 25f9ef61ae67e9041c2a8367fb530d9fcc5700f046be9229466859b3aa509de0(本轮重建) +- `evidence/round-12/00-arena-bg-result.json` b9c5cb4152ce480b05a569baf9e9bb1f63a2b70788dfd5c0582d52b46c676f92 +- 六张截图 sha256 见同目录 `00-arena-bg-result.json` 各 `shots[].sha256`。 + +## 六、需创始人决(非阻断,已眼验截图) + +1. **CONCERN 3a 黑边真根在 jpg**(§四):要彻底去掉左/右/顶纯黑框,需重做 bg 图边(把黑边补成岩纹/裁掉黑边),或批准 render 侧遮黑边二选一(bg 放大~1.3× / 不透明岩色遮带)。当前暗角已按规约软化,render 侧已无更多可做的「微调」。 +2. **CONCERN 3b 颗粒**:bicubic 已上;若仍嫌噪,根同样在 jpg(AI 纹理+JPEG 噪点),可考虑更高质源图或渲染期轻度模糊(材料级,另议)。 +3. **CONCERN 4 墙角死角**:按你的决定保留(有界竞技场设计),本轮未动。 +4. **⑧win**:本检查点未重跑,留下检查点(避免超载)。 + +## 七、外部状态变更 + +无 git add/commit/push(本检查点采集阶段);无 staging;未碰 mini-infra;未 ssh mini-desktop;**未碰 plugins / game-runtime/src / contracts / balance.js / art.js / bg jpg**(本会话仅编辑 sim.js + render.js,其余入库文件为 round-11 既有未提交改动);本地 serve:4518 + headless Chrome:9318 已被脚本 finally 杀、端口释放、/tmp/arena-chrome-profile-r12 已删,ps/lsof 复核无孤儿。 diff --git a/game-runtime/games/shanhai-xingji/src/art.js b/game-runtime/games/shanhai-xingji/src/art.js index 0811e61d..534b8bae 100644 --- a/game-runtime/games/shanhai-xingji/src/art.js +++ b/game-runtime/games/shanhai-xingji/src/art.js @@ -32,10 +32,13 @@ const PAGE_ASSET = { }; /** 地表 tile 表(8 列 × 6 行,128px;index=row×8+col)。 */ const TILE_ASSET = { ref: 'tiles/tiles-map1', url: './assets/tiles/tiles-map1.webp', size: 128, cols: 8 }; -/** 背景/底图(远景 vista + 出击面板底纹)。 */ +/** 背景/底图(远景 vista + 出击面板底纹 + 竞技场整幅战场背景)。 */ const BG_ASSET = { 'bg-map1-vista-01': { ref: 'bg/bg-map1-vista-01', url: './assets/bg/bg-map1-vista-01.webp' }, 'ui-panel-sortie-01': { ref: 'bg/ui-panel-sortie-01', url: './assets/bg/ui-panel-sortie-01.webp' }, + // 竞技场整幅战场背景(1560×2960,render 一档地纹)。本机无 webp 写工具(cwebp/ffmpeg-libwebp/sips 均不可写) + // → 落 jpg(drawBg 走浏览器 解码,jpg/png/webp 皆可,url 后缀对应即可)。 + 'bg-map1-arena-01': { ref: 'bg/bg-map1-arena-01', url: './assets/bg/bg-map1-arena-01.jpg' }, }; /** 首屏预载页(boss 走懒加载,不进首屏)。 */ const FIRST_SCREEN_PAGES = ['actors', 'beasts', 'ui', 'map1']; @@ -68,8 +71,9 @@ export function createArt(boot) { const canLoad = typeof Image === 'function'; // 浏览器/真机才能自载;node headless 为 false /** @type {Record} 页 group → 图对象(优先宿主预载,否则自载)。 */ const pageImg = {}; - /** @type {any} tile 表图 / bg 图。 */ + /** @type {any} tile 表图 / bg 图。tileImg 改懒载(ensureTile):null=未就绪,tileTried 防重复拉。 */ let tileImg = null; + let tileTried = false; const bgImg = {}; /** 取证/日志。 */ const stats = { drawImage: 0, drawn: 0, fellBack: 0 }; @@ -98,14 +102,30 @@ export function createArt(boot) { pageImg[group] = fromHost(spec.ref) || lazyImage(spec.url); } - /** init 预载:首屏页 + tile + bg vista(boss 懒加载不在此)。 */ + /** + * 懒载地表 tile 表(大图接起后 tiles 仅作二档兜底,降为非首屏):首次 drawTile 需要时才拉, + * 不占首屏字节余量。tileTried 防重复拉(失败则恒 null → drawTile 回退程序色块)。 + */ + function ensureTile() { + if (tileTried) return; + tileTried = true; + tileImg = fromHost(TILE_ASSET.ref) || lazyImage(TILE_ASSET.url); + } + + /** + * init 预载:首屏页 + bg(vista/panel/arena)。boss 页走懒加载不在此。 + * 竞技场大图 bg-map1-arena-01:init 后台拉起、菜单驻留期异步解码(非阻塞);未载完 hasBg=false, + * 渲染层 drawGround 自动落二档(视差瓦片)兜底,载完切入一档,不闪白。 + * tiles-map1 不在此预载(降为非首屏,ensureTile 首次需要时懒拉)。 + */ function preloadFirstScreen() { for (const g of FIRST_SCREEN_PAGES) ensurePage(g); - tileImg = fromHost(TILE_ASSET.ref) || lazyImage(TILE_ASSET.url); const vista = BG_ASSET['bg-map1-vista-01']; bgImg['bg-map1-vista-01'] = fromHost(vista.ref) || lazyImage(vista.url); const panel = BG_ASSET['ui-panel-sortie-01']; bgImg['ui-panel-sortie-01'] = fromHost(panel.ref) || lazyImage(panel.url); + const arena = BG_ASSET['bg-map1-arena-01']; + bgImg['bg-map1-arena-01'] = fromHost(arena.ref) || lazyImage(arena.url); // 后台拉起,菜单期解码 } /** 取某帧的页图(未 ensure 过则顺带 ensure=容错自愈)。 */ @@ -231,8 +251,9 @@ export function createArt(boot) { /** 独立 bg 图是否就绪。 */ function hasBg(id) { return imgReady(bgImg[id]); } - /** 地表 tile:index → 8 列网格源块 → 目标 (dx,dy,size)。 */ + /** 地表 tile:index → 8 列网格源块 → 目标 (dx,dy,size)。tiles 懒载(ensureTile):首次需要才拉。 */ function drawTile(g, index, dx, dy, size) { + ensureTile(); if (!imgReady(tileImg)) return false; const col = index % TILE_ASSET.cols, row = Math.floor(index / TILE_ASSET.cols); const s = TILE_ASSET.size; diff --git a/game-runtime/games/shanhai-xingji/src/balance.js b/game-runtime/games/shanhai-xingji/src/balance.js index ca176175..ef97335d 100644 --- a/game-runtime/games/shanhai-xingji/src/balance.js +++ b/game-runtime/games/shanhai-xingji/src/balance.js @@ -35,6 +35,15 @@ export const PLAYER = { hurtIframe: 0.25, // 受击后短无敌窗(秒)——防同帧多怪叠伤秒杀 }; +/** + * 竞技场边界(图 1 战场世界尺寸)。中心对称原点:玩家出生点 (0,0) = 场心, + * 世界坐标范围 x∈[-w/2, w/2]、y∈[-h/2, h/2]。 + * - sim 用它给玩家走位 clamp(留 player.r 边距,贴边不穿墙); + * - render 用它做镜头 clamp(竞技场≥视口,镜头不出世界边)+ 整幅战场背景图铺设范围。 + * 尺寸 1560×2960 = 竖屏战场(约 4 视口宽 × 3.5 视口高),与背景图 bg-map1-arena-01 同尺寸。 + */ +export const ARENA = { w: 1560, h: 2960 }; + /** 虚拟摇杆(手 1–3 断言的参数面;UI 层消费,sim 只收归一化方向)。 */ export const JOYSTICK = { radius: 64, // 摇杆逻辑半径(px) diff --git a/game-runtime/games/shanhai-xingji/src/render.js b/game-runtime/games/shanhai-xingji/src/render.js index 8f37b825..6a3955cd 100644 --- a/game-runtime/games/shanhai-xingji/src/render.js +++ b/game-runtime/games/shanhai-xingji/src/render.js @@ -12,7 +12,7 @@ 'use strict'; -import { JOYSTICK, ROUND_S, DIFFICULTY, SLOTS, PASSIVES } from './balance.js'; +import { JOYSTICK, ROUND_S, DIFFICULTY, SLOTS, PASSIVES, ARENA } from './balance.js'; /** 山海矿彩色板(程序回退唯一取色处;真图接入后仅缺图兜底与 HUD 文字用)。 */ const C = { @@ -113,6 +113,14 @@ export function menuLayout(viewport) { }; } +/** + * 镜头单轴 clamp(竞技场有界)。竞技场≥视口,镜头不出世界边: + * 下界 = -world/2(镜头左/上缘贴世界边),上界 = world/2 - view(镜头右/下缘贴世界边)。 + */ +function clampCam(cam, world, view) { + return Math.max(-world / 2, Math.min(world / 2 - view, cam)); +} + /** * 主绘制入口。 * @param {CanvasRenderingContext2D} g @@ -134,8 +142,10 @@ export function drawGame(g, s) { if (shake && (shake.x || shake.y)) g.translate(shake.x, shake.y); const v = s.sim.view(); - const camX = v.player.x - viewport.w / 2; - const camY = v.player.y - viewport.h / 2; + // 镜头 clamp(竞技场有界):竞技场≥视口,镜头不出世界边(下界=-world/2,上界=world/2-view)。 + // 玩家居中镜头随玩家;玩家近边镜头停世界边、玩家在屏内偏移(drawPlayer 用 camX/camY 还原其屏幕位)。 + const camX = clampCam(v.player.x - viewport.w / 2, ARENA.w, viewport.w); + const camY = clampCam(v.player.y - viewport.h / 2, ARENA.h, viewport.h); drawGround(g, camX, camY, viewport, art); drawEventOverlays(g, v, camX, camY, art); @@ -144,7 +154,7 @@ export function drawGame(g, s) { drawDrops(g, v, camX, camY, art); drawEnemies(g, v, camX, camY, art); drawProjectiles(g, v, camX, camY, art); - drawPlayer(g, v, viewport, art); + drawPlayer(g, v, viewport, art, camX, camY); drawFx(g, s.fx, camX, camY, viewport, art); drawBanners(g, s.fx, viewport); if (s.juice && s.juice.render) s.juice.render(g); @@ -194,56 +204,46 @@ function drawLevelupTitle(g, v, picker, viewport) { g.textAlign = 'left'; } -/** 地纹:真 tile 铺地(8 变奏按世界哈希选)+ 稀疏装饰物;缺图回退哈希撒点。 */ +/** + * 地纹三档(2026-07-24 接大图后分层): + * 一档 = 竞技场整幅背景图 bg-map1-arena-01(1560×2960 战场整图)+ 四边岩壁压边 + 场内装饰; + * 二档 = 视差远景 + 羽化瓦片 + 明暗(金标阶段二 A 缓解方案,大图未载完时的兜底); + * 三档 = 无图哈希撒点(占位期原路)。 + */ function drawGround(g, camX, camY, viewport, art) { - if (art && art.has('deco-rock-01')) { - // 真 tile 铺地:128px 世界格。选帧口径 2026-07-08 修——原以 base 段(0..15)为主铺, - // 但该段美术母题是「水面小岛」,满铺=规则重复小岛(真机眼验「同一水岛贴图网格平铺」的假)。 - // 改大尺度地貌分区:成片深岩(16..31) / 成片青绿矿岩(32..47)穿插,一暗一亮互为层次 - //(深岩沉稳、青绿提亮兼解深岩单铺过暗),读作「深岩地里的青绿矿脉/湿岩带」。水岸段(0..15)弃。 - const T = 128; - const OVER = 8; // 外扩重叠(原 2→8):加大压住 tile 非无缝边界的接缝 - const REGION = 384; // 地貌分区尺度(3×3 tile 一片):同一片连成深岩 or 青绿矿岩 - const x0 = Math.floor(camX / T) * T, y0 = Math.floor(camY / T) * T; - for (let wy = y0; wy < camY + viewport.h + T; wy += T) { - for (let wx = x0; wx < camX + viewport.w + T; wx += T) { - const h = ((wx * 374761393 + wy * 668265263) ^ 0x5bf03635) >>> 0; - // 大尺度分区哈希决定这一片是深岩还是青绿矿岩(而非每格胡椒面撒→避免噪点/打架) - const rgx = Math.floor(wx / REGION), rgy = Math.floor(wy / REGION); - const rh = ((rgx * 2654435761 + rgy * 40503) ^ 0x9e3779b1) >>> 0; - const idx = ((rh % 5) < 2 ? 32 : 16) + (h % 16); // ~40% 片区青绿矿岩,余深岩 - // 确定性 4 向翻转:同段 16 帧再乘 4 朝向,打散岩纹方向对齐的机械网格感(位提取只用 >>>) - const fx = (h >>> 16) & 1, fy = (h >>> 17) & 1; - const dx = wx - camX - OVER, dy = wy - camY - OVER, size = T + OVER * 2; - if (fx || fy) { - g.save(); - const cxp = wx - camX + T / 2, cyp = wy - camY + T / 2; // 以 tile 显示中心为翻转轴 - g.translate(cxp, cyp); g.scale(fx ? -1 : 1, fy ? -1 : 1); g.translate(-cxp, -cyp); - art.drawTile(g, idx, dx, dy, size); - g.restore(); - } else { - art.drawTile(g, idx, dx, dy, size); - } - } - } - // 大尺度地貌明暗:极轻径向光斑柔化(分区已提供主明暗,这里只轻抹起伏,治残余均匀感) - drawGroundShade(g, camX, camY, viewport); - // 稀疏装饰物(岩/图腾/骨/苔/芦苇/符石;补第 8 种 deco-totem-02;加密到约 2/5;世界哈希定位) - const DECO = ['deco-rock-01', 'deco-rock-02', 'deco-totem-01', 'deco-totem-02', 'deco-moss-01', 'deco-bone-01', 'deco-reed-01', 'deco-runestone-01']; - const D = 288; // 装饰格(原 384→288 加密,配合遮挡打断网格) - const dx0 = Math.floor(camX / D) * D, dy0 = Math.floor(camY / D) * D; - for (let wy = dy0; wy < camY + viewport.h + D; wy += D) { - for (let wx = dx0; wx < camX + viewport.w + D; wx += D) { - const h = ((wx * 2246822519 + wy * 3266489917) ^ 0x9e3779b1) >>> 0; - if (h % 5 < 3) continue; // 约 2/5 格有装饰(原 1/3→加密) - const name = DECO[h % DECO.length]; - const px = wx + (h % 200) - camX, py = wy + ((h >>> 10) % 200) - camY; - art.draw(g, name, px, py, 46 + (h >>> 4) % 26, { anchor: 'bottom', alpha: 0.92 }); - } - } + // ── 一档:竞技场整幅背景图(2026-07-24 接大图 bg-map1-arena-01)────────────── + // 战场整图铺进世界矩形[-w/2,w/2]×[-h/2,h/2](玩家 (0,0)=场心,与 sim 出生点/clamp 同源)。 + // 图未载完(imgReady 未就绪)→ hasBg=false,自动落二档兜底;菜单驻留期后台解码完成后切入,不闪白。 + if (art && art.hasBg('bg-map1-arena-01')) { + // 背景降颗粒(CONCERN 3b):源图 1080×2048 上采样到 1560×2960 世界矩形,再叠生产 DPR2 二倍 + // 绘 → 设备像素级放大约 2.9×,默认重采样出颗粒。绘前开高质量重采样(bicubic),让上采样 + // 平滑;只作用于背景这一档(save/restore 隔离,不影响精灵/tile 路)。 + g.save(); + g.imageSmoothingEnabled = true; + g.imageSmoothingQuality = 'high'; + art.drawBg(g, 'bg-map1-arena-01', -ARENA.w / 2 - camX, -ARENA.h / 2 - camY, ARENA.w, ARENA.h, 1); + g.restore(); + drawArenaBounds(g, camX, camY, viewport); // 四边自然暗角收口(遮背景图硬边,非硬黑框) + drawDeco(g, camX, camY, viewport, art, ARENA); // 稀疏装饰物(裁到场内) return; } - // 回退:整数哈希撒石点(占位期原路) + // ── 二档:背景拼接缓解(金标阶段二 A,2026-07-24;大图未载完的兜底)────────── + if (art && art.has('deco-rock-01')) { + // 病根:tiles-map1 的 128px 瓦片本身是「方块母题」非无缝纹理,满屏平铺 → 明显 tiling + // 接缝 + 满屏壁纸网格感(创始人④眼验「很多方块图拼接、接缝明显、很丑」)。原 OVER=8 外扩 + // + 144 上采样只是创可贴(上采样反把 128→144 模糊/闪烁,外扩压不住方块边界)。 + // 纯代码缓解(用现有资产,不新增美术),两步叠加: + // 方案③ 去上采样 + 边界羽化:瓦片改原尺寸 128 不放大(去模糊/闪烁);整层 destination-in + // 重复羽化 mask,瓦片四边 alpha 渐隐 → 相邻瓦片柔和过渡、硬接缝消失。 + // 方案② 视差底层:已入库的 bg-map1-vista-01 远景整图作慢速视差底层先铺,瓦片层降 alpha + // 且羽化边缘透出远景 → 破「整片死均匀壁纸网格」,给地面纵深。 + drawGroundParallax(g, camX, camY, viewport, art); // 方案②:远景视差底层(先画,垫底) + drawGroundTiles(g, camX, camY, viewport, art); // 方案③:原尺寸 + 羽化瓦片层(叠上) + drawGroundShade(g, camX, camY, viewport); // 大尺度明暗:极轻径向光斑柔化 + drawDeco(g, camX, camY, viewport, art, null); // 稀疏装饰物(无界,仅可见区) + return; + } + // ── 三档:无图哈希撒石点(占位期原路)────────────────────────────────────── const cell = 96; g.fillStyle = C.ground; const x0 = Math.floor(camX / cell) * cell, y0 = Math.floor(camY / cell) * cell; @@ -257,6 +257,56 @@ function drawGround(g, camX, camY, viewport, art) { } } +/** + * 稀疏装饰物(岩/图腾/骨/苔/芦苇/符石 8 种;世界哈希定位,约 2/5 格)。arena 传 ARENA 时迭代范围 + * 夹到场内 [-w/2,w/2]×[-h/2,h/2](装饰不出世界边);传 null 则无界(仅遍历可见区,二档兜底用)。 + * 哈希口径与定位逐字沿用旧版,无界时行为与接线前等价(零回归)。 + */ +function drawDeco(g, camX, camY, viewport, art, arena) { + const DECO = ['deco-rock-01', 'deco-rock-02', 'deco-totem-01', 'deco-totem-02', 'deco-moss-01', 'deco-bone-01', 'deco-reed-01', 'deco-runestone-01']; + const D = 288; // 装饰格(原 384→288 加密,配合遮挡打断网格) + let wxMin = Math.floor(camX / D) * D, wyMin = Math.floor(camY / D) * D; + let wxMax = camX + viewport.w + D, wyMax = camY + viewport.h + D; + if (arena) { // 裁到场内(竞技场有界) + wxMin = Math.max(wxMin, -arena.w / 2); wyMin = Math.max(wyMin, -arena.h / 2); + wxMax = Math.min(wxMax, arena.w / 2); wyMax = Math.min(wyMax, arena.h / 2); + } + for (let wy = wyMin; wy < wyMax; wy += D) { + for (let wx = wxMin; wx < wxMax; wx += D) { + const h = ((wx * 2246822519 + wy * 3266489917) ^ 0x9e3779b1) >>> 0; + if (h % 5 < 3) continue; // 约 2/5 格有装饰 + const name = DECO[h % DECO.length]; + const px = wx + (h % 200) - camX, py = wy + ((h >>> 10) % 200) - camY; + art.draw(g, name, px, py, 46 + (h >>> 4) % 26, { anchor: 'bottom', alpha: 0.92 }); + } + } +} + +/** + * 竞技场四边自然暗角(收口语义,CONCERN 3a 去硬黑框):沿世界矩形四边各叠一条「贴边暗、向内渐透」 + * 的柔和暗角,遮住整幅背景图自身的硬收口,读作天然暗角而非一圈纯黑硬框。要点: + * - 颜色用暖暗 rgba(16,11,6,·) 而非近纯黑 rgba(5,3,1,·),贴边峰值 alpha 仅 0.5(旧版 0.85); + * - 三段渐变把变暗集中在带的外半段(贴世界边),内半段(玩家 clamp 所在的岩壁内沿)近乎全透, + * 玩家贴边站的是清亮区、不再「站进黑墙」;四角暗角自然叠加成稍深的角落收口。 + * 只叠 4 条线性渐变 fillRect,廉价。玩家被 sim clamp 在场内、镜头被 clampCam 钳住,暗角恒落世界边。 + */ +function drawArenaBounds(g, camX, camY, viewport) { + const T = 96; // 暗角厚度(世界 px,与 sim 的 player.r+ARENA_WALL_INSET=96 同值) + const L = -ARENA.w / 2, R = ARENA.w / 2, Tp = -ARENA.h / 2, Bt = ARENA.h / 2; + const EDGE = 'rgba(16,11,6,0.50)', MID = 'rgba(16,11,6,0.16)', TRAN = 'rgba(16,11,6,0)'; + // 渐变方向恒「世界边(stop0=EDGE)→ 岩壁内沿(stop1=TRAN)」;0.5 处加 MID 使变暗收在外半段。 + const shade = (lg) => { lg.addColorStop(0, EDGE); lg.addColorStop(0.5, MID); lg.addColorStop(1, TRAN); }; + let lg; + lg = g.createLinearGradient(L - camX, 0, L + T - camX, 0); shade(lg); // 左壁(向内渐透) + g.fillStyle = lg; g.fillRect(L - camX, Tp - camY, T, ARENA.h); + lg = g.createLinearGradient(R - camX, 0, R - T - camX, 0); shade(lg); // 右壁 + g.fillStyle = lg; g.fillRect(R - T - camX, Tp - camY, T, ARENA.h); + lg = g.createLinearGradient(0, Tp - camY, 0, Tp + T - camY); shade(lg); // 上壁 + g.fillStyle = lg; g.fillRect(L - camX, Tp - camY, ARENA.w, T); + lg = g.createLinearGradient(0, Bt - camY, 0, Bt - T - camY); shade(lg); // 下壁 + g.fillStyle = lg; g.fillRect(L - camX, Bt - T - camY, ARENA.w, T); +} + /** * 大尺度地貌明暗:确定性径向光斑铺屏(约半数暗坡 + 半数受光金斑),世界坐标定位随镜头滚动。 * 大尺度格(512)远大于 128 tile、且不与格线对齐;平滑径向渐变叠在 tile 之上,给地面明暗 @@ -283,8 +333,160 @@ function drawGroundShade(g, camX, camY, viewport) { } } -function drawPlayer(g, v, viewport, art) { - const x = viewport.w / 2, y = viewport.h / 2; +// ── 背景拼接处理:方案②③ 参数与离屏状态(2026-07-24)───────────────────── +// 全部为可调旋钮,美丑交创始人眼验;此处只给合理初值。 +const TILE_T = 128; // 瓦片原尺寸(不再上采样:原 OVER=8 外扩 + size=144 放大已去除) +const FEATHER = 16; // 瓦片边界羽化带宽(px):四边各 16px alpha 渐隐,中心不透 +const TILE_ALPHA = 0.9; // 瓦片层整体透明度:略透,让视差底层微微渗出破「死均匀壁纸」 +const VISTA_ALPHA = 0.5; // 远景视差底层透明度(与出击页 0.55 同量级,垫底不抢) +const PAR_X = 0.12; // 视差系数 X:远景随镜头慢移(12% 速度= distant backdrop 语义) +const PAR_Y = 0.06; // 视差系数 Y:纵向更慢(竖屏战场横向滚动为主) +const BG_RGB = '20,16,9'; // C.bg(#141009)的 rgb 分量,羽化渐变透明端用,保证融进暗底不留硬边 + +// 离屏瓦片层 + 重复羽化 mask(懒建;非 DOM 环境(如 sim 扫描)不建,走直绘兜底) +let groundLayer = null, glx = null, featherPattern = null; + +/** + * 方案③ 瓦片层:原尺寸 128 平铺(选帧/分区/翻转口径与旧版一致)→ 整层 destination-in + * 重复羽化 mask(边缘 alpha 渐隐)→ 降 alpha 合成回主画布。 + * 关键:羽化让相邻瓦片硬接缝变成「柔和过渡带」,带内透出方案②的视差底层,网格感随破。 + */ +function drawGroundTiles(g, camX, camY, viewport, art) { + const T = TILE_T, REGION = 384; // 地貌分区尺度(3×3 tile 一片) + // 非 DOM 或离屏建不起来 → 原尺寸直绘兜底(不羽化,但不崩溃) + if (!ensureGroundLayer(g.canvas, viewport)) { + const x0 = Math.floor(camX / T) * T, y0 = Math.floor(camY / T) * T; + for (let wy = y0; wy < camY + viewport.h + T; wy += T) { + for (let wx = x0; wx < camX + viewport.w + T; wx += T) { + art.drawTile(g, tileIdx(wx, wy, REGION), wx - camX, wy - camY, T); + } + } + return; + } + glx.clearRect(0, 0, viewport.w, viewport.h); + const x0 = Math.floor(camX / T) * T, y0 = Math.floor(camY / T) * T; + for (let wy = y0; wy < camY + viewport.h + T; wy += T) { + for (let wx = x0; wx < camX + viewport.w + T; wx += T) { + const h = ((wx * 374761393 + wy * 668265263) ^ 0x5bf03635) >>> 0; + const idx = tileIdx(wx, wy, REGION); + // 确定性 4 向翻转:同段 16 帧再乘 4 朝向,打散岩纹方向对齐的机械网格感(位提取只用 >>>) + const fx = (h >>> 16) & 1, fy = (h >>> 17) & 1; + const dx = wx - camX, dy = wy - camY; // 原尺寸 128:无 OVER 外扩、无 144 上采样 + if (fx || fy) { + glx.save(); + const cxp = dx + T / 2, cyp = dy + T / 2; // 以 tile 显示中心为翻转轴 + glx.translate(cxp, cyp); glx.scale(fx ? -1 : 1, fy ? -1 : 1); glx.translate(-cxp, -cyp); + art.drawTile(glx, idx, dx, dy, T); + glx.restore(); + } else { + art.drawTile(glx, idx, dx, dy, T); + } + } + } + // 边界羽化:重复羽化 mask 周期 = T,相位对齐瓦片格线(gridOff∈(-T,0]),对整层 destination-in, + // 只在瓦片四边削 alpha(中心保留)→ 硬接缝柔化、边缘透出底层。 + const gridOffX = x0 - camX, gridOffY = y0 - camY; + glx.save(); + glx.globalCompositeOperation = 'destination-in'; + glx.translate(gridOffX, gridOffY); + glx.fillStyle = featherPattern; + glx.fillRect(-gridOffX - T, -gridOffY - T, viewport.w + 2 * T, viewport.h + 2 * T); + glx.restore(); + // 合成回主画布:降 alpha(TILE_ALPHA)让视差底层渗出;drawImage 至 viewport 逻辑矩形, + // 由主 ctx 的 DPR 变换映回设备像素(离屏已按同 DPR 建,1:1 清晰),且继承震屏 translate。 + g.save(); + g.globalAlpha = TILE_ALPHA; + g.drawImage(groundLayer, 0, 0, viewport.w, viewport.h); + g.restore(); +} + +/** 选帧口径(与旧版一致):大尺度分区哈希定深岩(16..31)/青绿矿岩(32..47),格内哈希选 16 帧之一。 */ +function tileIdx(wx, wy, REGION) { + const h = ((wx * 374761393 + wy * 668265263) ^ 0x5bf03635) >>> 0; + const rgx = Math.floor(wx / REGION), rgy = Math.floor(wy / REGION); + const rh = ((rgx * 2654435761 + rgy * 40503) ^ 0x9e3779b1) >>> 0; + return ((rh % 5) < 2 ? 32 : 16) + (h % 16); // ~40% 片区青绿矿岩,余深岩;水岸段(0..15)弃 +} + +/** + * 方案② 视差底层:把已入库远景整图 bg-map1-vista-01 作慢速视差垫底。 + * 世界无界(玩家自由移动、相机可无限增长),故用「取模环绕 + 多副本铺满」,每副本四边羽化到 + * C.bg → 非无缝远景图环绕相接处柔化成暗底雾带,绝无硬边/破图。视差随相机慢移(PAR_X/Y)。 + */ +function drawGroundParallax(g, camX, camY, viewport, art) { + if (!art || !art.hasBg('bg-map1-vista-01')) return; // 远景未就绪 → 跳过(C.bg 暗底兜底) + const mod = (a, n) => ((a % n) + n) % n; + const VW = viewport.w * 1.4, VH = viewport.h * 1.15; // 单幅显示尺寸(>viewport,留环绕余量) + const ox = -mod(camX * PAR_X, VW), oy = -mod(camY * PAR_Y, VH); // 视差偏移(慢于镜头) + const f = Math.round(Math.min(VW, VH) * 0.16); // 副本四边羽化带宽(柔化环绕接缝) + for (let ty = oy - VH; ty < viewport.h; ty += VH) { + for (let tx = ox - VW; tx < viewport.w; tx += VW) { + if (!art.drawBg(g, 'bg-map1-vista-01', tx, ty, VW, VH, VISTA_ALPHA)) continue; + featherRectEdges(g, tx, ty, VW, VH, f); + } + } +} + +/** 矩形四边羽化到 C.bg(线性渐变 opaque→transparent):用于视差副本接缝柔化,融进暗底不留硬线。 */ +function featherRectEdges(g, x, y, w, h, f) { + const TRAN = `rgba(${BG_RGB},0)`; // C.bg 的透明端 + let lg = g.createLinearGradient(0, y, 0, y + f); // 上边 + lg.addColorStop(0, C.bg); lg.addColorStop(1, TRAN); + g.fillStyle = lg; g.fillRect(x, y, w, f); + lg = g.createLinearGradient(0, y + h - f, 0, y + h); // 下边 + lg.addColorStop(0, TRAN); lg.addColorStop(1, C.bg); + g.fillStyle = lg; g.fillRect(x, y + h - f, w, f); + lg = g.createLinearGradient(x, 0, x + f, 0); // 左边 + lg.addColorStop(0, C.bg); lg.addColorStop(1, TRAN); + g.fillStyle = lg; g.fillRect(x, y, f, h); + lg = g.createLinearGradient(x + w - f, 0, x + w, 0); // 右边 + lg.addColorStop(0, TRAN); lg.addColorStop(1, C.bg); + g.fillStyle = lg; g.fillRect(x + w - f, y, f, h); +} + +/** + * 建/对齐离屏瓦片层(与主 canvas 同 backing 分辨率 + 同 DPR 变换,用户坐标=viewport 逻辑坐标), + * 并懒建重复羽化 mask 图案。@returns {boolean} 是否可用(非 DOM 环境返回 false)。 + */ +function ensureGroundLayer(canvas, viewport) { + if (typeof document === 'undefined' || !canvas) return false; + const bw = canvas.width, bh = canvas.height, s = bw / viewport.w; // 整数 DPR 倍(生产 DPR2) + if (!groundLayer) { + groundLayer = document.createElement('canvas'); + glx = groundLayer.getContext('2d'); + } + if (groundLayer.width !== bw || groundLayer.height !== bh) { + groundLayer.width = bw; groundLayer.height = bh; + featherPattern = null; // backing 变了重建图案(图案本身与尺寸无关,仅随 ctx 重建) + } + glx.setTransform(s, 0, 0, s, 0, 0); // 同主 ctx DPR:离屏画的内容 1:1 贴回设备像素 + if (!featherPattern) featherPattern = buildFeatherPattern(glx); + return true; +} + +/** + * 建 T×T 重复羽化 mask:四边各 FEATHER px 由透明渐变到不透(中心保留),可分离两次渐变叠乘。 + * destination-in 只取 alpha → 白色仅承载 alpha 曲线。图案 repeat 后周期=T,对齐瓦片格线即成接缝羽化。 + */ +function buildFeatherPattern(ctx) { + const c = document.createElement('canvas'); c.width = TILE_T; c.height = TILE_T; + const f = c.getContext('2d'); + const T = TILE_T, F = FEATHER, OPAQUE = 'rgba(255,255,255,1)', TRAN = 'rgba(255,255,255,0)'; + let lg = f.createLinearGradient(0, 0, T, 0); // 水平:左右各 F 渐隐 + lg.addColorStop(0, TRAN); lg.addColorStop(F / T, OPAQUE); + lg.addColorStop(1 - F / T, OPAQUE); lg.addColorStop(1, TRAN); + f.fillStyle = lg; f.fillRect(0, 0, T, T); + f.globalCompositeOperation = 'destination-in'; // 垂直叠乘:上下各 F 渐隐 → 四边+四角柔和 + lg = f.createLinearGradient(0, 0, 0, T); + lg.addColorStop(0, TRAN); lg.addColorStop(F / T, OPAQUE); + lg.addColorStop(1 - F / T, OPAQUE); lg.addColorStop(1, TRAN); + f.fillStyle = lg; f.fillRect(0, 0, T, T); + return ctx.createPattern(c, 'repeat'); +} + +function drawPlayer(g, v, viewport, art, camX, camY) { + // 镜头有界 clamp 后玩家不再恒在屏心:用世界坐标减镜头还原屏幕位(近边缘时贴边,不脱出精灵位) + const x = v.player.x - camX, y = v.player.y - camY; const blink = v.player.iframe > 0 && ((v.frame >> 2) & 1) === 0; const flip = v.player.dirX < -0.05; // 朝向:向左走翻转(art 默认朝右) const moving = Math.abs(v.player.dirX) > 0.05 || Math.abs(v.player.dirY) > 0.05; diff --git a/game-runtime/games/shanhai-xingji/src/sim.js b/game-runtime/games/shanhai-xingji/src/sim.js index 6500cec9..296ef63e 100644 --- a/game-runtime/games/shanhai-xingji/src/sim.js +++ b/game-runtime/games/shanhai-xingji/src/sim.js @@ -30,7 +30,7 @@ import { createUniformGrid, separateOne, seekStep } from '../../../src/plugins/s import { nearestViaGrid } from '../../../src/plugins/auto-targeting/impl.js'; import { deriveWeapon, derivePlayer, WEAPON_BY_ID, PASSIVE_BY_ID } from './build-model.js'; import { - PLAYER, WEAPONS, PASSIVES, XP, ENEMIES, WAVES, + PLAYER, WEAPONS, PASSIVES, XP, ENEMIES, WAVES, ARENA, MAX_ALIVE, MAX_ZONES, SPAWN_R, GRID_CELL, SEPARATION, CRIT, SLOTS, OFFER, EVO_PASSIVE_MIN, CHAIN_R, HARVEST, DIFFICULTY, TIDE, ELITE, AFFIXES, BOSSES, EVENTS, @@ -46,6 +46,15 @@ function easeInQuad(p) { return p * p; } /** 初始武器(角色 roster 归 M4;v1 固定持符巡山使 = 雷符链)。 */ const START_WEAPON_ID = 'leifu'; +/** + * 竞技场岩壁内沿留白(世界 px)。clamp 玩家时除碰撞半径 player.r 外再留这段,使玩家贴边时 + * 停在「岩壁暗带内沿」而非站进暗带里:render 四边暗角厚 T=96px,这里取 84 → 总边距 + * player.r(12)+ 84 = 96 = T,玩家中心恰落在暗带内沿;其碰撞圆再向外延 player.r 也仅触及 + * 暗带最内一圈(该处渐变已近全透),不再「站进黑墙」、高个主角贴顶边时躯体也不出视口。 + * 代价:可达区每边缩 ~84px —— 有界竞技场本就有墙,此为设计内取舍(CONCERN 4 墙角死角保留)。 + */ +const ARENA_WALL_INSET = 84; + /** * 创建一局纯逻辑模拟。 * @param {{seed?: number, fireEnabled?: boolean, difficulty?: string, meta?: object}} [opts] @@ -643,6 +652,12 @@ export function createSim(opts) { const speed = PLAYER.speed * pd.speedMul; player.x += player.dirX * speed * dt; player.y += player.dirY * speed * dt; + // 竞技场边界 clamp(图 1 战场有界):把玩家钳在场内,边距 = player.r + ARENA_WALL_INSET + // (=12+84=96px,恰为 render 岩壁暗角厚 T)。玩家中心停在暗带内沿——不站进黑墙、贴顶边 + // 躯体不出视口(CONCERN 1+2)。纯函数(只读当前位置),同输入同输出 → 不破确定性。 + const WALL = player.r + ARENA_WALL_INSET; // 岩壁内沿边距(碰撞半径 + 留白) + player.x = Math.max(-ARENA.w / 2 + WALL, Math.min(ARENA.w / 2 - WALL, player.x)); + player.y = Math.max(-ARENA.h / 2 + WALL, Math.min(ARENA.h / 2 - WALL, player.y)); if (player.iframe > 0) player.iframe -= dt; if (pd.regen > 0 && player.hp < player.maxHp) player.hp = Math.min(player.maxHp, player.hp + pd.regen * dt);