diablo2-web/src/walk.ts

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