fix(render): 还原原版暗黑2建筑与城门屋顶过道遮挡消失机制 (#72)
- 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
This commit is contained in:
parent
d0efb04395
commit
11e8a39e4e
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue