Generating a map image from map .dat file

Started by llamazing, March 11, 2018, 08:41:09 AM

Previous topic - Next topic
Given the discussion on the Ocean's Heart project topic about making a map menu, I wanted to test the feasibility of generating a mini-map image from the info in the map .dat file. The endeavor has been a success.

What I did is read the map .dat file from a lua script, which gives the size, position and stacking order of all tiles. Also reading the tileset .dat file used by that map gives the ground type (e.g. traversable, wall, etc.) for each tile. I assigned colors to the tiles based on the ground type. Then on a surface I drew rectangles whose size and position corresponds to each tile at 1/8th scale, drawn in the same order as on the map. I drew layers 0 to 2 separately and ended up with this result for the overworld maps:



Note that sometimes the layer 2 image got clipped because the window I was using was only 480 pixels in height. It didn't appear to be losing anything in the cases where it was clipped.

I then manually overlaid the layers, using 50% opacity for layers 1 & 2, and lined up the maps for how they fit together. In hindsight I realize I could have made the script to do the same thing.



Note that the Hourglass Fort Outside and Naerreturn Bay maps overlap with each other, so I moved the Hourglass map off to the side.

This mapping technique could be useful either in generating a reference image from which to create hand-drawn images of the maps, or a modified version of the script could be used to have the quest itself generate the map images to use for the map menu. For pixel-precise accuracy, the purple border between maps (so that edge teletranpsorters are visible) should be removed.

In order to reproduce my results and run the script, do the following:
1) Download version 0.1 of the Ocean's Heart project from the topic linked above (back up any existing save files if you already have the quest)
2) Change the quest size to 640 by 480 in quest.dat
3) In main.lua of that quest comment out line 5, require("scripts/features"), so that the HUD is not displayed
4) Replace the scripts/game_manager.lua file with this one:
Code (lua) Select
local game_manager = {}

local initial_game = require("scripts/initial_game")
require("scripts/multi_events")

-- Starts the game from the given savegame file,
-- initializing it if necessary.
function game_manager:start_game(file_name)

local exists = sol.game.exists(file_name)
local game = sol.game.load(file_name)
if not exists then
-- Initialize a new savegame.
initial_game:initialize_new_savegame(game)
end
sol.video.set_window_size(640, 480) --to prevent display from scaling at 2x
game:start()

local map_layers = require("scripts/menus/display_map")

function game:on_draw(dst_surface)
dst_surface:fill_color{0,0,0} --fill black, don't want to see what's underneath

for layer = 0,2 do
local surface = map_layers[layer]
if surface then
local width, height = surface:get_size()
surface:draw(dst_surface, 1, layer*(height+1)+1) --1 px from edge of screen with images separated by 1 px
end
end
end
end

return game_manager

5) Add this script to the scripts/menus directory, named display_map.lua:
Code (lua) Select
local map_id = "new_limestone/limestone_present"
--local map_id = "goatshead_island/goatshead_harbor"
--local map_id = "goatshead_island/west_goat"
--local map_id = "goatshead_island/goat_hill"
--local map_id = "goatshead_island/lighthouse_cape"
--local map_id = "goatshead_island/poplar_forest"
--local map_id = "goatshead_island/poplar_coast"
--local map_id = "goatshead_island/crabhook_village"
--local map_id = "Yarrowmouth/yarrowmouth_village"
--local map_id = "Yarrowmouth/juniper_grove"
--local map_id = "Yarrowmouth/foothills"
--local map_id = "Yarrowmouth/silent_glade"
--local map_id = "Yarrowmouth/naerreturn_bay"
--local map_id = "Yarrowmouth/kingsdown"
--local map_id = "Yarrowmouth/hourglass_fort/outside"
--local map_id = "ballast_harbor/ballast_harbor"

local tilesets = {}
local maps = {}

local SCALE_FACTOR = 8 --draw maps at 1/8th actual size

--color to draw tile based on ground property; ignore if not listed
local tile_colors = {
traversable = {0, 255, 0}, --green
wall = {127, 127, 127}, --grey
low_wall = {204, 204, 204}, --light grey
teletransporter = {255, 255, 0}, --yellow; this is a type of entity, not a tile ground property
shallow_water = {0, 255, 255}, --light blue
deep_water = {0, 0, 255}, --blue
hole = {0, 0, 0}, --black
ladder = {255, 0, 255}, --pink
lava = {255, 0, 0}, --red
prickles = {255, 127, 0}, --orange
ice = {127, 255, 255}, --pale blue
}

--load tileset .dat file to determine ground property of each tile
local function load_tileset(tileset_id)
local tileset = {}

local env = {}
function env.tile_pattern(properties)
local id = properties.id
assert(id, "tile pattern without id")
id = tonumber(id)
assert(id, "tile pattern id must be a number")

local ground = properties.ground
assert(ground, "tile pattern without ground")

if tile_colors[ground] then --ignore ground properties that don't have a color assigned
tileset[id] = tile_colors[ground] --link the color to use with the tile id
end
end

setmetatable(env, {__index = function() return function() end end})

local chunk = sol.main.load_file("tilesets/"..tileset_id..".dat")
setfenv(chunk, env)
chunk()

return tileset
end

--load map .dat file to get list of tiles used
local function load_map(map_id)
local map = { tiles = {} }

local env = {}

--properties stores the size and coordinates for the map and the tileset used
function env.properties(properties)
local x = tonumber(properties.x)
assert(x, "property x must be a number")
local y = tonumber(properties.y)
assert(y, "property y must be a number")

local width = tonumber(properties.width)
assert(width, "property width must be a number")
local height = tonumber(properties.height)
assert(height, "property height must be a number")

local tileset = properties.tileset
assert(tileset, "properties without tileset")

map.x = x
map.y = y
map.width = width
map.height = height
map.tileset = tileset
end

--each tile defines a size, coordinates and layer as well as the tile id to use
function env.tile(properties)
local pattern = properties.pattern --pattern is the tile id
assert(pattern, "tile without pattern")
pattern = tonumber(pattern)
assert(pattern, "tile pattern must be a number")

local layer = properties.layer
assert(layer, "tile without layer")
layer = tonumber(layer)
assert(layer, "tile layer must be a number")

local x = tonumber(properties.x)
assert(x, "tile x must be a number")
local y = tonumber(properties.y)
assert(y, "tile y must be a number")

local width = tonumber(properties.width)
assert(width, "tile width must be a number")
local height = tonumber(properties.height)
assert(height, "tile height must be a number")

table.insert(map.tiles, {
pattern = pattern,
layer = layer,
x = x,
y = y,
width = width,
height = height,
})
end

--also extract teletransporter usage to be able to draw teletransporter locations
function env.teletransporter(properties)
local layer = properties.layer
assert(layer, "tile without layer")
layer = tonumber(layer)
assert(layer, "tile layer must be a number")

local x = tonumber(properties.x)
assert(x, "tile x must be a number")
local y = tonumber(properties.y)
assert(y, "tile y must be a number")

local width = tonumber(properties.width)
assert(width, "tile width must be a number")
local height = tonumber(properties.height)
assert(height, "tile height must be a number")

table.insert(map.tiles, {
pattern = "teletransporter", --instead of using tile pattern to determine color, tile_colors has a "teletransporter" entry
layer = layer,
x = x,
y = y,
width = width,
height = height,
})
end

setmetatable(env, {__index = function() return function() end end})

local chunk, err = sol.main.load_file("maps/"..map_id..".dat")
setfenv(chunk, env)
chunk()

return map
end

local function generate_map(map_id)
assert(type(map_id)=="string", "Bad argument #1 to 'generate_map' (string expected)")

--load map info from .dat file if not already loaded
if not maps[map_id] then maps[map_id] = load_map(map_id) end
local map = maps[map_id]

--load tileset info from .dat file if not already loaded
if not tilesets[map.tileset] then tilesets[map.tileset] = load_tileset(map.tileset) end
local tileset = tilesets[map.tileset]

--create a surface for each layer
local layers = {}
for layer = 0,2 do
layers[layer] = sol.surface.create(map.width/SCALE_FACTOR+2, map.height/SCALE_FACTOR+2) --include room for 1px border
layers[layer]:fill_color({127, 0, 255}, 0, 0, map.width/SCALE_FACTOR+2, 1) --top 1px purple border
layers[layer]:fill_color({127, 0, 255}, 0, 0, 1, map.height/SCALE_FACTOR+2) --left 1px purple border
layers[layer]:fill_color({127, 0, 255}, 0, map.height/SCALE_FACTOR+1, map.width/SCALE_FACTOR+2, 1) --bottom 1px purple border
layers[layer]:fill_color({127, 0, 255}, map.width/SCALE_FACTOR+1, 0, 1, map.height/SCALE_FACTOR+2) --right 1px purple border
end

--draw each tile
for _,tile in ipairs(map.tiles) do
local pattern = tile.pattern
local tile_color = tile_colors[pattern] or tileset[pattern]

--draw the tile as solid color on surface corresponding to the correct layer
if tile_color and layers[tile.layer] then --ignore corner and empty tiles
layers[tile.layer]:fill_color(
tile_color,
tile.x/SCALE_FACTOR+1,
tile.y/SCALE_FACTOR+1,
tile.width/SCALE_FACTOR,
tile.height/SCALE_FACTOR
)
end
end

return layers
end

return generate_map(map_id)



I wanted to say this is really cool! I might look into using something like this for my next game. I'm most impressed with how readable it is, considering everything that is impassible is brown, be it cliff, house, tree, rock, fence, whatever, it's still easy to look at just the shapes and be like, oh, there's a town there.

It's interesting how it looks like stairs and teleporters and prickles (etc?) are their own colors too. That's cool that you've designed it to go into such detail, yet wisely omitted things like NPCs. I wonder if it would be configured to show particular NPCs- a person who's the object of your quest, for example, or if particular teleporters could be color coded differently to indicate they're the dungeon you're looking for.

I wouldn't personally want that because I like games about exploring, but it'd be an interesting take if you were interested in further developing this idea.

Small critique- I wouldn't go with purple for stairs, I'd go with white or something that looked more natural with the blue, green, and brown that dominate the colors.

Quote from: Max on March 14, 2018, 03:00:06 AMSmall critique- I wouldn't go with purple for stairs, I'd go with white or something that looked more natural with the blue, green, and brown that dominate the colors.
Fair enough. This was really more of a proof of concept and I was more concerned about different things being discernible, however obnoxious their appearance may be. There are a number of things I don't like about the colors that I would tweak on my next pass. I'm also thinking that showing prickles is probably too much detail, and it is probably better to just ignore them.

Quote from: Max on March 14, 2018, 03:00:06 AMThat's cool that you've designed it to go into such detail, yet wisely omitted things like NPCs. I wonder if it would be configured to show particular NPCs- a person who's the object of your quest, for example, or if particular teleporters could be color coded differently to indicate they're the dungeon you're looking for.
NPCs are an entirely different hurdle to tackle, but still entirely possible to implement. Due to how they wander around, you'd want to find their present locations on the current map rather than getting their starting point from the map.dat file. Dynamic tiles are a similar obstacle that would need to properly be accounted for too. Coding particular teletransporters to appear differently at certain times is also doable.

Quote from: Max on March 14, 2018, 03:00:06 AMI wouldn't personally want that because I like games about exploring, but it'd be an interesting take if you were interested in further developing this idea.
Another possibility is to hide tiles that the player hasn't seen yet by painting over them in black. I'm not sure if that would end up being too processor intensive, though, since I have not tried it.

An additional improvement I'd make would be to do certain tileset-specific coloring, but this would require deliberate planning when naming the tiles of the tileset. For example, all exterior house tiles could be given a name that ends in "_house" and then instead of coloring those tiles grey like a wall, they could have a unique color. I would do something similar with tree tops and give those partial transparency.

Also, as I was manually overlaying the individual layer images, I ended up manually darkening the traversible tiles on layer 1 so that bridges could be properly seen. In retrospect in would have made more sense to have the upper layers brighter and the lower layers darker. It would be pretty easy to make the script slightly lighten or darken the color depending on what layer it is on (but it would probably only make sense to do that for traversable type tiles and possibly wall tiles as well).

March 18, 2018, 06:22:34 PM #4 Last Edit: March 18, 2018, 06:24:13 PM by MetalZelda
This is what I try to pull with my minimap / Pause Subscreen Minimap
Scaling to a 50x50 square should be the hardest thing to pull

This is awesome