-
Notifications
You must be signed in to change notification settings - Fork 18
Map Archetypes
We should settle on getting this map serialization and renewal thing out of the way, so we can have something playable.
We have to solve these problems:
- You can create an arbitrary number of maps as objects and place them on the world map.
- Maps can declare logic for features like how the player should be placed on them. This was defined as a map ID constant pointing to a function for renewal/placement in the original source. But I'm thinking we should allow any arbitrary function to be used for these features. However functions can't be serialized in a convenient way, or at least one that isn't ergonomic (the source of the function has to be a string, so you don't get first-class syntax highlighting).
- Some maps are unique, and for all practical purposes there should be a unique constant identifier that will always return the single instance of that map. This was tied to the generation logic and such, as in point #2.
- Dungeons have multiple maps chained together on multiple levels. Actually, special maps like the thief's hideout or the sewer level were also implemented with dungeon levels, but this was a hack because the level of the map is also tied to the difficulty of the creatures inside it, so setting the map's level to 40 semantically means giving it a high difficulty, even thought it feels like going to a separate map altogether. These two concepts should be separated, but there should still be the ability to jump between the different floors of a dungeon with ease, like
goto_floor("map_id", 6)
.
Here are my thoughts on these issues, so I can at least have them written down somewhere and not just keep them in my head forever, so they won't bother me anymore.
It should be trivial to create a new dungeon with multiple floors and place it on the world map without having to go through an abstraction layer that generates a complete dungeon for you.
First we need some floors.
local floors = {}
for i = 1, 10 do
floors[i] = InstancedMap:new(20, 30)
floors[i]:clear("cobble")
end
Next we need to bundle these into an area somehow.
local area = InstancedArea:new()
for floor_number, map in ipairs(floors) do
area:add_map(map, { floor = floor_number })
end
area.image = "elona.feat_area_castle"
Then we need to register this area globally. This will be kept track of with a monotonically increasing integer UID.
local uid = Area.register(area)
By doing this, we can now create an entrance to this map. The map's overworld image is taken from its metadata.
local entrance = Area.create_entrance(uid, x, y, map)
assert(class.is_an(IFeat, entrance))
assert(entrance.image == "elona.feat_area_castle")
assert(entrance.params.start_floor == 1)
Finally, when activating this feat by trying to travel down on it, we can retrieve its map and let the traveling logic take over from there.
local map = Area.load_starting_map(entrance)
local success, err = Map.travel_to(map)
This creates a dungeon with all the floors pregenerated. But this isn't how vanilla actually generates dungeons. What it actually does is it keeps the current dungeon floor as a global, gLevel
, and only generates a map when moving between stairs based on this current floor. It starts you off on the area's areaMinLevel
property when entering a dungeon, unless the metadata on the stairs feat declares a specific floor. Incrementing the floor count means to go deeper, decrementing means to go towards the surface. The deepest floor is defined as areaMaxLevel
, and no down stairs are generated on that floor. When a dungeon floor is generated it creates a new down stair which will trigger incrementing logic. When traveling down this stair, if the map is not found on disk, or mCanSave
is false
(puppy cave), it is regenerated, else it is loaded.
What this means is that we need some additional logic for generating a floor on entering a map based on a stair or entrance. So how do we define what kind of map gets generated? vanilla just uses simple pieces of hardcoded logic: if the map ID is areaRandDungeon
or some other ID that is supposed to "act like" a dungeon, like the Tower of Fire, generate a dungeon floor. This is messy since it relies on a ton of global variables and special-case hackery to specify which kinds of floors should be generated. I'm pretty sure this won't scale if we suddenly want a dungeon that generates nothing but floors in the shape of putits.
Maybe what we need is a way to specify the layout of the dungeon, but nothing else, and state that we're expecting a map to be generated at this location at a future time, with this generator.
local area = {
{ floor = 1, generator = { __id = "my_mod.putit_floor" } }
{ floor = 2, generator = { __id = "my_mod.yeek_floor" } }
}
I'm not sure this is a good idea, because ideally you'd be able to just pass in a function that generates the map for you on each floor. But this has to get serialized, so no functions. So that means that logic has to be moved into a data definition.
But, you know, why not just aggregate everything into a single "area generator" function, which takes a dungeon floor and returns a map? That means less IDs to keep track of.
local function generate_floor(floor)
if floor == 1 then
return generate_putit_floor()
elseif floor == 2 then
return generate_yeek_floor()
end
return Dungeon.generate_floor(floor)
end
data:add {
_type = "base.area_generator",
_id = "my_neat_dungon",
on_generate_floor = generate_floor
}
area.floor_generator = "my_mod.my_neat_dungeon"
Seems pretty simple. on_generate_floor
must return a map, so we can just pass in a floor number and guarantee that we can get back something valid from it. Anything else is user error. This allows us to not have to worry about an area missing the blueprints for a specific floor and can just keep going until we reach the deepest level.
This is also simpler than vanilla's dungeon creation logic, in that you could build vanilla's dungeon picking algorithms on top of this system. But this way you could also have a dungeon with nothing but wide open floors filled to the brim with kamikaze yeeks, if that's your thing.
So now we have to be careful, in that an area cannot have two maps with the same floor number. But I think this is okay, because I don't understand why you'd want two floors with the same number. The important detail about map levels in OpenNefia is that the map's level, determining the quality of items generated on it and the difficulty of its monsters, is now separated from the ID of the floor, unlike vanilla where generating dungeon floor 40 meant you'd get a map of difficulty 40. So the specific floor number now carries no relevance except for identifying the floor in the area. But you still have the choice of making it significant, like when calling vanilla's dungeon generator, where the floor ID would be copied to map.level
such that the dungeon gets harder the deeper you go.
So now the question becomes: as someone traveling into a dungeon, how does generating new maps get handled when you travel down the stairs?
To be honest, I think the way it was in vanilla was decent enough. One single integer, incremented when you travel down stairs, decremented when you travel up. We could customize this by configuring the stairs also, like if you wanted to invert the direction. Since the dungeon floor number is no longer tied directly to the map's difficulty, we won't have to worry about the significance of the number itself.
So now we need a way of getting the current dungeon floor and updating the area as we travel down the stairs.
local next_floor = Area.current_floor() + self.floor_delta
local area = Area.current()
local generator_id = area.generator -- "my_mod.my_neat_dungeon"
local generator = data["base.area_generator"][generator_id]
local map = generator.generate_floor(next_floor)
Area.set_map(area, next_floor, map)
Map.travel_to(map)
But what if you don't care about dungeon floors, and just want a bunch of maps linked together, perhaps in ways not strictly vertical? For example, what if you wanted a branching path that takes you to a totally different dungeon on the side, like in Crawl?
Here is a thing I want. I want the quickstart scenario to feature multiple rooms for testing different features that you can travel between.
Let's say that the quickstart area contains all these related testing rooms. Then we should create an area, create the floors, put the maps in the area, and hook them up with stairs:
local main_hub = InstancedMap:new(20, 20)
local item_room = InstancedMap:new(20, 20)
local ai_room = InstancedMap:new(20, 20)
local area = InstancedArea:new()
local main_hub_uid = area:add_floor(main_hub)
local item_room_uid = area:add_floor(item_room)
local ai_room_uid = area:add_floor(ai_room)
Area.create_stairs_down(item_room_uid, 5, 10, main_hub)
Area.create_stairs_down(ai_room_uid, 15, 10, main_hub)
Area.create_stairs_up(main_hub_uid, 10, 10, item_room)
Area.create_stairs_up(main_hub_uid, 10, 10, ai_room)
local player = Chara.create("elona.putit", nil, nil, { ownerless = true })
Chara.set_player(player)
Map.set_map(main_hub)
main_hub:take_object(player, 10, 10)
This should be the bare minimum amount of code it takes to accomplish this. At this point everything should be completely playable and serializable.
Here's a thing that irks me: InstancedMap
isn't a game object, so it isn't backed by a data definition. This means that there's no way of conveniently defining events on maps, because events use functions, and functions can't be declared ergonomically in a way that is also serializable.
So we need a static definition for the map, which is a way of saying that this map should act like this when entered, should spawn these types of characters, and so on, while still being able to specify this logic using functions and making it serializable.
Enter base.map_archetype
:
data:add {
_type = "base.map_archetype",
_id = "putit_paradise"
on_spawn = function(map)
local level = map:calc("level")
Charagen.create(nil, nil, { level = level, categories = {"my_mod.putit"} })
end,
on_restock = function(map)
for _, chara in Chara.iter_others(map) do
if chara:has_category("my_mod.putit") then
local putitoro = Item.create("elona.putitoro", nil, nil, {}, chara)
if putitoro then
Action.eat(chara, putitoro)
end
end
end
end,
on_renew = function(map)
for _, chara in Chara.iter_others(map) do
if chara:has_category("my_mod.putit") then
Gui.mes(("%s looks at you quizzically."):format(chara.name))
end
end
end,
_events = {
{
_id = "base.on_map_entered",
name = "The putits are watching."
callback = function(map)
Gui.mes("The putits welcome your return to putit paradise.")
end
}
}
}
This is a data definition where the logic for the map will live. Here are the purposes of each field:
-
on_spawn
handles spawning in new critters at periodic intervals, based on how crowded the map is. -
on_renew
gets called when the map is renewed. "Renewed" is the proper term for what happens when a map's geometry should be completely regenerated from scratch. This is what allows for resetting all the walls you dug through in Vernis while still preserving the levels of shopkeepers you painstakingly increased with investment and small medal locations. -
on_restock
gets called more frequently thanon_renew
. This is what replenishes the amount of fruit on fruit trees, for example. -
_events
is for any other events that should be bound right after this map is loaded from disk.
You could implement on_spawn
and such in terms of _events
, but this syntax makes it easier to just jump in and code things up.
Here are some facts about map archetypes:
- Maps don't have to have an archetype. They're just a way of putting all the logic with functions in a static place. If a map is missing an archetype, all the bits it provides are ignored.
- You don't have to include the features of the archetype you don't need. For example, if you had a mod that allowed you to set up a new town in the middle of a dungeon, you might not want to allow renewal of that map, because there's no initial template to generate the original geometry from, so there's no need to have an
on_renew
function. - You can change a map's archetype at any time, or remove it if you want.
I'm thinking we can use map archetypes to implement "unique" maps, also. These are maps that get globally registered as an instance, and can be created like so:
local vernis = Map.get_or_create("elona.vernis")
Without this, we'd have no good way of figuring out where the one copy of Vernis is located if we wanted to update something inside it as part of an event or something. So this is one instance where a global registry works relatively well, as it did in Vanilla.
But there's also nothing preventing you from doing this, either:
local my_vernis = Elona122Map.generate("vernis")
my_vernis:set_archetype("elona.vernis")
This map is basically exactly the same in functionality as the global copy of Vernis, except that it isn't the one that's globally registered.
So on the archetype, we'd have a special flag for declaring that a map can be retrieved by Map.get_or_create(id)
. If it isn't declared to be generatable like this, then an error would be thrown in that function.
data:add {
_type = "base.map_archetype",
_id = "vernis",
register_unique = true,
}
Actually no. This means you can only have one unique copy of said archetype. What if you wanted two?
Instead we might do this.
data:add {
_type = "base.map_archetype",
_id = "vernis",
register_unique = { "elona.vernis", "my_mod.my_vernis" },
}
Or actually, maybe what we need is to define the set of unique identifiers and what archetype they map to.
data:add {
_type = "base.unique_map",
_id = "my_vernis" -- "my_mod.my_vernis"
archetype = "elona.vernis",
}
Then, somewhere in the code we set up where these maps get placed. Since they can get placed even inside maps without an archetype declared, I don't think we can put them on the base.unique_map
entry, so we have to do this programatically. But having base.unique_map
means we'll know what IDs are associated to which maps since it's written down somewhere, and we can guarantee that we can retrieve the map's copy like this:
local vernis = Map.get_or_create("my_mod.my_vernis")
local world_map = Map.get_or_create("elona.north_tyris")
Area.create_entrance(vernis, 26, 23, world_map)
vernis
might not be part of an area, though. Maybe we should allow create_entrance
to accept either an area or a map, for convenience.
Or how about an approach similar to vanilla: declare the position of this map based on the archetype of the world map it's contained within. This is how vanilla declares that the map's area exists in a world map.
areaId(p) = areaVernis
areaX(p) = 26
areaY(p) = 23
areaParent(p) = areaNorthTyris
So we can use base.unique_map
for this, and look up the parent map of a child based on its base.unique_map
identifier. I think it's reasonable to say that if you want to create a new world map you should give it an archetype. And if you don't give it an archetype, then you're responsible for setting everything up by hand by calling Area.create_entrance
repeatedly with the world map loaded, but at least that would still be possible.
With that assumption, this becomes doable:
data:add {
_type = "base.unique_map",
_id = "vernis"
archetype = "elona.vernis",
parent_map = "elona.north_tyris",
x = 26,
y = 23
}
Again, you can omit parent_map
and set everything up yourself; this would merely be for convenience. This system is set up to be triggered on base.on_map_enter
, where it checks if there's a parent map with a base.unique_map
matching an ungenerated unique area's parent_map
, and if so calls Area.create_entrance
for you.
Also, we should be able to generate all the maps that are contained inside a world map like this.
local unique_map_id = "elona.north_tyris"
Area.regenerate_all_child_maps(unique_map_id)
But note that if parent_map
is not specified on the unique area, then Area.regenerate_all_child_maps
will not generate the map and you'll have to do it yourself.
data:add {
_type = "base.unique_map",
_id = "vernis"
archetype = "elona.vernis"
}
local generate_child_maps(world_map)
if not Area.is_generated("my_mod.my_vernis")
local vernis_area = Area.get_or_create("my_mod.my_vernis")
Area.create_entrance(vernis_area, 26, 23, world_map)
end
end
data:add {
_type = "base.map_archetype",
_id = "my_world_map",
_events = {
{
_id = "base.on_map_entered",
name = "Set up custom world map",
callback = generate_child_maps
}
}
}
Probably not a big deal, since only a few world maps are ever needed for most purposes.
Okay, but what about Lesimas? It's not a single map, it's an area with a set of floors. So now we have to disambiguate between maps and areas just for this convenience. So I guess base.unique_map
should actually be base.unique_area
? This is what vanilla does anyway: all entrances on the world map lead to areas, and the parameters of the area like the map's ID and tileset are copied to the map when it gets loaded.
So I guess that would be more convenient, as long as something handles generating the area for us if we pass a map into Area.create_entrance
instead. And Map.get_or_create
would now become Area.get_or_create_map
:
local vernis = Area.get_or_create_map("my_mod.my_vernis", 1)
local world_map = Area.get_or_create_map("elona.north_tyris", 1)
Area.create_entrance(vernis, 26, 23, world_map)
Area.get_or_create_map
would accept a base.unique_area
and a floor number, and return the map. I think we will say that floor 1 is the default for simplicity, and allow omitting the floor number if you just want floor 1.
So with all this in place, let's say I wanted to make that putit dungeon, which would be a permanent dungeon map like Lesimas or the Tower of Fire. This is what the code might end up looking like.
local function generate_floor(floor)
local map = PutitParadise.generate_putit_shaped_map(floor)
map:set_archetype("my_mod.putit_paradise")
return map
end
data:add {
_type = "base.area_generator",
_id = "putit_paradise",
on_generate_floor = generate_floor
}
data:add {
_type = "base.map_archetype",
_id = "putit_paradise"
on_spawn = function(map)
local level = map:calc("level")
Charagen.create(nil, nil, { level = level, categories = {"my_mod.putit"} })
end,
}
data:add {
_type = "base.unique_area",
_id = "putit_paradise"
area_generator = "my_mod.putit_paradise",
image = "elona.feat_area_castle",
parent_map = "elona.north_tyris",
x = 43,
y = 6,
}
So I guess what base.unique_area
is turning into from this stream of consciousness brain dump is simply a way of defining an area, as in vanilla. I guess the legacy way of doing things wasn't so bad after all, in this case.
And now, it's time for the putits to roost, at last.
local north_tyris = Area.get_or_create_map("elona.north_tyris")
local area, entrance = Area.create("my_mod.putit_paradise", nil, nil, north_tyris)
assert(area._id = "my_mod.putit_paradise")
assert(entrance.x == 43)
assert(entrance.y == 6)
assert(entrance.params.area_uid == area.uid)
local putit_paradise = Area.get_or_create_map(area)
Map.travel_to(putit_paradise)
There should be a function for retrieving the list of map IDs for the current area.
local related = Area.all_maps()
So a map has to know what area it is a part of, or there should be some way of looking this up.
local area = Area.current()
local map = Map.current()
local area = Area.get(map)
So is area
an integer or a table?
If it's an integer we can just have a global table of all the maps like this:
save.base.map_registry = {
{ uid = 1, id = "elona.vernis", area = 1 },
{ uid = 2, id = "elona.thieves_hideout", area = 1 },
{ uid = 3, id = "elona.palmia", area = 2 },
-- ...
}
That way we don't have to go searching through all the maps by loading them from disk just to figure out where this one map is located.
Does the parent map of each map in the registry need to be recorded? Say you want to regenerate all the dungeons given a map or UID.
Dungeon.regenerate_all_in(map)
for _, dungeon_entrance in Dungeon.iter(map)
-- dungeon_entrance is an IFeat
Log.info("%s", dungeon.name)
end
Does the map already need to be loaded to regenerate all the dungeons on it? I think so. This is how it's done in vanilla.
areaId(p) =areaRandDungeon
areaIcon(p) =133
areaX(p) =x
areaY(p) =y
areaStartOn(p) =mStartUpstairs
areaTileFile(p) =1
areaTileSet(p) =1
areaTimescale(p) =10000
areaField(p) =mFieldIndoor
areaParent(p) =gWorld
areaParent(p)
is set to gWorld
, the current global world map, so I guess it's okay to assume the map is loaded if we're operating on the dungeons it's connected to. So Dungeon.regenerate_absolutely_everything()
or something would require you to load each world map from disk.
Importantly, we have to know if a dungeon is cleared or not based on its area.
local dungeon_area = Area.get(map_entrance.params.area_uid)
if dungeon_area.is_conquered then
self.t.base.conquered_icon:draw(tx, ty)
end
So this means that groups of maps in an area must have some kind of metadata attached to them.
local area_metadata = Area.current()
area_metadata.is_conquered = true
area_metadata.deepest_floor = 20