pokepetter / ursina

A game engine powered by python and panda3d.
https://pokepetter.github.io/ursina/
MIT License
2.23k stars 327 forks source link

Dungeon Generator #35

Open Spritekin opened 4 years ago

Spritekin commented 4 years ago

This thread is to discuss the dungeon generator and see its progress. When finished the player should be able to walk around a dungeon as a FPS. We can later improve the same example to provide a bit of flavour like in the old Eye Of The Beholder or Dungeon Master rogue like games, or fly a top camera in Diablo style.

ZeroCool940711 commented 4 years ago

Hi there, you said there is another Dungeon generation library you will be using, can you share a link for that one so I can check it and tell you if that one is better or the one I have?. Also, I think instead of a FPS camera you should use a top-down camera, its as simple as adding one line of code in Ursina, two if you want to set the FOV to something different from the default value.

camera.orthographic = True
camera.fov = 20
Spritekin commented 4 years ago

@ZeroCool940711 Don't worry, I went and tested a few, I ended with the one you suggested. I have already built a small map and saved it into a file.

import dungeonGenerator

# define map size (x,y) max tiles to use in direction
levelSize = [64,64]

# create a generator
print('Generating map...')
dgen = dungeonGenerator.dungeonGenerator(levelSize[0], levelSize[1])
dgen.placeRandomRooms(7, 9, 2, 1, 1000)     # Generate rooms
dgen.generateCorridors('m')                 # Build corridors
dgen.connectAllRooms(10)                    # connect rooma to paths via door
dgen.mergeUnconnectedAreas()                # connect unconnected areas
dgen.pruneDeadends(10)                      # Remove corridors to nowhere
dgen.placeWalls()                           # Make the borders into walls
print('Map generated...')

# Define some characters we want to use to represent the map
TEMPTY    = ' '
TFLOOR    = '.'
TCORRIDOR = '='
TDOOR     = '&'
TDEADEND  = '^'
TWALL     = '#'
TOBSTACLE = '%'
TCAVE     = '@'
TWATER    = '~'
TROAD     = '*'

print('Saving the map...')
f = open('dungeon.map', 'w')
for y in range(dgen.height):
    for x in range(dgen.width):
        if dgen.grid[x][y] == dungeonGenerator.EMPTY:    print(TEMPTY, end = ''); f.write(TEMPTY)
        if dgen.grid[x][y] == dungeonGenerator.FLOOR:    print(TFLOOR, end = ''); f.write(TFLOOR)
        if dgen.grid[x][y] == dungeonGenerator.CORRIDOR: print(TCORRIDOR, end = ''); f.write(TCORRIDOR)
        if dgen.grid[x][y] == dungeonGenerator.DOOR:     print(TDOOR, end = ''); f.write(TDOOR)
        if dgen.grid[x][y] == dungeonGenerator.DEADEND:  print(TDEADEND, end = ''); f.write(TDEADEND)
        if dgen.grid[x][y] == dungeonGenerator.WALL:     print(TWALL, end = ''); f.write(TWALL)
        if dgen.grid[x][y] == dungeonGenerator.OBSTACLE: print(TOBSTACLE, end = ''); f.write(TOBSTACLE)
        if dgen.grid[x][y] == dungeonGenerator.CAVE:     print(TCAVE, end = ''); f.write(TCAVE)
        if dgen.grid[x][y] == dungeonGenerator.WATER:    print(TWATER, end = ''); f.write(TWATER)
        if dgen.grid[x][y] == dungeonGenerator.ROAD:     print(TROAD, end = ''); f.write(ROAD)
    print(''); f.write('\n')
f.close()

print("Map written to file")
ZeroCool940711 commented 4 years ago

@Spritekin I see, that one I suggested is pretty good, the best way to split the generation is in 2 parts, first the Structure generation which is the rooms, corridors, walls, floor,etc and the second part is the Overlays which are the stuff that you put over the structure like chests, monsters, entrances, exits, etc. As you can probably see if you checked the Demo.py and the dungeonGenerator.py you can add custom tiles and custom overlays to make almost anything, you can also use some of the functions inside the dungeonGenerator to find close tiles to the want where you want to place things so, in theory you could create Pack Based overlays, that means you can create a pack of monsters and place it inside a room or you can make sure there is only one chest in the room like for example if its a boss chest, its pretty customizable, in the repo you can find a lot of tilesets too if you want to test.

