├── .gitignore ├── requirements.txt ├── WorldMap ├── manifest.json ├── Patches.cs └── Wishes.cs ├── style.css ├── index.html ├── README.md ├── create_tiles.py └── script.js /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | worldmap/ 3 | tiles/ 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow==11.0.0 2 | tqdm>=4.67.0 3 | -------------------------------------------------------------------------------- /WorldMap/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "Kernelmethod_WorldMap", 3 | "title": "World Map", 4 | "description": "", 5 | "version": "0.1.0", 6 | "author": "{{G|kernelmethod}}", 7 | "tags": "", 8 | "PreviewImage": "" 9 | } 10 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | #map { 2 | height: 1024px; 3 | } 4 | 5 | .pixel-perfect .leaflet-image-layer, 6 | .pixel-perfect .leaflet-tile { 7 | image-rendering: pixelated; 8 | } 9 | 10 | .leaflet-tile { 11 | color: red; 12 | font-size: 12px; 13 | font-weight: bold; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /WorldMap/Patches.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | using Qud.API; 3 | 4 | namespace Kernelmethod.WorldMap.Patches { 5 | [HarmonyPatch(typeof(IBaseJournalEntry), nameof(IBaseJournalEntry.DisplayMessage))] 6 | public static class IBaseJournalEntryPatches { 7 | public static bool DisableMessages = false; 8 | 9 | public static bool Prefix() { 10 | return !DisableMessages; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 10 | 11 | 12 | 13 |

Qud world map

