diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f7e357f..10470ec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ - 设置新增「弹药数量」,是可选项、默认关闭(官方界面没有这个数字):打开后弹药类技能的黄色弹药条下方显示剩余弹药 / 总弹药数(如 13/14),格子密到画不出分隔线时也能读出(PR #552,感谢 @IceCodeNew)。 - 场上单位多时(画面质量「高」下通常在第 7 回合以后)模型改用缓存画面绘制,此前野鬃待机时的旗帜、浊心斯卡蒂三技能飞起时的头部会被裁掉,超出的部分还会画进相邻单位的缓存,在隐现的枪口等处留下错位的残影。现在每个模型按它会播放的全部动作预留缓存大小,每个单位只画在自己的区域内;少数特别大的动作(多为部署时从天而降)播放期间直接绘制;单位在眩晕中才加载完成的模型也不再显示初始姿势(#542,感谢 @ImPQlc)。 +- 显示官方场景时,射击台、特制水上平台和封印的地面重新画出来:此前它们只画在被场景替换掉的棋盘层里,战场#03 蓝门旁的高台是平的、战场#05(上半) 的水上平台看起来像一片空水面,干员却站在抬高的位置上(#569,感谢 @A1muE4aY)。 + ### 工具与开发 - Windows 下场景素材测试的仓库路径不再重复盘符(PR #547,感谢 @Xinglan233)。 diff --git a/docs/design/client.md b/docs/design/client.md index 9b1fa611..fea08729 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -94,7 +94,7 @@ Findings (2026-09-28): 卫戍协议 levels have no scene bundle (`mapId` null; t Correction (2026-10-11, GitHub #532; PR #533 by @andrew05060414): the first finding above was made off season and is wrong. While a season runs, the official hot-update list carries **one prebuilt scene per battlefield** — `scenes/activities/act1autochess/level_act1autochess_m01…m07` and `scenes/activities/act2autochess/level_act2autochess_m01…m04` (each with `…/lightingdata.ab` and `…_lighting_settings.ab`; Android versions `25-11-21-15-21-44_ee1197`, `26-03-13-06-11-47_14fb99`, `26-04-13-12-31-42_e9b560`); the lists of 2026-09-13 and 2026-10-08 have none, and no 卫戍协议 level data either. The asset index of those versions (`.idx`: each bundle's dependencies) gives each scene's art: act1 m01–m07 and act2 m02 / m04 depend on `arts/maps/map_autochesssand` (m05 and act2 m04 also on `map_doss_shore`), act2 m01 on `map_lakegarden` + `map_syracuse_metro`, act2 m03 on `map_kxmr` + `map_lm_center`. **No battlefield scene depends on `arts/maps/map_autochess`** (the metal theme the procedural board below draws; in the current client the 矢量突破 scenes `act3break` use it). A scene holds the logic tiles (`(row,col)#tileKey` at anchor-local (col, row, −height), high ground 0.4), the art under `GraphicHolder` (static parts merged into "Combined Mesh (root: scene)" objects, lightmapped: Shadowmask mode, one directional key light), and three sub-graphics `HIDDEN@1…3` (`MapSubGraphicHolder`) that the game switches on: the left normal field, the right normal field and the boss field — the areas of `layout.js AREAS`. -Official battlefield scenes (`board3d/load.js loadScenePack`, `scene.js setScenePack`, `materials.js sceneMaterial`): when `data/local-assets.json` lists `scene/`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below. The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Chunks of those shown parts that share a material and a lightmap page are one mesh when the area is built (`load.js mergeSceneChunks`); water shares a mesh across pages because its shader has no lightmap. A switched-off part stays out of that mesh. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. `SCENE_LIGHT` `direct` / `indirect` are 1, the scene's own key light and baked light (0.2.6, GitHub #549, PR #554 by @xcdoge; 0.2.5 had 0.8 / 0.4 tuned by eye); **[ASSUMED]** `ambient` 0.2 (a mesh without a lightmap page; the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files. +Official battlefield scenes (`board3d/load.js loadScenePack`, `scene.js setScenePack`, `materials.js sceneMaterial`): when `data/local-assets.json` lists `scene/`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below, and so do the device slabs — 射击台, 特制水上平台, 封印的地面 (`layout.js buckets.raised`, their sides reaching 0.08 under the scene's ground): they are runtime devices the scenes do not contain, while the scenes do model the 'h' high ground (act2 m01 / m02), which is not repeated (0.2.6, GitHub #569). The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Chunks of those shown parts that share a material and a lightmap page are one mesh when the area is built (`load.js mergeSceneChunks`); water shares a mesh across pages because its shader has no lightmap. A switched-off part stays out of that mesh. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. `SCENE_LIGHT` `direct` / `indirect` are 1, the scene's own key light and baked light (0.2.6, GitHub #549, PR #554 by @xcdoge; 0.2.5 had 0.8 / 0.4 tuned by eye); **[ASSUMED]** `ambient` 0.2 (a mesh without a lightmap page; the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files. Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) underneath the existing Pixi layer: - Geometry generated from `data/stages.json` like the official builder: LOW tiles = top quads; HIGH tiles = raised blocks with side walls; forbidden/void, fences, gates (red), objective (blue), hand/temp pads, special terrain; UVs mapped onto the official atlas regions (reuse/extend `public/assets/local/map/autochess/tiles.json`); normal + emission + metallic maps; lighting tuned to match official screenshots (key directional light, ambient/hemisphere, soft contact shadows); official background meshes & texture; device meshes (OBJ exported by `tools/local-extract`) with their textures; animated gate/objective effects. diff --git a/docs/history/0.2.6.md b/docs/history/0.2.6.md index e54cf4be..f062743d 100644 --- a/docs/history/0.2.6.md +++ b/docs/history/0.2.6.md @@ -313,3 +313,31 @@ Sources: [PRTS 维什戴尔](https://prts.wiki/w/维什戴尔), the talent's nea combat tile, through the deployment talent and an actual S3 activation), and a solo / pair case — her own free tiles filled, the empty half takes no shadow and the teammate's half does. Normative: DESIGN §3 ([geometry-time.md](../design/geometry-time.md)); API note: [SIM.md](../SIM.md). Golden: unchanged. + +### 31.11 Device slabs on the official scenes (#569) + +Issue #569 by @A1muE4aY. + +**Seen.** With an official scene on the 3D board, 战场#03's two raised ranged tiles beside the blue gate were drawn flat, +while the operators placed on them stood at their raised height. The scene replaced the whole procedural board +(`scene.js setStage` skipped its board, glass, decal and pipe buckets), and the raised slabs of the stage devices — +射击台 (`platform`, 0.26), 特制水上平台 (`waterPlatform`, 0.2) and 封印的地面 (`sealedFloor`, 0.05, a hazard decal +top) — exist only in that board bucket (`layout.js slabDevice`). The scenes do not contain them: they are runtime +devices of the stage (`stageDevices`), and the scene geometry has no raised top on those tiles. Affected while a scene +is shown: 战场#03 (射击台), 战场#05(上半) (the 16 water platforms of the normal field, 32 on the boss field — the pool read +as empty water) and 战场#06(上半) (射击台 and 封印的地面). + +**Now.** `buildBoard` repeats each device slab into `buckets.raised` (tops and sides; a sealed floor's hazard top into +`buckets.raisedDecal`), with the side faces reaching `RAISED_FOOT` (0.08) below the tile so no gap shows against the +scene's ground (about z 0). With an official scene `setStage` draws only those two buckets of the procedural board, in +the board's own materials; without one the board draws the slabs itself as before. The 'h' high ground is not repeated: +the scenes model it (act2 m01's plates and act2 m02's sandstone blocks, checked side by side); an earlier draft that +also drew our 'h' plates covered them. Units already stood at `tiles.js heightAt`, which includes the device heights. + +**[ASSUMED]** The slabs keep the metal board's plate and side textures: no official prefab of these devices is extracted. + +**Tests.** `test/render/board3d.test.js` (every stage and area: one plate or decal top and four sides per slab device, +every vertex on a slab tile at its top or its foot; 战场#03 and #05(上半) have them; act2 m01 / m02 repeat nothing), +`test/render/board3d-scene.test.js` (an official scene on 战场#03 keeps the slabs in the board material and nothing else of +the procedural board; dropping the scene restores the board; act2 m02 adds no slab mesh). Screenshots of the five +affected stages before / after / metal board were compared by hand. Golden: unchanged (client only). diff --git a/public/js/render/board3d/layout.js b/public/js/render/board3d/layout.js index c702e17b..21aea691 100644 --- a/public/js/render/board3d/layout.js +++ b/public/js/render/board3d/layout.js @@ -3,8 +3,11 @@ // the buckets into merged BufferGeometries). // // const board = buildBoard(stage, { uv: resolveUvTable(tiles), crate?: objMesh, area?: AREAS.normal }) -// → { grid, buckets: { board, decal, pipe }, gates: [{ r, c, kind: 'start'|'end' }], devices: [...], +// → { grid, buckets: { board, decal, pipe, glass, raised, raisedDecal }, gates: [{ r, c, kind: 'start'|'end' }], devices: [...], // terrain: { water, mire, smog, infection: [[r,c]…] }, edges: [...cyan field edge strips], bounds, stats } +// `raised` / `raisedDecal` repeat only the device slabs — 射击台, 特制水上平台, 封印的地面 — with their side faces +// reaching below the ground: those are runtime devices that an official scene (scene.js) does not contain, so they are +// drawn on it (GitHub #569). The 'h' high ground is part of the scenes (act2 m01 / m02 model it) and is not repeated. // `devices` = the active stage devices standing on a built tile — separator walls included (act2 m01's blowers: the // row-13 ones at the top edge of the normal / 联防 field, the row-6 ones at its bottom edge and at the top edge of the // Final Assault field — user playtest #5 item 6 and its follow-up). @@ -40,6 +43,8 @@ export const CLIFF = 0.75; export const BEVEL = Object.freeze({ low: [0.035, 0.018], block: [0.04, 0.035] }); /** Deep-sea basins: floor depth below the ground. */ export const BASIN = 0.14; +/** How far below the ground the device slabs' side faces reach on an official scene (its ground sits about z 0). */ +export const RAISED_FOOT = 0.08; const CONTENT_OF = (g) => g !== '#' && g !== 'X'; @@ -321,6 +326,7 @@ export function buildBoard(stage, opts = {}) { const at = (r, c) => grid[r]?.[c] || null; const topZ = (t) => (t.glyph === 'd' ? -BASIN : t.h); const board = new Geom(), decal = new Geom(), pipe = new Geom(), glass = new Geom(); + const raised = new Geom(), raisedDecal = new Geom(); const gates = [], terrain = { water: [], mire: [], smog: [], infection: [] }; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; @@ -441,12 +447,15 @@ export function buildBoard(stage, opts = {}) { const devices = opts.devices === false ? [] : stageDevices(stage, grid); for (const d of devices) { - if (d.kind === 'platform' || d.kind === 'sealedFloor' || d.kind === 'waterPlatform') slabDevice(board, decal, UVT, d, grid); + if (d.kind === 'platform' || d.kind === 'sealedFloor' || d.kind === 'waterPlatform') { + slabDevice(board, decal, UVT, d, grid); + slabDevice(raised, raisedDecal, UVT, d, grid, RAISED_FOOT); + } } return { grid, gates, devices, terrain, edges, - buckets: { board: board.finish(), decal: decal.finish(), pipe: pipe.finish(), glass: glass.finish() }, + buckets: { board: board.finish(), decal: decal.finish(), pipe: pipe.finish(), glass: glass.finish(), raised: raised.finish(), raisedDecal: raisedDecal.finish() }, bounds: { x0: minX, x1: maxX, y0: minY, y1: maxY }, stats: { tiles: grid.flat().filter((t) => t.drawn).length, vertices: board.vertexCount + decal.vertexCount + pipe.vertexCount }, }; @@ -513,17 +522,18 @@ export function stageDevices(stage, grid) { /** Device heights on their tile (platform tops are standing surfaces: tiles.js heightAt adds them). */ export const DEVICE_H = Object.freeze({ platform: TILE_H.platform, sealedFloor: 0.05, waterPlatform: 0.2 }); -function slabDevice(board, decal, UVT, d, grid) { +/** `foot` lowers the side faces' bottom (the scene-board copy reaches under an official scene's ground). */ +function slabDevice(board, decal, UVT, d, grid, foot = 0) { const size = d.kind === 'sealedFloor' ? 0.92 : 0.94; const h = DEVICE_H[d.kind] || 0.2; - const z0 = grid[d.r][d.c].h, zt = z0 + h; + const zt = grid[d.r][d.c].h + h, z0 = grid[d.r][d.c].h - foot; const a = size / 2, x0 = d.c - a, x1 = d.c + a, y0 = d.r - a, y1 = d.r + a; const topSurf = d.kind === 'sealedFloor' ? null : UVT.plateS; if (topSurf) board.quad([[x0, y0, zt], [x1, y0, zt], [x1, y1, zt], [x0, y1, zt]], surfaceUV(topSurf), [0, 0, 1], [1.02, 1.02, 1.02]); else decal.quad([[x0, y0, zt], [x1, y0, zt], [x1, y1, zt], [x0, y1, zt]], surfaceUV(UVT.hazardX), [0, 0, 1], [1, 1, 1]); const side = UVT.graySide; for (const dir of ['S', 'E', 'N', 'W']) { - const uv = surfaceUV(side, sideRect(side, size, h)); + const uv = surfaceUV(side, sideRect(side, size, h + foot)); board.quad(sideCorners(dir, x0, x1, y0, y1, z0, zt), uv, NRM[dir], [[0.5, 0.53, 0.56], [0.5, 0.53, 0.56], [0.85, 0.88, 0.9], [0.85, 0.88, 0.9]]); } } diff --git a/public/js/render/board3d/scene.js b/public/js/render/board3d/scene.js index 502b7806..9730e7f6 100644 --- a/public/js/render/board3d/scene.js +++ b/public/js/render/board3d/scene.js @@ -19,7 +19,8 @@ // static crates (merged, same material), // decals (MT_autochess_common), fence pipes, blowers (merged, unlit), gate boxes (additive + alpha, merged per // material), cyan field edges, terrain overlays (≤ 4), background plane + its shadow catcher — ~12–16 in total. -// An official scene replaces the tiles, glass, decals, pipes and background with one mesh per material and lightmap +// An official scene replaces the tiles, glass, decals, pipes and background (all but the device slabs, layout.js +// buckets.raised — GitHub #569) with one mesh per material and lightmap // page of the parts the built areas show (load.js mergeSceneChunks). Devices, gates, edges and terrain stay. // The key light's shadow map is rendered only when the geometry changes (autoUpdate off). @@ -303,6 +304,11 @@ export class BoardScene { M.glass = this._mesh(board.buckets.glass, this.mat.glass); M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false }); M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe); + } else { + // the scene has no device slabs: the 射击台 / 特制水上平台 / 封印的地面 slabs stay drawn on it (units stand + // on them at tiles.js heightAt; GitHub #569) + M.raised = this._mesh(board.buckets.raised, this.mat.board); + M.raisedDecal = this._mesh(board.buckets.raisedDecal, this.mat.decal, { cast: false }); } this._buildDevices(board); this._buildGates(board); diff --git a/test/render/board3d-scene.test.js b/test/render/board3d-scene.test.js index e07b0761..5bdefdf0 100644 --- a/test/render/board3d-scene.test.js +++ b/test/render/board3d-scene.test.js @@ -385,6 +385,23 @@ describe('BoardScene with an official scene (stub renderer)', () => { assert.equal(board.sceneRes.size, 0, 'scene resources disposed'); }); + test('#569: the device slabs stay drawn on an official scene (the procedural board is gone); a stage without slabs adds nothing', () => { + const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() }); + const m03 = stages.act1autochess_m03; + board.setScenePack(m03.id, fakeScenePack()); + board.setStage(m03); + assert.ok(board.official && !board.meshes.board, 'the scene replaces the board'); + assert.ok(board.meshes.raised, 'the 射击台 slabs beside the blue gate stay'); + assert.equal(board.meshes.raised.geometry.getAttribute('position').count, board.board.buckets.raised.vertexCount); + assert.equal(board.meshes.raised.material, board.mat.board, 'drawn like the procedural board'); + assert.equal(board.setScenePack(m03.id, null), true); + assert.ok(board.meshes.board && !board.meshes.raised, 'without the scene the board draws them itself'); + board.setScenePack(stage.id, fakeScenePack()); + board.setStage(stage); + assert.ok(board.official && !board.meshes.raised && !board.meshes.raisedDecal, `${stage.id}: no slab devices`); + board.destroy(); + }); + test('another stage, or dropping the scene, shows the procedural board again', () => { const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() }); board.setScenePack(stage.id, fakeScenePack()); diff --git a/test/render/board3d.test.js b/test/render/board3d.test.js index 5b3c6700..d091618f 100644 --- a/test/render/board3d.test.js +++ b/test/render/board3d.test.js @@ -13,6 +13,7 @@ import { SURFACES, SOURCES, surfaceUV, resolveUvTable, cleanSurface, sideRect, t import { parseObj, mapMesh } from '../../public/js/render/board3d/obj.js'; import { buildBoard, classifyStage, heightOf, AREAS, areaFor, unionAreas, objToBoard, boxProjectUV, tube, Geom, uvAt, ROWS, COLS, + DEVICE_H, RAISED_FOOT, } from '../../public/js/render/board3d/layout.js'; import { BoardScene, gatePulse, DIR_TURNS, boxData, LIGHTING } from '../../public/js/render/board3d/scene.js'; import { loadBoardPack, loadThree, resetBoardPack, PACK_IMAGES } from '../../public/js/render/board3d/load.js'; @@ -173,6 +174,33 @@ describe('board geometry (layout.js)', () => { assert.ok(!buildBoard(stages.act1autochess_m02).devices.some((d) => d.kind === 'crate')); }); + test('#569: the device slabs (射击台 / 特制水上平台 / 封印的地面) are repeated for an official scene, their sides reaching under its ground; nothing else is', () => { + const SLAB = new Set(['platform', 'waterPlatform', 'sealedFloor']); + for (const [id, st] of Object.entries(stages)) { + if (!Array.isArray(st?.rows)) continue; + for (const area of [AREAS.normal, AREAS.boss]) { + const b = buildBoard(st, { area }); + const slabs = b.devices.filter((d) => SLAB.has(d.kind)); + const decals = slabs.filter((d) => d.kind === 'sealedFloor').length; + // per slab four sides, plus its top: a plate (raised) or the hazard decal of a sealed floor (raisedDecal) + assert.equal(b.buckets.raised.vertexCount, (slabs.length * 5 - decals) * 4, `${id}: raised quads`); + assert.equal(b.buckets.raisedDecal.vertexCount, decals * 4, `${id}: raised decal quads`); + const P = b.buckets.raised.position; + for (let i = 0; i < P.length; i += 3) { + const d = slabs.find((x) => Math.abs(P[i] - x.c) <= 0.5 && Math.abs(P[i + 1] - x.r) <= 0.5); + assert.ok(d, `${id}: every raised vertex is on a slab tile`); + const h = b.grid[d.r][d.c].h; + assert.ok(near(P[i + 2], h + DEVICE_H[d.kind]) || near(P[i + 2], h - RAISED_FOOT), `${id}: a slab top or its foot, z ${P[i + 2]}`); + } + } + } + // the report's 射击台 beside the blue gate (战场#03) and the water platforms (战场#05 上半) are there; the 'h' high + // ground of act2 m01 / m02 is the scenes' own geometry and is not repeated + assert.ok(buildBoard(stages.act1autochess_m03, { area: AREAS.normal }).buckets.raised.vertexCount > 0); + assert.equal(buildBoard(stages.act1autochess_m05, { area: AREAS.normal }).devices.filter((d) => d.kind === 'waterPlatform').length, 16); + for (const id of ['act2autochess_m01', 'act2autochess_m02']) assert.equal(buildBoard(stages[id], { area: AREAS.normal }).buckets.raised.vertexCount, 0, id); + }); + test('malformed stages never throw and still build an island', () => { for (const st of [null, {}, { rows: ['xyz'] }, { rows: Array(19).fill('r'.repeat(21)), devices: [{ role: 'crate', pos: [3, 3] }, { role: 'crate', pos: 'x' }] }]) { const b = buildBoard(st, { area: AREAS.all });