Rewarding the example you wrote above on the room generation part I recommend using decreasing the number of attempts to 500 instead of 1000, the more attempts the slower it's going to be and even with 500 that's more than enough to ensure there are a lot of rooms. also increase the margin between rooms to at least 2 or 3 so there is always enough space to place corridors or something, what that does is that it create empty tiles between rooms, that should avoid weird room generation, or you could also reduce it to put more rooms together if you want to create continuous rooms, something like this should be ok. d.placeRandomRooms(5, 9, 1, 3, 500)

Here is the documentation of what thats doing in case you want to read it but its also inside the code as a docstring.

randomly places quads in the grid takes a brute force approach: randomly a generate quad in a random place -> check if fits -> reject if not Populates self.rooms
Args:
minRoomSize: integer, smallest size of the quad
maxRoomSize: integer, largest the quad can be
roomStep: integer, the amount the room size can grow by, so to get rooms of odd or even numbered sizes set roomSize to 2 and the minSize to odd/even number accordingly
margin: integer, space in grid cells the room needs to be away from other tiles
attempts: the amount of tries to place rooms, larger values will give denser room placements, but slower generation times
Returns:
none
ZeroCool940711 commented 4 years ago

Here are two images showing a result created with the Demo.py so there is some example of what it can be done with this Dungeon Generator, they show two dungeons with rooms and caves where caves are open areas, this could be used to create complex maps where there are indoor and outdoors areas or just open areas like a huge cave. There are also 5 types of overlays used, OVERLAY_ARTIFACT, OVERLAY_TREASURE, OVERLAY_TRAP, OVERLAY_FOE, this should simulate some content a normal dungeon in most games has. The first image was a dungeon with a size of 100x100 it took 0.3 seconds to generate, the second one is a huge dungeon that probably is not common to use, it can be used to create a contry size map or you could use it to procedurally generate a map like Minecraft, if you control where the generation starts and ends you can create a continuous grid, the size for the second one was 5000x5000 and took 20 seconds to generate, at least in my machine.

When checking the images make sure you zoom in, normally you would only see the basic structure from far but if you zoom in you can see the overlays and also more details on the structure.

screenshot screenshot

Spritekin commented 4 years ago

@ZeroCool940711 I guess the idea is to demo how to build the maps only. If we try to build a big map we will have to go into advanced rendering technniques like BSP trees or level chunking... lets keep this simple for now.

Same for overlays, I understand what you mean but let me finish this first part, then I can make it more advanced.

Regards

ZeroCool940711 commented 4 years ago

@Spritekin I understand, sorry, I just wanted to let you know what can be done with it, of course in order to build big maps its necessary to use something like chunking and also use LOD and occlusion to optimize the map or otherwise will be hard to render such a huge map.

ZeroCool940711 commented 4 years ago

Another thing, when i tried using Ursina to render the dungeon created by the dungeon generator I noticed that when creating even a single Entity it made everything run really slow, I think the problem is probably related to the way Ursina load models and textures, seems like instead of having to specify a path to the model or texture it instead loads everything in memory or at least walks every directory inside the folder where the script is and when you have some tilesets there like the folder in the repository for the dungeon generator it takes forever to run the script, I found myself having to wait up to 10 minutes before the script even finished loading, if im right then I think something needs to be implemented in Ursina to handle better those cases, otherwise using Ursina for real game development will be impossible as a real game will have a lot of textures and models that will be loaded when the game starts.

Spritekin commented 4 years ago

Well I'm building in a single folder with a few textures and all goes fine. I'm using cubes to build the map and I'm rendering the 64x64 map and it still runs at 60FPS. I'm using a MacbookPro 15 inch so I don't think its the video card giving me an edge at all.

If it truly is a problem with the resource loader then yes it should be fixed.

Other things to fix are light management, UI standardisation, file formats, shaders... but hey it is version 0.1.

Even if it is used for learning, it is good.

