diablo2-web/src/ui/minimap.ts

223 lines
7.6 KiB
TypeScript

/**
* The automap.
*
* Drawn inside the canvas with the renderer's solid-colour quad, the same way
* the orbs and the inventory grid in `map-scene.ts` are: the map has to sit on
* top of the world and scale with the viewport, and a DOM overlay would need
* its own coordinate system and its own resize handling.
*
* ## What "explored" means
*
* Diablo II reveals the automap by room, not by line of sight — walk into a
* room and the whole room appears, including the parts behind you. This is a
* cell-radius approximation of that: every cell within {@link REVEAL_RADIUS} of
* the player is marked, permanently, per level. It is generous in corridors and
* stingy in large halls, but it has the property that matters: what you have
* walked past stays on the map, and what you have not is not there.
*
* Exploration is per level and survives leaving and coming back, so it is held
* here rather than in the scene, which is rebuilt on every transition.
*/
import type { SpriteRenderer } from '../render/renderer.ts'
/** How far around the player the map reveals, in cells. */
export const REVEAL_RADIUS = 9
/** Cell colours, as premultiplied RGBA in 0..1. */
const COLOUR = {
/** Open ground the player has seen. */
walkable: [0.55, 0.50, 0.42, 0.55] as const,
/** Walls and cliffs, drawn brighter so the shape of the level reads. */
blocked: [0.22, 0.20, 0.18, 0.75] as const,
/** The player. */
player: [1.0, 0.95, 0.7, 1.0] as const,
/** A way out of the level. */
exit: [0.35, 0.75, 1.0, 1.0] as const,
/** A waypoint pedestal. */
waypoint: [0.45, 0.55, 1.0, 1.0] as const,
/** The frame around the map. */
border: [0.05, 0.05, 0.06, 0.8] as const,
}
/** Something worth showing on the map even before the player reaches it. */
export interface MinimapMarker {
/** Position in cells. */
readonly cellX: number
readonly cellY: number
/** Which colour to use. */
readonly kind: 'exit' | 'waypoint'
}
/** What the minimap needs to know about the level under it. */
export interface MinimapLevel {
/** A key that changes when the level does; exploration is kept per key. */
readonly key: string
readonly cellsX: number
readonly cellsY: number
/** One byte per sub-tile, non-zero meaning impassable. */
readonly blocked: Uint8Array
/** Sub-tiles across. */
readonly gridWidth: number
/** Landmarks to draw once their cell is explored. */
readonly markers: readonly MinimapMarker[]
}
/**
* The automap overlay.
*
* One instance for the whole session; it keeps the explored set for every level
* visited, so walking back into the Cold Plains shows the roads you already
* found.
*/
export class Minimap {
/** Whether the map is on screen. */
visible = false
/** level key -> one byte per cell, non-zero meaning explored. */
private readonly explored = new Map<string, Uint8Array>()
/**
* Mark the ground around a point as seen.
*
* Cheap enough to call every tick: the loop is a square of side
* `2 * REVEAL_RADIUS + 1` over a byte array.
*
* @param level - the level being walked.
* @param cellX - the player's cell.
* @param cellY - the player's cell.
*/
reveal(level: MinimapLevel, cellX: number, cellY: number): void {
const seen = this.seenFor(level)
for (let dy = -REVEAL_RADIUS; dy <= REVEAL_RADIUS; dy += 1) {
const y = cellY + dy
if (y < 0 || y >= level.cellsY) continue
for (let dx = -REVEAL_RADIUS; dx <= REVEAL_RADIUS; dx += 1) {
const x = cellX + dx
if (x < 0 || x >= level.cellsX) continue
if (dx * dx + dy * dy > REVEAL_RADIUS * REVEAL_RADIUS) continue
seen[y * level.cellsX + x] = 1
}
}
}
/**
* How much of a level has been seen.
*
* @param level - the level.
* @returns the explored fraction, 0..1.
*/
exploredFraction(level: MinimapLevel): number {
const seen = this.seenFor(level)
let count = 0
for (const value of seen) count += value
return seen.length === 0 ? 0 : count / seen.length
}
/**
* Draw the map.
*
* Call between `renderer.begin` and `renderer.flush`. Everything is divided
* by the camera zoom so the map stays the same size on screen however far the
* world is zoomed, matching how the HUD in `map-scene.ts` behaves.
*
* @param renderer - the renderer, already begun.
* @param level - the level under the map.
* @param playerCellX - the player's cell.
* @param playerCellY - the player's cell.
* @param view - the camera's centre and zoom, for placing the panel.
* @param viewport - the canvas size in device pixels.
*/
draw(
renderer: SpriteRenderer,
level: MinimapLevel,
playerCellX: number,
playerCellY: number,
view: { readonly x: number; readonly y: number; readonly zoom: number },
viewport: { readonly width: number; readonly height: number },
): void {
if (!this.visible) return
const seen = this.seenFor(level)
// The panel occupies a quarter of the shorter side, top right.
const panel = Math.min(viewport.width, viewport.height) * 0.38 / view.zoom
const margin = 12 / view.zoom
const halfW = viewport.width / 2 / view.zoom
const halfH = viewport.height / 2 / view.zoom
const left = view.x + halfW - panel - margin
const top = view.y - halfH + margin
const cellSize = Math.max(
0.5 / view.zoom,
Math.min(panel / Math.max(1, level.cellsX), panel / Math.max(1, level.cellsY)),
)
const width = cellSize * level.cellsX
const height = cellSize * level.cellsY
renderer.drawSolid(left - 2 / view.zoom, top - 2 / view.zoom, width + 4 / view.zoom, height + 4 / view.zoom, COLOUR.border)
for (let y = 0; y < level.cellsY; y += 1) {
for (let x = 0; x < level.cellsX; x += 1) {
if (seen[y * level.cellsX + x] === 0) continue
const colour = this.cellBlocked(level, x, y) ? COLOUR.blocked : COLOUR.walkable
renderer.drawSolid(left + x * cellSize, top + y * cellSize, cellSize, cellSize, colour)
}
}
const markerSize = Math.max(cellSize * 2, 3 / view.zoom)
for (const marker of level.markers) {
if (marker.cellX < 0 || marker.cellY < 0) continue
if (marker.cellX >= level.cellsX || marker.cellY >= level.cellsY) continue
if (seen[marker.cellY * level.cellsX + marker.cellX] === 0) continue
renderer.drawSolid(
left + marker.cellX * cellSize - markerSize / 2,
top + marker.cellY * cellSize - markerSize / 2,
markerSize,
markerSize,
marker.kind === 'waypoint' ? COLOUR.waypoint : COLOUR.exit,
)
}
const dot = Math.max(cellSize * 2.5, 4 / view.zoom)
renderer.drawSolid(
left + playerCellX * cellSize - dot / 2,
top + playerCellY * cellSize - dot / 2,
dot,
dot,
COLOUR.player,
)
}
/**
* Whether a cell is wall.
*
* A cell is five sub-tiles square; it counts as wall when its middle sub-tile
* is blocked, which is what the eye reads as "you cannot go there".
*
* @param level - the level.
* @param cellX - the cell.
* @param cellY - the cell.
* @returns true when blocked.
*/
private cellBlocked(level: MinimapLevel, cellX: number, cellY: number): boolean {
const sx = cellX * 5 + 2
const sy = cellY * 5 + 2
return level.blocked[sy * level.gridWidth + sx] !== 0
}
/**
* The explored bytes for a level, created on first use.
*
* @param level - the level.
* @returns its explored map.
*/
private seenFor(level: MinimapLevel): Uint8Array {
let seen = this.explored.get(level.key)
if (seen === undefined || seen.length !== level.cellsX * level.cellsY) {
seen = new Uint8Array(level.cellsX * level.cellsY)
this.explored.set(level.key, seen)
}
return seen
}
}