diablo2-web/tests/collision-113c.test.ts

280 lines
10 KiB
TypeScript

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)
})
})