diff --git a/shared/map/engine.ts b/shared/map/engine.ts index f28601f..0089e7e 100644 --- a/shared/map/engine.ts +++ b/shared/map/engine.ts @@ -31,7 +31,7 @@ export interface MapViewOptions { interface Tile { img: HTMLImageElement; state: 'loading' | 'ok' | 'missing'; at: number; used: number } const MIN_SCALE = 1 / 128; -const MAX_SCALE = 20; +const MAX_SCALE = 48; const TILE = 256; const MAX_LOADS = 10; @@ -63,6 +63,8 @@ export class MapView { private pinchStart: { dist: number; scale: number } | null = null; private disposed = false; private resizeObserver: ResizeObserver; + private texture: CanvasPattern | null = null; + private hoverBlock: { x: number; z: number } | null = null; constructor(private canvas: HTMLCanvasElement, private opts: MapViewOptions) { const ctx = canvas.getContext('2d'); @@ -261,6 +263,8 @@ export class MapView { if (id !== this.hoverId) { this.hoverId = id; this.invalidate(); } this.canvas.style.cursor = hit ? 'pointer' : 'grab'; 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 }); }; @@ -277,7 +281,7 @@ export class MapView { }; 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 }); }; @@ -413,6 +417,7 @@ export class MapView { if (!this.dimension || !this.info) return; this.drawTiles(ctx); 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.pins) this.drawPins(ctx); if (this.layers.players) this.drawPlayers(ctx); @@ -429,7 +434,7 @@ export class MapView { ctx.imageSmoothingEnabled = this.scale < 1; ctx.imageSmoothingQuality = 'high'; // 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 tx = x0; tx <= x1; tx++) { 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) { const step = 16 * this.scale; const a = this.toWorld(0, 0); @@ -685,3 +735,36 @@ function drawGlyph(ctx: CanvasRenderingContext2D, kind: string, x: number, y: nu } 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'); +}