ZeroCool940711 commented 4 years ago

Im not saying its not good, im just giving some feedback on what can be improved and a potential problem for those who might want to use Ursina for real stuff, there are not many Python game engine, at least not good ones so if Ursina becomes one of the good ones it would be nice. I have another Python game engine im using for serious stuff and its awesome but im stuck with something so if I can switch to use ursina until I can figure out how to do what I need with that other engine I will do it, if you are interested or you know someone who want to give me a hand with the game engine and the game im making just let me know, im currently looking for people with knowledge in Python but also need people with C++ knowledge as the game engine is mainly written in C++ and Python is just used for scripting, it has some nice features but its old so, not everyone might like it xD.

pokepetter commented 4 years ago

Entities are not really meant for big levels like that, but for a handful of dynamic intractable things. For something like this you should take look at the Mesh class.

# read from a texture and place tiles based on the color
quad = load_model('quad')
dungeon = Entity(model=Mesh(), texture='brick')
model = dungeon.model

texture = load_texture('heightmap_1')
for y in range(texture.height):
    for x in range(texture.width):
        col = texture.get_pixel(x,y)
        # print(col)
        if col.v > .5: #
            model.vertices.extend([Vec3(x,y,0)+v for v in quad.vertices]) # add quad vertices, but offset.
            model.colors.extend((color.random_color(),) * len(quad.vertices)) # add vertex colors.

'''
the uvs are the same for all the squares in this case, but you could also have
them change based on the tile type so you can use a tilemap
'''
model.uvs = (quad.uvs) * (texture.width * texture.height)
model.generate() # call to create the mesh

This way I get ~400 fps even with 131 072 squares

Generating a vary big level can still take a few seconds though, so you might want chunk it into parts and make them as needed.

Spritekin commented 4 years ago
Screen Shot 2020-04-18 at 1 37 26 am
Spritekin commented 4 years ago

@pokepetter Nice trick with the mesh... maybe in a more advanced version as an optimisation.

ZeroCool940711 commented 4 years ago

@Spritekin I can see you made it work but why are your rooms so close to each other and the corridors so small? Is that intentional or you cant create long corridors? Also, I have a question, how can you make it so things like walls and doors block the player or have collision but not the floors and other tiles?

ZeroCool940711 commented 4 years ago

Also I think you could rotate the wall tiles that are vertically so they look better.

Spritekin commented 4 years ago

@ZeroCool940711 If you are building a classic roguelike game, I don't think you need to worry about that, the logic that controls colissions is done on the map level not the scene level.

In other words if you are moving in one direction and your map says there is a wall, you can't move there. You don;t test on the quad that is on the scene.

If you are doing 3D (which I plan to do next) then collision is managed by the floor and the walls which are built on top of the floor, in that case yes you need collisions but mostly for some effects.

Spritekin commented 4 years ago

@ZeroCool940711 I just generated a dungeon with some parameters, adjust it anyway you prefer. Here is the code that builds the map that was generated with the generator.py I sent before.

from ursina import *                    # this will import everything we need from ursina with just one line.

# Define some characters we want to use to represent the map
TEMPTY    = ' '
TFLOOR    = '.'
TCORRIDOR = '='
TDOOR     = '&'
TDEADEND  = '^'
TWALL     = '#'
TOBSTACLE = '%'
TCAVE     = '@'
TWATER    = '~'
TROAD     = '*'

CAMERA_SPEED = 10

def update():
    if held_keys['w']:                                          # If q is pressed
        camera.position += (0, time.dt * CAMERA_SPEED, 0)       # move camera up
    if held_keys['s']:                                          # If a is pressed
        camera.position -= (0, time.dt * CAMERA_SPEED, 0)       # move camera down
    if held_keys['d']:                                          # If d is pressed
        camera.position += (time.dt * CAMERA_SPEED, 0, 0)       # move camera right
    if held_keys['a']:                                          # If a is pressed
        camera.position -= (time.dt * CAMERA_SPEED, 0, 0)       # move camera left
    if held_keys['q']:                                          # If d is pressed
        camera.position += (0, 0, time.dt * CAMERA_SPEED)       # move camera right
    if held_keys['z']:                                          # If a is pressed
        camera.position -= (0, 0, time.dt * CAMERA_SPEED)       # move camera left

app = Ursina()

window.title = 'Ursina Dungeons'                # The window title
window.borderless = False               # Show a border
window.fullscreen = False               # Go Fullscreen
window.exit_button.visible = False      # Show the in-game red X that loses the window
window.fps_counter.enabled = True       # Show the FPS (Frames per second) counter

# Open your dungeon map for read
map=[]
with open('dungeon.map') as f:
   for line in f:                       # For each line
       map.append(list(line[:-1]))      # append the line to the map but break it in individual characters first

for py, line in enumerate(map):
    for px, tile in enumerate(line):
        if tile == TEMPTY:
            continue
        if tile == TFLOOR:    
            Entity(model='quad', color=color.white, texture="floor", collider="box", position=(px, py, 0))
        if tile == TCORRIDOR:
            Entity(model='quad', color=color.white, texture="corridor", collider="box", position=(px, py, 0))
        if tile == TDOOR:
            Entity(model='quad', color=color.white, texture="door", collider="box", position=(px, py, 0))
        if tile == TDEADEND:
            Entity(model='quad', color=color.white, texture="deadend", collider="box", position=(px, py, 0))
        if tile == TWALL: 
            Entity(model='quad', color=color.white, texture="wall", collider="box", position=(px, py, 0))
        if tile == TOBSTACLE:
            Entity(model='quad', color=color.white, texture="obstacle", collider="box", position=(px, py, 0))
        if tile == TCAVE:
            Entity(model='quad', color=color.white, texture="cave", collider="box", position=(px, py, 0))
        if tile == TWATER:
            Entity(model='quad', color=color.white, texture="water", collider="box", position=(px, py, 0))
        if tile == TROAD:
            Entity(model='quad', color=color.white, texture="road", collider="box", position=(px, py, 0))

app.run()                               # opens a window and starts the game.
Spritekin commented 4 years ago
Screen Shot 2020-04-18 at 2 35 03 am Screen Shot 2020-04-18 at 2 36 42 am
Spritekin commented 4 years ago

Ok guys... got the final program and the tutorial on how to get there from scratch.

from ursina import *                    # this will import everything we need from ursina with just one line.

# Define some characters we want to use to represent the map
TEMPTY    = ' '
TFLOOR    = '.'
TCORRIDOR = '='
TDOOR     = '&'
TDEADEND  = '^'
TWALL     = '#'
TOBSTACLE = '%'
TCAVE     = '@'
TWATER    = '~'
TROAD     = '*'
TENTRY    = 'E'

CAMERA_SPEED = 10
SCALE = 2
texoffset = 0.0                         # define a variable that will keep the texture offset

def update():
    global texoffset                                 # Inform we are going to use the variable defined outside
    texoffset += time.dt * 0.05                       # Add a small number to this variable
    for tile in water:
        setattr(tile, "texture_offset", (texoffset, texoffset))  # Assign as a texture offset

# Move the camera to a new position but check if it is valid before moving
def moveto(newposition):
    mapposition = newposition / SCALE                       # get the map coords from that position
    if tilemap[int(mapposition[2])][int(mapposition[0])] != TWALL:        # if the new position doesn't have a wall
        camera.position = newposition
    else:
        print("There is a wall in that direction")

def input(key):
    if key == 'i': moveto(camera.position + camera.forward * SCALE)    # where will I move
    if key == 'k': moveto(camera.position + camera.back * SCALE)
    if key == 'j': moveto(camera.position + camera.left * SCALE)
    if key == 'l': moveto(camera.position + camera.right * SCALE)
    if key == 'u': camera.rotation_y -= 90              # rotate camera left
    if key == 'o': camera.rotation_y += 90              # rotate camera righa

app = Ursina()

window.title = 'Ursina Dungeons'                # The window title
window.borderless = False               # Show a border
window.fullscreen = False               # Go Fullscreen
window.exit_button.visible = False      # Show the in-game red X that loses the window
window.fps_counter.enabled = True       # Show the FPS (Frames per second) counter

# Open your dungeon map for read
tilemap=[]
with open('dungeon.map') as f:
   for line in f:                       # For each line
       tilemap.append(list(line[:-1]))      # append the line to the map but break it in individual characters first

water = []

for py, line in enumerate(tilemap):
    for px, tile in enumerate(line):
        if tile == TEMPTY:
            continue
        if tile == TFLOOR or tile == TENTRY:    
            if tilemap[px+1][py] == TWATER or tilemap[px-1][py] == TWATER or tilemap[px][py+1] == TWATER or tilemap[px][py-1] == TWATER:
                Entity(model='cube', color=color.white, texture="floor", collider="box", position=(px * SCALE, -0.5 * SCALE, py * SCALE), scale=(SCALE,SCALE,SCALE))
            else:
                Entity(model='plane', color=color.white, texture="floor", collider="box", position=(px * SCALE, 0, py * SCALE), scale=(SCALE,SCALE,SCALE))
            if tile == TENTRY: camera.position = (px*SCALE, 1, py*SCALE)
        if tile == TCORRIDOR:
            Entity(model='plane', color=color.white, texture="corridor", collider="box", position=(px * SCALE, 0, py * SCALE), scale=(SCALE,SCALE,SCALE))
        if tile == TDOOR:
            Entity(model='plane', color=color.white, texture="floor", collider="box", position=(px * SCALE, 0, py * SCALE), scale=(SCALE,SCALE,SCALE))
            Entity(model='cube', color=color.white, texture="door", collider="box", position=(px * SCALE, 0.5 * SCALE, py * SCALE), scale=(SCALE,SCALE,SCALE))
        if tile == TDEADEND:
            Entity(model='plane', color=color.white, texture="deadend", collider="box", position=(px * SCALE, 0, py * SCALE), scale=(SCALE,SCALE,SCALE))
        if tile == TWALL: 
            Entity(model='plane', color=color.white, texture="floor", collider="box", position=(px * SCALE, 0, py * SCALE), scale=(SCALE,SCALE,SCALE))
            Entity(model='cube', color=color.white, texture="wall", collider="box", position=(px * SCALE, 0.5 * SCALE, py * SCALE), scale=(SCALE,SCALE,SCALE))
        if tile == TOBSTACLE:
            Entity(model='plane', color=color.white, texture="obstacle", collider="box", position=(px * SCALE, 0, py * SCALE), scale=(SCALE,SCALE,SCALE))
        if tile == TCAVE:
            Entity(model='plane', color=color.white, texture="cave", collider="box", position=(px * SCALE, 0, py * SCALE), scale=(SCALE,SCALE,SCALE))
        if tile == TWATER:
            Entity(model='plane', color=color.white, texture="floor", collider="box", position=(px * SCALE, -1 * SCALE, py * SCALE), scale=(SCALE,SCALE,SCALE))
            water.append(Entity(model='plane', color=color.rgba(256,256,256,128), texture="water", collider="box", position=(px * SCALE, 0, py * SCALE), scale=(SCALE,SCALE,SCALE)))
        if tile == TROAD:
            Entity(model='plane', color=color.white, texture="road", collider="box", position=(px * SCALE, 0, py * SCALE), scale=(SCALE,SCALE,SCALE))

camera.fov = 110
camera.lens.setNearFar(0.6, 1000)

app.run()                               # opens a window and starts the game.
[Ursina Dungeons for Dummies.txt](https://github.com/pokepetter/ursina/files/4496622/Ursina.Dungeons.for.Dummies.txt)
Spritekin commented 4 years ago

Notice the tutorial takes the reader rendering on 2D first, then on 3D. This program is the final part only. Also there are no textures for walls, doors, water, etc because I want the user to search his textures.

Spritekin commented 4 years ago
Screen Shot 2020-04-18 at 10 49 35 pm
Spritekin commented 4 years ago

Final view with camera properly setup and transparent water with simple animation.

bearney74 commented 2 years ago

Any way to get the source for this? It looks pretty cool.

bearney74 commented 2 years ago

I think I found what I need at https://github.com/pokepetter/ursina/files/4496622/Ursina.Dungeons.for.Dummies.txt