los-cocos / cocos

graphic library for games and multimedia, for python language on PC-like hardware
http://los-cocos.github.io/cocos-site/
Other
633 stars 147 forks source link

TmxObjectMapCollider should not redefine collide_map #252

Closed dangillet closed 8 years ago

dangillet commented 8 years ago

After fixing issue #248 it was discovered that TmxObjectMapCollider is inheriting from RectMapCollider but it redefines its collide_map method. This is now unnecessary and TmxObjectMapCollider could now just inherit from RectMapCollider without any additional changes.

dangillet commented 8 years ago

Hi Claudio,

Fix is done in branch TmxObjectMapCollider a0495523.

Let me know if it's ok to merge to master.

ccanepa commented 8 years ago

The parent class RectMapCollider has the docstring """This class implements collisions between a moving rect object and a tilemap.""" and the core functionallity is in method collide_map(map, ...), which restricts a rect movement to honor whatever collision restrictions imposed by the tilemap 'map'

What would be the semantics for TmxObjectMapCollider(RectMapCollider) ? Seems natural that the docstringd become """This class implements collisions between a moving rect object and a the objects in a TmxObjectLayer.""" and collide_map(map,...) becoming "restricts a rect movement to honor whatever collision restrictions imposed by the TmxObjectMap 'map'"

But the code in RectMapCollider (and he one in the old TmxObjectMapCollider) only considers collision with tiles. To allign the code with the stated semantic, the lines

            if cell is None or cell.tile is None or not cell.intersects(new):
                continue

should be removed, and hence we need to write explicitly TmxObjectMapCollider.collide_map

What do you think ?

ccanepa commented 8 years ago

correction: the lines should be changed to

            if not cell.intersects(new):
                continue
dangillet commented 8 years ago

Hi Claudio,

Well I'm not too familiar with TMX maps. But I think I understand what you mean. Correct me if I'm wrong, but it could be that the TmxObject does not have a tile, but you would like to know when you collide with it, like an invisible trigger zone used to start the fight with the final boss.

The problem is that the collide_map method does not allow our moving rect to move into the zones of the map. It pushes it back so that there is no overlapping. The real question is : What is the desired behaviour for collisions with TmxObjectLayer ?

Maybe if there is a tile attached to the cell, then the rect movements should be restricted, and if not, the rect can move freely, but in both cases, the collide_bottom, collide_top, etc should be called. This could be implemented rather easily.

Daniel.

ccanepa commented 8 years ago

Lots of different desired behavior are possible, it really depends on the game mechanics and the specific objects in the interaction.

Some concrete examples when the desired behavior is to forbid some actor to step into the boundaries of other objects.

We can build TmxObjectMapCollider.collide_map to that case (more later)

Examples when only detect superposition is necessary, and not blocking player

Those are handled by TmxObjectLayer methods .get_in_region(...) and .collide(...)

Going back to TmxObjectMapCollider.collide_map (map,...)

What do you think ?

dangillet commented 8 years ago

The user will be forced to add properties collide_bottom, etc to any object

Maybe I'm misunderstanding you. These methods have to be defined for a RectMapCollider or TmxMapCollider. This is normally for the player.

Thinking more about this, my understanding is that you design a map in Tiled Map Editor. You will place tiles, some of which are non-blocking (say the grass) and others are (say walls). For those you can set a top, bottom, left and right property to define that all sides should be blocking. If you want to allow one side to be non-blocking (say the bottom of a platform in a platform game, where you can jump through platforms from below) you just don't set the bottom property.

Our player can have as an instance attribute a derived class of RectMapCollider where the methods collide_xxx can be re-defined to have additional behaviour when that side hits an obstacle. collide_bottom could be used to set a variable player.on_ground = True, for instance.And maybe the player is hurt if his heads hits something. test_platformer.py shows an implementation where the RectMapCollider is actually an Action. So it can access the player attributes through self.target.

Now we can also have invisible cells, which don't have a tile. They are just zones which could be used to know when to trigger some actions. Say when the player moves through that zone, some ennemies appear. But I don't think this should be checked in the TmxMapCollider. As you said 3 messages ago, its purpose is only to restrict the Rect (player) movements to honnor whatever collision restrictions are imposed by the map. For such trigger zones, we agree that TmxObjectLayer methods .get_in_region(...) and .collide(...) must be used.

