315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
/**
|
|
* Walkable scene (milestone M1 skeleton): a real animated character moving on
|
|
* a collided map at the game's own simulation rate.
|
|
*
|
|
* Everything here is format-agnostic except the four lines that name Diablo I
|
|
* members. The engine pieces — atlas, WebGL2 batcher, fixed 25 Hz loop,
|
|
* facing/resolution, axis-separated collision, camera follow — are the ones
|
|
* Diablo II art will plug into unchanged; only the decoders behind them differ.
|
|
*
|
|
* The character art is real (the warrior's walk and stand sheets, decoded with
|
|
* the width the game's own table gives), while the floor and walls are
|
|
* placeholder geometry: real level art arrives with the DS1/DT1 decoders, and
|
|
* nothing here should pretend otherwise.
|
|
*/
|
|
import { MpqArchive } from './mpq/archive.ts'
|
|
import { blobSource } from './mpq/source.ts'
|
|
import { decodeSpriteFile } from './formats/cel.ts'
|
|
import { decodePal } from './formats/pal.ts'
|
|
import { PLAYER_SPRITE_WIDTH } from './formats/sprite.ts'
|
|
import type { SpriteSheet } from './formats/sprite.ts'
|
|
import { buildAtlas } from './render/atlas.ts'
|
|
import { SpriteRenderer } from './render/renderer.ts'
|
|
import type { AtlasFrame } from './render/atlas.ts'
|
|
import { GameLoop } from './sim/loop.ts'
|
|
import { DIRECTION, KeyboardInput, directionOf } from './sim/input.ts'
|
|
import type { Direction } from './sim/input.ts'
|
|
|
|
/** One world tile, in pixels (Diablo's subtile grid is 32 wide). */
|
|
const TILE = 32
|
|
/** Map extent in tiles. */
|
|
const MAP_TILES_X = 40
|
|
const MAP_TILES_Y = 30
|
|
/** Walk speed in tiles per second, applied per tick. */
|
|
const WALK_TILES_PER_SECOND = 6
|
|
/** Ticks per animation frame while walking. */
|
|
const TICKS_PER_WALK_FRAME = 2
|
|
/** Collision box at the character's feet. */
|
|
const FEET_WIDTH = 18
|
|
const FEET_HEIGHT = 12
|
|
|
|
/** Placeholder world colours (real tiles replace these). */
|
|
const GROUND_COLOR: readonly [number, number, number, number] = [0.16, 0.13, 0.11, 1]
|
|
const WALL_COLOR: readonly [number, number, number, number] = [0.34, 0.28, 0.22, 1]
|
|
const WALL_TOP_COLOR: readonly [number, number, number, number] = [0.44, 0.37, 0.29, 1]
|
|
|
|
/** An axis-aligned world rectangle. */
|
|
interface Rect {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly width: number
|
|
readonly height: number
|
|
}
|
|
|
|
/** Player state carried across ticks. */
|
|
interface PlayerState {
|
|
x: number
|
|
y: number
|
|
facing: Direction
|
|
walkFrame: number
|
|
moving: boolean
|
|
}
|
|
|
|
/** Live scene state, also published for automated checks. */
|
|
export interface WalkState {
|
|
/** True once art is loaded and the loop is running. */
|
|
ready: boolean
|
|
/** Ticks simulated. */
|
|
tick: number
|
|
/** Measured ticks per second. */
|
|
tickRate: number
|
|
/** Player position in world pixels. */
|
|
x: number
|
|
y: number
|
|
/** Facing index (0 = south). */
|
|
facing: number
|
|
/** Current walk animation frame. */
|
|
walkFrame: number
|
|
/** Whether the character moved on the last tick. */
|
|
moving: boolean
|
|
/** Draw calls issued on the last rendered frame. */
|
|
drawCalls: number
|
|
/** Reason the scene failed to start, if any. */
|
|
error: string | null
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
/** Scene state for automation and debugging. */
|
|
__d2web?: WalkState
|
|
}
|
|
}
|
|
|
|
const state: WalkState = {
|
|
ready: false, tick: 0, tickRate: 0, x: 0, y: 0,
|
|
facing: DIRECTION.South, walkFrame: 0, moving: false, drawCalls: 0, error: null,
|
|
}
|
|
window.__d2web = state
|
|
|
|
/** Walls: a border plus a few blocks, enough to exercise collision. */
|
|
function buildWalls(): Rect[] {
|
|
const walls: Rect[] = []
|
|
const push = (x: number, y: number, width: number, height: number): void => {
|
|
walls.push({ x: x * TILE, y: y * TILE, width: width * TILE, height: height * TILE })
|
|
}
|
|
// Border.
|
|
push(0, 0, MAP_TILES_X, 1)
|
|
push(0, MAP_TILES_Y - 1, MAP_TILES_X, 1)
|
|
push(0, 0, 1, MAP_TILES_Y)
|
|
push(MAP_TILES_X - 1, 0, 1, MAP_TILES_Y)
|
|
// Interior blocks, laid out so a diagonal walk has to slide along them.
|
|
push(6, 5, 4, 2)
|
|
push(14, 4, 2, 8)
|
|
push(20, 10, 6, 2)
|
|
push(26, 6, 2, 10)
|
|
push(9, 14, 6, 2)
|
|
push(18, 18, 8, 2)
|
|
push(30, 16, 4, 6)
|
|
push(4, 20, 3, 5)
|
|
return walls
|
|
}
|
|
|
|
/**
|
|
* Whether a feet box overlaps any wall.
|
|
*
|
|
* @param x - feet centre x.
|
|
* @param y - feet centre y.
|
|
* @param walls - wall rectangles.
|
|
* @returns true on overlap.
|
|
*/
|
|
function collides(x: number, y: number, walls: readonly Rect[]): boolean {
|
|
const left = x - FEET_WIDTH / 2
|
|
const right = x + FEET_WIDTH / 2
|
|
const top = y - FEET_HEIGHT / 2
|
|
const bottom = y + FEET_HEIGHT / 2
|
|
for (const wall of walls) {
|
|
if (right <= wall.x || left >= wall.x + wall.width) continue
|
|
if (bottom <= wall.y || top >= wall.y + wall.height) continue
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
let activeLoop: GameLoop | null = null
|
|
let activeInput: KeyboardInput | null = null
|
|
let activeRenderer: SpriteRenderer | null = null
|
|
|
|
/**
|
|
* Boot the scene from an archive.
|
|
*
|
|
* @param archive - the open archive.
|
|
*/
|
|
async function start(archive: MpqArchive): Promise<void> {
|
|
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
|
|
const status = document.querySelector<HTMLElement>('#status')!
|
|
const hud = document.querySelector<HTMLElement>('#hud')!
|
|
|
|
const palette = decodePal(await archive.readByName('levels\\towndata\\town.pal'))
|
|
// The game's own width for stand and walk: 96 for the base classes.
|
|
const width = PLAYER_SPRITE_WIDTH.walk
|
|
const load = async (name: string): Promise<SpriteSheet> =>
|
|
decodeSpriteFile(await archive.readByName(name), 'cl2', { width })
|
|
|
|
const walk = await load('plrgfx\\warrior\\wlh\\wlhwl.cl2')
|
|
let stand: SpriteSheet | null = null
|
|
try {
|
|
stand = await load('plrgfx\\warrior\\wlh\\wlhst.cl2')
|
|
} catch {
|
|
// A missing stand sheet is not fatal: walking art covers every facing.
|
|
stand = null
|
|
}
|
|
const joined: SpriteSheet = {
|
|
groups: stand === null ? walk.groups : [...walk.groups, ...stand.groups],
|
|
width,
|
|
}
|
|
const atlas = buildAtlas(joined, palette)
|
|
const walkFrames: readonly (readonly AtlasFrame[])[] = atlas.groups.slice(0, walk.groups.length)
|
|
const standFrames: readonly (readonly AtlasFrame[])[] | null =
|
|
stand === null ? null : atlas.groups.slice(walk.groups.length)
|
|
|
|
activeLoop?.stop()
|
|
activeInput?.detach()
|
|
activeRenderer?.dispose()
|
|
const renderer = new SpriteRenderer(canvas, {
|
|
onContextLost: () => {
|
|
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
|
|
},
|
|
onContextRestored: () => {
|
|
status.textContent = 'WebGL 上下文已恢复,正在重新加载…'
|
|
location.reload()
|
|
},
|
|
})
|
|
activeRenderer = renderer
|
|
renderer.setAtlas(atlas)
|
|
|
|
const walls = buildWalls()
|
|
const player: PlayerState = {
|
|
x: (MAP_TILES_X / 2) * TILE,
|
|
y: (MAP_TILES_Y / 2) * TILE,
|
|
facing: DIRECTION.South,
|
|
walkFrame: 0,
|
|
moving: false,
|
|
}
|
|
const feetOffset = FEET_HEIGHT / 2
|
|
|
|
const input = new KeyboardInput()
|
|
input.attach()
|
|
|
|
const loop = new GameLoop({
|
|
tickRate: 25,
|
|
onTick: () => {
|
|
const movement = input.movement()
|
|
const direction = directionOf(movement)
|
|
const moving = direction !== null
|
|
if (direction !== null) player.facing = direction
|
|
player.moving = moving
|
|
if (moving) {
|
|
const step = (WALK_TILES_PER_SECOND * TILE) / 25
|
|
// Axis-separated resolution: a blocked axis stops, the other slides.
|
|
const nextX = player.x + movement.x * step
|
|
if (!collides(nextX, player.y, walls)) player.x = nextX
|
|
const nextY = player.y + movement.y * step
|
|
if (!collides(player.x, nextY, walls)) player.y = nextY
|
|
player.walkFrame = (player.walkFrame + 1) % (walk.groups[player.facing]?.frames.length * TICKS_PER_WALK_FRAME)
|
|
} else {
|
|
player.walkFrame = 0
|
|
}
|
|
state.tick = loop.tick
|
|
state.x = player.x
|
|
state.y = player.y
|
|
state.facing = player.facing
|
|
state.walkFrame = Math.floor(player.walkFrame / TICKS_PER_WALK_FRAME)
|
|
state.moving = player.moving
|
|
},
|
|
onRender: () => {
|
|
const camera = { x: player.x, y: player.y - 24, zoom: 2 }
|
|
renderer.begin(camera, [0.05, 0.04, 0.03])
|
|
// Ground, then walls, then the character: painter's order, no depth test.
|
|
renderer.drawSolid(0, 0, MAP_TILES_X * TILE, MAP_TILES_Y * TILE, GROUND_COLOR)
|
|
for (const wall of walls) {
|
|
renderer.drawSolid(wall.x, wall.y, wall.width, wall.height, WALL_COLOR)
|
|
renderer.drawSolid(wall.x, wall.y, wall.width, 4, WALL_TOP_COLOR)
|
|
}
|
|
const sheet = player.moving || standFrames === null ? walkFrames : standFrames
|
|
const frames = sheet[player.facing]
|
|
const frame = frames?.[player.moving ? state.walkFrame % frames.length : 0]
|
|
if (frame !== undefined) {
|
|
renderer.draw(frame, player.x - frame.width / 2, player.y - frame.height + feetOffset)
|
|
}
|
|
renderer.flush()
|
|
state.drawCalls = renderer.drawCalls
|
|
state.tickRate = loop.tickRate
|
|
hud.textContent = `tick ${String(state.tick)} · ${loop.tickRate.toFixed(1)} tps`
|
|
+ ` · pos ${player.x.toFixed(0)},${player.y.toFixed(0)}`
|
|
+ ` · facing ${String(player.facing)} · frame ${String(state.walkFrame)}`
|
|
+ ` · ${String(atlas.groups.length)} groups / ${String(walk.groups.reduce((n, g) => n + g.frames.length, 0))} walk frames`
|
|
},
|
|
})
|
|
activeInput = input
|
|
activeLoop = loop
|
|
loop.start()
|
|
state.ready = true
|
|
status.textContent = 'WASD / 方向键移动。角色为真实暗黑1 素材(战士行走/站立,宽度 96 = 游戏表数值);地面与墙为占位几何。'
|
|
}
|
|
|
|
/** Wire the drop zone and any `?sample=` URL. */
|
|
function boot(): void {
|
|
const drop = document.querySelector<HTMLElement>('#drop')!
|
|
const picker = document.querySelector<HTMLInputElement>('#picker')!
|
|
const status = document.querySelector<HTMLElement>('#status')!
|
|
|
|
const open = async (file: File): Promise<void> => {
|
|
try {
|
|
status.textContent = `正在读取 ${file.name} …`
|
|
const archive = await MpqArchive.open(blobSource(file))
|
|
await start(archive)
|
|
} catch (error) {
|
|
state.error = (error as Error).message
|
|
status.textContent = `启动失败:${state.error}`
|
|
}
|
|
}
|
|
|
|
drop.onclick = () => { picker.click() }
|
|
picker.onchange = () => { const file = picker.files?.[0]; if (file) void open(file) }
|
|
drop.addEventListener('dragover', (event) => { event.preventDefault() })
|
|
drop.addEventListener('drop', (event) => {
|
|
event.preventDefault()
|
|
const file = event.dataTransfer?.files?.[0]
|
|
if (file) void open(file)
|
|
})
|
|
|
|
const sample = new URLSearchParams(location.search).get('sample')
|
|
if (sample !== null) {
|
|
void (async () => {
|
|
status.textContent = `正在通过 HTTP 读取 ${sample} …`
|
|
const response = await fetch(sample)
|
|
if (!response.ok) {
|
|
state.error = `sample HTTP ${String(response.status)}`
|
|
status.textContent = state.error
|
|
return
|
|
}
|
|
const blob = await response.blob()
|
|
await open(new File([blob], sample.split('/').pop() ?? 'sample.mpq'))
|
|
})()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('beforeunload', () => {
|
|
activeLoop?.stop()
|
|
activeInput?.detach()
|
|
activeRenderer?.dispose()
|
|
activeRenderer = null
|
|
})
|
|
|
|
boot()
|