Map: individual blocks at close zoom (texture, bevel, chunk lines, hover outline)
This commit is contained in:
1 file changed
+86
-3
+86
-3
@@ -31,7 +31,7 @@ export interface MapViewOptions {
|
|||||||
interface Tile { img: HTMLImageElement; state: 'loading' | 'ok' | 'missing'; at: number; used: number }
|
interface Tile { img: HTMLImageElement; state: 'loading' | 'ok' | 'missing'; at: number; used: number }
|
||||||
|
|
||||||
const MIN_SCALE = 1 / 128;
|
const MIN_SCALE = 1 / 128;
|
||||||
const MAX_SCALE = 20;
|
const MAX_SCALE = 48;
|
||||||
const TILE = 256;
|
const TILE = 256;
|
||||||
const MAX_LOADS = 10;
|
const MAX_LOADS = 10;
|
||||||
|
|
||||||
@@ -63,6 +63,8 @@ export class MapView {
|
|||||||
private pinchStart: { dist: number; scale: number } | null = null;
|
private pinchStart: { dist: number; scale: number } | null = null;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
private resizeObserver: ResizeObserver;
|
private resizeObserver: ResizeObserver;
|
||||||
|
private texture: CanvasPattern | null = null;
|
||||||
|
private hoverBlock: { x: number; z: number } | null = null;
|
||||||
|
|
||||||
constructor(private canvas: HTMLCanvasElement, private opts: MapViewOptions) {
|
constructor(private canvas: HTMLCanvasElement, private opts: MapViewOptions) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@@ -261,6 +263,8 @@ export class MapView {
|
|||||||
if (id !== this.hoverId) { this.hoverId = id; this.invalidate(); }
|
if (id !== this.hoverId) { this.hoverId = id; this.invalidate(); }
|
||||||
this.canvas.style.cursor = hit ? 'pointer' : 'grab';
|
this.canvas.style.cursor = hit ? 'pointer' : 'grab';
|
||||||
const w = this.toWorld(p.x, p.y);
|
const w = this.toWorld(p.x, p.y);
|
||||||
|
const block = { x: Math.floor(w.x), z: Math.floor(w.z) };
|
||||||
|
if (this.scale >= 8 && (!this.hoverBlock || this.hoverBlock.x !== block.x || this.hoverBlock.z !== block.z)) { this.hoverBlock = block; this.invalidate(); }
|
||||||
this.opts.onhover?.({ cursor: { x: w.x, z: w.z }, label: hit ? hitLabel(hit) : null });
|
this.opts.onhover?.({ cursor: { x: w.x, z: w.z }, label: hit ? hitLabel(hit) : null });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -277,7 +281,7 @@ export class MapView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private leave = () => {
|
private leave = () => {
|
||||||
if (this.hoverId) { this.hoverId = null; this.invalidate(); }
|
if (this.hoverId || this.hoverBlock) { this.hoverId = null; this.hoverBlock = null; this.invalidate(); }
|
||||||
this.opts.onhover?.({ cursor: null, label: null });
|
this.opts.onhover?.({ cursor: null, label: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -413,6 +417,7 @@ export class MapView {
|
|||||||
if (!this.dimension || !this.info) return;
|
if (!this.dimension || !this.info) return;
|
||||||
this.drawTiles(ctx);
|
this.drawTiles(ctx);
|
||||||
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||||
|
if (this.scale >= 6) this.drawBlocks(ctx);
|
||||||
if (this.layers.claims) this.drawClaims(ctx);
|
if (this.layers.claims) this.drawClaims(ctx);
|
||||||
if (this.layers.pins) this.drawPins(ctx);
|
if (this.layers.pins) this.drawPins(ctx);
|
||||||
if (this.layers.players) this.drawPlayers(ctx);
|
if (this.layers.players) this.drawPlayers(ctx);
|
||||||
@@ -429,7 +434,7 @@ export class MapView {
|
|||||||
ctx.imageSmoothingEnabled = this.scale < 1;
|
ctx.imageSmoothingEnabled = this.scale < 1;
|
||||||
ctx.imageSmoothingQuality = 'high';
|
ctx.imageSmoothingQuality = 'high';
|
||||||
// A faint chunk grid when close enough to matter.
|
// A faint chunk grid when close enough to matter.
|
||||||
if (this.scale >= 3) this.drawGrid(ctx);
|
if (this.scale >= 3 && this.scale < 6) this.drawGrid(ctx);
|
||||||
for (let ty = y0; ty <= y1; ty++) {
|
for (let ty = y0; ty <= y1; ty++) {
|
||||||
for (let tx = x0; tx <= x1; tx++) {
|
for (let tx = x0; tx <= x1; tx++) {
|
||||||
if (!this.inBounds(zoom, tx, ty)) continue;
|
if (!this.inBounds(zoom, tx, ty)) continue;
|
||||||
@@ -452,6 +457,51 @@ export class MapView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close up, every block is its own cell: a fine texture, a light and a dark edge, stronger chunk lines, and an outline on the
|
||||||
|
* block under the cursor. The tiles only know each block's colour, so the texture is drawn on top rather than stored.
|
||||||
|
*/
|
||||||
|
private drawBlocks(ctx: CanvasRenderingContext2D) {
|
||||||
|
const strength = clamp((this.scale - 6) / 10, 0, 1);
|
||||||
|
if (!this.texture) this.texture = makeTexture(ctx);
|
||||||
|
if (this.texture) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 0.55 * strength + 0.25;
|
||||||
|
ctx.globalCompositeOperation = 'overlay';
|
||||||
|
const k = this.scale / 16 * this.dpr;
|
||||||
|
// Pattern covers 8 x 8 blocks (128 px); align it to the world so blocks stay put while panning.
|
||||||
|
this.texture.setTransform(new DOMMatrix([k, 0, 0, k, this.dpr * (this.width / 2 - this.cx * this.scale), this.dpr * (this.height / 2 - this.cz * this.scale)]));
|
||||||
|
ctx.fillStyle = this.texture;
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||||
|
// Chunk borders every 16 blocks.
|
||||||
|
const a = this.toWorld(0, 0);
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.34)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let x = Math.floor(a.x / 16) * 16; (x - this.cx) * this.scale + this.width / 2 < this.width + 20; x += 16) {
|
||||||
|
const sx = Math.round((x - this.cx) * this.scale + this.width / 2) + 0.5;
|
||||||
|
ctx.moveTo(sx, 0); ctx.lineTo(sx, this.height);
|
||||||
|
}
|
||||||
|
for (let z = Math.floor(a.z / 16) * 16; (z - this.cz) * this.scale + this.height / 2 < this.height + 20; z += 16) {
|
||||||
|
const sy = Math.round((z - this.cz) * this.scale + this.height / 2) + 0.5;
|
||||||
|
ctx.moveTo(0, sy); ctx.lineTo(this.width, sy);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
if (this.hoverBlock && this.scale >= 8) {
|
||||||
|
const s = this.toScreen(this.hoverBlock.x, this.hoverBlock.z);
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.strokeRect(s.x + 1, s.y + 1, this.scale - 2, this.scale - 2);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = 'rgba(0,0,0,0.6)';
|
||||||
|
ctx.strokeRect(s.x - 0.5, s.y - 0.5, this.scale + 1, this.scale + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private drawGrid(ctx: CanvasRenderingContext2D) {
|
private drawGrid(ctx: CanvasRenderingContext2D) {
|
||||||
const step = 16 * this.scale;
|
const step = 16 * this.scale;
|
||||||
const a = this.toWorld(0, 0);
|
const a = this.toWorld(0, 0);
|
||||||
@@ -685,3 +735,36 @@ function drawGlyph(ctx: CanvasRenderingContext2D, kind: string, x: number, y: nu
|
|||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An 8 x 8 block texture sheet (16 px per block): fine noise that differs block to block, a light top-left edge and a dark
|
||||||
|
* bottom-right edge, as grey values for the "overlay" blend (mid grey = no change).
|
||||||
|
*/
|
||||||
|
function makeTexture(ctx: CanvasRenderingContext2D): CanvasPattern | null {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = c.height = 128;
|
||||||
|
const g = c.getContext('2d');
|
||||||
|
if (!g) return null;
|
||||||
|
g.fillStyle = '#808080';
|
||||||
|
g.fillRect(0, 0, 128, 128);
|
||||||
|
let seed = 1234567;
|
||||||
|
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
|
||||||
|
for (let by = 0; by < 8; by++) {
|
||||||
|
for (let bx = 0; bx < 8; bx++) {
|
||||||
|
const ox = bx * 16, oy = by * 16;
|
||||||
|
const base = 120 + Math.floor(rnd() * 16); // each block a touch lighter or darker
|
||||||
|
g.fillStyle = `rgb(${base},${base},${base})`;
|
||||||
|
g.fillRect(ox, oy, 16, 16);
|
||||||
|
for (let i = 0; i < 4; i++) { // 4 x 4 chunky speckles, like a texture
|
||||||
|
for (let j = 0; j < 4; j++) {
|
||||||
|
const v = base + Math.floor((rnd() - 0.5) * 34);
|
||||||
|
g.fillStyle = `rgb(${v},${v},${v})`;
|
||||||
|
g.fillRect(ox + i * 4, oy + j * 4, 4, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.fillStyle = 'rgba(255,255,255,0.55)'; g.fillRect(ox, oy, 16, 1); g.fillRect(ox, oy, 1, 16);
|
||||||
|
g.fillStyle = 'rgba(0,0,0,0.6)'; g.fillRect(ox, oy + 15, 16, 1); g.fillRect(ox + 15, oy, 1, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx.createPattern(c, 'repeat');
|
||||||
|
}
|
||||||
Reference in new issue
Block a user