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 |
--------------------------------------------------------------------------------