Handle mouse hovering image inside of canvas isometric grid - javascript

I got a isometric grid in html canvas.
I am trying to handle the mouse hover the buildings.
Some buildings will have different heights.
As you can see in the image below I am hovering a tile, the mouse pointer is inside the blueish tile.
The problem is when the mouse pointer is off the ground tile, or in the middle of the building image, the highlighted tile goes off.
Need a way to click on each individual building, how can this be resolved?
Main basic functions:
let applied_map = ref([]); // tileMap
let tile_images = ref([]); // this will contain loaded IMAGES for canvas to consume from
let tile_height = ref(50);
let tile_width = ref(100);
const renderTiles = (x, y) => {
let tileWidth = tile_width.value;
let tileHeight = tile_height.value;
let tile_half_width = tileWidth / 2;
let tile_half_height = tileHeight / 2;
for (let tileX = 0; tileX < gridSize.value; ++tileX) {
for (let tileY = 0; tileY < gridSize.value; ++tileY) {
let renderX = x + (tileX - tileY) * tile_half_width;
let renderY = y + (tileX + tileY) * tile_half_height;
let tile = applied_map.value[tileY * gridSize.value + tileX];
renderTileBackground(renderX, renderY + 50, tileWidth, tileHeight);
if (tile !== -1) {
if (tile_images.value.length) {
renderTexturedTile(
tile_images.value[tile].img,
renderX,
renderY + 40,
tileHeight
);
}
}
}
}
if (
hoverTileX.value >= 0 &&
hoverTileY.value >= 0 &&
hoverTileX.value < gridSize.value &&
hoverTileY.value < gridSize.value
) {
let renderX = x + (hoverTileX.value - hoverTileY.value) * tile_half_width;
let renderY = y + (hoverTileX.value + hoverTileY.value) * tile_half_height;
renderTileHover(renderX, renderY + 50, tileWidth, tileHeight);
}
};
const renderTileBackground = (x, y, width, height) => {
ctx.value.beginPath();
ctx.value.setLineDash([5, 5]);
ctx.value.strokeStyle = "black";
ctx.value.fillStyle = "rgba(25,34, 44,0.2)";
ctx.value.lineWidth = 1;
ctx.value.moveTo(x, y);
ctx.value.lineTo(x + width / 2, y - height / 2);
ctx.value.lineTo(x + width, y);
ctx.value.lineTo(x + width / 2, y + height / 2);
ctx.value.lineTo(x, y);
ctx.value.stroke();
ctx.value.fill();
};
const renderTexturedTile = (imgSrc, x, y, tileHeight) => {
let offsetY = tileHeight - imgSrc.height;
ctx.value.drawImage(imgSrc, x, y + offsetY);
};
const renderTileHover = (x, y, width, height) => {
ctx.value.beginPath();
ctx.value.setLineDash([]);
ctx.value.strokeStyle = "rgba(161, 153, 255, 0.8)";
ctx.value.fillStyle = "rgba(161, 153, 255, 0.4)";
ctx.value.lineWidth = 2;
ctx.value.moveTo(x, y);
ctx.value.lineTo(x + width / 2, y - height / 2);
ctx.value.lineTo(x + width, y);
ctx.value.lineTo(x + width / 2, y + height / 2);
ctx.value.lineTo(x, y);
ctx.value.stroke();
ctx.value.fill();
};
Updates after answer below
Based on Helder Sepulveda answer I created a function drawCube.
And added to my click function and to the renderTiles. So on click and frame update it creates a cube with 3 faces,and its placed on same position as the building and stores the Path on a global variable, the cube follows the isometric position.
In the drawCube, there is a condition where i need to hide the right face from the cube. Hide if there's a building on the next tile. So if you hover the building it wont trigger the last building on.
//...some code click function
//...
if (tile_images.value[tileIndex] !== undefined) {
drawCube(
hoverTileX.value + tile_height.value,
hoverTileY.value +
Number(tile_images.value[tileIndex].img.height / 2) -
10,
tile_height.value, // grow X pos to left
tile_height.value, // grow X pos to right,
Number(tile_images.value[tileIndex].img.height / 2), // height,
ctx.value,
{
tile_index: tileIndex - 1 < 0 ? 0 : tileIndex - 1,
}
);
}
This is the drawCube
const drawCube = (x, y, wx, wy, h, the_ctx, options = {}) => {
// https://codepen.io/AshKyd/pen/JYXEpL
let path = new Path2D();
let hide_options = {
left_face: false,
right_face: false,
top_face: false,
};
if (options.hasOwnProperty("hide")) {
hide_options = Object.assign(hide_options, options.hide);
}
// left face
if (!hide_options.left_face) {
path.moveTo(x, y);
path.lineTo(x - wx, y - wx * 0.5);
path.lineTo(x - wx, y - h - wx * 0.5);
path.lineTo(x, y - h * 1);
}
// right;
if (
!hide_options.right_face &&
!coliders.value[options.tile_index].hide_right_face
) {
path.moveTo(x, y);
path.lineTo(x + wy, y - wy * 0.5);
path.lineTo(x + wy, y - h - wy * 0.5);
path.lineTo(x, y - h * 1);
}
//top
if (!hide_options.right_face) {
path.moveTo(x, y - h);
path.lineTo(x - wx, y - h - wx * 0.5);
path.lineTo(x - wx + wy, y - h - (wx * 0.5 + wy * 0.5));
path.lineTo(x + wy, y - h - wy * 0.5);
}
// the_ctx.beginPath();
let isONHover = the_ctx.isPointInPath(
path,
mousePosition.x - 10,
mousePosition.y - 10
);
the_ctx.fillStyle = null;
if (isONHover) {
// let indx = options.tile_pos.y * gridSize.value + options.tile_pos.x;
//this is the click on object event
if (isMouseDown.value) {
//Trigger
if (buildozer.value === true) {
coliders.value[options.tile_index] = -1;
applied_map.value[options.tile_index] = -1;
}
isMouseDown.value = false;
}
the_ctx.fillStyle = "green";
}
the_ctx.fill(path);
if (
coliders.value[options.tile_index] == -1 &&
applied_map.value[options.tile_index]
) {
coliders.value[options.tile_index] = path;
}
};

In a nutshell you need to be able to detect mouseover on more complex shapes ...
I recommend you to use Path2d:
https://developer.mozilla.org/en-US/docs/Web/API/Path2D
That way you can build any shape you like and then we have access to isPointInPath to detect if the mouse is over our shape.
https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/isPointInPath
Here is a small example:
class Shape {
constructor(x, y, width, height) {
this.path = new Path2D()
this.path.arc(x, y, 12, 0, 2 * Math.PI)
this.path.arc(x, y - 9, 8, 0, 1.5 * Math.PI)
this.path.lineTo(x + width / 2, y)
this.path.lineTo(x, y + height / 2)
this.path.lineTo(x - width / 2, y)
this.path.lineTo(x, y - height / 2)
this.path.lineTo(x + width / 2, y)
}
draw(ctx, pos) {
ctx.beginPath()
ctx.fillStyle = ctx.isPointInPath(this.path, pos.x, pos.y) ? "red" : "green"
ctx.fill(this.path)
}
}
function getMousePos(canvas, evt) {
var rect = canvas.getBoundingClientRect()
return {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top
}
}
var canvas = document.getElementById("canvas")
var ctx = canvas.getContext("2d")
shapes = []
for (var i = 0; i < 4; i++) {
for (var j = 0; j < 4; j++) {
shapes.push(new Shape(50 + i * 40, 40 + j * 40, 40, 20))
}
}
canvas.addEventListener("mousemove", function(evt) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
var mousePos = getMousePos(canvas, evt)
shapes.forEach((s) => {s.draw(ctx, mousePos)})
},
false
)
shapes.forEach((s) => {
s.draw(ctx, {x: 0, y: 0})
})
<canvas id="canvas" width="200" height="200"></canvas>
This example draws a "complex" shape (two arcs and a few lines) and the shape changes color to red when the mouse is hovering the shape

Related

Calculate rotated rectangle coordinates with top left transform origin

I'm trying to draw the 4 cornes of this rotated rectangle with a top left transform origin. Right now, the red rectangle is drawn exactly like I want but I'm trying to draw the fours cornes but as you can see it looks like the coordinates are shifted but I don't understand why. How can I correct the green corner coordinates so they match the red rectangle.
The x and y coordinates of the red rectangle corresponds to the rotated top left corner of the rectangle
function radians(deg) {
return deg * (Math.PI / 180);
}
function getPointRotated(X, Y, R, Xos, Yos) {
var rotatedX = X + Xos * Math.cos(radians(R)) - Yos * Math.sin(radians(R));
var rotatedY = Y + Xos * Math.sin(radians(R)) + Yos * Math.cos(radians(R));
return {
x: rotatedX,
y: rotatedY
};
}
const rect = {
x: 100,
y: 100,
width: 150,
height: 50,
rotation: 45
};
const htmlRect = document.createElement("div");
htmlRect.style.height = rect.height + "px";
htmlRect.style.width = rect.width + "px";
htmlRect.style.position = "absolute";
htmlRect.style.background = "red";
htmlRect.style.transformOrigin = "top left";
htmlRect.style.transform = `translate3d(${rect.x}px,${rect.y}px,0) rotate(${rect.rotation}deg)`;
document.body.appendChild(htmlRect);
function drawPoint(point) {
const el = document.createElement("div");
el.style.height = 10 + "px";
el.style.width = 10 + "px";
el.style.position = "absolute";
el.style.background = "green";
el.style.transformOrigin = "center center";
el.style.transform = `translate3d(${point.x}px,${point.y}px,0)`;
document.body.appendChild(el);
}
var pointRotated = [];
pointRotated.push(
getPointRotated(
rect.x,
rect.y,
rect.rotation,
-rect.width / 2,
rect.height / 2
)
);
pointRotated.push(
getPointRotated(
rect.x,
rect.y,
rect.rotation,
rect.width / 2,
rect.height / 2
)
);
pointRotated.push(
getPointRotated(
rect.x,
rect.y,
rect.rotation,
-rect.width / 2,
-rect.height / 2
)
);
pointRotated.push(
getPointRotated(
rect.x,
rect.y,
rect.rotation,
rect.width / 2,
-rect.height / 2
)
);
for (let point of pointRotated) {
drawPoint(point);
}
A rotation around the origin follows the equation
X = c x - s y
Y = s x + c y
where c, s are the cosine and sine of the angle.
A rotation around an arbitrary point (u, v) is
X = c (x - u) - s (y - v) + u
Y = s (x - u) + c (y - v) + v

Canvas - Create random flakes on image

Can anyone give me a hint how I can create something like that with javascript:
The requirement is that I can set the density of the flakes. and add up to 5 different colors.
I do know how to create a canvas and put pixels in there, but I don't know how to create the "flakes".
Is there a way to create random shapes like this?
You can tessellate a simple shape and draw it at some random point.
The example below will create a 3 sided point, testate it randomly to a detail level of about 2 pixels and then add it to a path.
Then the path is filled with a color and another set of shapes are added.
function testate(amp, points) {
const p = [];
var i = points.length - 2, x1, y1, x2, y2;
p.push(x1 = points[i++]);
p.push(y1 = points[i]);
i = 0;
while (i < points.length) {
x2 = points[i++];
y2 = points[i++];
const dx = x2 - x1;
const dy = y2 - y1;
const r = (Math.random() - 0.5) * 2 * amp;
p.push(x1 + dx / 2 - dy * r);
p.push(y1 + dy / 2 + dx * r);
p.push(x1 = x2);
p.push(y1 = y2);
}
return p;
}
function drawFlake(ctx, size, x, y, noise) {
const a = Math.random() * Math.PI;
var points = [];
const step = Math.PI * (2/3);
var i = 0;
while (i < 3) {
const r = (Math.random() * size + size) / 2;
points.push(Math.cos(a + i * step) * r);
points.push(Math.sin(a + i * step) * r);
i++;
}
while (size > 2) {
points = testate(noise, points);
size >>= 1;
}
i = 0;
ctx.setTransform(1,0,0,1,x,y);
ctx.moveTo(points[i++], points[i++]);
while (i < points.length) {
ctx.lineTo(points[i++], points[i++]);
}
}
function drawRandomFlakes(ctx, count, col, min, max, noise) {
ctx.fillStyle = col;
ctx.beginPath();
while (count-- > 0) {
const x = Math.random() * ctx.canvas.width;
const y = Math.random() * ctx.canvas.height;
const size = min + Math.random() * (max- min);
drawFlake(ctx, size, x, y, noise);
}
ctx.fill();
}
const ctx = canvas.getContext("2d");
canvas.addEventListener("click",drawFlakes);
drawFlakes();
function drawFlakes(){
ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle = "#341";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
const noise = Math.random() * 0.3 + 0.3;
drawRandomFlakes(ctx, 500, "#572", 5, 10, noise)
drawRandomFlakes(ctx, 200, "#421", 10, 15, noise)
drawRandomFlakes(ctx, 25, "#257", 15, 30, noise)
}
body { background: #341 }
div {
position: absolute;
top: 20px;
left: 20px;
color: white;
}
<canvas id="canvas" width = "600" height = "512"></canvas>
<div>Click to redraw</div>
You'll need a certain noise algorithm.
In this example I used Perlin noise, but you can use any noise algorithm that fits your needs. By using Perlin noise we can define a blob as an area where the noise value is above a certain threshold.
I used a library that I found here and based my code on the sample code. The minified code is just a small portion of it (I cut out simplex and perlin 3D).
LICENSE
You can tweek it by changing the following parameters
Math.abs(noise.perlin2(x / 25, y / 25))
Changing the 25 to a higher value will zoom in, lower will zoom out
if (value > 0.4){
Changing the 0.4 to a lower value will increase blob size, higher will decrease blob size.
!function(n){var t=n.noise={};function e(n,t,e){this.x=n,this.y=t,this.z=e}e.prototype.dot2=function(n,t){return this.x*n+this.y*t},e.prototype.dot3=function(n,t,e){return this.x*n+this.y*t+this.z*e};var r=[new e(1,1,0),new e(-1,1,0),new e(1,-1,0),new e(-1,-1,0),new e(1,0,1),new e(-1,0,1),new e(1,0,-1),new e(-1,0,-1),new e(0,1,1),new e(0,-1,1),new e(0,1,-1),new e(0,-1,-1)],o=[151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,88,237,149,56,87,174,20,125,136,171,168,68,175,74,165,71,134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89,18,169,200,196,135,130,116,188,159,86,164,100,109,198,173,186,3,64,52,217,226,250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,167,43,172,9,129,22,39,253,19,98,108,110,79,113,224,232,178,185,112,104,218,246,97,228,251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,14,239,107,49,192,214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,150,254,138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180],i=new Array(512),w=new Array(512);function u(n){return n*n*n*(n*(6*n-15)+10)}function f(n,t,e){return(1-e)*n+e*t}t.seed=function(n){n>0&&n<1&&(n*=65536),(n=Math.floor(n))<256&&(n|=n<<8);for(var t=0;t<256;t++){var e;e=1&t?o[t]^255&n:o[t]^n>>8&255,i[t]=i[t+256]=e,w[t]=w[t+256]=r[e%12]}},t.seed(0),t.perlin2=function(n,t){var e=Math.floor(n),r=Math.floor(t);n-=e,t-=r;var o=w[(e&=255)+i[r&=255]].dot2(n,t),h=w[e+i[r+1]].dot2(n,t-1),s=w[e+1+i[r]].dot2(n-1,t),a=w[e+1+i[r+1]].dot2(n-1,t-1),c=u(n);return f(f(o,s,c),f(h,a,c),u(t))}}(this);
const c = document.getElementById("canvas");
const cc = c.getContext("2d");
noise.seed(Math.random());
let image = cc.createImageData(canvas.width, canvas.height);
let data = image.data;
for (let x = 0; x < c.width; x++){
for (let y = 0; y < c.height; y++){
const value = Math.abs(noise.perlin2(x / 25, y / 25));
const cell = (x + y * c.width) * 4;
if (value > 0.4){
data[cell] = 256;
data[cell + 1] = 0;
data[cell + 2] = 0;
data[cell + 3] = 256;
}
else {
data[cell] = 0;
data[cell + 1] = 0;
data[cell + 2] = 0;
data[cell + 3] = 0;
}
}
}
cc.putImageData(image, 0, 0);
<canvas id="canvas" width=500 height=500></canvas>

html5 canvas triangle with rounded corners

I'm new to HTML5 Canvas and I'm trying to draw a triangle with rounded corners.
I have tried
ctx.lineJoin = "round";
ctx.lineWidth = 20;
but none of them are working.
Here's my code:
var ctx = document.querySelector("canvas").getContext('2d');
ctx.scale(5, 5);
var x = 18 / 2;
var y = 0;
var triangleWidth = 18;
var triangleHeight = 8;
// how to round this triangle??
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + triangleWidth / 2, y + triangleHeight);
ctx.lineTo(x - triangleWidth / 2, y + triangleHeight);
ctx.closePath();
ctx.fillStyle = "#009688";
ctx.fill();
ctx.fillStyle = "#8BC34A";
ctx.fillRect(0, triangleHeight, 9, 126);
ctx.fillStyle = "#CDDC39";
ctx.fillRect(9, triangleHeight, 9, 126);
<canvas width="800" height="600"></canvas>
Could you help me?
Rounding corners
An invaluable function I use a lot is rounded polygon. It takes a set of 2D points that describe a polygon's vertices and adds arcs to round the corners.
The problem with rounding corners and keeping within the constraint of the polygons area is that you can not always fit a round corner that has a particular radius.
In these cases you can either ignore the corner and leave it as pointy or, you can reduce the rounding radius to fit the corner as best possible.
The following function will resize the corner rounding radius to fit the corner if the corner is too sharp and the lines from the corner not long enough to get the desired radius in.
Note the code has comments that refer to the Maths section below if you want to know what is going on.
roundedPoly(ctx, points, radius)
// ctx is the context to add the path to
// points is a array of points [{x :?, y: ?},...
// radius is the max rounding radius
// this creates a closed polygon.
// To draw you must call between
// ctx.beginPath();
// roundedPoly(ctx, points, radius);
// ctx.stroke();
// ctx.fill();
// as it only adds a path and does not render.
function roundedPoly(ctx, points, radiusAll) {
var i, x, y, len, p1, p2, p3, v1, v2, sinA, sinA90, radDirection, drawDirection, angle, halfAngle, cRadius, lenOut,radius;
// convert 2 points into vector form, polar form, and normalised
var asVec = function(p, pp, v) {
v.x = pp.x - p.x;
v.y = pp.y - p.y;
v.len = Math.sqrt(v.x * v.x + v.y * v.y);
v.nx = v.x / v.len;
v.ny = v.y / v.len;
v.ang = Math.atan2(v.ny, v.nx);
}
radius = radiusAll;
v1 = {};
v2 = {};
len = points.length;
p1 = points[len - 1];
// for each point
for (i = 0; i < len; i++) {
p2 = points[(i) % len];
p3 = points[(i + 1) % len];
//-----------------------------------------
// Part 1
asVec(p2, p1, v1);
asVec(p2, p3, v2);
sinA = v1.nx * v2.ny - v1.ny * v2.nx;
sinA90 = v1.nx * v2.nx - v1.ny * -v2.ny;
angle = Math.asin(sinA < -1 ? -1 : sinA > 1 ? 1 : sinA);
//-----------------------------------------
radDirection = 1;
drawDirection = false;
if (sinA90 < 0) {
if (angle < 0) {
angle = Math.PI + angle;
} else {
angle = Math.PI - angle;
radDirection = -1;
drawDirection = true;
}
} else {
if (angle > 0) {
radDirection = -1;
drawDirection = true;
}
}
if(p2.radius !== undefined){
radius = p2.radius;
}else{
radius = radiusAll;
}
//-----------------------------------------
// Part 2
halfAngle = angle / 2;
//-----------------------------------------
//-----------------------------------------
// Part 3
lenOut = Math.abs(Math.cos(halfAngle) * radius / Math.sin(halfAngle));
//-----------------------------------------
//-----------------------------------------
// Special part A
if (lenOut > Math.min(v1.len / 2, v2.len / 2)) {
lenOut = Math.min(v1.len / 2, v2.len / 2);
cRadius = Math.abs(lenOut * Math.sin(halfAngle) / Math.cos(halfAngle));
} else {
cRadius = radius;
}
//-----------------------------------------
// Part 4
x = p2.x + v2.nx * lenOut;
y = p2.y + v2.ny * lenOut;
//-----------------------------------------
// Part 5
x += -v2.ny * cRadius * radDirection;
y += v2.nx * cRadius * radDirection;
//-----------------------------------------
// Part 6
ctx.arc(x, y, cRadius, v1.ang + Math.PI / 2 * radDirection, v2.ang - Math.PI / 2 * radDirection, drawDirection);
//-----------------------------------------
p1 = p2;
p2 = p3;
}
ctx.closePath();
}
You may wish to add to each point a radius eg {x :10,y:10,radius:20} this will set the max radius for that point. A radius of zero will be no rounding.
The maths
The following illistration shows one of two possibilities, the angle to fit is less than 90deg, the other case (greater than 90) just has a few minor calculation differences (see code).
The corner is defined by the three points in red A, B, and C. The circle radius is r and we need to find the green points F the circle center and D and E which will define the start and end angles of the arc.
First we find the angle between the lines from B,A and B,C this is done by normalising the vectors for both lines and getting the cross product. (Commented as Part 1) We also find the angle of line BC to the line at 90deg to BA as this will help determine which side of the line to put the circle.
Now we have the angle between the lines, we know that half that angle defines the line that the center of the circle will sit F but we do not know how far that point is from B (Commented as Part 2)
There are two right triangles BDF and BEF which are identical. We have the angle at B and we know that the side DF and EF are equal to the radius of the circle r thus we can solve the triangle to get the distance to F from B
For convenience rather than calculate to F is solve for BD (Commented as Part 3) as I will move along the line BC by that distance (Commented as Part 4) then turn 90deg and move up to F (Commented as Part 5) This in the process gives the point D and moving along the line BA to E
We use points D and E and the circle center F (in their abstract form) to calculate the start and end angles of the arc. (done in the arc function part 6)
The rest of the code is concerned with the directions to move along and away from lines and which direction to sweep the arc.
The code section (special part A) uses the lengths of both lines BA and BC and compares them to the distance from BD if that distance is greater than half the line length we know the arc can not fit. I then solve the triangles to find the radius DF if the line BD is half the length of shortest line of BA and BC
Example use.
The snippet is a simple example of the above function in use. Click to add points to the canvas (needs a min of 3 points to create a polygon). You can drag points and see how the corner radius adapts to sharp corners or short lines. More info when snippet is running. To restart rerun the snippet. (there is a lot of extra code that can be ignored)
The corner radius is set to 30.
const ctx = canvas.getContext("2d");
const mouse = {
x: 0,
y: 0,
button: false,
drag: false,
dragStart: false,
dragEnd: false,
dragStartX: 0,
dragStartY: 0
}
function mouseEvents(e) {
mouse.x = e.pageX;
mouse.y = e.pageY;
const lb = mouse.button;
mouse.button = e.type === "mousedown" ? true : e.type === "mouseup" ? false : mouse.button;
if (lb !== mouse.button) {
if (mouse.button) {
mouse.drag = true;
mouse.dragStart = true;
mouse.dragStartX = mouse.x;
mouse.dragStartY = mouse.y;
} else {
mouse.drag = false;
mouse.dragEnd = true;
}
}
}
["down", "up", "move"].forEach(name => document.addEventListener("mouse" + name, mouseEvents));
const pointOnLine = {x:0,y:0};
function distFromLines(x,y,minDist){
var index = -1;
const v1 = {};
const v2 = {};
const v3 = {};
const point = P2(x,y);
eachOf(polygon,(p,i)=>{
const p1 = polygon[(i + 1) % polygon.length];
v1.x = p1.x - p.x;
v1.y = p1.y - p.y;
v2.x = point.x - p.x;
v2.y = point.y - p.y;
const u = (v2.x * v1.x + v2.y * v1.y)/(v1.y * v1.y + v1.x * v1.x);
if(u >= 0 && u <= 1){
v3.x = p.x + v1.x * u;
v3.y = p.y + v1.y * u;
dist = Math.hypot(v3.y - point.y, v3.x - point.x);
if(dist < minDist){
minDist = dist;
index = i;
pointOnLine.x = v3.x;
pointOnLine.y = v3.y;
}
}
})
return index;
}
function roundedPoly(ctx, points, radius) {
var i, x, y, len, p1, p2, p3, v1, v2, sinA, sinA90, radDirection, drawDirection, angle, halfAngle, cRadius, lenOut;
var asVec = function(p, pp, v) {
v.x = pp.x - p.x;
v.y = pp.y - p.y;
v.len = Math.sqrt(v.x * v.x + v.y * v.y);
v.nx = v.x / v.len;
v.ny = v.y / v.len;
v.ang = Math.atan2(v.ny, v.nx);
}
v1 = {};
v2 = {};
len = points.length;
p1 = points[len - 1];
for (i = 0; i < len; i++) {
p2 = points[(i) % len];
p3 = points[(i + 1) % len];
asVec(p2, p1, v1);
asVec(p2, p3, v2);
sinA = v1.nx * v2.ny - v1.ny * v2.nx;
sinA90 = v1.nx * v2.nx - v1.ny * -v2.ny;
angle = Math.asin(sinA); // warning you should guard by clampling
// to -1 to 1. See function roundedPoly in answer or
// Math.asin(Math.max(-1, Math.min(1, sinA)))
radDirection = 1;
drawDirection = false;
if (sinA90 < 0) {
if (angle < 0) {
angle = Math.PI + angle;
} else {
angle = Math.PI - angle;
radDirection = -1;
drawDirection = true;
}
} else {
if (angle > 0) {
radDirection = -1;
drawDirection = true;
}
}
halfAngle = angle / 2;
lenOut = Math.abs(Math.cos(halfAngle) * radius / Math.sin(halfAngle));
if (lenOut > Math.min(v1.len / 2, v2.len / 2)) {
lenOut = Math.min(v1.len / 2, v2.len / 2);
cRadius = Math.abs(lenOut * Math.sin(halfAngle) / Math.cos(halfAngle));
} else {
cRadius = radius;
}
x = p2.x + v2.nx * lenOut;
y = p2.y + v2.ny * lenOut;
x += -v2.ny * cRadius * radDirection;
y += v2.nx * cRadius * radDirection;
ctx.arc(x, y, cRadius, v1.ang + Math.PI / 2 * radDirection, v2.ang - Math.PI / 2 * radDirection, drawDirection);
p1 = p2;
p2 = p3;
}
ctx.closePath();
}
const eachOf = (array, callback) => { var i = 0; while (i < array.length && callback(array[i], i++) !== true); };
const P2 = (x = 0, y = 0) => ({x, y});
const polygon = [];
function findClosestPointIndex(x, y, minDist) {
var index = -1;
eachOf(polygon, (p, i) => {
const dist = Math.hypot(x - p.x, y - p.y);
if (dist < minDist) {
minDist = dist;
index = i;
}
});
return index;
}
// short cut vars
var w = canvas.width;
var h = canvas.height;
var cw = w / 2; // center
var ch = h / 2;
var dragPoint;
var globalTime;
var closestIndex = -1;
var closestLineIndex = -1;
var cursor = "default";
const lineDist = 10;
const pointDist = 20;
var toolTip = "";
// main update function
function update(timer) {
globalTime = timer;
cursor = "crosshair";
toolTip = "";
ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform
ctx.globalAlpha = 1; // reset alpha
if (w !== innerWidth - 4 || h !== innerHeight - 4) {
cw = (w = canvas.width = innerWidth - 4) / 2;
ch = (h = canvas.height = innerHeight - 4) / 2;
} else {
ctx.clearRect(0, 0, w, h);
}
if (mouse.drag) {
if (mouse.dragStart) {
mouse.dragStart = false;
closestIndex = findClosestPointIndex(mouse.x,mouse.y, pointDist);
if(closestIndex === -1){
closestLineIndex = distFromLines(mouse.x,mouse.y,lineDist);
if(closestLineIndex === -1){
polygon.push(dragPoint = P2(mouse.x, mouse.y));
}else{
polygon.splice(closestLineIndex+1,0,dragPoint = P2(mouse.x, mouse.y));
}
}else{
dragPoint = polygon[closestIndex];
}
}
dragPoint.x = mouse.x;
dragPoint.y = mouse.y
cursor = "none";
}else{
closestIndex = findClosestPointIndex(mouse.x,mouse.y, pointDist);
if(closestIndex === -1){
closestLineIndex = distFromLines(mouse.x,mouse.y,lineDist);
if(closestLineIndex > -1){
toolTip = "Click to cut line and/or drag to move.";
}
}else{
toolTip = "Click drag to move point.";
closestLineIndex = -1;
}
}
ctx.lineWidth = 4;
ctx.fillStyle = "#09F";
ctx.strokeStyle = "#000";
ctx.beginPath();
roundedPoly(ctx, polygon, 30);
ctx.stroke();
ctx.fill();
ctx.beginPath();
ctx.strokeStyle = "red";
ctx.lineWidth = 0.5;
eachOf(polygon, p => ctx.lineTo(p.x,p.y) );
ctx.closePath();
ctx.stroke();
ctx.strokeStyle = "orange";
ctx.lineWidth = 1;
eachOf(polygon, p => ctx.strokeRect(p.x-2,p.y-2,4,4) );
if(closestIndex > -1){
ctx.strokeStyle = "red";
ctx.lineWidth = 4;
dragPoint = polygon[closestIndex];
ctx.strokeRect(dragPoint.x-4,dragPoint.y-4,8,8);
cursor = "move";
}else if(closestLineIndex > -1){
ctx.strokeStyle = "red";
ctx.lineWidth = 4;
var p = polygon[closestLineIndex];
var p1 = polygon[(closestLineIndex + 1) % polygon.length];
ctx.beginPath();
ctx.lineTo(p.x,p.y);
ctx.lineTo(p1.x,p1.y);
ctx.stroke();
ctx.strokeRect(pointOnLine.x-4,pointOnLine.y-4,8,8);
cursor = "pointer";
}
if(toolTip === "" && polygon.length < 3){
toolTip = "Click to add a corners of a polygon.";
}
canvas.title = toolTip;
canvas.style.cursor = cursor;
requestAnimationFrame(update);
}
requestAnimationFrame(update);
canvas {
border: 2px solid black;
position: absolute;
top: 0px;
left: 0px;
}
<canvas id="canvas"></canvas>
I started by using #Blindman67 's answer, which works pretty well for basic static shapes.
I ran into the problem that when using the arc approach, having two points right next to each other is very different than having just one point. With two points next to each other, it won't be rounded, even if that is what your eye would expect. This is extra jarring if you are animating the polygon points.
I fixed this by using Bezier curves instead. IMO this is conceptually a little cleaner as well. I just make each corner with a quadratic curve where the control point is where the original corner was. This way, having two points in the same spot is virtually the same as only having one point.
I haven't compared performance but seems like canvas is pretty good at drawing Beziers.
As with #Blindman67 's answer, this doesn't actually draw anything so you will need to call ctx.beginPath() before and ctx.stroke() after.
/**
* Draws a polygon with rounded corners
* #param {CanvasRenderingContext2D} ctx The canvas context
* #param {Array} points A list of `{x, y}` points
* #radius {number} how much to round the corners
*/
function myRoundPolly(ctx, points, radius) {
const distance = (p1, p2) => Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2)
const lerp = (a, b, x) => a + (b - a) * x
const lerp2D = (p1, p2, t) => ({
x: lerp(p1.x, p2.x, t),
y: lerp(p1.y, p2.y, t)
})
const numPoints = points.length
let corners = []
for (let i = 0; i < numPoints; i++) {
let lastPoint = points[i]
let thisPoint = points[(i + 1) % numPoints]
let nextPoint = points[(i + 2) % numPoints]
let lastEdgeLength = distance(lastPoint, thisPoint)
let lastOffsetDistance = Math.min(lastEdgeLength / 2, radius)
let start = lerp2D(
thisPoint,
lastPoint,
lastOffsetDistance / lastEdgeLength
)
let nextEdgeLength = distance(nextPoint, thisPoint)
let nextOffsetDistance = Math.min(nextEdgeLength / 2, radius)
let end = lerp2D(
thisPoint,
nextPoint,
nextOffsetDistance / nextEdgeLength
)
corners.push([start, thisPoint, end])
}
ctx.moveTo(corners[0][0].x, corners[0][0].y)
for (let [start, ctrl, end] of corners) {
ctx.lineTo(start.x, start.y)
ctx.quadraticCurveTo(ctrl.x, ctrl.y, end.x, end.y)
}
ctx.closePath()
}
Styles for joining of lines such as ctx.lineJoin="round" apply to the stroke operation on paths - which is when their width, color, pattern, dash/dotted and similar line style attributes are taken into account.
Line styles do not apply to filling the interior of a path.
So to affect line styles a stroke operation is needed. In the following adaptation of posted code, I've translated canvas output to see the result without cropping, and stroked the triangle's path but not the rectangles below it:
var ctx = document.querySelector("canvas").getContext('2d');
ctx.scale(5, 5);
ctx.translate( 18, 12);
var x = 18 / 2;
var y = 0;
var triangleWidth = 48;
var triangleHeight = 8;
// how to round this triangle??
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + triangleWidth / 2, y + triangleHeight);
ctx.lineTo(x - triangleWidth / 2, y + triangleHeight);
ctx.closePath();
ctx.fillStyle = "#009688";
ctx.fill();
// stroke the triangle path.
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "orange";
ctx.stroke();
ctx.fillStyle = "#8BC34A";
ctx.fillRect(0, triangleHeight, 9, 126);
ctx.fillStyle = "#CDDC39";
ctx.fillRect(9, triangleHeight, 9, 126);
<canvas width="800" height="600"></canvas>

HTML5 canvas draw a rectangle with gelatine effect

I'm creating a platformer game in javascript in which players are rectangles and they can jump on and off platforms. This is pretty straightforward but now I'm trying to add a 'gelatine effect' to the players so that when they land on a platform they move a bit(like a gelatine). I've been searching for quite some time now but I can't seem to find any good examples on how I can change the shape of a rectangle.
All I've come up with is by using #keyframes in css and i've tried implementing it, which works if I use it on html elements. This is because with the DOM elements I can access the CSSStyleDeclaration with .style but if I create a new player object I can't(as seen in my code below).
I'm not sure how I can convert the css #keyframes to javascript or if what i'm doing isn't possible.. or if there perhaps are other (better) ways to achieve my goal?
var keystate = [];
var players = [];
var jelly = document.getElementById('jelly');
console.log(jelly.style); // shows the CSSStyleDeclarations
document.body.addEventListener("keydown", function(e) {
keystate[e.keyCode] = true;
});
document.body.addEventListener("keyup", function(e) {
keystate[e.keyCode] = false;
});
function gelatine(e) {
if (e.style.webkitAnimationName !== 'gelatine') {
e.style.webkitAnimationName = 'gelatine';
e.style.webkitAnimationDuration = '0.5s';
e.style.display = 'inline-block';
setTimeout(function() {
e.style.webkitAnimationName = '';
}, 1000);
}
}
var canvas = document.getElementById("canvas");
var context = canvas.getContext('2d');
canvas.width = 500;
canvas.height = 300;
function Player(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.jumping = false;
this.velocityY = 0;
this.gravity = 0.3;
this.speed = 5;
this.timer = 0;
this.delay = 120;
}
Player.prototype.render = function render() {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = 'blue';
context.fillRect(this.x, this.y, this.width, this.height);
context.fillStyle = 'black';
context.font = '20pt sans-serif';
context.fillText("I'm not jelly:(", this.x - 160, this.y + 30);
};
Player.prototype.update = function update() {
// arrow up key to jump with the player
if (keystate[38]) {
if (!this.jumping) {
this.jumping = true;
this.velocityY = -this.speed * 2;
}
}
this.velocityY += this.gravity;
this.y += this.velocityY;
if (this.y >= canvas.height - this.height) {
this.y = canvas.height - this.height;
this.jumping = false;
}
if (this.timer === 0) {
gelatine(jelly);
this.timer = this.delay;
}
if (this.timer > 0 && this.timer <= this.delay) {
this.timer--;
}
};
players.push(new Player((canvas.width / 2) - 25, (canvas.height / 2), 50, 50));
console.log(players[0].style); // no CSSStyleDeclarations :(
function render() {
for (var i = 0; i < players.length; i++) {
players[i].render();
}
}
function update() {
for (var i = 0; i < players.length; i++) {
players[i].update();
}
}
function tick() {
update();
render();
requestAnimationFrame(tick);
}
tick();
<html>
<head>
<style>
#jelly {
position: absolute;
margin-left: auto;
margin-right: auto;
top: 80px;
bottom: 0;
left: 0;
right: 0;
display: inline-block;
width: 100px;
height: 100px;
background: blue;
}
p {
font-size: 20px;
color: white;
}
canvas {
border: 1px solid #000;
position: absolute;
margin: auto;
top: 20px;
bottom: 0;
left: 0;
right: 0;
}
#keyframes gelatine {
25% {
-webkit-transform: scale(0.9, 1.1);
transform: scale(0.9, 1.1);
}
50% {
-webkit-transform: scale(1.1, 0.9);
transform: scale(1.1, 0.9);
}
75% {
-webkit-transform: scale(0.95, 1.05);
transform: scale(0.95, 1.05);
}
}
</style>
<title>Jelly rectangle</title>
</head>
<body>
<div id="jelly">
<p>&nbsp&nbsp i'm jelly :)</p>
</div>
<canvas id='canvas'></canvas>
</body>
</html>
Jelly.
To create a jelly effect for using in a game we can take advantage of a step based effect that is open ended (ie the effect length is dependent on the environment and has no fixed time)
Jelly and most liquids have a property that they are incompressible. This means that no matter the force applied to them the volume does not change. For a 2D game this mean that for jelly the area does not change. This means we can squash the height and knowing the area calculate the width.
Thus when a object drops and hits the ground we can apply a squashing force to the object in the height direction. Simulating a dampened spring we can produce a very realistic jelly effect.
Defining the jelly
I am being a little silly as it is the season so the variables wobbla and bouncy define the dampened spring with wobbla being the stiffness of the spring from 0 to 1, with 0.1 being very soft to 1 being very stiff. Bouncy being the damping of the spring from 0 to 1, with 0 having 100% damping and 1 having no damping.
I have also added react which is a simple multiplier to the force applied to the spring. As this is a game there is a need to exaggerate effects. react multiplies the force applied to the spring. Values under 1 reduce the effect, values over 1 increase the effect.
hr and hd (yes bad names) are the real height (displayed) and the height delta ( the change in height per frame). These two variable control the spring effect.
There is a flag to indicate that the jelly is on the ground and the sticky flag if true keeps the jelly stuck to the ground.
There are also three functions to control the jelly
function createJellyBox(image,x, y, wobbla, bouncy, react){
return {
img:image,
x : x, // position
y : y,
dx : 0, // movement deltas
dy : 0,
w : image.width, // width
h : image.height, // height
area : image.width * image.height, // area
hr : image.height, // squashed height chaser
hd : 0, // squashed height delta
wobbla : wobbla, // higher values make it wobble more
bouncy : bouncy, // higher values make it react to change in speed
react : react, // additional reaction multiplier. < 1 reduces reaction > 1 increases reaction
onGround : false, // true if on the ground
sticky : true, // true to stick to the ground. false to let it bounce
squashed : 1, // the amount of squashing or stretching along the height
force : 0, // the force applied to the jelly when it hits the ground
draw : drawJelly, // draw function
update : updateJelly, // update function
reset : resetJelly, // reset function
}
}
The functions
There are three functions to control the jelly, reset, update and draw. Sorry the answer is over the 30000 character limit so find the functions in the demo below.
Draw
Draw simply draws the jelly. It uses the squashed value to calculate the height and from that calculates the width, then simply draws the image with the set width and height. The image is drawn at its center to keep calculations simpler.
Reset
Simply resets the jelly at a position defined by x and y you can modify it to remove any wobbles by setting jelly.hr = jelly.h and jelly.hd = 0
Update
This is where the hard work is. It updates the object position by adding gravity to the delta Y. It checks if the jelly has hit the ground, if it has it applies the force to the jelly.hr spring by adding to the jelly.hd (height delta) the force equal to the speed of the inpact times jelly.react
I found that the force applied should be over time so there is a little cludge (marked with comments) to apply a squashing force over time. That can be removed but it just reduces the smoothness of the jelly wobbly effect.
Last thing is to recalculate the height and move the jelly so that it does not cross into the ground.
Clear as mud cake I am sure. So feel free anyone to ask questions if there is a need to clarify.
DEMO
What would any good SO answer be without a demo. So here is a jelly simulation. Click on the canvas to drop the jelly. The three sliders on the top left control the values Wobbla, Bouncy, and React. Play witht the values to change the jelly effect. The check box turn on and off sticky, but you need a long screen to get the impact you need to bounce up. Remove the cludge code to see sticky real purpose.
As I needed a UI there is a lot of extra code that does not apply to the answer. The Answer code is clearly marked so to be easy to find. The main loop is at the bottom. I have not yet tested it on FF but it should work. If not let me know and I will fix it.
var STOP = false; // stops the app
var jellyApp = function(){
/** Compiled by GROOVER Quick run 9:43pm DEC-22-2015 **/
/** fullScreenCanvas.js begin **/
var canvas = (function(){
var canvas = document.getElementById("canv");
if(canvas !== null){
document.body.removeChild(canvas);
}
// creates a blank image with 2d context
canvas = document.createElement("canvas");
canvas.id = "canv";
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.position = "absolute";
canvas.style.top = "0px";
canvas.style.left = "0px";
canvas.style.zIndex = 1000;
canvas.ctx = canvas.getContext("2d");
document.body.appendChild(canvas);
return canvas;
})();
var ctx = canvas.ctx;
/** fullScreenCanvas.js end **/
/** MouseFull.js begin **/
var canvasMouseCallBack = undefined; // if needed
var mouse = (function(){
var mouse = {
x : 0, y : 0, w : 0, alt : false, shift : false, ctrl : false,
interfaceId : 0, buttonLastRaw : 0, buttonRaw : 0,
over : false, // mouse is over the element
bm : [1, 2, 4, 6, 5, 3], // masks for setting and clearing button raw bits;
getInterfaceId : function () { return this.interfaceId++; }, // For UI functions
startMouse:undefined,
};
function mouseMove(e) {
var t = e.type, m = mouse;
m.x = e.offsetX; m.y = e.offsetY;
if (m.x === undefined) { m.x = e.clientX; m.y = e.clientY; }
m.alt = e.altKey;m.shift = e.shiftKey;m.ctrl = e.ctrlKey;
if (t === "mousedown") { m.buttonRaw |= m.bm[e.which-1];
} else if (t === "mouseup") { m.buttonRaw &= m.bm[e.which + 2];
} else if (t === "mouseout") { m.buttonRaw = 0; m.over = false;
} else if (t === "mouseover") { m.over = true;
} else if (t === "mousewheel") { m.w = e.wheelDelta;
} else if (t === "DOMMouseScroll") { m.w = -e.detail;}
if (canvasMouseCallBack) { canvasMouseCallBack(m.x, m.y); }
e.preventDefault();
}
function startMouse(element){
if(element === undefined){
element = document;
}
"mousemove,mousedown,mouseup,mouseout,mouseover,mousewheel,DOMMouseScroll".split(",").forEach(
function(n){element.addEventListener(n, mouseMove);});
}
mouse.mouseStart = startMouse;
return mouse;
})();
if(typeof canvas === "undefined"){
mouse.mouseStart(canvas);
}else{
mouse.mouseStart();
}
/** MouseFull.js end **/
// unit size for rendering scale
var ep = canvas.height / 72;
// font constants
const FONT = "px Arial Black";
const FONT_SIZE = Math.ceil(3 * ep);
const GRAVITY = ep * (1/5);
const GROUND_AT = canvas.height - canvas.height * (1/20);
// Answer code.
//----------------------------------------------------------------------- // draw the jelly
function drawJelly(ctx){
var w,h;
w = this.w,
h = this.h;
h *= this.squashed;
// the width is ajusted so that the area of the rectagle remains constant
w = this.area / h; // to keep the area constant
ctx.drawImage(this.img,this.x - w / 2, this.y - h / 2, w, h);
}
function resetJelly(x,y){ // reset the jelly position
this.x = x;
this.y = y;
this.onGround = false;
this.dx = 0;
this.dy = 0;
}
// do the jelly math
function updateJelly(){
var h; // temp height
var hitG = false; // flag that the ground has just been hit
h = this.h * this.squashed; // get the height from squashed
if(!this.onGround){ // if not on the ground add grav
this.dy += GRAVITY;
}else{ // if on the ground move it so it touches correctly
this.dy = 0;
this.y = GROUND_AT - h / 2;
}
// update the position
this.x += this.dx;
this.y += this.dy;
// check if it has hit the ground
if(this.y + h / 2 >= GROUND_AT && this.dy >= 0){
this.hd += this.dy * this.react; // add the hit speed to the height delta
// multiply with react to inhance or reduce effect
this.force += this.dy * this.react;
hitG = true;
this.onGround = true; // flag that jelly is on the ground
}
if(this.force > 0){
this.hd += this.force;
this.force *= this.wobbla;
}
this.hd += (this.h - this.hr) * this.wobbla; // add wobbla to delta height
this.hd *= this.bouncy; // reduce bounce
this.hr += this.hd; // set the real height
this.squashed = this.h / this.hr; // calculate the new squashed amount
h = this.h * this.squashed; // recalculate hieght to make sure
// the jelly does not overlap the ground
// do the finnal position ajustment to avoid overlapping the ground
if(this.y + h / 2 >= GROUND_AT || hitG || (this.sticky && this.onGround)){
this.y = GROUND_AT - h / 2;
}
}
// create a jelly box
function createJellyBox(image,x, y, wobbla, bouncy, react){
return {
img:image,
x : x, // position
y : y,
dx : 0, // movement deltas
dy : 0,
w : image.width, // width
h : image.height, // height
area : image.width * image.height, // area
hr : image.height, // squashed height chaser
hd : 0, // squashed height delta
wobbla : wobbla, // higher values make it wobble more
bouncy : bouncy, // higher values make it react to change in speed
react : react, // additional reaction multiplier. < 1 reduces reaction > 1 increases reaction
onGround : false, // true if on the ground
sticky : true, // true to stick to the groun. false to let it bounce
squashed : 1, // the amount of squashing or streaching along the height
force : 0,
draw : drawJelly, // draw function
update : updateJelly, // update function
reset : resetJelly, // reset function
}
}
// --------------------------------------------------------------------------------------------
// END OF ANSWER CODE.
// FIND the usage at the bottom inside the main loop function.
// --------------------------------------------------------------------------------------------
// The following code is just helpers and UI stuff and are not related to the answer.
var createImage = function (w, h ){
var image = document.createElement("canvas");
image.width = w;
image.height = h;
image.ctx = image.getContext("2d");
return image;
}
var drawSky = function (img, col1, col2){
var c, w, h;
c = img.ctx;
w = img.width;
h = img.height;
var g = c.createRadialGradient(w * (1 / 2), h * (3 / 2), h * (1 / 2) +w * (1 / 4), w * (1 / 2), h * (3 / 2), h * (3 / 2) + w * ( 1 / 4));
g.addColorStop(0,col1);
g.addColorStop(1,col2);
c.fillStyle = g;
c.fillRect(0, 0, w, h);
return img;
}
var drawGround = function (img, col1, col2,lineColour, lineWidth) {
var c, w, h, lw;
c = img.ctx;
w = img.width;
h = img.height;
lw = lineWidth;
var g = c.createLinearGradient(0, 0, 0, h + lineWidth);
g.addColorStop(0, col1);
g.addColorStop(1, col2);
c.fillStyle = g;
c.lineWidth = lw;
c.strokeStyle = lineColour;
c.strokeRect(-lw * 2, lw / 2, w + lw * 4, h + lw * 4);
c.fillRect(-lw * 2, lw / 2, w + lw * 4, h + lw * 4);
return img;
}
/** CanvasUI.js begin **/
var drawRoundedBox = function (img, colour, rounding, lineColour, lineWidth) {
var c, x, y, w, h, r, p
p = Math.PI/2; // 90 deg
c = img.ctx;
w = img.width;
h = img.height;
lw = lineWidth;
r = rounding ;
c.lineWidth = lineWidth;
c.fillStyle = colour;
c.strokeStyle = lineColour;
c.beginPath();
c.arc(w - r - lw / 2, h - r - lw / 2, r, 0, p);
c.lineTo(r + lw / 2, h - lw / 2);
c.arc(r + lw / 2, h - r - lw / 2, r, p, p * 2);
c.lineTo(lw / 2, h - r - lw / 2);
c.arc(r + lw / 2, r + lw / 2, r, p * 2, p * 3);
c.lineTo(w-r - lw / 2, lw / 2);
c.arc(w - r - lw / 2, r + lw / 2, r, p * 3, p * 4);
c.closePath();
c.stroke();
c.fill();
return img;
}
var drawTick = function (img , col, lineColour, lineWidth){
var c, w, h, lw, m, l;
m = function (x, y) {c.moveTo(lw / 2 + w * x, lw / 2 + h * y);};
l = function (x, y) {c.lineTo(lw / 2 + w * x, lw / 2 + h * y);};
lw = lineWidth;
c = img.ctx;
w = img.width - lw;
h = img.height - lw;
c.fillStyle = col;
c.strokeStyle = lineColour;
c.lineWidth = lw;
c.beginPath();
m(1, 0);
l(5 / 8, 1);
l(0, 3 / 4);
l(1 / 4, 2 / 4);
l(2 / 4, 3 / 4);
l(1, 0);
c.stroke();
c.fill();
return img;
}
var setFont = function(ctx,font,align){
ctx.font = font;
ctx.textAlign = align;
}
var measureText = function(ctx,text){
return ctx.measureText(text).width;
}
var drawText = function(ctx,text,x,y,col,col1){
var of;
of = Math.floor(FONT_SIZE/10);
ctx.fillStyle = col1;
ctx.fillText(text,x+of,y+of);
ctx.fillStyle = col;
ctx.fillText(text,x,y);
}
var drawSlider = function(ctx){
var x,y;
x = this.owner.x;
y = this.owner.y;
ctx.drawImage(this.image, this.x + x, this.y + y);
ctx.drawImage(this.nob, this.nx + x, this.ny + y);
}
var updateSlider = function(mouse){
var mx, my;
mx = mouse.x - this.owner.x;
my = mouse.y - this.owner.y;
this.cursor = "";
if(this.owner.dragging === -1 || this.owner.dragging === this.id ){
if(mx >= this.x && mx < this.x + this.w &&
my >= this.y && my <= this.y + this.h){
this.mouseOver = true;
this.cursor = "pointer"
}else{
this.mouseOver = false;
}
if(mx >= this.nx && mx < this.nx + this.nw &&
my >= this.ny && my <= this.ny + this.nh){
this.mouseOverNob = true;
this.cursor = "ew-resize"
}else{
this.mouseOverNob = false;
}
if((mouse.buttonRaw&1) === 1 && (this.mouseOver||this.mouseOverNob) && !this.dragging){
this.owner.dragging = this.id;
this.dragging = true;
this.cursor = "ew-resize"
}else
if(this.dragging){
this.cursor = "ew-resize"
if((mouse.buttonRaw & 1)=== 0){
this.dragging = false;
this.owner.dragging = -1;
this.cursor = "pointer";
}
var p = mx- (this.x+this.nw/2);
p /= (this.w-this.nw);
p *= this.range;
p += this.min;
this.value = Math.min(this.max, Math.max(this.min, p));
}
if(this.mouseOver || this.mouseOverNob || this.dragging){
this.owner.toolTip = this.toolTip.replace("##",this.value.toFixed(this.decimals));
}
}
this.nx = (this.value - this.min) / this.range * (this.w - this.nw) + this.x;
}
var createSlider = function(image,nobImage, x, y, value, min, max, toolTip) {
var decimals = 0;
if(toolTip.indexOf("#.") > -1){
if(toolTip.indexOf(".DDD") > -1){
decimals = 3;
toolTip = toolTip.replace("#.DDD","##");
}else
if(toolTip.indexOf(".DD") > -1){
decimals = 2;
toolTip = toolTip.replace("#.DD","##");
}else
if(toolTip.indexOf(".D") > -1){
decimals = 1;
toolTip = toolTip.replace("#.D","##");
}else{
toolTip = toolTip.replace("#.","##");
}
}
return {
id : undefined,
image : image,
nob : nobImage,
min : min,
max : max,
x : x,
y : y + (nobImage.height - image.height)/2,
ny : y ,
nx : ((value - min) / (max - min)) * (image.width - nobImage.width) + x,
range : max - min,
w : image.width,
h : image.height,
nw : nobImage.width,
nh : nobImage.height,
value : value,
maxH : Math.max( image.height, nobImage.height),
mouseOver : false,
mouseOverNob : false,
toolTip:toolTip,
decimals:decimals,
dragging : false,
update : updateSlider,
draw : drawSlider,
position : function (x, y){
this.x += x;
this.y += y;
this.nx += x;
this.ny += y;
},
}
}
var drawTickCont = function(ctx){
var x,y, ofx, ofy;
x = this.owner.x;
y = this.owner.y;
ctx.drawImage(this.image, this.x + x, this.y + y);
ofy = this.h / 2 - this.textImage.height / 2;
ofx = this.w / 2;
ctx.drawImage(this.textImage, this.x + x + this.w + ofx, this.y + y + ofy);
if(this.value){
x -= this.tickImage.width * ( 1/ 4);
y -= this.tickImage.height * ( 2/ 5);
ctx.drawImage(this.tickImage, this.x + x, this.y + y);
}
}
var updateTick = function(mouse){
var mx, my;
mx = mouse.x - this.owner.x;
my = mouse.y - this.owner.y;
this.cursor = "";
if(this.owner.dragging === -1 || this.owner.dragging === this.id ){
if(mx >= this.x && mx < this.x + this.w &&
my >= this.y && my <= this.y + this.h){
this.mouseOver = true;
this.cursor = "pointer"
}else{
this.mouseOver = false;
}
if((mouse.buttonRaw&1) === 1 && this.mouseOver && !this.dragging){
this.owner.dragging = this.id;
this.dragging = true;
}else
if(this.dragging){
if((mouse.buttonRaw & 1)=== 0){
if(this.mouseOver){
this.value = ! this.value;
}
this.dragging = false;
this.owner.dragging = -1;
this.cursor = "pointer";
}
}
if(this.mouseOver || this.dragging){
this.owner.toolTip = this.toolTip;
}
}
}
var createTick= function(image,tickImage,textImage, x, y, value, toolTip) {
return {
id : undefined,
image : image,
tickImage : tickImage,
textImage : textImage,
x : x,
y : y,
w : image.width,
h : image.height,
value : value,
maxH : Math.max( image.height, tickImage.height),
mouseOver : false,
mouseOverNob : false,
toolTip:toolTip,
dragging : false,
update : updateTick,
draw : drawTickCont,
position : function (x, y){
this.x += x;
this.y += y;
},
}
}
function UI(ctx, mouse, x, y){
this.dragging = -1;
var ids = 0;
var controls = [];
var length = 0;
this.x = x;
this.y = y;
var posX = 0;
var posY = 0;
this.addControl = function (control, name) {
control.id = ids ++;
control.owner = this;
control.position(posX, posY);
posY += control.maxH + ep;
length = controls.push(control)
this[name] = control;
}
this.update = function(){
var i, cursor, c;
cursor = "";
this.toolTip = "";
for(i = 0; i < length; i ++){
c = controls[i];
c.update(mouse);
if(c.cursor !== ""){
cursor = c.cursor;
}
c.draw(ctx);
}
if(cursor === ""){
ctx.canvas.style.cursor = "default";
}else{
ctx.canvas.style.cursor = cursor;
}
if(this.toolTip !== ""){
if(mouse.y - FONT_SIZE * (5 / 3) < 0){
drawText(ctx, this.toolTip, mouse.x, mouse.y + FONT_SIZE * (4 / 3), "#FD4", "#000");
}else{
drawText(ctx, this.toolTip, mouse.x, mouse.y - FONT_SIZE * (2 / 3), "#FD4", "#000");
}
}
}
}
/** CanvasUI.js end **/
//-----------------------------
// create UI
//-----------------------------
setFont(ctx, FONT_SIZE + FONT, "left");
// images for UI
var tickBox = drawRoundedBox(createImage(4 * ep, 4 * ep), "#666", (3 / 2) * ep, "#000", (1 / 2) * ep);
var tickBoxTick = drawTick(createImage(6 * ep, 6 * ep), "#0D0", "#000", (1 / 2) * ep);
var w = measureText(ctx, "Sticky");
var tickBoxText = createImage(w, FONT_SIZE);
setFont(tickBoxText.ctx, FONT_SIZE + FONT, "left");
drawText(tickBoxText.ctx, "Sticky", 0, FONT_SIZE * (3 / 4), "white", "black");
var sliderBar = drawRoundedBox(createImage(20 * ep, 2 * ep), "#666", ep * 0.9, "#000", (1 / 2) * ep);
var sliderNob = drawRoundedBox(createImage(3 * ep, 4 * ep), "#AAA", ep, "#000", (1 / 2) * ep);
// UI control
var controls = new UI(ctx, mouse, 10, 10);
controls.addControl(createSlider(sliderBar, sliderNob, 0, 0, 0.3, 0, 1, "Wobbla #.DD"), "wobbla");
controls.addControl(createSlider(sliderBar, sliderNob, 0, 0, 0.8, 0, 1, "Bouncy #.DD"), "bouncy");
controls.addControl(createSlider(sliderBar, sliderNob, 0, 0, 1.5, 0, 2, "React #.DD"), "react");
controls.addControl(createTick(tickBox, tickBoxTick, tickBoxText, 0, 0, true, "Activate / Deactivate sticky option."), "sticky");
//-----------------------------
// create playfield
//-----------------------------
var skyImage = drawSky(createImage(32, 32), "#9EF", "#48D");
var groundImage = drawGround(createImage(100, canvas.height - GROUND_AT), "#5F5", "#5A5", "#181", 4);
//-----------------------------
// create jelly
//-----------------------------
var jellyImage = drawRoundedBox(createImage(ep * 20, ep * 20), "#FA4", ep * 2, "black", ep * (3 / 5));
var jelly = createJellyBox(
jellyImage,
canvas.width / 2,
100,
0.1,
0.8,
1.2
);
// some more settings
ctx.imageSmoothingEnabled = true;
setFont(ctx, FONT_SIZE + FONT, "left");
//-----------------------------
// Main animtion loop
//-----------------------------
function updateAnim(){
// draw the background and ground
ctx.drawImage(skyImage, 0, 0, canvas.width, canvas.height)
ctx.drawImage(groundImage, 0, GROUND_AT, canvas.width, canvas.height - GROUND_AT)
// update and draw jelly
jelly.update();
jelly.draw(ctx);
// update and draw controls
controls.update();
// update jelly setting from controls.
jelly.wobbla = controls.wobbla.value;
jelly.bouncy = controls.bouncy.value;
jelly.react = controls.react.value;
jelly.sticky = controls.sticky.value;
// if the mouse is not busy then use left mouse click and drag to position jellu
if(controls.dragging < 0 && mouse.buttonRaw === 1){
controls.dragging = -2;
jelly.reset(mouse.x,mouse.y);
}else
if(controls.dragging === -2){ // release mouse
controls.dragging = -1;
}
if(!STOP){
requestAnimationFrame(updateAnim);
}else{
STOP = false;
}
}
updateAnim();
};
function resizeEvent(){
var waitForStopped = function(){
if(!STOP){ // wait for stop to return to false
jellyApp();
return;
}
setTimeout(waitForStopped,200);
}
STOP = true;
setTimeout(waitForStopped,100);
}
window.addEventListener("resize",resizeEvent);
jellyApp();

