feat(collision): 还原原版 1.13c 16位多层碰撞掩码系统与投射物/视线穿透 (Issue #75)

- 在 d2map.ts 中新增 1.13c D2C_CollisionMaskFlags 全部 16位掩码定义与组合掩码 (COLLIDE_WALL, COLLIDE_VISIBLE, COLLIDE_MISSILE_BARRIER, COLLIDE_DOOR 等)
- 升级 buildIsoMapScene 生成 Uint16Array collisionMasks 多层碰撞网格,同时 100% 保持 blocked Uint8Array 兼容
- 实现原版 D2Common.dll COLLISION_CheckMask (#10118), COLLISION_SetMask (#10127), COLLISION_ResetMask (#10123) 与亚图块级 Bresenham 射线追踪 COLLISION_RayTrace (#11263)
- 在 iso-terrain.ts 与 skills.ts 中接入弹道穿透与视线判定,允许弓箭与法术越过河流/熔岩/低矮障碍并阻挡于实体高墙与闭合门洞

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-18 07:58:49 +00:00
parent 97ab449231
commit ef59646631
4 changed files with 676 additions and 19 deletions

View File

@ -80,6 +80,86 @@ const ROOF_WALL_TYPE = 15
const TOP_CORNER_RIGHT_TYPE = 3
/** The companion half of {@link TOP_CORNER_RIGHT_TYPE}. */
const TOP_CORNER_LEFT_TYPE = 4
/** DS1 wall type for left-facing door wall (`TILETYPE_WALL_LEFT_DOOR`). */
const LEFT_DOOR_WALL_TYPE = 8
/** DS1 wall type for right-facing door wall (`TILETYPE_WALL_RIGHT_DOOR`). */
const RIGHT_DOOR_WALL_TYPE = 9
/**
* Blizzard D2 1.13c 16-bit Multi-Layer Collision Mask Flags (`D2C_CollisionMaskFlags`).
* Reference: D2Common.dll (v1.13c) / D2MOO `include/D2Collision.h`.
*/
export const COLLIDE_NONE = 0x0000
/** 'black space' in arcane sanctuary, cliff walls, water/river walk barriers. Blocks ground walking. */
export const COLLIDE_WALL = 0x0001
/** Tile-based solid obstacles that block line of sight (LoS) and ranged targeting. */
export const COLLIDE_VISIBLE = 0x0002
/** Obstacles that block flying projectiles / missiles and leap/teleport barriers. */
export const COLLIDE_MISSILE_BARRIER = 0x0004
/** Blocks player movement specifically (but not necessarily monsters). */
export const COLLIDE_NOPLAYER = 0x0008
/** Preset floor/tile flag. */
export const COLLIDE_PRESET = 0x0010
/** Returned if the sub-tile is out-of-bounds / invalid. */
export const COLLIDE_BLANK = 0x0020
/** Occupied by an active missile. */
export const COLLIDE_MISSILE = 0x0040
/** Occupied by a player unit. */
export const COLLIDE_PLAYER = 0x0080
/** Water / river terrain flag. */
export const COLLIDE_WATER = 0x00c0
/** Occupied by a monster unit. */
export const COLLIDE_MONSTER = 0x0100
/** Occupied by a ground item. */
export const COLLIDE_ITEM = 0x0200
/** Solid object (shrine, chest, barrel, obstacle). */
export const COLLIDE_OBJECT = 0x0400
/** Closed door barrier (can be cleared via `collisionResetMask` when opened). */
export const COLLIDE_DOOR = 0x0800
/** Unit / pathing restriction flag. */
export const COLLIDE_NO_PATH = 0x1000
/** Attackable pet / summon presence. */
export const COLLIDE_PET = 0x2000
export const COLLIDE_4000 = 0x4000
/** Dead monster corpse / portal flag. */
export const COLLIDE_CORPSE = 0x8000
export const COLLIDE_ALL_MASK = 0xffff
/** Composite mask for invalid sub-tiles (`D2Collision.h`). */
export const COLLIDE_MASK_INVALID =
COLLIDE_BLANK | COLLIDE_MISSILE_BARRIER | COLLIDE_VISIBLE | COLLIDE_WALL
/** Composite mask for player ground pathfinding (`COLLIDE_MASK_PLAYER_PATH`). */
export const COLLIDE_MASK_PLAYER_PATH =
COLLIDE_WALL | COLLIDE_NOPLAYER | COLLIDE_OBJECT | COLLIDE_DOOR | COLLIDE_NO_PATH
/** Composite mask for flying skills / leap (`COLLIDE_MASK_PLAYER_FLYING`). */
export const COLLIDE_MASK_PLAYER_FLYING =
COLLIDE_DOOR | COLLIDE_MISSILE_BARRIER
/** Composite mask for Whirlwind (`COLLIDE_MASK_PLAYER_WW`). */
export const COLLIDE_MASK_PLAYER_WW =
COLLIDE_WALL | COLLIDE_OBJECT | COLLIDE_DOOR
/** Composite mask for projectiles / missiles (arrows, bolts, spells). */
export const COLLIDE_MASK_MISSILE =
COLLIDE_MISSILE_BARRIER | COLLIDE_OBJECT | COLLIDE_DOOR
/** Composite mask for Line-of-Sight (LoS) raycasting. */
export const COLLIDE_MASK_LOS =
COLLIDE_VISIBLE | COLLIDE_DOOR
/** Result of a 1.13c sub-tile `COLLISION_RayTrace` check. */
export interface RayTraceResult {
/** True if the ray struck a sub-tile matching the collision mask before reaching the target. */
readonly hit: boolean
/** Sub-tile X where the ray terminated (either the hit sub-tile or the target sub-tile). */
readonly hitSubX: number
/** Sub-tile Y where the ray terminated (either the hit sub-tile or the target sub-tile). */
readonly hitSubY: number
/** Scene-space X corresponding to `hitSubX` / `hitSubY`. */
readonly hitX: number
/** Scene-space Y corresponding to `hitSubX` / `hitSubY`. */
readonly hitY: number
/** Number of sub-tile steps traversed along the ray. */
readonly steps: number
}
/** One tile to draw, in screen pixels. */
export interface IsoDraw {
/** Index into {@link IsoMapScene.frames}. */
@ -139,6 +219,11 @@ export interface IsoMapScene {
readonly roofs: readonly IsoDraw[]
/** Collision grid, row-major, 1 = blocked, 5×5 per cell. */
readonly blocked: Uint8Array
/**
* 16-bit multi-layer collision masks per sub-tile (`D2C_CollisionMaskFlags`).
* Row-major, 5×5 per cell (`gridWidth * gridHeight`).
*/
readonly collisionMasks: Uint16Array
/** Collision grid width in sub-tiles. */
readonly gridWidth: number
/** Collision grid height in sub-tiles. */
@ -464,6 +549,7 @@ 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 collisionMasks = new Uint16Array(gridWidth * gridHeight)
const rawFloors: IsoDraw[] = []
const rawWalls: IsoDraw[] = []
/** Roof draws (`wall.type` 15): kept apart so they can be painted last. */
@ -481,23 +567,31 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
// The engine ORs the sub-tile flags of *every* layer of a cell (floor, wall,
// shadow) and then reads walkability off the combined flags — OpenDiablo2's
// `SubTileFlags.Combine` is a plain `||` of every bit (`d2dt1/subtile.go`).
// Stamping each layer into the same grid is the same union for walkability, but
// shadows must be included: they are a distinct DS1 layer (`type 13` tiles) and
// skipping them would silently drop blocking sub-tiles.
// `SubTileFlags.Combine` is a plain `||` of every bit (`d2dt1/subtile.go`),
// and D2MOO `sub_6FD411F0` (`D2Collision.cpp`) bitwise-ORs each DT1 subtile
// byte directly into the 16-bit `pCollisionMask` array.
let shadowBlocked = 0
let looseRefs = 0
let cornerPairs = 0
let missingCornerPairs = 0
const stamp = (cellX: number, cellY: number, tile: Dt1Tile): void => {
const stamp = (cellX: number, cellY: number, tile: Dt1Tile, isDoorWall = false): void => {
for (let subY = 0; subY < SUB_TILES_PER_TILE; subY += 1) {
for (let subX = 0; subX < SUB_TILES_PER_TILE; subX += 1) {
const flags = tile.subTileFlags[subY * SUB_TILES_PER_TILE + subX]
if (flags === undefined || (!flags.blockWalk && !flags.blockPlayerWalk)) continue
if (flags === undefined) continue
const gx = cellX * SUB_TILES_PER_TILE + subX
const gy = cellY * SUB_TILES_PER_TILE + subY
if (gx < 0 || gx >= gridWidth || gy < 0 || gy >= gridHeight) continue
blocked[gy * gridWidth + gx] = 1
const idx = gy * gridWidth + gx
if (flags.raw !== 0) {
let maskBits = flags.raw & 0xffff
if (isDoorWall && (flags.blockWalk || flags.blockPlayerWalk)) {
maskBits |= COLLIDE_DOOR
}
collisionMasks[idx] = (collisionMasks[idx] ?? 0) | maskBits
}
if (!flags.blockWalk && !flags.blockPlayerWalk) continue
blocked[idx] = 1
}
}
}
@ -547,12 +641,17 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
const tile = libraries[found.library]!.tiles[found.tile]!
for (let sub = 0; sub < SUB_TILES_PER_TILE * SUB_TILES_PER_TILE; sub += 1) {
const flags = tile.subTileFlags[sub]
if (flags === undefined || (!flags.blockWalk && !flags.blockPlayerWalk)) continue
if (flags === undefined) continue
const gx = cellX * SUB_TILES_PER_TILE + (sub % SUB_TILES_PER_TILE)
const gy = cellY * SUB_TILES_PER_TILE + Math.floor(sub / SUB_TILES_PER_TILE)
if (gx < 0 || gx >= gridWidth || gy < 0 || gy >= gridHeight) continue
if (blocked[gy * gridWidth + gx] === 0) shadowBlocked += 1
blocked[gy * gridWidth + gx] = 1
const idx = gy * gridWidth + gx
if (flags.raw !== 0) {
collisionMasks[idx] = (collisionMasks[idx] ?? 0) | (flags.raw & 0xffff)
}
if (!flags.blockWalk && !flags.blockPlayerWalk) continue
if (blocked[idx] === 0) shadowBlocked += 1
blocked[idx] = 1
}
}
@ -581,7 +680,8 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
} else {
rawWalls.push(draw)
}
stamp(cellX, cellY, tile)
const isDoorWall = wall.type === LEFT_DOOR_WALL_TYPE || wall.type === RIGHT_DOOR_WALL_TYPE
stamp(cellX, cellY, tile, isDoorWall)
// A top-corner-right wall drags its left half along (see
// {@link TOP_CORNER_RIGHT_TYPE}): same cell, same style/sequence, resolved as
@ -654,6 +754,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
walls,
roofs,
blocked,
collisionMasks,
gridWidth,
gridHeight,
missingTiles,
@ -688,6 +789,8 @@ export interface CollisionGrid {
readonly blocked: Uint8Array
/** Collision grid width in sub-tiles. */
readonly gridWidth: number
/** Optional 16-bit collision mask array (Blizzard D2 1.13c D2C_CollisionMaskFlags). */
readonly collisionMasks?: Uint16Array | undefined
}
/**
@ -711,14 +814,236 @@ export function cellAt(scene: CollisionGrid, x: number, y: number): { x: number;
}
/**
* Whether a scene-space point lies on a blocked sub-tile.
* Convert a scene-space point to sub-tile grid coordinates (`0 .. cellsX*5 - 1`).
*
* @param scene - the scene.
* @param x - scene-space x.
* @param y - scene-space y.
* @returns true when walking is blocked there.
* @returns integer sub-tile coordinates and whether they fall within the map bounds.
*/
export function isBlockedAt(scene: CollisionGrid, x: number, y: number): boolean {
export function subTileAt(
scene: CollisionGrid,
x: number,
y: number,
): { subX: number; subY: number; inBounds: boolean } {
const cell = cellAt(scene, x, y)
const cellX = Math.floor(cell.x)
const cellY = Math.floor(cell.y)
const inBounds = cellX >= 0 && cellY >= 0 && cellX < scene.cellsX && cellY < scene.cellsY
const localSubX = Math.min(Math.max(0, Math.floor((cell.x - cellX) * SUB_TILES_PER_TILE)), SUB_TILES_PER_TILE - 1)
const localSubY = Math.min(Math.max(0, Math.floor((cell.y - cellY) * SUB_TILES_PER_TILE)), SUB_TILES_PER_TILE - 1)
const subX = cellX * SUB_TILES_PER_TILE + localSubX
const subY = cellY * SUB_TILES_PER_TILE + localSubY
return { subX, subY, inBounds }
}
/**
* Convert sub-tile grid coordinates (`subX, subY`) back to scene-space centre coordinates.
*
* @param scene - the scene.
* @param subX - sub-tile column (`0 .. cellsX*5 - 1`).
* @param subY - sub-tile row (`0 .. cellsY*5 - 1`).
* @returns scene-space (x, y) centre of the sub-tile diamond.
*/
export function subTileCentre(
scene: CollisionGrid,
subX: number,
subY: number,
): { x: number; y: number } {
const cx = (subX + 0.5) / SUB_TILES_PER_TILE
const cy = (subY + 0.5) / SUB_TILES_PER_TILE
return {
x: (cx - cy) * ORTHO_CELL_WIDTH + scene.originX,
y: (cx + cy) * ORTHO_CELL_HEIGHT + scene.originY,
}
}
/**
* Retrieve the 16-bit `D2C_CollisionMaskFlags` value at sub-tile coordinates `(subX, subY)`.
* Out-of-bounds returns `COLLIDE_MASK_INVALID` (`0x0027`).
* If `scene.collisionMasks` is absent (e.g. legacy test stub), synthesizes from `scene.blocked`.
*/
export function getCollisionMask(scene: CollisionGrid, subX: number, subY: number): number {
const gridHeight = scene.cellsY * SUB_TILES_PER_TILE
if (subX < 0 || subY < 0 || subX >= scene.gridWidth || subY >= gridHeight) {
return COLLIDE_MASK_INVALID
}
const idx = subY * scene.gridWidth + subX
if (scene.collisionMasks !== undefined) {
return scene.collisionMasks[idx] ?? COLLIDE_NONE
}
return scene.blocked[idx] === 1
? (COLLIDE_WALL | COLLIDE_VISIBLE | COLLIDE_MISSILE_BARRIER)
: COLLIDE_NONE
}
/**
* Retrieve the 16-bit `D2C_CollisionMaskFlags` value at a scene-space point `(x, y)`.
*/
export function getCollisionMaskAt(scene: CollisionGrid, x: number, y: number): number {
const { subX, subY, inBounds } = subTileAt(scene, x, y)
if (!inBounds) return COLLIDE_MASK_INVALID
return getCollisionMask(scene, subX, subY)
}
/**
* Blizzard D2 1.13c `COLLISION_CheckMask` (D2Common.dll Ordinal 10118).
* Tests whether the sub-tile at `(subX, subY)` has any bit in `mask` set.
*/
export function collisionCheckMask(
scene: CollisionGrid,
subX: number,
subY: number,
mask: number,
): boolean {
return (getCollisionMask(scene, subX, subY) & mask) !== 0
}
/**
* Blizzard D2 1.13c `COLLISION_SetMask` (D2Common.dll Ordinal 10127).
* Sets `mask` bits on a sub-tile footprint `(subX, subY)` of size `widthSubTiles × heightSubTiles`,
* and synchronizes `scene.blocked` if any `COLLIDE_MASK_PLAYER_PATH` bit is set.
*/
export function collisionSetMask(
scene: CollisionGrid,
subX: number,
subY: number,
mask: number,
widthSubTiles = 1,
heightSubTiles = 1,
): void {
const gridHeight = scene.cellsY * SUB_TILES_PER_TILE
for (let dy = 0; dy < heightSubTiles; dy += 1) {
const gy = subY + dy
if (gy < 0 || gy >= gridHeight) continue
for (let dx = 0; dx < widthSubTiles; dx += 1) {
const gx = subX + dx
if (gx < 0 || gx >= scene.gridWidth) continue
const idx = gy * scene.gridWidth + gx
if (scene.collisionMasks !== undefined) {
scene.collisionMasks[idx] = (scene.collisionMasks[idx] ?? 0) | (mask & 0xffff)
}
if ((mask & COLLIDE_MASK_PLAYER_PATH) !== 0) {
scene.blocked[idx] = 1
}
}
}
}
/**
* Blizzard D2 1.13c `COLLISION_ResetMask` (D2Common.dll Ordinal 10123).
* Clears `mask` bits on a sub-tile footprint `(subX, subY)` (e.g., when a closed door is opened),
* and updates `scene.blocked` according to whether any `COLLIDE_MASK_PLAYER_PATH` bit remains.
*/
export function collisionResetMask(
scene: CollisionGrid,
subX: number,
subY: number,
mask: number,
widthSubTiles = 1,
heightSubTiles = 1,
): void {
const gridHeight = scene.cellsY * SUB_TILES_PER_TILE
for (let dy = 0; dy < heightSubTiles; dy += 1) {
const gy = subY + dy
if (gy < 0 || gy >= gridHeight) continue
for (let dx = 0; dx < widthSubTiles; dx += 1) {
const gx = subX + dx
if (gx < 0 || gx >= scene.gridWidth) continue
const idx = gy * scene.gridWidth + gx
if (scene.collisionMasks !== undefined) {
const nextMask = (scene.collisionMasks[idx] ?? 0) & ~(mask & 0xffff)
scene.collisionMasks[idx] = nextMask
scene.blocked[idx] = (nextMask & COLLIDE_MASK_PLAYER_PATH) !== 0 ? 1 : 0
} else if ((mask & COLLIDE_MASK_PLAYER_PATH) !== 0) {
scene.blocked[idx] = 0
}
}
}
}
/**
* Blizzard D2 1.13c `COLLISION_RayTrace` (D2Common.dll Ordinal 11263).
*
* Performs a Bresenham sub-tile line trace from `(startSubX, startSubY)` to `(endSubX, endSubY)`,
* testing each sub-tile against `mask` (e.g. `COLLIDE_MASK_MISSILE` or `COLLIDE_MASK_LOS`).
* If any visited sub-tile matches `mask`, terminates early and reports the hit coordinate.
*/
export function collisionRayTrace(
scene: CollisionGrid,
startSubX: number,
startSubY: number,
endSubX: number,
endSubY: number,
mask: number = COLLIDE_MASK_MISSILE,
): RayTraceResult {
let x0 = Math.floor(startSubX)
let y0 = Math.floor(startSubY)
const x1 = Math.floor(endSubX)
const y1 = Math.floor(endSubY)
const dx = Math.abs(x1 - x0)
const dy = Math.abs(y1 - y0)
const sx = x0 < x1 ? 1 : -1
const sy = y0 < y1 ? 1 : -1
let err = dx - dy
let steps = 0
while (true) {
// Check collision on current sub-tile (skipping the shooter's exact starting sub-tile at step 0
// so a unit standing at the edge of an obstacle can still fire outward, unless start === end).
if ((steps > 0 || (x0 === x1 && y0 === y1)) && collisionCheckMask(scene, x0, y0, mask)) {
const pt = subTileCentre(scene, x0, y0)
return { hit: true, hitSubX: x0, hitSubY: y0, hitX: pt.x, hitY: pt.y, steps }
}
if (x0 === x1 && y0 === y1) {
break
}
const e2 = 2 * err
if (e2 > -dy) {
err -= dy
x0 += sx
}
if (e2 < dx) {
err += dx
y0 += sy
}
steps += 1
}
const pt = subTileCentre(scene, x1, y1)
return { hit: false, hitSubX: x1, hitSubY: y1, hitX: pt.x, hitY: pt.y, steps }
}
/**
* Perform a 1.13c sub-tile ray trace between two scene-space coordinates `(startX, startY)` and `(endX, endY)`.
*/
export function rayTraceScene(
scene: CollisionGrid,
startX: number,
startY: number,
endX: number,
endY: number,
mask: number = COLLIDE_MASK_MISSILE,
): RayTraceResult {
const start = subTileAt(scene, startX, startY)
const end = subTileAt(scene, endX, endY)
return collisionRayTrace(scene, start.subX, start.subY, end.subX, end.subY, mask)
}
/**
* Whether a scene-space point lies on a blocked sub-tile.
*
* When `mask` is omitted, checks `scene.blocked === 1` (`COLLIDE_MASK_PLAYER_PATH`).
* When `mask` is provided, checks the 16-bit `collisionMasks` against `mask`.
*
* @param scene - the scene.
* @param x - scene-space x.
* @param y - scene-space y.
* @param mask - optional 16-bit collision mask (`D2C_CollisionMaskFlags`).
* @returns true when blocked for the given mask.
*/
export function isBlockedAt(scene: CollisionGrid, x: number, y: number, mask?: number): boolean {
const cell = cellAt(scene, x, y)
const cellX = Math.floor(cell.x)
const cellY = Math.floor(cell.y)
@ -727,9 +1052,28 @@ export function isBlockedAt(scene: CollisionGrid, x: number, y: number): boolean
const subY = Math.floor((cell.y - cellY) * SUB_TILES_PER_TILE)
const gx = cellX * SUB_TILES_PER_TILE + Math.min(subX, SUB_TILES_PER_TILE - 1)
const gy = cellY * SUB_TILES_PER_TILE + Math.min(subY, SUB_TILES_PER_TILE - 1)
if (mask !== undefined) {
return collisionCheckMask(scene, gx, gy, mask)
}
return scene.blocked[gy * scene.gridWidth + gx] === 1
}
/**
* Whether a projectile / missile is blocked at scene-space `(x, y)` (`COLLIDE_MASK_MISSILE`).
* Water, lava, rivers, and low cliffs (`COLLIDE_WALL` without `COLLIDE_MISSILE_BARRIER`) return `false`,
* allowing projectiles to fly across them.
*/
export function isMissileBlockedAt(scene: CollisionGrid, x: number, y: number): boolean {
return isBlockedAt(scene, x, y, COLLIDE_MASK_MISSILE)
}
/**
* Whether Line-of-Sight (LoS) is blocked at scene-space `(x, y)` (`COLLIDE_MASK_LOS`).
*/
export function isLosBlockedAt(scene: CollisionGrid, x: number, y: number): boolean {
return isBlockedAt(scene, x, y, COLLIDE_MASK_LOS)
}
/**
* Scene-space centre of a cell.
*

View File

@ -27,7 +27,15 @@
* asking {@link isBlockedAt} per sample sidesteps the inversion entirely and
* stays correct under the 2:1 projection.
*/
import { ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH, isBlockedAt } from './d2map.ts'
import {
COLLIDE_MASK_LOS,
COLLIDE_MASK_MISSILE,
ORTHO_SUB_TILE_HEIGHT,
ORTHO_SUB_TILE_WIDTH,
isBlockedAt,
isMissileBlockedAt,
rayTraceScene,
} from './d2map.ts'
import type { CollisionGrid } from './d2map.ts'
/**
@ -116,6 +124,15 @@ export interface CountingTerrain {
* @returns 0 when clear, larger the deeper the body is inside geometry.
*/
overlap(x: number, y: number): number
/**
* Whether a projectile / missile is blocked at `(x, y)` or along the segment from `(fromX, fromY)` to `(x, y)`.
* Uses `COLLIDE_MASK_MISSILE` so projectiles pass over rivers, lava, and low obstacles while stopping on walls/doors.
*/
isMissileBlocked?(x: number, y: number, fromX?: number, fromY?: number): boolean
/**
* Whether there is an unobstructed Line-of-Sight (LoS) between `(fromX, fromY)` and `(toX, toY)` (`COLLIDE_MASK_LOS`).
*/
hasLineOfSight?(fromX: number, fromY: number, toX: number, toY: number): boolean
}
/**
@ -137,5 +154,14 @@ export function createIsoTerrain(
widthPx,
heightPx,
overlap: (x: number, y: number): number => isoOverlap(grid, x, y, body.width, body.height),
isMissileBlocked: (x: number, y: number, fromX?: number, fromY?: number): boolean => {
if (fromX !== undefined && fromY !== undefined) {
return rayTraceScene(grid, fromX, fromY, x, y, COLLIDE_MASK_MISSILE).hit
}
return isMissileBlockedAt(grid, x, y)
},
hasLineOfSight: (fromX: number, fromY: number, toX: number, toY: number): boolean => {
return !rayTraceScene(grid, fromX, fromY, toX, toY, COLLIDE_MASK_LOS).hit
},
}
}

View File

@ -247,7 +247,11 @@ export interface ProjectileOutcome {
export function tickProjectiles(
projectiles: readonly Projectile[],
targets: readonly ProjectileTarget[],
options: { readonly overlap: (x: number, y: number) => number; readonly radius?: number },
options: {
readonly overlap: (x: number, y: number) => number
readonly isMissileBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined
readonly radius?: number
},
): ProjectileOutcome {
const radius = options.radius ?? 8
const alive: Projectile[] = []
@ -260,9 +264,13 @@ export function tickProjectiles(
const x = projectile.x + projectile.vx
const y = projectile.y + projectile.vy
const moved: Projectile = { ...projectile, x, y, ttl: projectile.ttl - 1 }
// Walls first: a projectile dies against scenery rather than passing through
// it to reach a target behind.
if (options.overlap(x, y) > 0) { wallHits += 1; continue }
// Walls first: a projectile dies against missile barriers (solid walls, closed doors)
// while passing freely over ground-only barriers (rivers, lava, chasms) via COLLIDE_MASK_MISSILE.
const blockedByTerrain =
options.isMissileBlocked !== undefined
? options.isMissileBlocked(x, y, projectile.x, projectile.y)
: options.overlap(x, y) > 0
if (blockedByTerrain) { wallHits += 1; continue }
let struck = false
for (const target of targets) {
if (!target.alive) continue

View File

@ -0,0 +1,279 @@
import { describe, expect, it } from 'vitest'
import {
COLLIDE_DOOR,
COLLIDE_MASK_INVALID,
COLLIDE_MASK_LOS,
COLLIDE_MASK_MISSILE,
COLLIDE_MASK_PLAYER_FLYING,
COLLIDE_MASK_PLAYER_PATH,
COLLIDE_MISSILE_BARRIER,
COLLIDE_NONE,
COLLIDE_NOPLAYER,
COLLIDE_OBJECT,
COLLIDE_VISIBLE,
COLLIDE_WALL,
buildIsoMapScene,
cellCentre,
collisionCheckMask,
collisionRayTrace,
collisionResetMask,
collisionSetMask,
getCollisionMask,
isBlockedAt,
isLosBlockedAt,
isMissileBlockedAt,
subTileAt,
} from '../src/game/d2map.ts'
import type { CollisionGrid } from '../src/game/d2map.ts'
import { createIsoTerrain } from '../src/game/iso-terrain.ts'
import { tickProjectiles } from '../src/game/skills.ts'
import { subTileFlagsOf } from '../src/formats/dt1.ts'
import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
function createMockGrid(cellsX: number, cellsY: number): CollisionGrid & { collisionMasks: Uint16Array } {
const gridWidth = cellsX * 5
const gridHeight = cellsY * 5
return {
originX: cellsY * 80,
originY: 0,
cellsX,
cellsY,
gridWidth,
blocked: new Uint8Array(gridWidth * gridHeight),
collisionMasks: new Uint16Array(gridWidth * gridHeight),
}
}
function makeTestTile(type: number, style: number, sequence: number, rawFlag: number): Dt1Tile {
const flags = Array.from({ length: 25 }, () => subTileFlagsOf(rawFlag))
return {
direction: type,
roofHeight: 0,
materialFlags: 0,
height: -80,
width: 160,
type,
style,
sequence,
rarityFrameIndex: 1,
animated: 0,
subTileFlags: flags,
minBlockY: -80,
bitmapHeight: 80,
blocks: [
{
x: 0,
y: -80,
gridX: 0,
gridY: 0,
format: 1,
pixels: new Uint8Array(160 * 80).fill(1),
},
],
}
}
describe('Blizzard D2 1.13c 16-bit Multi-Layer Collision Masks & RayTrace (Issue #75)', () => {
it('defines canonical 1.13c D2C_CollisionMaskFlags bit values and composite masks', () => {
expect(COLLIDE_NONE).toBe(0x0000)
expect(COLLIDE_WALL).toBe(0x0001)
expect(COLLIDE_VISIBLE).toBe(0x0002)
expect(COLLIDE_MISSILE_BARRIER).toBe(0x0004)
expect(COLLIDE_NOPLAYER).toBe(0x0008)
expect(COLLIDE_OBJECT).toBe(0x0400)
expect(COLLIDE_DOOR).toBe(0x0800)
expect(COLLIDE_MASK_PLAYER_PATH & COLLIDE_WALL).toBe(COLLIDE_WALL)
expect(COLLIDE_MASK_PLAYER_PATH & COLLIDE_DOOR).toBe(COLLIDE_DOOR)
expect(COLLIDE_MASK_PLAYER_PATH & COLLIDE_OBJECT).toBe(COLLIDE_OBJECT)
expect(COLLIDE_MASK_MISSILE & COLLIDE_WALL).toBe(0)
expect(COLLIDE_MASK_MISSILE & COLLIDE_MISSILE_BARRIER).toBe(COLLIDE_MISSILE_BARRIER)
expect(COLLIDE_MASK_MISSILE & COLLIDE_DOOR).toBe(COLLIDE_DOOR)
expect(COLLIDE_MASK_LOS & COLLIDE_VISIBLE).toBe(COLLIDE_VISIBLE)
expect(COLLIDE_MASK_LOS & COLLIDE_WALL).toBe(0)
expect(COLLIDE_MASK_PLAYER_FLYING).toBe(COLLIDE_DOOR | COLLIDE_MISSILE_BARRIER)
})
it('stamps 16-bit multi-layer flags into collisionMasks while preserving exact blocked Uint8Array', () => {
// Tile 0: walkable floor (0x00)
// Tile 1: river/water floor (0x01 = blockWalk only, missiles/LoS pass)
// Tile 2: solid stone wall (0x07 = blockWalk | blockLos | blockJump)
// Tile 3: door wall (type 8, 0x07 -> gets COLLIDE_DOOR 0x0800 OR-ed)
const dt1: Dt1 = {
versionMajor: 7,
versionMinor: 6,
warnings: [],
tiles: [
makeTestTile(0, 1, 0, 0x00),
makeTestTile(0, 1, 1, COLLIDE_WALL),
makeTestTile(1, 2, 0, COLLIDE_WALL | COLLIDE_VISIBLE | COLLIDE_MISSILE_BARRIER),
makeTestTile(8, 2, 1, COLLIDE_WALL | COLLIDE_VISIBLE | COLLIDE_MISSILE_BARRIER),
],
}
const ds1: Ds1 = {
version: 18,
width: 4,
height: 1,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
objects: [],
npcPathOffset: null,
cells: [
[
{
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
},
{
floors: [{ prop1: 1, sequence: 1, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
},
{
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [{ prop1: 1, sequence: 0, style: 2, type: 1, hidden: false, unknown1: 0, unknown2: 0 }],
shadows: [],
substitutions: [],
},
{
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [{ prop1: 1, sequence: 1, style: 2, type: 8, hidden: false, unknown1: 0, unknown2: 0 }],
shadows: [],
substitutions: [],
},
],
],
}
const scene = buildIsoMapScene(ds1, [dt1], 42)
expect(scene.collisionMasks).toBeInstanceOf(Uint16Array)
expect(scene.collisionMasks.length).toBe(scene.blocked.length)
// Cell (0,0): Clear floor
const c0 = cellCentre(scene, 0, 0)
expect(isBlockedAt(scene, c0.x, c0.y)).toBe(false)
expect(isMissileBlockedAt(scene, c0.x, c0.y)).toBe(false)
expect(isLosBlockedAt(scene, c0.x, c0.y)).toBe(false)
// Cell (1,0): River / low barrier (COLLIDE_WALL = 0x0001 only)
const c1 = cellCentre(scene, 1, 0)
expect(isBlockedAt(scene, c1.x, c1.y)).toBe(true) // Blocks player walking
expect(isMissileBlockedAt(scene, c1.x, c1.y)).toBe(false) // Projectiles fly across!
expect(isLosBlockedAt(scene, c1.x, c1.y)).toBe(false) // Can see across river!
// Cell (2,0): Solid wall (0x0007)
const c2 = cellCentre(scene, 2, 0)
expect(isBlockedAt(scene, c2.x, c2.y)).toBe(true)
expect(isMissileBlockedAt(scene, c2.x, c2.y)).toBe(true)
expect(isLosBlockedAt(scene, c2.x, c2.y)).toBe(true)
// Cell (3,0): Door wall (type 8) -> has COLLIDE_DOOR (0x0800) set
const c3 = cellCentre(scene, 3, 0)
const sub3 = subTileAt(scene, c3.x, c3.y)
expect(collisionCheckMask(scene, sub3.subX, sub3.subY, COLLIDE_DOOR)).toBe(true)
})
it('supports COLLISION_SetMask and COLLISION_ResetMask for dynamic doors and obstacles', () => {
const grid = createMockGrid(3, 3)
const doorSubX = 7
const doorSubY = 7
// Initially open
expect(collisionCheckMask(grid, doorSubX, doorSubY, COLLIDE_MASK_PLAYER_PATH)).toBe(false)
expect(grid.blocked[doorSubY * grid.gridWidth + doorSubX]).toBe(0)
// Close door: set COLLIDE_DOOR | COLLIDE_VISIBLE | COLLIDE_MISSILE_BARRIER
const doorFlags = COLLIDE_DOOR | COLLIDE_VISIBLE | COLLIDE_MISSILE_BARRIER
collisionSetMask(grid, doorSubX, doorSubY, doorFlags, 2, 1)
expect(collisionCheckMask(grid, doorSubX, doorSubY, COLLIDE_DOOR)).toBe(true)
expect(collisionCheckMask(grid, doorSubX + 1, doorSubY, COLLIDE_MASK_PLAYER_PATH)).toBe(true)
expect(collisionCheckMask(grid, doorSubX, doorSubY, COLLIDE_MASK_MISSILE)).toBe(true)
expect(collisionCheckMask(grid, doorSubX, doorSubY, COLLIDE_MASK_LOS)).toBe(true)
expect(grid.blocked[doorSubY * grid.gridWidth + doorSubX]).toBe(1)
// Open door: reset door flags
collisionResetMask(grid, doorSubX, doorSubY, doorFlags, 2, 1)
expect(collisionCheckMask(grid, doorSubX, doorSubY, COLLIDE_DOOR)).toBe(false)
expect(collisionCheckMask(grid, doorSubX, doorSubY, COLLIDE_MASK_PLAYER_PATH)).toBe(false)
expect(collisionCheckMask(grid, doorSubX, doorSubY, COLLIDE_MASK_MISSILE)).toBe(false)
expect(collisionCheckMask(grid, doorSubX, doorSubY, COLLIDE_MASK_LOS)).toBe(false)
expect(grid.blocked[doorSubY * grid.gridWidth + doorSubX]).toBe(0)
})
it('performs sub-tile COLLISION_RayTrace and separates river flyover from wall hits in tickProjectiles', () => {
const grid = createMockGrid(6, 6)
// Place a river strip at subX = 10 (COLLIDE_WALL = 0x0001 only)
collisionSetMask(grid, 10, 0, COLLIDE_WALL, 2, 30)
// Place a solid pillar at subX = 20, subY = 10..15 (COLLIDE_WALL | COLLIDE_VISIBLE | COLLIDE_MISSILE_BARRIER)
collisionSetMask(grid, 20, 10, COLLIDE_WALL | COLLIDE_VISIBLE | COLLIDE_MISSILE_BARRIER, 1, 6)
// 1. RayTrace across river (subX 5 -> 15 at subY = 12):
// Walking path raytrace hits the river at subX = 10:
const walkRay = collisionRayTrace(grid, 5, 12, 15, 12, COLLIDE_MASK_PLAYER_PATH)
expect(walkRay.hit).toBe(true)
expect(walkRay.hitSubX).toBe(10)
// Missile raytrace flies right over the river without hitting!
const missileRayOverRiver = collisionRayTrace(grid, 5, 12, 15, 12, COLLIDE_MASK_MISSILE)
expect(missileRayOverRiver.hit).toBe(false)
expect(missileRayOverRiver.hitSubX).toBe(15)
// LoS raytrace sees right across the river!
const losRayOverRiver = collisionRayTrace(grid, 5, 12, 15, 12, COLLIDE_MASK_LOS)
expect(losRayOverRiver.hit).toBe(false)
// 2. Missile raytrace toward the solid pillar at subX = 20 stops at (20, 12):
const missileRayIntoPillar = collisionRayTrace(grid, 5, 12, 25, 12, COLLIDE_MASK_MISSILE)
expect(missileRayIntoPillar.hit).toBe(true)
expect(missileRayIntoPillar.hitSubX).toBe(20)
expect(missileRayIntoPillar.hitSubY).toBe(12)
// 3. Verify tickProjectiles with createIsoTerrain allows arrows to fly over the river and hit a monster on the other side
const terrain = createIsoTerrain(grid, 960, 480, { width: 16, height: 8 })
const startPt = cellCentre(grid, 1, 2) // subX ~ 7, subY ~ 12
const targetPt = cellCentre(grid, 3, 2) // subX ~ 17, subY ~ 12 (across the river at subX=10..11!)
// Confirm river blocks walking between startPt and targetPt
const riverPt = cellCentre(grid, 2, 2) // subX ~ 10..12
collisionSetMask(grid, 10, 10, COLLIDE_WALL, 5, 5)
expect(terrain.overlap(riverPt.x, riverPt.y)).toBeGreaterThan(0)
// Fire a fast arrow from startPt directly to targetPt across the river
const outcome = tickProjectiles(
[
{
skillId: 'arrow',
x: startPt.x,
y: startPt.y,
vx: targetPt.x - startPt.x,
vy: targetPt.y - startPt.y,
damage: 25,
ttl: 5,
fromPlayer: true,
},
],
[{ index: 0, x: targetPt.x, y: targetPt.y, radius: 16, alive: true }],
terrain,
)
expect(outcome.wallHits).toBe(0)
expect(outcome.hits).toHaveLength(1)
expect(outcome.hits[0]?.damage).toBe(25)
// Out-of-bounds returns COLLIDE_MASK_INVALID
expect(getCollisionMask(grid, -1, -1)).toBe(COLLIDE_MASK_INVALID)
})
})