14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Caves of Qud - world map viewer 2 | 3 | This repository contains a very loose collection of scripts to export the 4 | surface map of a Caves of Qud run and viewing them in the browser. 5 | 6 | For a real-world example, check out https://kernelmethod.org/notes/qud_worldmap/ 7 | 8 | > **WARNING:** the zone export process takes up a lot of memory. I was able to 9 | > run it on a laptop with 16GB of memory, but YMMV. 10 | 11 | > **WARNING:** do not use this for a game that you want to play. The export 12 | > setup procedure will grant you a ton of XP and reveal every location on the 13 | > world map, which you might not particularly want. 14 | > 15 | > If you really wish to export the world map for a run that you're still 16 | > playing, you should make a copy of the save and export the world map from 17 | > that copy. 18 | 19 | ## Step 1: generate tiles 20 | 21 | The first (and most tedious) step is to export an image of every zone of the 22 | world from the game. 23 | 24 | In order to do this, Caves of Qud must build every zone in the z-level that 25 | you're exporting, then take a snapshot of the zone. It has to do this 18,000 26 | times for each zone in the same z-level. 27 | 28 | An added challenge here (and the part that makes this require a lot of work) is 29 | that there are memory leaks in parts of the zone generation process (@gnarf 30 | points out that this is likely a memory leak in the waveform collapse 31 | implementation used by Caves of Qud -- thanks for the tip!) So to do this 32 | export, **you will need to manually restart the game 25 times, one for each 33 | y-slice of parasangs that you want to export**. 34 | 35 | ### Step 1.1: install the `WorldMap/` subdirectory as an offline mod 36 | 37 | Follow the "Manual Download" step on the 38 | ["Installing a Mod" wiki page](https://wiki.cavesofqud.com/wiki/Modding:Installing_a_mod#Manual_Download). 39 | This just entails copying the `WorldMap/` folder into the correct directory for 40 | your operating system. 41 | 42 | ### Step 1.2: start the game and run the `exportworldsetup` wish 43 | 44 | Start Caves of Qud and open the game that you want to export the world map for. 45 | Run the `exportworldsetup` wish; this will reveal village locations, historic 46 | sites, and more. It'll also grant you a lot of XP (since you will occasionally 47 | gain XP when revealing a zone, and you don't want to have to deal with a 48 | hundred level-up prompts while exporting these zones). 49 | 50 | Save and quit, then re-open the game. 51 | 52 | ### Step 1.3: export first strip of parasangs 53 | 54 | Now run the `exportworld` wish. You will be prompted for the following: 55 | 56 | - **The location you want to save the map screenshots to.** I've tried to 57 | choose a reasonable default location in your 58 | [configuration files](https://wiki.cavesofqud.com/wiki/File_locations) 59 | directory, but you may wish to use a different location. 60 | - **The y-level you wish to slice.** This is the y-coordinate of the parasangs 61 | that you're going to export. For the first iteration you will want to use a 62 | y-coordinate of `0`. 63 | - **The z-level you wish to slice.** This is the z-coordinate of the parasangs 64 | that you're going to export. Use the default of `10` for the surface map. For 65 | underground maps, you'll need to use the corresponding z-coordinate, e.g. 66 | `11` for the first layer under the surface. 67 | 68 | The game will then run through all of the parasangs with a y-coordinate of `0`, 69 | build them, and export images of them to the directory that you provided. 70 | 71 | ### Step 1.4: repeat for each y-strip of parasangs 72 | 73 | You will now need to restart the game and repeat the previous step 24 more 74 | times. :) 75 | 76 | Because of a memory leak in zone generation, the game won't de-allocate the 77 | memory it used to build zones even after you save and quit. So you'll need to 78 | _fully exit and restart the Caves of Qud program_ before you can export the 79 | next strip of parasangs. 80 | 81 | For the next strip, you'll want to export the strip with a y-coordinate of `1`; 82 | then you'll want to export the strip with a y-coordinate of `2`; and so on, up 83 | until y-coordinate `24`. 84 | 85 | ## Step 2: convert to square tiles 86 | 87 | Once you've exported all of the zones, run the `convert_tiles.py` script. You 88 | may need to install dependencies from `requirements.txt` first, e.g. using 89 | 90 | ``` 91 | python3 -m pip install -r requirements.txt 92 | ``` 93 | 94 | This script will do three things: 95 | 96 | - It'll convert all of the tiles to lossless `.webp` rather than `.png`, which 97 | will use a lot less storage and bandwidth. 98 | - It'll convert all of your screenshots to squares, which Leaflet.js needs to 99 | render your map correctly. 100 | - It'll combine the square tiles into larger tiles covering 2400px x 2400px 101 | regions, for when viewers are still relatively zoomed-out from the map. 102 | 103 | Run the script with 104 | 105 | ``` 106 | python3 convert_tiles.py \ 107 | --worldmap_dir /wherever/you/stored/all/of/your/screenshots \ 108 | --output ./tiles/ 109 | ``` 110 | 111 | E.g. if you're on Linux and you used the default screenshot location, you would 112 | run 113 | 114 | ``` 115 | python3 convert_tiles.py \ 116 | --worldmap_dir ~/.config/unity3d/Freehold\ Games/CavesOfQud/worldmap \ 117 | --output ./tiles/ 118 | ``` 119 | 120 | ## Step 3: run the proof-of-concept web page 121 | 122 | Once you've converted all of the tiles, you can use the web server of your 123 | choice to serve the proof-of-concept `index.html` page. My preferred method 124 | is just to use Python's built-in server: 125 | 126 | ``` 127 | python3 -m http.server 128 | ``` 129 | 130 | And that's it! If everything went correctly you should be able to see the 131 | tiled world map in your browser. 132 | -------------------------------------------------------------------------------- /create_tiles.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Create square tiles using zone images from the game. 5 | """ 6 | 7 | import argparse 8 | import math 9 | import os 10 | import shutil 11 | from PIL import Image 12 | from pathlib import Path 13 | from tqdm import tqdm 14 | 15 | # Dimension for the height and width of each tile. Must divide the height and 16 | # width of the entire map. 17 | TILE_LENGTH = 600 18 | 19 | ZONE_HEIGHT_PX = 25 * 24 20 | ZONE_WIDTH_PX = 80 * 16 21 | MAP_HEIGHT_PX = (25 * 3) * ZONE_HEIGHT_PX 22 | MAP_WIDTH_PX = (80 * 3) * ZONE_WIDTH_PX 23 | 24 | # Scale factor for second layer of tiles 25 | SCALE_FACTOR = 4 26 | LARGE_TILE_LENGTH = TILE_LENGTH * SCALE_FACTOR 27 | 28 | if MAP_HEIGHT_PX % TILE_LENGTH != 0: 29 | raise Exception("Tile length must divide height of world map in pixels") 30 | 31 | if MAP_WIDTH_PX % TILE_LENGTH != 0: 32 | raise Exception("Tile width must divide width of world map in pixels") 33 | 34 | tile_width = MAP_WIDTH_PX // TILE_LENGTH 35 | tile_height = MAP_HEIGHT_PX // TILE_LENGTH 36 | 37 | 38 | def clamp(x: int, min: int, max: int) -> int: 39 | if x < min: 40 | return min 41 | if x > max: 42 | return max 43 | return x 44 | 45 | 46 | def get_zone_for_pixel(x: int, y: int, z: int) -> str: 47 | # Pixel is interpreted with (0, 0) in the top left of the world map 48 | wx = x // (ZONE_WIDTH_PX * 3) 49 | wy = y // (ZONE_HEIGHT_PX * 3) 50 | px = (x // ZONE_WIDTH_PX) % 3 51 | py = (y // ZONE_HEIGHT_PX) % 3 52 | 53 | return f"JoppaWorld.{wx}.{wy}.{px}.{py}.{z}" 54 | 55 | 56 | def fetch_rectangle( 57 | bottom: tuple[int, int], 58 | top: tuple[int, int], 59 | z: int, 60 | basedir: str | os.PathLike | None = None, 61 | ) -> Image: 62 | """Fetch a rectangle of pixels using the bounding box defined by the 63 | provided coordinates.""" 64 | 65 | if basedir is None: 66 | basedir = Path("worldmap") 67 | if isinstance(basedir, str): 68 | basedir = Path(basedir) 69 | 70 | assert bottom <= top 71 | 72 | tile = Image.new("RGB", (top[0] - bottom[0], top[1] - bottom[1])) 73 | 74 | # Stride over the pixels row-wise 75 | x = bottom[0] 76 | while x < top[0]: 77 | x_max = x + ZONE_WIDTH_PX 78 | x_max -= x % ZONE_WIDTH_PX 79 | x_max = clamp(x_max, bottom[0], top[0]) 80 | 81 | y = bottom[1] 82 | 83 | while y < top[1]: 84 | # Get the zone containing this pixel 85 | zoneid = get_zone_for_pixel(x, y, z) 86 | zone_img = Image.open(basedir / f"{zoneid}.png") 87 | 88 | # Crop zone 89 | y_max = y + ZONE_HEIGHT_PX 90 | y_max -= y % ZONE_HEIGHT_PX 91 | y_max = clamp(y_max, bottom[1], top[1]) 92 | assert x_max >= x 93 | assert y_max >= y 94 | 95 | x_zone = x % ZONE_WIDTH_PX 96 | y_zone = y % ZONE_HEIGHT_PX 97 | x_zone_max = clamp((x_max - x) + x_zone, 0, ZONE_WIDTH_PX) 98 | y_zone_max = clamp((y_max - y) + y_zone, 0, ZONE_HEIGHT_PX) 99 | assert x_zone < x_zone_max 100 | assert y_zone < y_zone_max 101 | 102 | zone_img_cropped = zone_img.crop((x_zone, y_zone, x_zone_max, y_zone_max)) 103 | 104 | # Paste into tile 105 | x_tile = x - bottom[0] 106 | y_tile = y - bottom[1] 107 | 108 | tile.paste(zone_img_cropped, (x_tile, y_tile)) 109 | 110 | y = y_max 111 | 112 | x = x_max 113 | 114 | # Return constructed tile 115 | return tile 116 | 117 | 118 | def main(args) -> None: 119 | num_tiles = MAP_HEIGHT_PX * MAP_WIDTH_PX // (TILE_LENGTH**2) 120 | pbar_iterator = iter(pbar := tqdm(range(num_tiles))) 121 | 122 | basedir = Path(args.worldmap_dir) 123 | 124 | (outdir := Path(args.output)).mkdir(exist_ok=True) 125 | world = Image.open(basedir / "world.png") 126 | world.save(outdir / "world.webp", lossless=True) 127 | 128 | for x in range(0, MAP_WIDTH_PX // TILE_LENGTH): 129 | for y in range(0, MAP_HEIGHT_PX // TILE_LENGTH): 130 | if (outpath := outdir / f"tile_0_{x}_{y}_{args.zlevel}.webp").exists(): 131 | next(pbar_iterator) 132 | continue 133 | 134 | pixel_x = x * TILE_LENGTH 135 | pixel_y = y * TILE_LENGTH 136 | 137 | upper_corner = (pixel_x, pixel_y) 138 | lower_corner = (pixel_x + TILE_LENGTH, pixel_y + TILE_LENGTH) 139 | 140 | tile = fetch_rectangle( 141 | upper_corner, lower_corner, args.zlevel, basedir=basedir 142 | ) 143 | tile.save(outpath, lossless=True) 144 | i = next(pbar_iterator) 145 | 146 | # Recombine tiles into next tier 147 | 148 | num_tiles = math.ceil(MAP_HEIGHT_PX / LARGE_TILE_LENGTH) * math.ceil( 149 | MAP_WIDTH_PX / LARGE_TILE_LENGTH 150 | ) 151 | pbar_iterator = iter(tqdm(range(num_tiles))) 152 | 153 | for x in range(0, math.ceil(MAP_WIDTH_PX / LARGE_TILE_LENGTH)): 154 | for y in range(0, math.ceil(MAP_HEIGHT_PX / LARGE_TILE_LENGTH)): 155 | if (outpath := outdir / f"tile_1_{x}_{y}_{args.zlevel}.webp").exists(): 156 | continue 157 | 158 | tile = Image.new("RGBA", (TILE_LENGTH, TILE_LENGTH), (255, 0, 0, 0)) 159 | 160 | tile_x_start = 4 * x 161 | tile_x_end = min(4 * x + 4, MAP_WIDTH_PX // TILE_LENGTH) 162 | tile_y_start = 4 * y 163 | tile_y_end = min(4 * y + 4, MAP_HEIGHT_PX // TILE_LENGTH) 164 | 165 | for tile_x in range(tile_x_start, tile_x_end): 166 | for tile_y in range(tile_y_start, tile_y_end): 167 | subtile_path = ( 168 | outdir / f"tile_0_{tile_x}_{tile_y}_{args.zlevel}.webp" 169 | ) 170 | 171 | corner_x = (tile_x - tile_x_start) * (TILE_LENGTH // SCALE_FACTOR) 172 | corner_y = (tile_y - tile_y_start) * (TILE_LENGTH // SCALE_FACTOR) 173 | 174 | subtile = Image.open(subtile_path) 175 | subtile = subtile.resize( 176 | (TILE_LENGTH // SCALE_FACTOR, TILE_LENGTH // SCALE_FACTOR), 177 | Image.Resampling.LANCZOS, 178 | ) 179 | tile.paste(subtile, (corner_x, corner_y)) 180 | 181 | tile.save(outpath) 182 | i = next(pbar_iterator) 183 | 184 | 185 | if __name__ == "__main__": 186 | parser = argparse.ArgumentParser() 187 | parser.add_argument( 188 | "-w", 189 | "--worldmap_dir", 190 | default="./worldmap", 191 | help="Directory containing exported zone images", 192 | ) 193 | parser.add_argument( 194 | "-o", "--output", default="./tiles", help="Default directory to write output to" 195 | ) 196 | parser.add_argument( 197 | "-z", "--zlevel", type=int, default=10, help="z-level to create tiles for" 198 | ) 199 | 200 | args = parser.parse_args() 201 | main(args) 202 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | const attribution = `Caves of Qud`; 2 | const ZLEVEL = 10; 3 | 4 | /* Map element */ 5 | const mapEl = document.getElementById("map"); 6 | 7 | /* Zoom level at which we should transition away from the world map 8 | * overlay and switch to tiles. */ 9 | const tileZoomThreshold_L1 = 3; 10 | const tileZoomThreshold_L0 = tileZoomThreshold_L1 + 2; 11 | 12 | /* Zoom level at which we should apply pixelated image rendering 13 | * for tiles. */ 14 | const pixelatedZoomThreshold = 6; 15 | 16 | const worldMapPixelWidth = 80 * 16; 17 | const worldMapPixelHeight = 25 * 24; 18 | 19 | const tiledMapWidth_L1 = 128; 20 | const tiledMapHeight_L1 = 19; 21 | 22 | const tiledMapWidth_L0 = 512; 23 | const tiledMapHeight_L0 = 75; 24 | 25 | // const tiledMapPixelWidth = tiledMapWidth_L0 * 16; 26 | // const tiledMapPixelHeight = tiledMapHeight_L0 * 16; 27 | const tiledMapPixelWidth_L1 = tiledMapWidth_L1 * 32; 28 | const tiledMapPixelHeight_L1 = tiledMapHeight_L1 * 32; 29 | 30 | function clamp(num, min, max) { 31 | if (max !== null && num > max) 32 | return max; 33 | if (min !== null && num < min) 34 | return min; 35 | return num; 36 | } 37 | 38 | var bounds = [[0, 0], [worldMapPixelHeight, worldMapPixelWidth]]; 39 | 40 | var map = L.map("map", { 41 | crs: L.CRS.Simple, 42 | minZoom: 0, 43 | maxZoom: 10, 44 | zoomSnap: 1, 45 | zoomDelta: 0.5, 46 | center: [worldMapPixelHeight / 2, worldMapPixelWidth / 2] 47 | }); 48 | map.fitBounds(bounds); 49 | 50 | var startCoords = map.getCenter(); 51 | var startZoom = map.getZoom(); 52 | 53 | map.on("zoomstart", function() { 54 | startCoords = map.getCenter(); 55 | startZoom = map.getZoom(); 56 | }); 57 | 58 | map.on("zoomend", function() { 59 | if (map.getZoom() >= tileZoomThreshold_L1 && map.hasLayer(worldMapOverlay)) { 60 | // Based on the previous coordinates, pan to the corresponding location 61 | // in the tiled map. 62 | let coords = map.getCenter(); 63 | let fracX = coords.lng / worldMapPixelWidth; 64 | let fracY = coords.lat / worldMapPixelHeight; 65 | 66 | let tiledMapX = fracX * tiledMapPixelWidth_L1; 67 | let tiledMapY = fracY * tiledMapPixelHeight_L1; 68 | 69 | map.panTo([tiledMapY, tiledMapX], { 70 | animate: false 71 | }); 72 | 73 | map.removeLayer(worldMapOverlay); 74 | } 75 | if (map.getZoom() < tileZoomThreshold_L1 && !map.hasLayer(worldMapOverlay)) { 76 | // Based on the previous coordinates, pan to the corresponding location 77 | // in the world map. 78 | let coords = map.getCenter(); 79 | let fracX = coords.lng / tiledMapPixelWidth_L1; 80 | let fracY = coords.lat / tiledMapPixelHeight_L1; 81 | 82 | let worldMapX = fracX * worldMapPixelWidth; 83 | let worldMapY = fracY * worldMapPixelHeight; 84 | 85 | map.panTo([worldMapY, worldMapX], { 86 | animate: false 87 | }); 88 | 89 | map.addLayer(worldMapOverlay); 90 | } 91 | 92 | // When transitioning from L1 to L0 there's a small shift that we need to correct for 93 | if (map.getZoom() >= tileZoomThreshold_L0 && startZoom < tileZoomThreshold_L0) { 94 | let panRatio = 1 / 75; 95 | let coords = map.getCenter(); 96 | let yDelta = tiledMapPixelHeight_L1 * panRatio; 97 | map.panTo([coords.lat - yDelta, coords.lng], { 98 | animate: false 99 | }); 100 | } 101 | if (map.getZoom() < tileZoomThreshold_L0 && startZoom >= tileZoomThreshold_L0) { 102 | let panRatio = 1 / 76; 103 | let coords = map.getCenter(); 104 | let yDelta = tiledMapPixelHeight_L1 * panRatio; 105 | map.panTo([coords.lat + yDelta, coords.lng], { 106 | animate: false 107 | }); 108 | } 109 | 110 | // When we get close enough to the individual tiles, use nearest-neighbor image scaling 111 | // to make the tiles look the same way they look in-game. 112 | // 113 | // We also add nearest-neighbor scaling to the world map for the same resaon. 114 | if (map.getZoom() >= pixelatedZoomThreshold || map.getZoom() < tileZoomThreshold_L1) { 115 | mapEl.classList.add("pixel-perfect"); 116 | } 117 | if (map.getZoom() < pixelatedZoomThreshold && map.getZoom() >= tileZoomThreshold_L1) { 118 | mapEl.classList.remove("pixel-perfect"); 119 | } 120 | }); 121 | 122 | /********************* Tile layers ***********************/ 123 | 124 | var worldMapOverlay = L.imageOverlay("tiles/world.webp", bounds); 125 | worldMapOverlay.addTo(map); 126 | 127 | L.TileLayer.Qud1 = L.TileLayer.extend({ 128 | options: { 129 | minNativeZoom: tileZoomThreshold_L1, 130 | maxNativeZoom: tileZoomThreshold_L1, 131 | minZoom: tileZoomThreshold_L1, 132 | maxZoom: tileZoomThreshold_L0 133 | }, 134 | 135 | initialize: function (options) { 136 | L.Util.setOptions(this, options); 137 | }, 138 | 139 | getTileUrl: function(coords) { 140 | let x = coords.x; 141 | let y = coords.y; 142 | let z = clamp(coords.z - tileZoomThreshold_L1, 0, null); 143 | 144 | let normalization = 2**z; 145 | x = Math.floor(x / normalization); 146 | y = tiledMapHeight_L1 + Math.floor(y / normalization); 147 | 148 | return `/tiles/tile_1_${x}_${y}_${ZLEVEL}.webp`; 149 | }, 150 | getAttribution: function() { 151 | return attribution; 152 | } 153 | }); 154 | 155 | L.tileLayer.qud1 = function() { 156 | return new L.TileLayer.Qud1(); 157 | } 158 | 159 | L.tileLayer.qud1().addTo(map); 160 | 161 | L.TileLayer.Qud0 = L.TileLayer.extend({ 162 | options: { 163 | minNativeZoom: tileZoomThreshold_L0, 164 | maxNativeZoom: tileZoomThreshold_L0, 165 | minZoom: tileZoomThreshold_L0, 166 | }, 167 | 168 | initialize: function (options) { 169 | L.Util.setOptions(this, options); 170 | }, 171 | 172 | getTileUrl: function(coords) { 173 | let x = coords.x; 174 | let y = coords.y; 175 | let z = clamp(coords.z - tileZoomThreshold_L0, 0, null); 176 | 177 | let normalization = 2**z; 178 | x = Math.floor(x / normalization); 179 | y = tiledMapHeight_L0 + Math.floor(y / normalization); 180 | 181 | const url = `/tiles/tile_0_${x}_${y}_${ZLEVEL}.webp`; 182 | return url; 183 | }, 184 | getAttribution: function() { 185 | return attribution; 186 | } 187 | }); 188 | 189 | L.tileLayer.qud0 = function() { 190 | return new L.TileLayer.Qud0(); 191 | } 192 | 193 | L.tileLayer.qud0().addTo(map); 194 | 195 | /* 196 | L.GridLayer.DebugCoords = L.GridLayer.extend({ 197 | createTile: function (coords) { 198 | let tile = document.createElement('div'); 199 | 200 | tile.innerHTML = [coords.x, coords.y, coords.z].join(', '); 201 | tile.style.outline = '1px solid red'; 202 | return tile; 203 | } 204 | }); 205 | 206 | L.gridLayer.debugCoords = function(opts) { 207 | return new L.GridLayer.DebugCoords(opts); 208 | }; 209 | 210 | map.addLayer( L.gridLayer.debugCoords() ); 211 | */ 212 | -------------------------------------------------------------------------------- /WorldMap/Wishes.cs: -------------------------------------------------------------------------------- 1 | using ConsoleLib.Console; 2 | using Genkit; 3 | using Kobold; 4 | using System; 5 | using System.IO; 6 | using UnityEngine; 7 | using XRL; 8 | using XRL.Wish; 9 | using XRL.World; 10 | using XRL.World.Capabilities; 11 | using XRL.UI; 12 | 13 | using Kernelmethod.WorldMap.Patches; 14 | 15 | namespace Kernelmethod.WorldMap { 16 | [HasWishCommand] 17 | public class WishHandler { 18 | /// 19 | /// Reveal secrets, level character, etc. in preparation for exporting the world 20 | /// map. 21 | /// 22 | [WishCommand(Command = "exportworldsetup")] 23 | public static bool ExportWorldSetup() { 24 | Wishing.HandleWish(The.Player, "xp:1000000"); 25 | Wishing.HandleWish(The.Player, "villagereveal"); 26 | Wishing.HandleWish(The.Player, "sultanreveal"); 27 | Wishing.HandleWish(The.Player, "revealmapnotes"); 28 | Wishing.HandleWish(The.Player, "revealsecret:$beylah"); 29 | Wishing.HandleWish(The.Player, "revealsecret:$skrefcorpse"); 30 | Wishing.HandleWish(The.Player, "revealsecret:$hydropon"); 31 | The.ZoneManager.GetZone("JoppaWorld").BroadcastEvent("BeyLahReveal"); 32 | return true; 33 | } 34 | 35 | [WishCommand(Command = "exportzone")] 36 | public static bool ExportZoneWishHandler() { 37 | var zone = The.Player?.GetCurrentZone(); 38 | if (zone == null) { 39 | Popup.Show("Could not find player's current zone"); 40 | return true; 41 | } 42 | 43 | var savePath = DataManager.SavePath("zone.png"); 44 | ExportZone(zone, savePath); 45 | 46 | return true; 47 | } 48 | 49 | [WishCommand(Command = "exportworld")] 50 | public static bool ExportWorldWishHandler() { 51 | var defaultPath = DataManager.SavePath("worldmap"); 52 | var location = Popup.AskString( 53 | "Where would you like to save the world map to?", 54 | Default: defaultPath, 55 | MaxLength: 4096, 56 | ReturnNullForEscape: true 57 | ); 58 | 59 | if (location == null) 60 | return true; 61 | 62 | var y = Popup.AskNumber( 63 | "What y-coordinate would you like to slice?" 64 | ); 65 | if (y == null) 66 | return true; 67 | 68 | var z = Popup.AskNumber( 69 | "What z-coordinate would you like to slice?", Start: 10 70 | ); 71 | if (z == null) 72 | return true; 73 | 74 | Directory.CreateDirectory(location); 75 | 76 | var zone = The.ZoneManager.GetZone("JoppaWorld"); 77 | ExportZone(zone, Path.Combine(location, "world.png")); 78 | 79 | for (var j = 0; j < 80; j++) { 80 | for (var k = 0; k < 3; k++) { 81 | for (var l = 0; l < 3; l++) { 82 | var zoneID = $"JoppaWorld.{j}.{y}.{k}.{l}.{z}"; 83 | zone = The.ZoneManager.GetZone(zoneID); 84 | ExportZone(zone, Path.Combine(location, $"{zoneID}.png")); 85 | } 86 | } 87 | } 88 | 89 | return true; 90 | } 91 | 92 | /// 93 | /// Convert a zone to a .PNG file and save it at the specified location. 94 | /// 95 | public static void ExportZone(Zone zone, string savePath) { 96 | var start = DateTime.Now; 97 | 98 | IBaseJournalEntryPatches.DisableMessages = true; 99 | var cells = new SnapshotRenderable[zone.Width, zone.Height]; 100 | for (var j = 0; j < zone.Height; j++) { 101 | for (var i = 0; i < zone.Width; i++) { 102 | var ch = new ConsoleChar(); 103 | var rendered = zone.GetCell(i, j).Render(ch, Visible: true, LightLevel.Light, Explored: true, Alt: false); 104 | var snapshot = new SnapshotRenderable(rendered); 105 | snapshot.DetailColor = ch.DetailCode; 106 | cells[i, j] = snapshot; 107 | } 108 | } 109 | 110 | var zoneWidth = zone.Width; 111 | var zoneHeight = zone.Height; 112 | var zoneID = zone.ZoneID; 113 | 114 | GameManager.Instance.uiQueue.queueTask(delegate { 115 | var MapTexture = new Texture2D(16 * zoneWidth, 24 * zoneHeight, textureFormat: TextureFormat.ARGB32, mipChain: false); 116 | // MapTexture.filterMode = UnityEngine.FilterMode.Point; 117 | // var MapTexture = UniversalPublicTexture; 118 | 119 | var baseColor = ConsoleLib.Console.ColorUtility.FromWebColor("041312"); 120 | var black = ConsoleLib.Console.ColorUtility.ColorMap['K']; 121 | 122 | var mapPixels = MapTexture.GetPixels(); 123 | for (var i = 0; i < mapPixels.Length; i++) { 124 | mapPixels[i] = new Color(0f, 0f, 0f, 0f); 125 | } 126 | 127 | for (var j = 0; j < zoneHeight; j++) { 128 | for (var i = 0; i < zoneWidth; i++) { 129 | var cellTile = cells[i, j]; 130 | 131 | var sprite = SpriteManager.GetUnitySprite(cellTile.GetSpriteName()); 132 | var fgc = ConsoleLib.Console.ColorUtility.colorFromChar(cellTile.GetForegroundColor()); 133 | var bgc = ConsoleLib.Console.ColorUtility.colorFromChar(cellTile.getDetailColor()); 134 | 135 | for (var k = 0; k < 16; k++) { 136 | for (var l = 0; l < 24; l++) { 137 | var x = cellTile.getHFlip() ? 16 - k : k; 138 | var y = cellTile.getVFlip() ? 24 - l : l; 139 | var pixel = sprite.texture.GetPixel(x, y); 140 | var pixelColor = baseColor; 141 | 142 | // Reference: MapScrollerController.RenderAccomplishment 143 | if (pixel.a <= 0f) { 144 | pixelColor = baseColor; 145 | } 146 | else if (pixel.r < 0.5) { 147 | pixelColor = Color.Lerp(fgc, black, 0f); 148 | } 149 | else if (pixel.r >= 0.5) { 150 | pixelColor = Color.Lerp(bgc, black, 0f); 151 | } 152 | 153 | var idxX = i * 16 + k; 154 | var idxY = (25 - j - 1) * 24 + l; 155 | mapPixels[idxX + idxY * zoneWidth * 16] = pixelColor; 156 | } 157 | } 158 | } 159 | } 160 | 161 | MapTexture.SetPixels(mapPixels); 162 | File.WriteAllBytes(savePath, MapTexture.EncodeToPNG()); 163 | }); 164 | 165 | IBaseJournalEntryPatches.DisableMessages = false; 166 | 167 | var time = DateTime.Now - start; 168 | var timeFormatted = $"{time.Seconds}.{time.Milliseconds.ToString().PadLeft(3, '0')}"; 169 | MetricsManager.LogInfo($"Wrote map for zone {zoneID} to {savePath} in {timeFormatted}s"); 170 | } 171 | } 172 | } 173 | --------------------------------------------------------------------------------