From ef59646631517497722ae7d2b64e422561677d12 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 07:58:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(collision):=20=E8=BF=98=E5=8E=9F=E5=8E=9F?= =?UTF-8?q?=E7=89=88=201.13c=2016=E4=BD=8D=E5=A4=9A=E5=B1=82=E7=A2=B0?= =?UTF-8?q?=E6=92=9E=E6=8E=A9=E7=A0=81=E7=B3=BB=E7=BB=9F=E4=B8=8E=E6=8A=95?= =?UTF-8?q?=E5=B0=84=E7=89=A9/=E8=A7=86=E7=BA=BF=E7=A9=BF=E9=80=8F=20(Issu?= =?UTF-8?q?e=20#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 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 --- src/game/d2map.ts | 372 +++++++++++++++++++++++++++++++++-- src/game/iso-terrain.ts | 28 ++- src/game/skills.ts | 16 +- tests/collision-113c.test.ts | 279 ++++++++++++++++++++++++++ 4 files changed, 676 insertions(+), 19 deletions(-) create mode 100644 tests/collision-113c.test.ts diff --git a/src/game/d2map.ts b/src/game/d2map.ts index 5dee2ae..c0504e9 100644 --- a/src/game/d2map.ts +++ b/src/game/d2map.ts @@ -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. * diff --git a/src/game/iso-terrain.ts b/src/game/iso-terrain.ts index a401de4..c03edff 100644 --- a/src/game/iso-terrain.ts +++ b/src/game/iso-terrain.ts @@ -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 + }, } } diff --git a/src/game/skills.ts b/src/game/skills.ts index c5af237..d7ff18d 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -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 diff --git a/tests/collision-113c.test.ts b/tests/collision-113c.test.ts new file mode 100644 index 0000000..8076d9d --- /dev/null +++ b/tests/collision-113c.test.ts @@ -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) + }) +})