Javascript Canvas apply Radial Gradient to Segment?

I am trying to create a shadow system for my 2D Game in a HTML5 Canvas. Right now, I am rendering my shadows like so:
function drawShadows(x, y, width) {
if (shadowSprite == null) {
shadowSprite = document.createElement('canvas');
var tmpCtx = shadowSprite.getContext('2d');
var shadowBlur = 20;
shadowSprite.width = shadowResolution;
shadowSprite.height = shadowResolution;
var grd = tmpCtx.createLinearGradient(-(shadowResolution / 4), 0,
shadowResolution, 0);
grd.addColorStop(0, "rgba(0, 0, 0, 0.1)");
grd.addColorStop(1, "rgba(0, 0, 0, 0)");
tmpCtx.fillStyle = grd;
tmpCtx.shadowBlur = shadowBlur;
tmpCtx.shadowColor = "#000";
tmpCtx.fillRect(0, 0, shadowResolution, shadowResolution);
}
graph.save();
graph.rotate(sun.getDir(x, y));
graph.drawImage(shadowSprite, 0, -(width / 2), sun.getDist(x, y), width);
graph.restore();
}
This renders a cube with a linear gradient that fades from black to alpha 0.
This however does not produce a realistic result, since it will always be a rectangle. Here is an illustration to describe the problem:
Sorry i'm not very artistic. It would not be an issue to draw the trapezoid shape. (Seen in blue). The issue is that I still there to be a gradient. Is it possible to draw a shape like that with a gradient?
The canvas is very flexible. Almost anything is possible. This example draws the light being cast. But it can just as easily be the reverse. Draw the shadows as a gradient.
If you are after realism then instead of rendering a gradient for the lighting (or shadows) use the shape created to set a clipping area and then render a accurate lighting and shadow solution.
With lineTo and gradients you can create any shape and gradient you my wish. Also to get the best results use globalCompositeOperation as they have a large variety of filters.
The demo just shows how to mix a gradient and a shadow map. (Very basic no recursion implemented, and shadows are just approximations.)
var canvas = document.getElementById("canV");
var ctx = canvas.getContext("2d");
var mouse = {
x:0,
y:0,
};
function mouseMove(event){
mouse.x = event.offsetX; mouse.y = event.offsetY;
if(mouse.x === undefined){ mouse.x = event.clientX; mouse.y = event.clientY;}
}
// add mouse controls
canvas.addEventListener('mousemove',mouseMove);
var boundSize = 10000; // a number....
var createImage = function(w,h){ // create an image
var image;
image = document.createElement("canvas");
image.width = w;
image.height = h;
image.ctx = image.getContext("2d");
return image;
}
var directionC = function(x,y,xx,yy){ // this should be inLine but the angles were messing with my head
var a; // so moved it out here
a = Math.atan2(yy - y, xx - x); // for clarity and the health of my sanity
return (a + Math.PI * 2) % (Math.PI * 2); // Dont like negative angles.
}
// Create background image
var back = createImage(20, 20);
back.ctx.fillStyle = "#333";
back.ctx.fillRect(0, 0, 20, 20);
// Create background image
var backLight = createImage(20, 20);
backLight .ctx.fillStyle = "#ACD";
backLight .ctx.fillRect(0, 0, 20, 20);
// create circle image
var circle = createImage(64, 64);
circle.ctx.fillStyle = "red";
circle.ctx.beginPath();
circle.ctx.arc(32, 32, 30, 0, Math.PI * 2);
circle.ctx.fill();
// create some circles semi random
var circles = [];
circles.push({
x : 200 * Math.random(),
y : 200 * Math.random(),
scale : Math.random() * 0.8 + 0.3,
});
circles.push({
x : 200 * Math.random() + 200,
y : 200 * Math.random(),
scale : Math.random() * 0.8 + 0.3,
});
circles.push({
x : 200 * Math.random() + 200,
y : 200 * Math.random() + 200,
scale : Math.random() * 0.8 + 0.3,
});
circles.push({
x : 200 * Math.random(),
y : 200 * Math.random() + 200,
scale : Math.random() * 0.8 + 0.3,
});
// shadows on for each circle;
var shadows = [{},{},{},{}];
var update = function(){
var c, dir, dist, x, y, x1, y1, x2, y2, dir1, dir2, aAdd, i, j, s, s1 ,nextDir, rev, revId;
rev = false; // if inside a circle reverse the rendering.
// set up the gradient at the mouse pos
var g = ctx.createRadialGradient(mouse.x, mouse.y, canvas.width * 1.6, mouse.x, mouse.y, 2);
// do each circle and work out the two shadow lines coming from it.
for(var i = 0; i < circles.length; i++){
c = circles[i];
dir = directionC(mouse.x, mouse.y, c.x, c.y);
dist = Math.hypot(mouse.x - c.x, mouse.y - c.y);
// cludge factor. Could not be bother with the math as the light sourse nears an object
if(dist < 30* c.scale){
rev = true;
revId = i;
}
aAdd = (Math.PI / 2) * (0.5 / (dist - 30 * c.scale));
x1 = Math.cos(dir - (Math.PI / 2 + aAdd)) * 30 * c.scale;
y1 = Math.sin(dir - (Math.PI / 2 + aAdd)) * 30 * c.scale;
x2 = Math.cos(dir + (Math.PI / 2 + aAdd)) * 30 * c.scale;
y2 = Math.sin(dir + (Math.PI / 2 + aAdd)) * 30 * c.scale;
// direction of both shadow lines
dir1 = directionC(mouse.x, mouse.y, c.x + x1, c.y + y1);
dir2 = directionC(mouse.x, mouse.y, c.x + x2, c.y + y2);
// create the shadow object to hold details
shadows[i].dir = dir;
shadows[i].d1 = dir1;
if (dir2 < dir1) { // make sure second line is always greater
dir2 += Math.PI * 2;
}
shadows[i].d2 = dir2;
shadows[i].x1 = (c.x + x1); // set the shadow start pos
shadows[i].y1 = (c.y + y1);
shadows[i].x2 = (c.x + x2); // for both lines
shadows[i].y2 = (c.y + y2);
shadows[i].circle = c; // ref the circle
shadows[i].dist = dist; // set dist from light
shadows[i].branch1 = undefined; //.A very basic tree for shadows that interspet other object
shadows[i].branch2 = undefined; //
shadows[i].branch1Dist = undefined;
shadows[i].branch2Dist = undefined;
shadows[i].active = true; // false if the shadow is in a shadow
shadows[i].id = i;
}
shadows.sort(function(a,b){ // sort by distance from light
return a.dist - b.dist;
});
// cull shdows with in shadows and connect circles with joined shadows
for(i = 0; i < shadows.length; i++){
s = shadows[i];
for(j = i + 1; j < shadows.length; j++){
s1 = shadows[j];
if(s1.d1 > s.d1 && s1.d2 < s.d2){ // if shadow in side another
s1.active = false; // cull it
}else
if(s.d1 > s1.d1 && s.d1 < s1.d2){ // if shodow intercepts going twards light
s1.branch1 = s;
s.branch1Dist = s1.dist - s.dist;
s.active = false;
}else
if(s.d2 > s1.d1 && s.d2 < s1.d2){ // away from light
s.branch2 = s1;
s.branch2Dist = s1.dist - s.dist;
s1.active = false;
}
}
}
// keep it quick so not using filter
// filter culled shadows
var shadowsShort = [];
for (i = 0; i < shadows.length; i++) {
if ((shadows[i].active && !rev) || (rev && shadows[i].id === revId)) { // to much hard work makeng shadow from inside the circles. Was a good idea at the time. But this i just an example after all;
shadowsShort.push(shadows[i])
}
}
// sort shadows in clock wise render order
if(rev){
g.addColorStop(0.3, "rgba(210,210,210,0)");
g.addColorStop(0.6, "rgba(128,128,128,0.5)");
g.addColorStop(1, "rgba(0,0,0,0.9)");
shadowsShort.sort(function(a,b){
return b.dir - a.dir;
});
// clear by drawing background image.
ctx.drawImage(backLight, 0, 0, canvas.width, canvas.height);
}else{
g.addColorStop(0.3, "rgba(0,0,0,0)");
g.addColorStop(0.6, "rgba(128,128,128,0.5)");
g.addColorStop(1, "rgba(215,215,215,0.9)");
shadowsShort.sort(function(a,b){
return a.dir - b.dir;
});
// clear by drawing background image.
ctx.drawImage(back, 0, 0, canvas.width, canvas.height);
}
// begin drawin the light area
ctx.fillStyle = g; // set the gradient as the light
ctx.beginPath();
for(i = 0; i < shadowsShort.length; i++){ // for each shadow move in to the light across the circle and then back out away from the light
s = shadowsShort[i];
x = s.x1 + Math.cos(s.d1) * boundSize;
y = s.y1 + Math.sin(s.d1) * boundSize;
if (i === 0) { // if the start move to..
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
ctx.lineTo(s.x1, s.y1);
if (s.branch1 !== undefined) { // if braching. (NOTE this is not recursive. the correct solution would to math this a function and use recursion to climb in an out)
s = s.branch1;
x = s.x1 + Math.cos(s.d1) * s.branch1Dist;
y = s.y1 + Math.sin(s.d1) * s.branch1Dist;
ctx.lineTo(x, y);
ctx.lineTo(s.x1, s.y1);
}
ctx.lineTo(s.x2, s.y2);
if (s.branch2 !== undefined) {
x = s.x2 + Math.cos(s.d2) * s.branch2Dist;
y = s.y2 + Math.sin(s.d2) * s.branch2Dist;
ctx.lineTo(x, y);
s = s.branch2;
ctx.lineTo(s.x2, s.y2);
}
x = s.x2 + Math.cos(s.d2) * boundSize;
y = s.y2 + Math.sin(s.d2) * boundSize;
ctx.lineTo(x, y);
// now fill in the light between shadows
s1 = shadowsShort[(i + 1) % shadowsShort.length];
nextDir = s1.d1;
if(rev){
if (nextDir > s.d2) {
nextDir -= Math.PI * 2
}
}else{
if (nextDir < s.d2) {
nextDir += Math.PI * 2
}
}
x = Math.cos((nextDir+s.d2)/2) * boundSize + canvas.width / 2;
y = Math.sin((nextDir+s.d2)/2) * boundSize + canvas.height / 2;
ctx.lineTo(x, y);
}
// close the path.
ctx.closePath();
// set the comp to lighten or multiply
if(rev){
ctx.globalCompositeOperation ="multiply";
}else{
ctx.globalCompositeOperation ="lighter";
}
// draw the gradient
ctx.fill()
ctx.globalCompositeOperation ="source-over";
// draw the circles
for (i = 0; i < circles.length; i++) {
c = circles[i];
ctx.drawImage(circle, c.x - 32 * c.scale, c.y - 32 * c.scale, 64 * c.scale, 64 * c.scale);
}
// feed the herbervors.
window.requestAnimationFrame(update);
}
update();
.canC { width:400px; height:400px;}
<canvas class="canC" id="canV" width=400 height=400></canvas>

Categories

Resources