From 11e8a39e4eb822b4bb8ecd6867edf21b9a4b33c6 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 02:12:37 +0000 Subject: [PATCH] =?UTF-8?q?fix(render):=20=E8=BF=98=E5=8E=9F=E5=8E=9F?= =?UTF-8?q?=E7=89=88=E6=9A=97=E9=BB=912=E5=BB=BA=E7=AD=91=E4=B8=8E?= =?UTF-8?q?=E5=9F=8E=E9=97=A8=E5=B1=8B=E9=A1=B6=E8=BF=87=E9=81=93=E9=81=AE?= =?UTF-8?q?=E6=8C=A1=E6=B6=88=E5=A4=B1=E6=9C=BA=E5=88=B6=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - roofs.ts: 将默认最低透明度 DEFAULT_MIN_ROOF_ALPHA 设为 0.0,还原原版遮挡完全隐形机制 - roofs.ts: getRoofBounds 引入 roofHeight 等距高程投影补偿,将悬空屋顶包围盒延伸至地面投影视野 - roofs.ts: 将 DEFAULT_CLUSTER_CELL_DISTANCE 增至 4,并在 clusterRoofs 中自动插值补全城门与建筑群间隙走廊网格单元 - act-scene.ts & d2map.ts: 在 Drawable 和 IsoDraw 中接线传递 DT1 roofHeight 高程属性 - tests/roofs.test.ts: 补充高程下投、哈洛加斯西门跨过道聚类遮挡及拱梁/上墙全消失测试 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/d2map.ts | 10 ++- src/render/roofs.ts | 60 ++++++++++++--- src/scene/act-scene.ts | 14 +++- tests/roofs.test.ts | 166 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 19 deletions(-) diff --git a/src/game/d2map.ts b/src/game/d2map.ts index ae61a53..5dee2ae 100644 --- a/src/game/d2map.ts +++ b/src/game/d2map.ts @@ -102,6 +102,8 @@ export interface IsoDraw { readonly library: number /** The library's tile index, for diagnostics. */ readonly tile: number + /** Roof elevation height from DT1 tile record, if this is a roof tile. */ + readonly roofHeight?: number } /** A renderable, walkable Diablo II level. */ @@ -462,10 +464,10 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0 const gridWidth = cellsX * SUB_TILES_PER_TILE const gridHeight = cellsY * SUB_TILES_PER_TILE const blocked = new Uint8Array(gridWidth * gridHeight) - const rawFloors: { frameIndex: number; animatedFrames?: readonly number[]; x: number; y: number; cellX: number; cellY: number; library: number; tile: number }[] = [] - const rawWalls: typeof rawFloors = [] + const rawFloors: IsoDraw[] = [] + const rawWalls: IsoDraw[] = [] /** Roof draws (`wall.type` 15): kept apart so they can be painted last. */ - const rawRoofs: typeof rawFloors = [] + const rawRoofs: IsoDraw[] = [] let missingTiles = 0 let clippedTiles = 0 const missingRefs: string[] = [] @@ -575,7 +577,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0 if (wall.type === ROOF_WALL_TYPE) { // Roofs use the engine's own roof offset and are painted after // everything else, so they leave the wall painter order entirely. - rawRoofs.push({ ...draw, y: orthoY - tile.roofHeight }) + rawRoofs.push({ ...draw, y: orthoY - tile.roofHeight, roofHeight: tile.roofHeight }) } else { rawWalls.push(draw) } diff --git a/src/render/roofs.ts b/src/render/roofs.ts index 98d0e44..c740627 100644 --- a/src/render/roofs.ts +++ b/src/render/roofs.ts @@ -19,6 +19,8 @@ export interface RoofDrawable { readonly y: number readonly cellX?: number readonly cellY?: number + readonly roofHeight?: number + readonly wallType?: number } /** 2D axis-aligned bounding box in world/scene coordinates. */ @@ -50,7 +52,7 @@ export type PlayerTarget = Point2D | Box2D | RoofBounds export interface RoofFadeConfig { /** * Minimum opacity when player is under roof (0.0 .. 1.0). - * Default: 0.25 (within 0.0 ~ 0.3 as specified in Issue #44). + * Default: 0.0 (vanilla Diablo II completely fades occluding roofs). */ readonly minAlpha?: number @@ -80,7 +82,7 @@ export interface RoofFadeConfig { /** * Maximum cell distance to cluster adjacent roof tiles into a single shelter. - * Default: 2 cells. + * Default: 4 cells. */ readonly clusterCellDistance?: number @@ -91,26 +93,37 @@ export interface RoofFadeConfig { readonly clusterPixelMargin?: number } -export const DEFAULT_MIN_ROOF_ALPHA = 0.25 +export const DEFAULT_MIN_ROOF_ALPHA = 0.0 export const DEFAULT_MAX_ROOF_ALPHA = 1.0 export const DEFAULT_ROOF_FADE_DURATION_MS = 200 export const DEFAULT_PLAYER_PADDING_X = 16 export const DEFAULT_PLAYER_HEIGHT = 48 -export const DEFAULT_CLUSTER_CELL_DISTANCE = 2 +export const DEFAULT_CLUSTER_CELL_DISTANCE = 4 export const DEFAULT_CLUSTER_PIXEL_MARGIN = 40 /** * Compute the world-space bounding box of a single roof tile. * + * For elevated roofs (with `roofHeight > 0`), the bounding box is projected + * downward to the ground footprint so that a character standing on the ground + * beneath the elevated sprite is detected within the occlusion area. + * * @param roof - Roof drawable with position and dimensions. * @returns 2D axis-aligned bounding box. */ export function getRoofBounds(roof: RoofDrawable): RoofBounds { + let elevation = roof.roofHeight ?? 0 + if (elevation === 0 && roof.cellX !== undefined && roof.cellY !== undefined) { + const orthoY = (roof.cellX + roof.cellY) * 40 + if (orthoY > roof.y) { + elevation = orthoY - roof.y + } + } return { minX: roof.x, minY: roof.y, maxX: roof.x + roof.frame.width, - maxY: roof.y + roof.frame.height, + maxY: roof.y + roof.frame.height + Math.max(0, elevation), } } @@ -128,13 +141,11 @@ export function getCombinedRoofBounds(roofs: readonly RoofDrawable[]): RoofBound let maxY = -Infinity for (let i = 0; i < roofs.length; i += 1) { - const r = roofs[i]! - if (r.x < minX) minX = r.x - if (r.y < minY) minY = r.y - const rMaxX = r.x + r.frame.width - const rMaxY = r.y + r.frame.height - if (rMaxX > maxX) maxX = rMaxX - if (rMaxY > maxY) maxY = rMaxY + const b = getRoofBounds(roofs[i]!) + if (b.minX < minX) minX = b.minX + if (b.minY < minY) minY = b.minY + if (b.maxX > maxX) maxX = b.maxX + if (b.maxY > maxY) maxY = b.maxY } return { minX, minY, maxX, maxY } @@ -433,6 +444,31 @@ export function clusterRoofs( } } + // Bridge passage / corridor cells between nearby roofs in the same cluster + const len = clusterRoofsList.length + for (let i = 0; i < len; i += 1) { + const a = clusterRoofsList[i]! + if (a.cellX === undefined || a.cellY === undefined) continue + for (let j = i + 1; j < len; j += 1) { + const b = clusterRoofsList[j]! + if (b.cellX === undefined || b.cellY === undefined) continue + if ( + Math.abs(a.cellX - b.cellX) <= cellDist && + Math.abs(a.cellY - b.cellY) <= cellDist + ) { + const minCx = Math.min(a.cellX, b.cellX) + const maxCx = Math.max(a.cellX, b.cellX) + const minCy = Math.min(a.cellY, b.cellY) + const maxCy = Math.max(a.cellY, b.cellY) + for (let cx = minCx; cx <= maxCx; cx += 1) { + for (let cy = minCy; cy <= maxCy; cy += 1) { + cells.add(`${String(cx)},${String(cy)}`) + } + } + } + } + } + const bounds = getCombinedRoofBounds(clusterRoofsList)! clusters.push({ id: clusterId, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 674cf16..b4956a7 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -282,6 +282,7 @@ interface Drawable extends AnimatableTile { /** Atlas page index this frame lives on. */ page: number readonly animatedFrames?: readonly AnimatedFrame[] | undefined + readonly roofHeight?: number } /** One object sprite to draw. */ @@ -620,13 +621,19 @@ async function buildPackRuntime( page: scene.framePlacement[fIdx]?.[0] ?? 0, })) : undefined + const cellX = row[3] ?? 0 + const cellY = row[4] ?? 0 + const y = row[2] ?? 0 + const orthoY = (cellX + cellY) * 40 + const roofHeight = orthoY > y ? orthoY - y : undefined return { frame: frames[frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 }, x: row[1] ?? 0, - y: row[2] ?? 0, - cellX: row[3] ?? 0, - cellY: row[4] ?? 0, + y, + cellX, + cellY, page: scene.framePlacement[frameIndex]?.[0] ?? 0, + ...(roofHeight !== undefined ? { roofHeight } : {}), ...(anim ? { animatedFrames: anim } : {}), } } @@ -923,6 +930,7 @@ async function loadLiveRuntime( cellX: row.cellX, cellY: row.cellY, page: 0, + ...(row.roofHeight !== undefined ? { roofHeight: row.roofHeight } : {}), ...(anim ? { animatedFrames: anim } : {}), } } diff --git a/tests/roofs.test.ts b/tests/roofs.test.ts index 64d5a6f..f6bde12 100644 --- a/tests/roofs.test.ts +++ b/tests/roofs.test.ts @@ -15,6 +15,7 @@ import { DEFAULT_MIN_ROOF_ALPHA, DEFAULT_MAX_ROOF_ALPHA, DEFAULT_ROOF_FADE_DURATION_MS, + DEFAULT_CLUSTER_CELL_DISTANCE, type RoofDrawable, } from '../src/render/roofs.ts' @@ -26,6 +27,8 @@ function makeRoof( height: number, cellX?: number, cellY?: number, + roofHeight?: number, + wallType?: number, ): RoofDrawable { return { x, @@ -33,6 +36,8 @@ function makeRoof( frame: { width, height }, ...(cellX !== undefined ? { cellX } : {}), ...(cellY !== undefined ? { cellY } : {}), + ...(roofHeight !== undefined ? { roofHeight } : {}), + ...(wallType !== undefined ? { wallType } : {}), } } @@ -366,3 +371,164 @@ describe('Roof Opacity Fade', () => { }) }) }) + +describe('Issue #72: Harrogath West Gate and Overhead Occlusion Fade', () => { + it('projects elevated roof bounds down to ground footprint for occlusion detection', () => { + // A roof tile elevated by 160 px (isometric YAdjust = -roofHeight = -160) + // The visual sprite is rendered at y=200..260, but ground under it is at y=360 + const elevatedRoof = makeRoof(100, 200, 80, 60, undefined, undefined, 160) + const bounds = getRoofBounds(elevatedRoof) + + // Projected down: maxY = 200 + 60 + 160 = 420 + expect(bounds).toEqual({ + minX: 100, + minY: 200, + maxX: 180, + maxY: 420, + }) + + // Player standing on the ground beneath the elevated roof: + // Player feet at y=360, sprite height 48 -> player bounds y: 312..360 + const playerOnGround = { x: 140, y: 360 } + + // Unelevated roof (height=0) would fail to detect this player + const flatRoof = makeRoof(100, 200, 80, 60, undefined, undefined, 0) + expect(isPlayerOverlappingRoofBounds(playerOnGround, flatRoof)).toBe(false) + + // Elevated roof correctly detects the player on the ground + expect(isPlayerOverlappingRoofBounds(playerOnGround, elevatedRoof)).toBe(true) + expect(isPlayerUnderRoof(playerOnGround, elevatedRoof)).toBe(true) + }) + + it('computes elevation from cell coordinate offset when roofHeight is omitted', () => { + // Roof placed at cell (5, 10), orthoY = (5 + 10) * 40 = 600 + // Sprite top y = 440, so implicit elevation = 600 - 440 = 160 + const roof = makeRoof(100, 440, 80, 60, 5, 10) + const bounds = getRoofBounds(roof) + expect(bounds.maxY).toBe(440 + 60 + 160) // 660 + + // Player at ground orthoY=600 is detected under roof bounds + const playerAtGround = { x: 140, y: 600 } + expect(isPlayerOverlappingRoofBounds(playerAtGround, roof)).toBe(true) + }) + + it('clusters Harrogath West Gate left shrine and right tower across corridor', () => { + // Harrogath West Gate layout: + // Left shrine roofs: cellX: 5..7, cellY: 27..29 + // Right tower roofs: cellX: 10..12, cellY: 27..30 + // Separated by 3-4 cells across the gate passage + const leftShrine = [ + makeRoof(200, 600, 80, 80, 5, 27), + makeRoof(240, 640, 80, 80, 6, 28), + makeRoof(280, 680, 80, 80, 7, 29), + ] + const rightTower = [ + makeRoof(480, 640, 80, 80, 10, 28), + makeRoof(480, 680, 80, 80, 10, 30), + makeRoof(520, 680, 80, 80, 11, 29), + makeRoof(560, 720, 80, 80, 12, 30), + ] + + expect(DEFAULT_CLUSTER_CELL_DISTANCE).toBe(4) + const clusters = clusterRoofs([...leftShrine, ...rightTower]) + + // With distance 4, the gatehouse halves form a single connected cluster + expect(clusters).toHaveLength(1) + expect(clusters[0]!.roofs).toHaveLength(7) + + // Corridor passage cells between shrine (cx: 7) and tower (cx: 10) are bridged + expect(clusters[0]!.cells.has('7,29')).toBe(true) + expect(clusters[0]!.cells.has('8,29')).toBe(true) + expect(clusters[0]!.cells.has('9,29')).toBe(true) + expect(clusters[0]!.cells.has('10,29')).toBe(true) + expect(clusters[0]!.cells.has('8,30')).toBe(true) + }) + + it('bridges corridor cells between gatehouse structures so passage triggers occlusion', () => { + const leftShrine = [ + makeRoof(200, 600, 80, 80, 6, 28), + makeRoof(240, 640, 80, 80, 7, 29), + ] + const rightTower = [ + makeRoof(480, 640, 80, 80, 10, 29), + makeRoof(520, 680, 80, 80, 11, 30), + ] + const controller = new RoofFadeController([...leftShrine, ...rightTower], { + fadeDurationMs: 200, + }) + + expect(controller.overallAlpha).toBe(1.0) + expect(controller.isPlayerUnderAnyRoof).toBe(false) + + // Player steps into the passage between shrine and tower: cell (8, 30) + const playerInPassage = { x: 340, y: 700 } + controller.update(playerInPassage, 200, { x: 8, y: 30 }) + + expect(controller.isPlayerUnderAnyRoof).toBe(true) + // Left shrine and right tower both fade completely to 0.0 in unison + expect(controller.getAlpha(0)).toBe(0.0) + expect(controller.getAlpha(1)).toBe(0.0) + expect(controller.getAlpha(2)).toBe(0.0) + expect(controller.getAlpha(3)).toBe(0.0) + expect(controller.overallAlpha).toBe(0.0) + + // Player steps outside into the open snowfield + const playerOutside = { x: 50, y: 300 } + controller.update(playerOutside, 200, { x: 2, y: 30 }) + + expect(controller.isPlayerUnderAnyRoof).toBe(false) + expect(controller.overallAlpha).toBe(1.0) + }) + + it('fades all connected gatehouse roofs and overhead archways/upper walls in unison to 0.0', () => { + // Cross-passage archways (wallType: 12) and upper walls (wallType: 14) + // alongside gatehouse roofs (wallType: 15) + const gateDrawables: RoofDrawable[] = [ + makeRoof(240, 640, 80, 80, 7, 28, 160, 15), // Left shrine roof + makeRoof(360, 600, 80, 80, 9, 28, 128, 12), // Overhead archway + makeRoof(400, 620, 80, 80, 10, 29, 96, 12), // Overhead archway + makeRoof(420, 580, 80, 80, 10, 30, 384, 14), // Upper wall cross beam + makeRoof(480, 640, 80, 80, 11, 28, 160, 15), // Right tower roof + ] + + const controller = new RoofFadeController(gateDrawables, { + fadeDurationMs: 200, + }) + + // All belong to the same cluster spanning the gate + expect(controller.clusters).toHaveLength(1) + expect(controller.clusters[0]!.roofs).toHaveLength(5) + + // Player under archway at cell (9, 28) + controller.update({ x: 360, y: 728 }, 200, { x: 9, y: 28 }) + expect(controller.isPlayerUnderAnyRoof).toBe(true) + // Every element (roofs, archways, upper walls) fades completely to 0.0 + for (let i = 0; i < gateDrawables.length; i += 1) { + expect(controller.getAlpha(i)).toBe(0.0) + } + + // Player under upper wall beam at cell (10, 30) + controller.update({ x: 420, y: 964 }, 200, { x: 10, y: 30 }) + expect(controller.isPlayerUnderAnyRoof).toBe(true) + for (let i = 0; i < gateDrawables.length; i += 1) { + expect(controller.getAlpha(i)).toBe(0.0) + } + + // Player exits gate + controller.update({ x: 100, y: 100 }, 200, { x: 1, y: 1 }) + expect(controller.isPlayerUnderAnyRoof).toBe(false) + for (let i = 0; i < gateDrawables.length; i += 1) { + expect(controller.getAlpha(i)).toBe(1.0) + } + }) + + it('defaults to complete transparency (minAlpha = 0.0) in vanilla Diablo II style', () => { + expect(DEFAULT_MIN_ROOF_ALPHA).toBe(0.0) + const roof = makeRoof(100, 100, 80, 80, 10, 10) + const controller = new RoofFadeController([roof]) + controller.update({ x: 140, y: 140 }, 200, { x: 10, y: 10 }) + expect(controller.getAlpha(0)).toBe(0.0) + expect(controller.overallAlpha).toBe(0.0) + }) +}) +