Help With 2D Collision (Sticky Wall Sliding)

Started by
2 comments, last by 0026sd 7 years, 10 months ago

Hi everyone,

I've been researching for days and am unable to find the solution to this problem, although everyone talks about it....

Anyway, I've created a simple HTML5 game. It's a top down 2D tilemap with free movement (like Zelda). I've implemented simple collision detection and response using SAT (Separating Axis Theorem) but cannot figure out how to fix the sticky wall sliding: when the player is hugging a wall and you try to slide along - pressing into the wall and down the axis (i.e.: holding down and left at the same time on a wall). The player moves along the wall but not without violent bouncing.

I've set up a JS FIDDLE with my issue: https://jsfiddle.net/L7an0c1g

Everything is all well and good until you hit a wall and attempt to continue moving diagonally through it.

To make it easier to find, the Player "class" is at line 116 and the collision handling happens around line 160. It's called from Player.ApplyPhysics() on line 221.

Thank you in advance for your feedback! It's much appreciated.

Scott.

EDIT 1: I just discovered that the jumping also occurs if you push straight down (or sideways) on a wall while straddling 2 tiles.

EDIT 2: I'll paste the code here as well


<!DOCTYPE html>
<html>
<head>
	<style type="text/css">
		* { padding: 0px; margin: 0px auto; }
		html, body { background-color: #FFFFFF; font:normal 9pt "Century Gothic", Verdana, Arial; color:#222222; }

		#wrapper { margin-top:50px; }
		#wrapper #heading { font-size:48pt; }
	</style>


	<script type="text/javascript" language="javascript" src="https://code.jquery.com/jquery-2.2.4.min.js"></script>
	<script type="text/javascript" language="javascript">
		/*******************************************
		**************  INPUT OBJECT  **************
		*******************************************/
		var Input = {
			Keys: {
				_isPressed: {},
				W: 87,
				A: 65,
				S: 83,
				D: 68,
				SPACE: 32,
				GetKey: function (keyCode) {
					return Input.Keys._isPressed[keyCode];
				},
				onKeyDown: function (e) {
					Input.Keys._isPressed[e.keyCode] = true;
				},
				onKeyUp: function (e) {
					delete Input.Keys._isPressed[e.keyCode];
				}
			}
		};

		/**********************************************************
		**************  RECTANGLE EXTENSIONS OBJECT  **************
		**********************************************************/
		var RectangleExtensions = {
			GetIntersectionDepth: function (rectA, rectB) {
				var halfWidthA, halfWidthB, halfHeightA, halfHeightB, centerA, centerB, distanceX, distanceY, minDistanceX, minDistanceY, depthX, depthY;
				// Calculate Half sizes
				halfWidthA		= rectA.Width / 2.0;
				halfWidthB		= rectB.Width / 2.0;
				halfHeightA		= rectA.Height / 2.0;
				halfHeightB		= rectB.Height / 2.0;

				// Calculate centers
				centerA			= {'x': rectA.left + halfWidthA, 'y': rectA.top + halfHeightA};
				centerB			= {'x': rectB.left + halfWidthB, 'y': rectB.top + halfHeightB};

				distanceX		= centerA.x - centerB.x;
				distanceY		= centerA.y - centerB.y;
				minDistanceX	= halfWidthA + halfWidthB;
				minDistanceY	= halfHeightA + halfHeightB;

				// If we are not intersecting, return (0, 0)
				if (Math.abs(distanceX) >= minDistanceX || Math.abs(distanceY) >= minDistanceY)
					return {'x': 0, 'y': 0};

				// Calculate and return intersection depths
				depthX			= distanceX > 0 ? minDistanceX - distanceX : -minDistanceX - distanceX;
				depthY			= distanceY > 0 ? minDistanceY - distanceY : -minDistanceY - distanceY;

				return {'x': depthX, 'y': depthY};
			}
		};

		/********************************************
		**************  TEXTURE CLASS  **************
		********************************************/
		function Texture (pos, size, fillColor)  {
			this.pos		= pos;
			this.size		= size;
			this.fillColor	= fillColor;
		}

		Texture.prototype.update = function (pos) {
			this.pos = pos;
		};

		Texture.prototype.draw = function () {
			game.context.save();
			game.context.beginPath();
			game.context.rect(this.pos.x, this.pos.y, this.size.width, this.size.height);
			game.context.fillStyle = this.fillColor;
			game.context.fill();
			game.context.closePath();
			game.context.restore();
		};

		/**********************************************
		**************  LEVEL MAP ARRAY  **************
		**********************************************/
		var LevelMap = [
			['w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w'],
			['w','w','w','-','-','-','-','-','-','-','w','w','-','-','w','w','-','-','w','w','w','w','w','w','w','-','w','-','s','w'],
			['w','w','-','-','-','-','-','-','-','-','-','-','-','-','w','w','-','-','-','w','w','w','w','w','w','-','w','-','-','w'],
			['w','-','-','-','-','-','-','-','-','-','w','w','-','-','w','w','-','-','-','w','w','w','w','w','w','-','w','-','-','w'],
			['w','-','-','-','-','-','-','-','-','-','w','w','-','-','w','w','w','w','-','w','w','w','w','w','w','-','w','w','-','w'],
			['w','w','w','-','-','w','w','w','w','w','w','w','w','w','w','w','w','w','-','w','w','w','w','w','w','-','w','w','-','w'],
			['w','w','w','-','-','w','w','w','w','w','w','w','-','-','-','-','w','w','-','w','w','w','w','w','w','-','w','w','-','w'],
			['w','w','w','-','-','-','-','-','-','-','-','-','-','-','-','-','w','w','-','w','w','w','-','-','-','-','w','w','-','w'],
			['w','-','-','-','-','w','w','w','w','w','w','-','-','-','-','-','w','w','-','w','w','w','-','-','-','-','-','w','-','w'],
			['w','w','-','-','w','w','w','w','w','w','w','w','-','-','-','-','w','w','-','w','w','w','w','w','w','w','-','w','-','w'],
			['w','w','-','-','w','w','w','w','w','w','w','w','-','-','-','-','w','w','-','w','w','w','w','w','w','w','-','w','-','w'],
			['w','w','-','-','w','w','w','w','w','w','w','w','-','-','-','-','w','w','-','w','w','-','-','-','w','w','-','w','-','w'],
			['w','w','-','-','-','w','w','w','w','w','w','w','-','-','-','-','-','-','-','w','w','-','-','-','w','w','-','w','-','w'],
			['w','w','-','-','-','w','w','w','w','w','w','w','-','-','-','w','w','w','w','w','w','-','-','-','w','w','-','-','-','w'],
			['w','-','-','-','-','w','w','w','w','w','w','w','-','-','-','-','-','-','-','-','-','-','-','-','-','-','-','-','-','w'],
			['w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w','w']
		];

		/*****************************************
		**************  TILE CLASS  **************
		*****************************************/
		function Tile (pos, tileBG, collision) {
			this.pos		= pos;
			this.size		= {'width': 25, 'height': 25};
			this.collision	= collision;
			this.texture	= new Texture(this.pos, this.size, tileBG);
		}

		Tile.prototype.draw = function () {
			this.texture.draw();
		};

		/*******************************************
		**************  PLAYER CLASS  **************
		*******************************************/
		function Player (level) {
			this.level					= level;
			this.pos					= {'x': 100, 'y': 100};
			this.size					= {'width': 25, 'height': 25};
			this.velocity				= {'x': 0, 'y': 0};
			// Constants for controling movement
			this.MoveAcceleration 		= 500.0;
			this.MaxMoveSpeed 			= 5;
			this.GroundDragFactor 		= 0.38;
			this.movementX 				= 0;
			this.movementY 				= 0;

			this.texture				= new Texture(this.pos, this.size, '#FFFFFF');
		}

		Player.prototype.Clamp = function (value, min, max) {
			return (value < min) ? min : ((value > max) ? max : value);
		};

		Player.prototype.SetPos = function (pos) {
			this.pos = pos;
		};

		Player.prototype.GetInput = function () {

			// Horizontal Movement
			if (Input.Keys.GetKey(Input.Keys.A)) {
				this.movementX = -1.0;
			} else if (Input.Keys.GetKey(Input.Keys.D)) {
				this.movementX = 1.0;
			}

			// Vertical Movement
			if (Input.Keys.GetKey(Input.Keys.W)) {
				this.movementY = -1.0;
			} else if (Input.Keys.GetKey(Input.Keys.S)) {
				this.movementY = 1.0;
			}

		};

		Player.prototype.HandleCollision = function (gameTime) {
			// Set local variables
			var i, j, bottom, localBoundsRect, tileSize, topTile, leftTile, bottomTile, rightTile, tile, tileRect, depth, absDepthX, abdDepthY;

			// Set bouding box for our player
			localBoundsRect = {'left': this.pos.x, 'top': this.pos.y, 'right': this.pos.x + this.size.width, 'bottom': this.pos.y + this.size.height, 'Width': this.size.width, 'Height': this.size.height};

			// Set the tile size (hard coded)
			tileSize		= {'width': 25, 'height': 25};

			// Get the closest tiles
			topTile			= parseInt(Math.floor(parseFloat(localBoundsRect.top / tileSize.height)), 10);
			leftTile		= parseInt(Math.floor(parseFloat(localBoundsRect.left / tileSize.width)), 10);
			bottomTile		= parseInt(Math.ceil(parseFloat(localBoundsRect.bottom / tileSize.height)) - 1, 10);
			rightTile		= parseInt(Math.ceil(parseFloat(localBoundsRect.right / tileSize.width)) - 1, 10);

			// Loop through each potentially colliding tile
			for (i = topTile; i <= bottomTile; ++i) {
				for (j = leftTile; j <= rightTile; ++j) {

					// Put the tile we're looping on in a variable for multiple use
					tile = this.level.tiles[i][j];
					// Create a bounding box for our tile
					tileRect = {'left': tile.pos.x, 'top': tile.pos.y, 'right': tile.pos.x + tileSize.width, 'bottom': tile.pos.y + tileSize.height, 'Width': tileSize.width, 'Height': tileSize.height};

					// Check if this tile is collidable. Else, check if it's the exit tile
					if (tile.collision === 'IMPASSABLE') {

						// Now we know that this tile is being collided with, we'll figure out
						// the axis of least separation and push the player out along that axis

						// Get the intersection depths between the player and this tile
						depth = RectangleExtensions.GetIntersectionDepth(localBoundsRect, tileRect);

						// Only continue if depth != 0
						if (depth.x !== 0 && depth.y !== 0) {

							absDepthX = Math.abs(depth.x);
							absDepthY = Math.abs(depth.y);

							// If the Y depth is shallower than the X depth, correct player's y position and set y velocity to 0.
							// If the X depth is shallower, correct player's x position and set x velocity to 0.
							// Else, we've hit a corner (both intersection depths are equal). Correct both axes and set velocity to 0
							if (absDepthY < absDepthX) {
								this.pos.y += depth.y;
								this.velocity.y = 0;
							} else if (absDepthX < absDepthY) {
								this.pos.x += depth.x;
								this.velocity.x = 0;
							} else {
								this.pos = {'x': this.pos.x + depth.x, 'y': this.pos.y + depth.y};
							}

						}

					}

				}
			}
		};

		Player.prototype.ApplyPhysics = function (gameTime) {

			this.velocity.x		+= this.movementX * this.MoveAcceleration;
			this.velocity.y		+= this.movementY * this.MoveAcceleration;

			// Apply pseudo-drag horizontally
			this.velocity.x *= this.GroundDragFactor;
			this.velocity.y *= this.GroundDragFactor;

			// Prevent player from going faster than top speed
			this.velocity.x = this.Clamp(this.velocity.x, -this.MaxMoveSpeed, this.MaxMoveSpeed);
			this.velocity.y = this.Clamp(this.velocity.y, -this.MaxMoveSpeed, this.MaxMoveSpeed);

			// Apply velocity to player
			this.pos.x += Math.round(this.velocity.x);
			this.pos.y += Math.round(this.velocity.y);

			// Handle Collisions
			this.HandleCollision();

		};

		Player.prototype.update = function () {

			this.GetInput();
			this.ApplyPhysics();

			// Update the player
			this.texture.update(this.pos);

			// Clear inputs
			this.movementX = 0;
			this.movementY = 0;

		};

		Player.prototype.draw = function () {
			// Draw player texture
			this.texture.draw();
		};

		/******************************************
		**************  LEVEL CLASS  **************
		******************************************/
		function Level () {
			this.bgPos			= {'x': 0, 'y': 0};
			this.bgSize			= {'width': game.CANVAS_WIDTH, 'height': game.CANVAS_HEIGHT};
			this.bgTexture		= new Texture(this.bgPos, this.bgSize, '#222222');
			this.player			= new Player(this);
			this.tiles			= [];
			this.LoadTiles();
		}

		Level.prototype.LoadTiles = function () {
			var i, j, x, y, map, tileBG, collision;

			// Store our map in a local variable
			map = LevelMap;

			// Loop through the map and add a new Tile to the array
			for (i = 0; i < map.length; i++) {

				// Create a row in our tiles array
				this.tiles[i] = [];

				// Loop through each column of the row
				for (j = 0; j < map[i].length; j++) {

					// Calcluate the x,y coordinates based on array indexes
					x = j * 25;
					y = i * 25;

					// Based on the character, determine properties of this tile
					switch (map[i][j]) {
						case 'w':
							tileBG = 'rgb(' + Math.floor((Math.random() * 150) + 1) + ', ' + Math.floor((Math.random() * 150) + 1) + ', ' + Math.floor((Math.random() * 150) + 1) + ')';
							collision = 'IMPASSABLE';
							break;
						case 's':
							tileBG = ''; // transparent
							collision = 'PASSABLE';
							this.player.SetPos({'x': x, 'y': y});
							break;
						case '-':
							tileBG = ''; // transparent
							collision = 'PASSABLE';
							break;
					}

					// Add this tile to the array
					this.tiles[i][j] = new Tile({'x': x, 'y': y}, tileBG, collision);

				}

			}

		};

		Level.prototype.update = function () {
			this.player.update();
		};

		Level.prototype.draw = function () {
			var i, j;

			// Draw Background
			this.bgTexture.draw();

			// Draw tiles
			for (i = 0; i < this.tiles.length; i++) {
				for (j = 0; j < this.tiles[i].length; j++) {
					this.tiles[i][j].draw();
				}
			}

			// Draw Player
			this.player.draw();

		};

		/******************************************
		**************  GAME OBJECT  **************
		******************************************/
		var game = {
			init: function () {
				this.isRunning		= true;
				this.FPS			= 30;
				this.CANVAS_WIDTH	= 750;
				this.CANVAS_HEIGHT	= 400;
				this.canvas			= $('#gameArea')[0];
				this.context		= this.canvas.getContext('2d');
				this.level			= new Level();

				// Set up the canvas
				$('#wrapper').width(this.CANVAS_WIDTH).height(this.CANVAS_HEIGHT);
				game.canvas.width	= this.CANVAS_WIDTH;
				game.canvas.height	= this.CANVAS_HEIGHT;

				// Create input event listeners
				window.addEventListener('keyup', function (e) { Input.Keys.onKeyUp(e); }, false);
				window.addEventListener('keydown', function (e) { Input.Keys.onKeyDown(e); }, false);

				// Game Loop
				game.run();
			},
			run: function () {
				setInterval(function () {
					if (game.isRunning) {
						game.update();
						game.draw();
					}
				}, 1000/game.FPS);
			},
			update: function () {
				game.level.update();
			},
			draw: function () {
				game.context.clearRect(0, 0, this.CANVAS_WIDTH, this.CANVAS_HEIGHT);
				game.level.draw();
			}
		};

		// Entry point for the game
		$(function () { game.init(); });
	</script>
</head>
<body>

	<div id="wrapper">
		<div id="heading">HTML5</div>
		<canvas id="gameArea"></canvas>
	</div>

</body>
</html>
Advertisement
When you loop through your list of tiles, you use localBoundsRect against each tile. You move the location of the player but don't update the value of localBoundsRect. If your player intersects with two blocks then the overlap is corrected for each block moving the player twice as far as needed. I would recommend setting the position of the player relative to localBoundsRect using the depth as an offset. You can repeat that multiple times without any negative side effects.
My current game project Platform RPG

When you loop through your list of tiles, you use localBoundsRect against each tile. You move the location of the player but don't update the value of localBoundsRect. If your player intersects with two blocks then the overlap is corrected for each block moving the player twice as far as needed. I would recommend setting the position of the player relative to localBoundsRect using the depth as an offset. You can repeat that multiple times without any negative side effects.

Thank you for your reply!! Sounds like it's exactly what my problem is - I'll give it a try tomorrow and let you know how it went!

When you loop through your list of tiles, you use localBoundsRect against each tile. You move the location of the player but don't update the value of localBoundsRect. If your player intersects with two blocks then the overlap is corrected for each block moving the player twice as far as needed. I would recommend setting the position of the player relative to localBoundsRect using the depth as an offset. You can repeat that multiple times without any negative side effects.

Updated JS FIDDLE: https://jsfiddle.net/L7an0c1g/3/

OK, so I've applied the changes you suggested and it works except for one thing. The ELSE portion of my IF statement, checking intersection depths beginning on line 203, was intending to correct the player's position if the depth on both axes were the same (hit a corner). When I set my position x,y to the localBoundsRect + depth, it stops the player when sliding on a wall. I think it's because the player is hitting the corner of the tile it's moving into and correcting both the x and the y, stopping him.

Right now, after commenting out the else block, if you press W + A simultaneously, the player should pass through the corner tile and stop when it is fully in that tile. Uncommenting the else block will fix this but cause the issue mentioned above (sticky wall sliding).

I have read that this is a common issue with corners and maybe it just comes down to level design to minimize that from happening, but if you have any thoughts on how to correct this it would be greatly appreciated.

Thanks!

This topic is closed to new replies.

Advertisement