diff --git a/src/lib/components/MiniMap.svelte b/src/lib/components/MiniMap.svelte index 756a0d0..2f5068d 100644 --- a/src/lib/components/MiniMap.svelte +++ b/src/lib/components/MiniMap.svelte @@ -23,6 +23,8 @@ $: mapWidth = $gameConfig.mapSize || 75; $: mapHeight = $gameConfig.mapSize || 75; + $: minimapWidth = mapHeight; + $: minimapHeight = mapWidth; const terrainColor = (type: TerrainType) => { switch (type) { @@ -49,15 +51,19 @@ const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); + const toMinimap = (x: number, y: number) => ({ x: mapHeight - 1 - y, y: x }); + const fromMinimap = (x: number, y: number) => ({ x: y, y: mapHeight - 1 - x }); + const minimapBounds = () => { if (dragBounds) return dragBounds; - const cols = mapWidth / minimapZoom; - const rows = mapHeight / minimapZoom; + const cols = minimapWidth / minimapZoom; + const rows = minimapHeight / minimapZoom; + const center = toMinimap($mapCenter.x, $mapCenter.y); return { cols, rows, - left: clamp($mapCenter.x - cols / 2, 0, mapWidth - cols), - top: clamp($mapCenter.y - rows / 2, 0, mapHeight - rows) + left: clamp(center.x - cols / 2, 0, minimapWidth - cols), + top: clamp(center.y - rows / 2, 0, minimapHeight - rows) }; }; @@ -75,19 +81,20 @@ if ($tiles.size) { const firstCol = Math.max(0, Math.floor(left)); - const lastCol = Math.min(mapWidth, Math.ceil(left + cols)); + const lastCol = Math.min(minimapWidth, Math.ceil(left + cols)); const firstRow = Math.max(0, Math.floor(top)); - const lastRow = Math.min(mapHeight, Math.ceil(top + rows)); - for (let y = firstRow; y < lastRow; y++) { - for (let x = firstCol; x < lastCol; x++) { + const lastRow = Math.min(minimapHeight, Math.ceil(top + rows)); + for (let minimapY = firstRow; minimapY < lastRow; minimapY++) { + for (let minimapX = firstCol; minimapX < lastCol; minimapX++) { + const { x, y } = fromMinimap(minimapX, minimapY); const visibility = visibilityAt(x, y); if (visibility === TileVisibilityState.UNEXPLORED) continue; const color = terrainColor($tiles.get(tileKey(x, y))?.terrain ?? TerrainType.GRASSLAND); ctx.fillStyle = visibility === TileVisibilityState.VISIBLE ? color : `${color}88`; - const x1 = Math.floor((x - left) * scaleX); - const y1 = Math.floor((y - top) * scaleY); - const x2 = Math.ceil((x + 1 - left) * scaleX); - const y2 = Math.ceil((y + 1 - top) * scaleY); + const x1 = Math.floor((minimapX - left) * scaleX); + const y1 = Math.floor((minimapY - top) * scaleY); + const x2 = Math.ceil((minimapX + 1 - left) * scaleX); + const y2 = Math.ceil((minimapY + 1 - top) * scaleY); ctx.fillRect(x1, y1, x2 - x1, y2 - y1); } } @@ -98,8 +105,9 @@ if (!c.start) continue; const own = c.owner?.value === $userId; if (!own && visibilityAt(c.start.x, c.start.y) !== TileVisibilityState.VISIBLE) continue; + const position = toMinimap(c.start.x, c.start.y + c.size - 1); ctx.fillStyle = own ? 'rgba(68,153,255,0.5)' : c.owner ? 'rgba(221,68,68,0.5)' : 'rgba(153,153,153,0.4)'; - ctx.fillRect(Math.floor((c.start.x - left) * scaleX), Math.floor((c.start.y - top) * scaleY), Math.ceil(c.size * scaleX), Math.ceil(c.size * scaleY)); + ctx.fillRect(Math.floor((position.x - left) * scaleX), Math.floor((position.y - top) * scaleY), Math.ceil(c.size * scaleX), Math.ceil(c.size * scaleY)); } // Buildings as bright pixels. @@ -107,24 +115,27 @@ for (const b of $buildings) { if (!b.coords) continue; if (visibilityAt(b.coords.x, b.coords.y) !== TileVisibilityState.VISIBLE) continue; - ctx.fillRect(Math.floor((b.coords.x - left) * scaleX), Math.floor((b.coords.y - top) * scaleY), Math.max(1, scaleX), Math.max(1, scaleY)); + const position = toMinimap(b.coords.x, b.coords.y); + ctx.fillRect(Math.floor((position.x - left) * scaleX), Math.floor((position.y - top) * scaleY), Math.max(1, scaleX), Math.max(1, scaleY)); } // Armies use owner colors and a slightly larger dot than buildings. for (const army of $armies) { if (!army.coords) continue; if (visibilityAt(army.coords.x, army.coords.y) !== TileVisibilityState.VISIBLE) continue; + const position = toMinimap(army.coords.x, army.coords.y); ctx.fillStyle = army.owner?.value === $userId ? '#60a5fa' : '#f87171'; - ctx.fillRect(Math.floor((army.coords.x - left) * scaleX) - 1, Math.floor((army.coords.y - top) * scaleY) - 1, Math.max(2, scaleX + 1), Math.max(2, scaleY + 1)); + ctx.fillRect(Math.floor((position.x - left) * scaleX) - 1, Math.floor((position.y - top) * scaleY) - 1, Math.max(2, scaleX + 1), Math.max(2, scaleY + 1)); } // Viewport rectangle from the live map center + visible span. if (viewCols > 0 && viewRows > 0) { - const vx = ($mapCenter.x - viewCols / 2 - left) * scaleX; - const vy = ($mapCenter.y - viewRows / 2 - top) * scaleY; + const center = toMinimap($mapCenter.x, $mapCenter.y); + const vx = (center.x - viewRows / 2 - left) * scaleX; + const vy = (center.y - viewCols / 2 - top) * scaleY; ctx.strokeStyle = '#efe2c4'; ctx.lineWidth = 1; - ctx.strokeRect(Math.round(vx) + 0.5, Math.round(vy) + 0.5, Math.round(viewCols * scaleX), Math.round(viewRows * scaleY)); + ctx.strokeRect(Math.round(vx) + 0.5, Math.round(vy) + 0.5, Math.round(viewRows * scaleX), Math.round(viewCols * scaleY)); } ctx.strokeStyle = 'rgba(255,255,255,0.12)'; @@ -151,8 +162,9 @@ const panFromPointer = (clientX: number, clientY: number) => { const rect = canvas.getBoundingClientRect(); const { cols, rows, left, top } = minimapBounds(); - const col = clamp(Math.round(left + ((clientX - rect.left) / rect.width) * cols), 0, mapWidth - 1); - const row = clamp(Math.round(top + ((clientY - rect.top) / rect.height) * rows), 0, mapHeight - 1); + const position = fromMinimap(left + ((clientX - rect.left) / rect.width) * cols, top + ((clientY - rect.top) / rect.height) * rows); + const col = clamp(Math.round(position.x), 0, mapWidth - 1); + const row = clamp(Math.round(position.y), 0, mapHeight - 1); onPan(col, row); }; diff --git a/src/routes/game/+page.svelte b/src/routes/game/+page.svelte index d0cf0a3..3c10ded 100644 --- a/src/routes/game/+page.svelte +++ b/src/routes/game/+page.svelte @@ -78,6 +78,9 @@ // tiles let loaded = new Map(); + let visibleTileKeys = new Set(); + let visibleBoundsKey = ''; + let tileLabels = new Map(); let tileData = new Map(); let constructionGfx = new Map(); let starvingGfx = new Map(); @@ -679,6 +682,12 @@ return screenToTile((-cont.x + cw / 2) / cont.scale.x, (-cont.y + ch / 2) / cont.scale.y); }; + const syncMapCenter = () => { + const center = getCenter(); + if (center.x === $mapCenter.x && center.y === $mapCenter.y) return; + mapCenter.set(center); + }; + // Clamp a container position to the iso map's screen-space AABB (pure). const clampPos = (x: number, y: number, s: number) => { const pad = 200; @@ -700,7 +709,7 @@ cont.y = tgtY; cont.scale.set(tgtScale); loadVisible(); - mapCenter.set(getCenter()); + syncMapCenter(); }; const centerCam = (col: number, row: number, snap = false) => { @@ -846,14 +855,17 @@ cont.y = ny; cont.scale.set(ns); loadVisible(); - mapCenter.set(getCenter()); + syncMapCenter(); }; // ── data actions ──────────────────────────────────────── const rebuildTiles = () => { for (const [, c] of loaded) c.destroy({ children: true }); loaded.clear(); + visibleTileKeys.clear(); + visibleBoundsKey = ''; for (const label of labelLayer?.removeChildren() ?? []) label.destroy({ children: true }); + tileLabels.clear(); constructionGfx.clear(); starvingGfx.clear(); trainingGfx.clear(); @@ -1484,6 +1496,7 @@ const c = clampPos(tgtX, tgtY, tgtScale); tgtX = c.x; tgtY = c.y; + visibleBoundsKey = ''; loadVisible(); }; @@ -1493,7 +1506,7 @@ app = new Application(); await app.init({ width: cw, height: ch, backgroundColor: 0x171c18, antialias: false, roundPixels: true, resolution: window.devicePixelRatio || 1, autoDensity: true }); el.appendChild(app.canvas); - cont = new Container(); + cont = new Container({ isRenderGroup: true }); cont.sortableChildren = true; cont.interactive = true; cont.hitArea = new Rectangle(-1e5, -1e5, 2e5, 2e5); @@ -1633,7 +1646,7 @@ }; // ── tile rendering ────────────────────────────────────── - const addCityLabel = (city: City, px: number, py: number) => { + const addCityLabel = (city: City, px: number, py: number, key: string) => { const owned = city.owner?.value === $userId; const ownerColor = owned ? 0x336ca8 : city.owner ? 0x9b3f38 : 0x66685f; const wrapper = new Container(); @@ -1676,6 +1689,7 @@ wrapper.addChild(badge, population, name); wrapper.pivot.x = (badgeWidth + 4 + name.width) / 2; labelLayer.addChild(wrapper); + tileLabels.set(key, wrapper); }; const addTrainingMarker = (building: Building, tile: Container, key: string) => { @@ -1875,7 +1889,13 @@ const renderTile = (col: number, row: number) => { if (col < 0 || row < 0 || col >= worldWidth || row >= worldHeight) return; const k = tileKey(col, row); - if (loaded.has(k)) return; + const existing = loaded.get(k); + if (existing) { + existing.visible = true; + const label = tileLabels.get(k); + if (label) label.visible = true; + return; + } const td = tileData.get(k); const { sx: px, sy: py } = tileToScreen(col, row); @@ -1912,7 +1932,7 @@ tc.addChild(memoryFog); } if (visible && td?.building) tc.addChild(getStructureSprite(structureKind(td.building.type))); - if (visible && td?.city && (td.building?.type === BuildingType.CITY_CENTER || td.building?.type === BuildingType.TOWN_CENTER)) addCityLabel(td.city, px, py); + if (visible && td?.city && (td.building?.type === BuildingType.CITY_CENTER || td.building?.type === BuildingType.TOWN_CENTER)) addCityLabel(td.city, px, py, k); const visibleArmies = visible ? td?.armies : undefined; if (visibleArmies?.length) { @@ -2091,10 +2111,32 @@ colMax = Math.ceil(xMax) + 2; const rowMin = Math.floor(yMin) - 2, rowMax = Math.ceil(yMax) + 4; + const firstCol = Math.max(0, colMin); + const lastCol = Math.min(worldWidth - 1, colMax); + const firstRow = Math.max(0, rowMin); + const lastRow = Math.min(worldHeight - 1, rowMax); + const boundsKey = `${firstCol},${lastCol},${firstRow},${lastRow}`; + if (boundsKey === visibleBoundsKey) return; + visibleBoundsKey = boundsKey; // Feed the minimap's viewport rectangle (tile-space AABB of the view). viewTilesW = xMax - xMin; viewTilesH = yMax - yMin; - for (let col = colMin; col <= colMax; col++) for (let row = rowMin; row <= rowMax; row++) renderTile(col, row); + const nextVisibleTileKeys = new Set(); + for (let col = firstCol; col <= lastCol; col++) { + for (let row = firstRow; row <= lastRow; row++) { + const key = tileKey(col, row); + nextVisibleTileKeys.add(key); + renderTile(col, row); + } + } + for (const key of visibleTileKeys) { + if (nextVisibleTileKeys.has(key)) continue; + const tile = loaded.get(key); + if (tile) tile.visible = false; + const label = tileLabels.get(key); + if (label) label.visible = false; + } + visibleTileKeys = nextVisibleTileKeys; }; // ── selection ─────────────────────────────────────────── @@ -2164,7 +2206,7 @@ lastMoveX = p.x; lastMoveY = p.y; loadVisible(); - mapCenter.set(getCenter()); + syncMapCenter(); }); cont.on('pointerup', (e) => { @@ -2204,7 +2246,7 @@ } else if (!easeMotion || performance.now() - lastMoveT > 80) { // No flick (or motion easing off): stop where released. velX = velY = 0; - mapCenter.set(getCenter()); + syncMapCenter(); } else { // Flick: cap the throw speed; the ticker coasts the target from here. velX = Math.max(-4000, Math.min(4000, velX)); @@ -2214,7 +2256,7 @@ }); cont.on('pointerupoutside', () => { - if (drag) mapCenter.set(getCenter()); + if (drag) syncMapCenter(); drag = false; });