Skip to content

Instantly share code, notes, and snippets.

@lostintangent
Last active August 21, 2023 08:28
Show Gist options
  • Save lostintangent/83dbb364c74d548df29c7e10a3219fbb to your computer and use it in GitHub Desktop.
Save lostintangent/83dbb364c74d548df29c7e10a3219fbb to your computer and use it in GitHub Desktop.
Stick Hero with Canvas
<div class="container">
<div id="score"></div>
<canvas id="game" width="375" height="375"></canvas>
<div id="introduction">Hold down the mouse to stretch out a stick</div>
<div id="perfect">DOUBLE SCORE</div>
<button id="restart">RESTART</button>
</div>
<a id="youtube" href="https://youtu.be/eue3UdFvwPo" target="_blank">
<span>See how this game was made</span>
</a>
/*
If you want to know how this game was made, check out this video, that explains how it's made:
https://youtu.be/eue3UdFvwPo
Follow me on twitter for more: https://twitter.com/HunorBorbely
*/
// Extend the base functionality of JavaScript
Array.prototype.last = function () {
return this[this.length - 1];
};
// A sinus function that acceps degrees instead of radians
Math.sinus = function (degree) {
return Math.sin((degree / 180) * Math.PI);
};
// Game data
let phase = "waiting"; // waiting | stretching | turning | walking | transitioning | falling
let lastTimestamp; // The timestamp of the previous requestAnimationFrame cycle
let heroX; // Changes when moving forward
let heroY; // Only changes when falling
let sceneOffset; // Moves the whole game
let platforms = [];
let sticks = [];
let trees = [];
// Todo: Save high score to localStorage (?)
let score = 0;
// Configuration
const canvasWidth = 375;
const canvasHeight = 375;
const platformHeight = 100;
const heroDistanceFromEdge = 10; // While waiting
const paddingX = 100; // The waiting position of the hero in from the original canvas size
const perfectAreaSize = 10;
// The background moves slower than the hero
const backgroundSpeedMultiplier = 0.2;
const hill1BaseHeight = 100;
const hill1Amplitude = 10;
const hill1Stretch = 1;
const hill2BaseHeight = 70;
const hill2Amplitude = 20;
const hill2Stretch = 0.5;
const stretchingSpeed = 4; // Milliseconds it takes to draw a pixel
const turningSpeed = 4; // Milliseconds it takes to turn a degree
const walkingSpeed = 4;
const transitioningSpeed = 2;
const fallingSpeed = 2;
const heroWidth = 17; // 24
const heroHeight = 30; // 40
const canvas = document.getElementById("game");
canvas.width = window.innerWidth; // Make the Canvas full screen
canvas.height = window.innerHeight;
const ctx = canvas.getContext("2d");
const introductionElement = document.getElementById("introduction");
const perfectElement = document.getElementById("perfect");
const restartButton = document.getElementById("restart");
const scoreElement = document.getElementById("score");
// Initialize layout
resetGame();
// Resets game variables and layouts but does not start the game (game starts on keypress)
function resetGame() {
// Reset game progress
phase = "waiting";
lastTimestamp = undefined;
sceneOffset = 0;
score = 0;
introductionElement.style.opacity = 1;
perfectElement.style.opacity = 0;
restartButton.style.display = "none";
scoreElement.innerText = score;
// The first platform is always the same
// x + w has to match paddingX
platforms = [{ x: 50, w: 50 }];
generatePlatform();
generatePlatform();
generatePlatform();
generatePlatform();
sticks = [{ x: platforms[0].x + platforms[0].w, length: 0, rotation: 0 }];
trees = [];
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
heroX = platforms[0].x + platforms[0].w - heroDistanceFromEdge;
heroY = 0;
draw();
}
function generateTree() {
const minimumGap = 30;
const maximumGap = 150;
// X coordinate of the right edge of the furthest tree
const lastTree = trees[trees.length - 1];
let furthestX = lastTree ? lastTree.x : 0;
const x =
furthestX +
minimumGap +
Math.floor(Math.random() * (maximumGap - minimumGap));
const treeColors = ["#6D8821", "#8FAC34", "#98B333"];
const color = treeColors[Math.floor(Math.random() * 3)];
trees.push({ x, color });
}
function generatePlatform() {
const minimumGap = 40;
const maximumGap = 200;
const minimumWidth = 20;
const maximumWidth = 100;
// X coordinate of the right edge of the furthest platform
const lastPlatform = platforms[platforms.length - 1];
let furthestX = lastPlatform.x + lastPlatform.w;
const x =
furthestX +
minimumGap +
Math.floor(Math.random() * (maximumGap - minimumGap));
const w =
minimumWidth + Math.floor(Math.random() * (maximumWidth - minimumWidth));
platforms.push({ x, w });
}
resetGame();
// If space was pressed restart the game
window.addEventListener("keydown", function (event) {
if (event.key == " ") {
event.preventDefault();
resetGame();
return;
}
});
window.addEventListener("mousedown", function (event) {
if (phase == "waiting") {
lastTimestamp = undefined;
introductionElement.style.opacity = 0;
phase = "stretching";
window.requestAnimationFrame(animate);
}
});
window.addEventListener("mouseup", function (event) {
if (phase == "stretching") {
phase = "turning";
}
});
window.addEventListener("resize", function (event) {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
draw();
});
window.requestAnimationFrame(animate);
// The main game loop
function animate(timestamp) {
if (!lastTimestamp) {
lastTimestamp = timestamp;
window.requestAnimationFrame(animate);
return;
}
switch (phase) {
case "waiting":
return; // Stop the loop
case "stretching": {
sticks.last().length += (timestamp - lastTimestamp) / stretchingSpeed;
break;
}
case "turning": {
sticks.last().rotation += (timestamp - lastTimestamp) / turningSpeed;
if (sticks.last().rotation > 90) {
sticks.last().rotation = 90;
const [nextPlatform, perfectHit] = thePlatformTheStickHits();
if (nextPlatform) {
// Increase score
score += perfectHit ? 2 : 1;
scoreElement.innerText = score;
if (perfectHit) {
perfectElement.style.opacity = 1;
setTimeout(() => (perfectElement.style.opacity = 0), 1000);
}
generatePlatform();
generateTree();
generateTree();
}
phase = "walking";
}
break;
}
case "walking": {
heroX += (timestamp - lastTimestamp) / walkingSpeed;
const [nextPlatform] = thePlatformTheStickHits();
if (nextPlatform) {
// If hero will reach another platform then limit it's position at it's edge
const maxHeroX = nextPlatform.x + nextPlatform.w - heroDistanceFromEdge;
if (heroX > maxHeroX) {
heroX = maxHeroX;
phase = "transitioning";
}
} else {
// If hero won't reach another platform then limit it's position at the end of the pole
const maxHeroX = sticks.last().x + sticks.last().length + heroWidth;
if (heroX > maxHeroX) {
heroX = maxHeroX;
phase = "falling";
}
}
break;
}
case "transitioning": {
sceneOffset += (timestamp - lastTimestamp) / transitioningSpeed;
const [nextPlatform] = thePlatformTheStickHits();
if (sceneOffset > nextPlatform.x + nextPlatform.w - paddingX) {
// Add the next step
sticks.push({
x: nextPlatform.x + nextPlatform.w,
length: 0,
rotation: 0
});
phase = "waiting";
}
break;
}
case "falling": {
if (sticks.last().rotation < 180)
sticks.last().rotation += (timestamp - lastTimestamp) / turningSpeed;
heroY += (timestamp - lastTimestamp) / fallingSpeed;
const maxHeroY =
platformHeight + 100 + (window.innerHeight - canvasHeight) / 2;
if (heroY > maxHeroY) {
restartButton.style.display = "block";
return;
}
break;
}
default:
throw Error("Wrong phase");
}
draw();
window.requestAnimationFrame(animate);
lastTimestamp = timestamp;
}
// Returns the platform the stick hit (if it didn't hit any stick then return undefined)
function thePlatformTheStickHits() {
if (sticks.last().rotation != 90)
throw Error(`Stick is ${sticks.last().rotation}°`);
const stickFarX = sticks.last().x + sticks.last().length;
const platformTheStickHits = platforms.find(
(platform) => platform.x < stickFarX && stickFarX < platform.x + platform.w
);
// If the stick hits the perfect area
if (
platformTheStickHits &&
platformTheStickHits.x + platformTheStickHits.w / 2 - perfectAreaSize / 2 <
stickFarX &&
stickFarX <
platformTheStickHits.x + platformTheStickHits.w / 2 + perfectAreaSize / 2
)
return [platformTheStickHits, true];
return [platformTheStickHits, false];
}
function draw() {
ctx.save();
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
drawBackground();
// Center main canvas area to the middle of the screen
ctx.translate(
(window.innerWidth - canvasWidth) / 2 - sceneOffset,
(window.innerHeight - canvasHeight) / 2
);
// Draw scene
drawPlatforms();
drawHero();
drawSticks();
// Restore transformation
ctx.restore();
}
restartButton.addEventListener("click", function (event) {
event.preventDefault();
resetGame();
restartButton.style.display = "none";
});
function drawPlatforms() {
platforms.forEach(({ x, w }) => {
// Draw platform
ctx.fillStyle = "black";
ctx.fillRect(
x,
canvasHeight - platformHeight,
w,
platformHeight + (window.innerHeight - canvasHeight) / 2
);
// Draw perfect area only if hero did not yet reach the platform
if (sticks.last().x < x) {
ctx.fillStyle = "red";
ctx.fillRect(
x + w / 2 - perfectAreaSize / 2,
canvasHeight - platformHeight,
perfectAreaSize,
perfectAreaSize
);
}
});
}
function drawHero() {
ctx.save();
ctx.fillStyle = "black";
ctx.translate(
heroX - heroWidth / 2,
heroY + canvasHeight - platformHeight - heroHeight / 2
);
// Body
drawRoundedRect(
-heroWidth / 2,
-heroHeight / 2,
heroWidth,
heroHeight - 4,
5
);
// Legs
const legDistance = 5;
ctx.beginPath();
ctx.arc(legDistance, 11.5, 3, 0, Math.PI * 2, false);
ctx.fill();
ctx.beginPath();
ctx.arc(-legDistance, 11.5, 3, 0, Math.PI * 2, false);
ctx.fill();
// Eye
ctx.beginPath();
ctx.fillStyle = "white";
ctx.arc(5, -7, 3, 0, Math.PI * 2, false);
ctx.fill();
// Band
ctx.fillStyle = "red";
ctx.fillRect(-heroWidth / 2 - 1, -12, heroWidth + 2, 4.5);
ctx.beginPath();
ctx.moveTo(-9, -14.5);
ctx.lineTo(-17, -18.5);
ctx.lineTo(-14, -8.5);
ctx.fill();
ctx.beginPath();
ctx.moveTo(-10, -10.5);
ctx.lineTo(-15, -3.5);
ctx.lineTo(-5, -7);
ctx.fill();
ctx.restore();
}
function drawRoundedRect(x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x, y + radius);
ctx.lineTo(x, y + height - radius);
ctx.arcTo(x, y + height, x + radius, y + height, radius);
ctx.lineTo(x + width - radius, y + height);
ctx.arcTo(x + width, y + height, x + width, y + height - radius, radius);
ctx.lineTo(x + width, y + radius);
ctx.arcTo(x + width, y, x + width - radius, y, radius);
ctx.lineTo(x + radius, y);
ctx.arcTo(x, y, x, y + radius, radius);
ctx.fill();
}
function drawSticks() {
sticks.forEach((stick) => {
ctx.save();
// Move the anchor point to the start of the stick and rotate
ctx.translate(stick.x, canvasHeight - platformHeight);
ctx.rotate((Math.PI / 180) * stick.rotation);
// Draw stick
ctx.beginPath();
ctx.lineWidth = 2;
ctx.moveTo(0, 0);
ctx.lineTo(0, -stick.length);
ctx.stroke();
// Restore transformations
ctx.restore();
});
}
function drawBackground() {
// Draw sky
var gradient = ctx.createLinearGradient(0, 0, 0, window.innerHeight);
gradient.addColorStop(0, "#BBD691");
gradient.addColorStop(1, "#FEF1E1");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
// Draw hills
drawHill(hill1BaseHeight, hill1Amplitude, hill1Stretch, "#95C629");
drawHill(hill2BaseHeight, hill2Amplitude, hill2Stretch, "#659F1C");
// Draw trees
trees.forEach((tree) => drawTree(tree.x, tree.color));
}
// A hill is a shape under a stretched out sinus wave
function drawHill(baseHeight, amplitude, stretch, color) {
ctx.beginPath();
ctx.moveTo(0, window.innerHeight);
ctx.lineTo(0, getHillY(0, baseHeight, amplitude, stretch));
for (let i = 0; i < window.innerWidth; i++) {
ctx.lineTo(i, getHillY(i, baseHeight, amplitude, stretch));
}
ctx.lineTo(window.innerWidth, window.innerHeight);
ctx.fillStyle = color;
ctx.fill();
}
function drawTree(x, color) {
ctx.save();
ctx.translate(
(-sceneOffset * backgroundSpeedMultiplier + x) * hill1Stretch,
getTreeY(x, hill1BaseHeight, hill1Amplitude)
);
const treeTrunkHeight = 5;
const treeTrunkWidth = 2;
const treeCrownHeight = 25;
const treeCrownWidth = 10;
// Draw trunk
ctx.fillStyle = "#7D833C";
ctx.fillRect(
-treeTrunkWidth / 2,
-treeTrunkHeight,
treeTrunkWidth,
treeTrunkHeight
);
// Draw crown
ctx.beginPath();
ctx.moveTo(-treeCrownWidth / 2, -treeTrunkHeight);
ctx.lineTo(0, -(treeTrunkHeight + treeCrownHeight));
ctx.lineTo(treeCrownWidth / 2, -treeTrunkHeight);
ctx.fillStyle = color;
ctx.fill();
ctx.restore();
}
function getHillY(windowX, baseHeight, amplitude, stretch) {
const sineBaseY = window.innerHeight - baseHeight;
return (
Math.sinus((sceneOffset * backgroundSpeedMultiplier + windowX) * stretch) *
amplitude +
sineBaseY
);
}
function getTreeY(x, baseHeight, amplitude) {
const sineBaseY = window.innerHeight - baseHeight;
return Math.sinus(x) * amplitude + sineBaseY;
}

Stick Hero with Canvas

Bridge-building game heavily inspired by Stick Hero with JavaScript and Canvas.

If you want to know how this game was made, check out this video, that goes through the main ideas:

https://youtu.be/eue3UdFvwPo

Follow me on twitter

I have no assotiation with the original game, but if you are interested you can find here for iOS and on Android

A Pen by Hunor Marton Borbely on CodePen.

License.

html,
body {
height: 100%;
margin: 0;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
cursor: pointer;
}
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
#score {
position: absolute;
top: 30px;
right: 30px;
font-size: 2em;
font-weight: 900;
}
#introduction {
width: 200px;
height: 150px;
position: absolute;
font-weight: 600;
font-size: 0.8em;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
text-align: center;
transition: opacity 2s;
}
#restart {
width: 120px;
height: 120px;
position: absolute;
border-radius: 50%;
color: white;
background-color: red;
border: none;
font-weight: 700;
font-size: 1.2em;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
display: none;
cursor: pointer;
}
#perfect {
position: absolute;
opacity: 0;
transition: opacity 2s;
}
#youtube {
display: none;
}
@media (min-height: 425px) {
/** Youtube logo by https://codepen.io/alvaromontoro */
#youtube {
z-index: 2;
display: block;
width: 100px;
height: 70px;
position: absolute;
bottom: 20px;
left: 20px;
background: red;
border-radius: 50% / 11%;
transform: scale(0.8);
transition: transform 0.5s;
}
#youtube:hover,
#youtube:focus {
transform: scale(0.9);
}
#youtube::before {
content: "";
display: block;
position: absolute;
top: 7.5%;
left: -6%;
width: 112%;
height: 85%;
background: red;
border-radius: 9% / 50%;
}
#youtube::after {
content: "";
display: block;
position: absolute;
top: 20px;
left: 40px;
width: 45px;
height: 30px;
border: 15px solid transparent;
box-sizing: border-box;
border-left: 30px solid white;
}
#youtube span {
font-size: 0;
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment