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:
parent
97ab449231
commit
ef59646631
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue