223 lines
7.6 KiB
TypeScript
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
|
|
}
|
|
}
|