Now sure, we could imagine that in a map, the level designer wants a blocking invisible zone. Then we would have a cell without a tile which could have top, bottom, left and right properties. To account for this, as you correctly said, we should redefine the collide_map method to

if cell is None or not cell.intersects(new):
    continue

But besides that, I don't see how TmxMapCollider could be different from RectMapCollider.

Any suggestions ?

ccanepa commented 8 years ago

On 4/4/16, Daniel Gillet notifications@github.com wrote:

The user will be forced to add properties collide_bottom, etc to any object

Maybe I'm misunderstanding you. These methods have to be defined for a RectMapCollider or TmxMapCollider. This is normally for the player.

My bad, I was thinking of the properties 'up', 'down', ...a tile (in RectMapLayer) must have to be collidable

Thinking more about this, my understanding is that you design a map in Tiled Map Editor. You will place tiles, some of which are non-blocking (say the grass) and others are (say walls). For those you can set a top, bottom, left and right property to define that all sides should be blocking. If you want to allow one side to be non-blocking (say the bottom of a platform in a platform game, where you can jump through platforms from below) you just don't set the bottom property.

Thats true, but it is not all the truth :-)

A map done in Tiled Map Editor can also contain rectangles, ellipses, polygonals, tiles not anchored to the grid and more.

Those objects don't care for the grid and are loaded in cocos in a TmxObjectLayer.

A Tiled map does not need to have tiles at all, and the objects mentioned, except the tile object, are not associated with any tile.

Hence, if the game logic needs to collide player with those objects, we need a TmxCollider collide_map that ignores the tile attribute.

If a particular game wants to collide with any side of any object being obliged to set 'top', etc in each object is a nuance, hence the posibility to override do_collision to not check those propereties.

Our player can have as an instance attribute a derived class of RectMapCollider where the methods collide_xxx can be re-defined to have additional behaviour when that side hits an obstacle. collide_bottom could be used to set a variable player.on_ground = True, for instance.And maybe the player is hurt if his heads hits something. test_platformer.py shows an implementation where the RectMapCollider is actually an Action. So it can access the player attributes through self.target.

I think this was the original intent.

Now we can also have invisible cells, which don't have a tile. They are just zones which could be used to know when to trigger some actions. Say when the player moves through that zone, some ennemies appear.But I don't think this should be checked in the TmxMapCollider. As you said 3 messages ago, its purpose is only to restrict the Rect (player) movements to honnor whatever collision restrictions are imposed by the map. For such trigger zones, we agree that TmxObjectLayer methods .get_in_region(...) and .collide(...) must be used.

Now sure, we could imagine that in a map, the level designer wants a blocking invisible zone. Then we would have a cell without a tile which could have top, bottom, left and right properties. To account for this, as you correctly said, we should redefine the collide_map method to

if cell is None or not cell.intersects(new):
    continue

But besides that, I don't see how TmxMapCollider could be different from RectMapCollider.

keep in mind that in the TmxMapCollider collide_map, when you see

    cells = map.get_in_region(*(new.bottomleft + new.topright))
    for cell in cells:

it is really objs = map.get_in_region(*(new.nottomleft + new.topright)) for obj in objs:

obj can can be a rect, and ellipse, etc

if you let the next line as in RectMapCollider, that is if cell is None or cell.tile is None or not cell.intersects(new):

you are throwing out non cell objects, because they have obj.tile = None

Any suggestions ?

think a moving platform in a level. think you don't want to depict with tiles, that you want more freedom for your graphic artist. Then you use a tmx rect object. And you can use a TmxMapCollider to do the collision.

dangillet commented 8 years ago

Ok, I see !

So I started by reimplementing the collide_map in TmxMapCollider to account for objects with a None tile attribute.

I tried to factor out the difference, but it didn't work as you can see in 39961cb. The problem was that we select the cells while we resolve collisions, which can mutate the new rect and so change the criteria (ie. cell.intersects(new)) when considering the new cell in region.

