feat(shanhai): 有界竞技场+完整背景图,彻底消除背景拼接

- balance.js: ARENA={w:1560,h:2960} 有界竞技场(中心对称原点)
- sim.js: 玩家走位 clamp 到场内(边距含岩壁内沿,贴边不站黑墙/不出屏)
- render.js: 镜头 clampCam + drawPlayer 解耦屏心 + drawGround 一档接整幅背景图(二档视差瓦片兜底)+ 四边自然暗角 + 背景降颗粒
- art.js: 登记 bg-map1-arena-01 + 后台拉起预载 + tiles 降懒载(不增首屏网络字节)
- assets/bg/bg-map1-arena-01.jpg: 矿彩国风裂谷原整图(1560x2960,AI 草图创始人审)
- 墙角死角作为有界竞技场设计保留;⑧win 重跑留下检查点
This commit is contained in:
lili 2026-07-25 01:12:24 -07:00
parent 6059f837d0
commit a4dc91c43a
10 changed files with 1434 additions and 59 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -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"
]
}

View File

@ -0,0 +1,83 @@
# round-11 — 竞技场边界 + 接大图 bg-map1-arena-01 + CDP 截实际效果
检查点目标:缩放定稿草图 → 放 assets → 加竞技场边界(玩家+镜头 clamp)→ 接整幅战场背景图 → 真浏览器 CDP 截接图后战场背景。本检查点**不重跑⑧win**(留下检查点,避免超载)。
- 机器:`lili-mac.local`(开发机本地,非 mini-desktop;未 ssh)。
- 采集时间:2026-07-25T06:4006: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 内 `<img>` 同 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/2view];玩家屏幕位 = 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 复核无孤儿。

View File

@ -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 走浏览器 <img> 解码,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<string, any>} 页 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 };
+
/** 虚拟摇杆(手 13 断言的参数面;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);

View File

@ -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"
]
}

View File

@ -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) | 彩色战场 |
| **左缘 040px** | **(0,0,0)** | **纯黑(烤进 jpg)** |
| **右缘 W40W** | **(0,0,0)** | **纯黑(烤进 jpg)** |
| 顶缘 040px | (16,21,21) | 近黑 |
| 底缘 H40H | (27,34,31) | 暗 |
| 左缘 40120px | (11,13,14) | 仍近黑 |
| 右缘 W120W40 | (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 复核无孤儿。

View File

@ -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 走浏览器 <img> 解码,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<string, any>} 页 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;

View File

@ -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 };
/** 虚拟摇杆(手 13 断言的参数面;UI 层消费,sim 只收归一化方向)。 */
export const JOYSTICK = {
radius: 64, // 摇杆逻辑半径(px)

View File

@ -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;

View File

@ -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);