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>