I could have made a method to be called in the for loop. But function calls are expensive, so I thought it's best to just copy everything in TmxMapCollider and make the necessary changes.

What do you think ?

ccanepa commented 8 years ago

There are multiple small issues with current code related to tmx collider. I created some basic examples at the repo https://github.com/los-cocos/etc_code/tree/master/tmx_collision and in parallel refactored code in cocos.tiles in the branch https://github.com/los-cocos/cocos/tree/tmx_colision

I think now the api is cleaner and easier to explain and use. I limited changes to tmx object related things, later some decisions can be ported to RectMapCollider.

I annotated the whats and the whys about the changes, and I will paste in next comment. Probably they are too long, but nobody is forced to read them.

So, @dangillet , and @r1chardj0n3s if he has time, care to review ?

ccanepa commented 8 years ago
dangillet commented 8 years ago

Hi Claudio,

Lots of good things here. I'm still digesting all that information.

All in all, I think this is a very good addition, and as you said, we might then go back to RectMapCollider to have similar semantics.

Two minor things:

class TmxObjectMapCollider(RectMapCollider):
    """Helper to handle collisions between an actor and objects in a TmxObjectLayer

    Arguments:
        velocity_on_bump (str) : one of 'bounce', 'stick', 'slide'

    The argument 'velocity_on_bump' selects how the velocity will be modified
    when the actor rectangle bumps into an object.
    """

    def __init__(self, velocity_on_bump='slide'):
        self.on_bump_handler = velocity_on_bump

    @property
    def on_bump_handler(self):
        return self._on_bump_handler

    @on_bump_handler.setter
    def on_bump_handler(self, velocity_on_bump):
        self._on_bump_handler = getattr(self, 'on_bump_' + velocity_on_bump)

I'll try to give you a better review later on, once I've digested everything.

In any case, well done ! This is a nice achievement.

Daniel.

dangillet commented 8 years ago

Hello,

I noticed the changes you made in the last commit cafbce37d.

One additional note that came to my mind while reading the docstring for do_collision. As we removed the functionality to test if the TmxObject has a property to know which side is collidable, would it make sense to add the old code as an example how one could subclass TmxObjectMapCollider.do_collision to provide this behaviour ?

Imagining how to do this subclassing, I realize that the user would have to copy an awful lot of code to only change the first part. Maybe we should split do_collision in 2 different methods. One is responsible to calculate the dx_correction and dy_correction and the other is responsible to react to that.

Here is a quick example of what I mean. Note that I didn't put any docstrings. It's just to show the functionality.

class TmxObjectMapCollider(RectMapCollider):
    def collide_map(self, maplayer, last, new, vx, vy):
        self.bumped_x = False
        self.bumped_y = False
        tested = set()
        cells = maplayer.get_in_region(*(new.bottomleft + new.topright))
        do_collision = self.do_collision
        resolve_collision = self.resolve_collision
        for cell in cells:
            # the if is not superfluous in the loop because 'new' can change
            if not cell.intersects(new):
                continue
            tested.add(cell)
            dx_correction, dy_correction = do_collision(cell, last, new)
            resolve_collision(cell, new, dx_correction, dy_correction)
        cells_collide_later = [cell for cell in tested
                               if hasattr(cell, 'collide_later')]
        for cell in cells_collide_later:
            if cell.intersects(new):
                dx_correction, dy_correction = do_collision(cell, last, new)
                resolve_collision(cell, new, dx_correction, dy_correction)
            del cell.collide_later

        vx, vy = self.on_bump_handler(vx, vy)

        return vx, vy

        return vx, vy

    def do_collision(self, cell, last, new):
        dx_correction = dy_correction = 0.0
        if last.bottom >= cell.top and new.bottom < cell.top:
            dy_correction = cell.top - new.bottom
        elif last.top <= cell.bottom and new.top > cell.bottom:
            dy_correction = cell.bottom - new.top
        if last.right <= cell.left and new.right > cell.left:
            dx_correction = cell.left - new.right
        elif last.left >= cell.right and new.left < cell.right:
            dx_correction = cell.right - new.left

        return dx_correction, dy_correction

    def resolve_collision(self, cell, new, dx_correction, dy_correction):
        if dx_correction != 0.0 and dy_correction != 0.0:
            # Correction on both axis
            if hasattr(cell, 'collide_later'):
                if abs(dx_correction) < abs(dy_correction):
                    # do correction only on X (below)
                    dy_correction = 0.0
                elif abs(dy_correction) < abs(dx_correction):
                    # do correction only on Y (below)
                    dx_correction = 0.0
                else:
                    # let both corrections happen below
                    pass
            else:
                cell.collide_later = True
                return

        if dx_correction != 0.0:
            self.bumped_x = True
            # Correction on X axis
            new.left += dx_correction
            if dx_correction > 0.0:
                self.collide_left(cell)
            else:
                self.collide_right(cell)
        if dy_correction != 0.0:
            # Correction on Y axis
            self.bumped_y = True
            new.top += dy_correction
            if dy_correction > 0.0:
                self.collide_bottom(cell)
            else:
                self.collide_top(cell)
        return

This comes at the cost of an extra function call in the loop. So less efficient. But the user would only need to override do_collision which is then a smaller amount of code to change. Not sure it's worth it though...

ccanepa commented 8 years ago

I like it. Performance would not be too different. Added a commit with this.

While looking to do docstrings (old) do_collision was a bit blurry. So tentatively I moved the conditional from do_collision to collide_map, now do_collision is clear cut. Now it was easy to eliminate the transitory flag collide_later, the 'tested' set and the 'del' associated. : For what you called do_collision I considered alternatives 'detect_collision', 'measure_overlap'; tentatively set to 'detect_collision'

I think both the overall code, docstrings and api is clearer. Added a commit with this.

I'm open to change in this feature, if you have more suggestions.

Otherwise, I will be thinking to refactor RectMapCollider as:

About this, it would be better to use same variable / parameter names in both classes: easier to spot the significant differences, and mix and match snippets of code. 'cell' sounded awkward while working in Tmx. Would replacing it by 'obj' sound too bad in RectMapCollider ? 'collidable' ? another ?

edit: commit added, typo.

dangillet commented 8 years ago

Hello,

Indeed the code is WAY better this way. It's really a joy to see the difference with the old code. It does not surprise me so much that by cutting the code into more functions, you were able to clean up the way we detected collisions in two passes. Well done ! I really like the new function names. I think it makes sense to have a detect_collision followed by a resolve_collision.

I took the liberty to adjust slightly the docstrings. It's generally only some cosmetic changes to better use sphinx autodoc utils. I pushed a new branch on top of yours, https://github.com/los-cocos/cocos/tree/tmx_collision_dan.

One question you didn't really answer : what about on_bump_handler as a property ? I don't think it's that important. But it doesn't cost much to have it.

Regarding the RectMapCollider refactoring, I agree with everything except I wouldn't use props as the default. What I would do is offer in the code RectMapCollider and RectMapWithPropsCollider which derives from RectMapCollider and shows how to use this feature. Of course we might need to amend some files which maybe used the old behaviour:

Daniel.

ccanepa commented 8 years ago

Thanks for the docstrings changes, adopted.

Also adopted RectMapCollider and RectMapWithPropsCollider.

Moved RectMapCollider to be near the other colliders

After that I did multiple passes to

No propagation of changes outside cocos/ was done

Committed at 3aee9eaebaa after rebasing on master

One question you didn't really answer : what about on_bump_handler as a property ? I don't think it's that important. But it doesn't cost much to have it.

I'm -0.1 about that; being that functions are first class entities in python it feels like bloat. If you think it is > 0.1 worthy i will include.

tiles.py is growing big, and the Collider related parts in tiles doc page are scattered around. To me it makes sense to move collider code to a new mapcolliders module (with some compatibility shims left on tiles).

Selecting bump style at __init__ plays a bit dirty with mixin style usage and custom on_bump_handler.

I will do a branch with both changes to see how it look

ccanepa commented 8 years ago

committed 589248c2c2 "moving colliders to cocos.mapcolliders"

and later

ad6e3ccfb eliminated string names for onbump*, changes propagated outside cocos/

The last was to have a more clean mixin style colliders.

also adapted exploratory scripts in repo los-cocos/etc_code to those changes, commit c89b2eed7ae

I like how it looks, but I'm open to suggestions.

After handling suggestions, if any, I will add to test/ one of the tmx collider examples, then maybe edit dosctrings have shorter line lenghts, then squash and merge.

dangillet commented 8 years ago

Hi Claudio,

You've been quite busy. Well done ! Sorry I couldn't review all this earlier.

I like a lot all the changes. I think this is a neat improvement on the older code. I only have small suggestions / remarks.

For docstrings, when you describe Arguments I found out that if you reference a type which is described somewhere in cocos, appending a dot before the type will make it automatically a link in the doc. So for instance:

Arguments:
    maplayer (.RectMapLayer) : layer with solid objects to collide with.

will make RectMapLayer a link to the class description.

eliminated string names for onbump*, changes propagated outside cocos/ The last was to have a more clean mixin style colliders.

I understand the reason. I'm just thinking that for casual coders not familiar with this module it's kind of tricky to remember that you need to instantiate a class and then you need to assign to on_bump_handler a method. But as you said, for mixin style, this is probably the preferred method. Maybe a solution would be to leave a __init__ method with an optional keyword. Something like this:

def __init__(self, velocity_on_bump=None):
    if velocity_on_bump:
        self.on_bump_handler = getattr(self, 'on_bump_' + velocity_on_bump)

So the client code is not forced to pass any parameters to the __init__ method, but it's still possible if that's how the client wants to define its on_bump_handler method.

With this in place, test_platformer.py which does not use mixin classes could be re-written like this:

    # give a collision handler to the player
    mapcollider = mapcolliders.RectMapWithPropsCollider(velocity_on_bump='slide')
    player.collision_handler = mapcolliders.make_collision_handler(mapcollider, tilemap)

Regarding my question above I was more like -0.1. And anyway this is now irrelevant as client code can redefine on_bump_handler as you show for the mixin class case.

One last tiny suggestion. In test_platformer.py, it's kind of frustrating that the character cannot jump over the obstacles. Changing the JUMP_SPEED to 1000 would allow jumping over the obstacles. We can then go to the end of the map and see the cauldron. :)

Dan.

ccanepa commented 8 years ago

For docstrings, when you describe Arguments I found out that if you reference a type which is described somewhere in cocos, appending a dot before the type will make it automatically a link in the doc.

Good to know. Things like this should be included in docgen/dev/documentation.txt

for mixin style, this is probably the preferred method. Maybe a solution would be to leave a init method with an optional keyword.

Good compromise, will do.

One last tiny suggestion. In test_platformer.py, it's kind of frustrating that the character cannot jump over the obstacles. Changing the JUMP_SPEED to 1000 would allow jumping over the obstacles. We can then go to the end of the map and see the cauldron. :)

Sure.

ccanepa commented 8 years ago

commit eb8ee8064ff reintroduces velocity_on_bump, as talked commit 64e43acb3a does a pass on docstrings (adjusted some content, shortened lines to be more near the 80 columns recommended by pep8)

Not changed the jump velocity, I prefer to change the map to have some ceiling that the player could hit and some more platform to allow reach the cauldron. This will wait after the merge, because tools/editor.py misbehaved when saving the edited map, and fixing that will probably need to touch tiles.py

rebased on master, squashed locally, merged to master. pushed as commit 058a359b8c79

The remote branch is unsquashed and I will let alive a few days, but obviously any further desired change must go on master or a new branch.

ccanepa commented 8 years ago

Changed test_platformer.py to use the RectMapCollider, props collision will not work well with the tileset it uses now. Player now reach the cauldron, interaction with walls are as expected.

I will wait a few days just in case some more comment, then will close.

dangillet commented 8 years ago

I saw your changes. Nice. I noticed indeed that the editor cannot save properly a map as there are not functions which would allow an atlas, image or tileset to be converted to XML. Looking at how the atlas is loaded, too much information is lost to be able to easily save the data back as it was in the file. So I guess this should be a separate issue which will involve some efforts.

For the rest, it was good fun to be able to run around in the platform. Kudos !