Exploring canvas by making Mega Man. I've had a life long dream to make a multiplayer Mega Man game. Maybe this is my chance!
A Pen by Drew Conley on CodePen.
<canvas id="js-canvas" width="400" height="300"></canvas> | |
<p>Use Arrow Keys!</p> |
Exploring canvas by making Mega Man. I've had a life long dream to make a multiplayer Mega Man game. Maybe this is my chance!
A Pen by Drew Conley on CodePen.
const initialState = { | |
counter: 0, | |
canvasWidth: 400, | |
canvasHeight: 300, | |
characterX: 80, | |
characterY: 0, | |
characterWidth: 20, //hitboxW | |
characterHeight: 24, //hitboxY | |
inAir: true, | |
characterFrame: 0, | |
characterPose: [ [0,0] ], | |
isAbleToJump: false, | |
isFacingLeft: true, //default is Right | |
//Jumping | |
verticalBoost: 0, | |
//Keyboard | |
isKeyboardLeftPressed: false, | |
isKeyboardRightPressed: false, | |
walls: [ | |
{ | |
"_id": "placement_1471799473495", | |
"x": 0, | |
"y": 0, | |
"width": 16, | |
"height": 288 | |
}, | |
{ | |
"_id": "placement_1471799503050", | |
"x": 16, | |
"y": 256, | |
"width": 32, | |
"height": 32 | |
}, | |
{ | |
"_id": "placement_1471799516043", | |
"x": 16, | |
"y": 128, | |
"width": 16, | |
"height": 16 | |
}, | |
{ | |
"_id": "placement_1471799517145", | |
"x": 368, | |
"y": 144, | |
"width": 16, | |
"height": 16 | |
}, | |
{ | |
"_id": "placement_1471804635390", | |
"x": 384, | |
"y": 0, | |
"width": 16, | |
"height": 288 | |
}, | |
{ | |
"_id": "placement_1471804662424", | |
"x": 352, | |
"y": 256, | |
"width": 32, | |
"height": 32 | |
}, | |
{ | |
"_id": "placement_1471804690729", | |
"x": 256, | |
"y": 224, | |
"width": 80, | |
"height": 16 | |
}, | |
{ | |
"_id": "placement_1471804739758", | |
"x": 144, | |
"y": 160, | |
"width": 32, | |
"height": 32 | |
}, | |
{ | |
"_id": "placement_1471808010628", | |
"x": 48, | |
"y": 272, | |
"width": 160, | |
"height": 16 | |
}, | |
{ | |
"_id": "placement_1471808096387", | |
"x": 64, | |
"y": 64, | |
"width": 64, | |
"height": 16 | |
}, | |
{ | |
"_id": "placement_1471808097749", | |
"x": 272, | |
"y": 96, | |
"width": 64, | |
"height": 16 | |
}, | |
{ | |
"_id": "placement_1471808105275", | |
"x": 144, | |
"y": 32, | |
"width": 112, | |
"height": 16 | |
}, | |
{ | |
"_id": "placement_1471808244933", | |
"x": 224, | |
"y": 128, | |
"width": 32, | |
"height": 32 | |
}, | |
{ | |
"_id": "placement_1471808330975", | |
"x": 64, | |
"y": 192, | |
"width": 32, | |
"height": 32 | |
} | |
] | |
} | |
/* SFX */ | |
window.landingSfx = new Howl({ | |
urls: ['https://s3-us-west-2.amazonaws.com/s.cdpn.io/21542/06_-_MegamanLand.wav'], | |
volume: 0.5 | |
}); | |
const Data = { | |
state: {}, | |
prevState: {}, | |
init(initialState) { | |
this.state = {...initialState} | |
this.prevState = {...initialState} | |
}, | |
getState() { | |
const copy = {...this.state}; | |
return { | |
...copy | |
} | |
}, | |
getPrevState() { | |
const copy = {...this.prevState}; | |
return { | |
...copy | |
} | |
}, | |
mergeState(newValues={}) { | |
this.prevState = { ...this.state } | |
this.state = { | |
...this.state, | |
...newValues | |
}; | |
}, | |
mergeNodeInCollection(collectionName="", nodeId="", newValues={}) { | |
this.prevState = { ...this.state } | |
var newState = { ...this.state }; | |
var collection = {...this.state[collectionName]} | |
var newNode = { | |
...collection[nodeId], | |
...newValues | |
}; | |
collection[nodeId] = {...newNode} | |
newState[collectionName] = {...collection} | |
this.state = { | |
...newState | |
} | |
}, | |
removeNodeInCollection(collectionName="", nodeId="") { | |
this.prevState = { ...this.state } | |
var newState = { ...this.state }; | |
var collection = {...this.state[collectionName]} | |
delete collection[nodeId]; | |
newState[collectionName] = {...collection} | |
this.state = { | |
...newState | |
} | |
} | |
}; | |
const Positions = { | |
Stand: [0, 0], | |
StepOff: [32, 0], | |
Run1: [0, 32], | |
Run2: [32, 32], | |
Run3: [64, 32], | |
Jump: [0, 64], | |
//Left | |
Left_Stand: [0, 96], | |
Left_StepOff: [32, 96], | |
Left_Run1: [0, 128], | |
Left_Run2: [32, 128], | |
Left_Run3: [64, 128], | |
Left_Jump: [0, 162], | |
}; | |
//Frame Arrays | |
const MegaManPoses = { | |
Stand: [Positions.Stand], | |
StepOff: [Positions.StepOff], | |
Run: [ | |
Positions.Run1, Positions.Run2, Positions.Run3, Positions.Run2 | |
], | |
Jump: [Positions.Jump], | |
//Left | |
Left_Stand: [Positions.Left_Stand], | |
Left_StepOff: [Positions.Left_StepOff], | |
Left_Run: [ | |
Positions.Left_Run1, Positions.Left_Run2, Positions.Left_Run3, Positions.Left_Run2 | |
], | |
Left_Jump: [Positions.Left_Jump], | |
} | |
function mergeState(newValues={}) { | |
Data.mergeState(newValues) | |
} | |
function isTouching(my,other) { | |
return my.x + my.width > other.x && | |
my.x <= other.x + other.width && | |
my.y + my.height > other.y && | |
my.y <= other.y + other.height; | |
} | |
function getSolidSurface(my, others=[]) { | |
//Create 1 px sliver as my underline; | |
//const underlineModel = { | |
// height:1, | |
// width: my.width, | |
// y: my.y + my.height, | |
// x: my.x | |
//}; | |
var touchingModel = null; //Assume False | |
others.forEach(otherModel => { | |
if (touchingModel) { | |
return; //Dont rerun if we already have a match | |
} | |
if (isTouching( my, otherModel)) { | |
touchingModel = {...otherModel}; | |
} | |
}); | |
return touchingModel; | |
} | |
function getSolidSurfaceDown(my, others=[]) { | |
//Create 1 px sliver as my underline; | |
const underlineModel = { | |
height:1, | |
width: my.width, | |
y: my.y + my.height, | |
x: my.x | |
}; | |
var touchingModel = null; //Assume False | |
others.forEach(otherModel => { | |
if (touchingModel) { | |
return; //Dont rerun if we already have a match | |
} | |
if (isTouching( underlineModel, otherModel)) { | |
touchingModel = {...otherModel}; | |
} | |
}); | |
return touchingModel; | |
} | |
function drawCharacter(ctx, state, assets) { | |
const characterWidth = state.characterWidth; | |
const characterHeight = state.characterHeight; | |
ctx.beginPath(); | |
/* Debug Rectangle */ /* This is the hitbox */ | |
//ctx.fillStyle = "#fff"; | |
//ctx.fillRect( | |
// state.characterX, state.characterY, | |
// characterWidth, characterHeight | |
//); | |
const currentPose = state.characterPose; | |
const activeFrame = currentPose[state.characterFrame] || currentPose[0]; | |
ctx.drawImage( | |
assets.mm, | |
activeFrame[0], activeFrame[1], //Where in the spritesheet x/y | |
32,32, | |
state.characterX - 5, state.characterY - 4, //nudging where the drawing of the sprite is | |
32,32 | |
); | |
} | |
function drawWalls(ctx, state) { | |
state.walls.forEach(wall => { | |
ctx.beginPath(); | |
ctx.fillStyle = "#222"; | |
ctx.fillRect(wall.x, wall.y, wall.width, wall.height); | |
}); | |
} | |
function draw(canvas, ctx, state, assets) { | |
drawSky(ctx, state); | |
drawWalls(ctx, state); | |
drawCharacter(ctx, state, assets); | |
} | |
function drawSky(ctx, state) { | |
ctx.beginPath(); | |
ctx.fillStyle = "#4AB5E2"; | |
ctx.fillRect(0,0, state.canvasWidth, state.canvasHeight); | |
} | |
function runSteps(state, prevState, frameCount, dt) { | |
//bulletSteps(state); | |
playerMovement(state, prevState, frameCount, dt); | |
} | |
function runInits(state) { | |
/* Run all of the "kickoff" processses, like keyboard bindings and intervals */ | |
bindKeyboardListeners(); | |
} | |
function playerMovement(state, prevState, frameCount, dt) { | |
let isFacingLeft = state.isFacingLeft; | |
let nextCharacterX = state.characterX; | |
let nextCharacterY = state.characterY; | |
let inAir = state.inAir; | |
let isAbleToJump = state.isAbleToJump; | |
const downUnit = 5; | |
const nextDownFrame = { | |
x: nextCharacterX, | |
y: nextCharacterY + (downUnit), | |
width: state.characterWidth, | |
height: state.characterHeight | |
}; | |
const surfaceCandidate = getSolidSurface(nextDownFrame, state.walls); | |
const surface = (surfaceCandidate && surfaceCandidate.y >= nextCharacterY) ? surfaceCandidate : null; | |
if (!surface) { | |
inAir = true; | |
nextCharacterY += downUnit | |
isAbleToJump = false; | |
} | |
if (surface) { | |
isAbleToJump = true; | |
} | |
if (surface && inAir) { | |
window.landingSfx.play(); | |
inAir = false; | |
isAbleToJump = true; | |
//Correcting you | |
nextCharacterY = surface.y - state.characterHeight; | |
} | |
//Horizontal Movement | |
const xMovementUnit = Math.round(dt * 130); | |
if (state.isKeyboardLeftPressed) { | |
const leftUnit = xMovementUnit; | |
const nextLeftFrame = { | |
x: nextCharacterX - leftUnit, | |
y: nextCharacterY, | |
width: state.characterWidth, | |
height: state.characterHeight | |
}; | |
const leftSurface = getSolidSurface(nextLeftFrame, state.walls); | |
if (!leftSurface) { | |
nextCharacterX -= leftUnit; | |
} | |
isFacingLeft = true; | |
} | |
if (state.isKeyboardRightPressed) { | |
const rightUnit = xMovementUnit; | |
const nextRightFrame = { | |
x: nextCharacterX + rightUnit, | |
y: nextCharacterY, | |
width: state.characterWidth, | |
height: state.characterHeight | |
}; | |
const rightSurface = getSolidSurface(nextRightFrame, state.walls); | |
if (!rightSurface) { | |
nextCharacterX += rightUnit; | |
} | |
isFacingLeft = false; | |
} | |
/////////////////////////////////// | |
let verticalBoost = state.verticalBoost; | |
/* VERTICAL BOOST */ | |
if (verticalBoost < 0) { | |
const unit = 9; | |
const nextUpY = nextCharacterY -= unit; | |
//CHECK FOR CEILINGS | |
const nextUpFrame = { | |
x: nextCharacterX, | |
y: nextUpY, | |
width: state.characterWidth, | |
height: state.characterHeight | |
}; | |
const surfaceUp = getSolidSurface(nextUpFrame, state.walls); | |
if (!surfaceUp) { | |
nextCharacterY = nextUpY; | |
//move boost back towards 0 | |
verticalBoost = state.verticalBoost + unit; | |
} else { | |
verticalBoost = 0; //Kill the boost. Hit your head | |
} | |
} | |
////ANIMATION | |
//Change active frame | |
let nextFrame = state.characterFrame; | |
if (frameCount % 8 == 0) { | |
nextFrame = (nextFrame <= 2) ? nextFrame + 1 : 0; | |
} | |
//Revive! | |
if (nextCharacterY > 300 + 50) { | |
nextCharacterY = -50 | |
} | |
/* Merge all state changes */ | |
mergeState({ | |
characterFrame: nextFrame, | |
characterPose: getCharacterPose({ //Sprite | |
...state, | |
inAir:inAir | |
}), | |
characterX: nextCharacterX, | |
characterY: nextCharacterY, | |
verticalBoost: verticalBoost, | |
isFacingLeft: isFacingLeft, | |
isAbleToJump: isAbleToJump, | |
inAir: inAir | |
}); | |
} | |
function bindKeyboardListeners() { | |
var jumpSafe = true; | |
document.addEventListener('keydown', function (e) { | |
if (e.which == 37) { | |
mergeState({ | |
isKeyboardLeftPressed: true | |
}); | |
} | |
if (e.which == 39) { | |
mergeState({ | |
isKeyboardRightPressed: true | |
}); | |
} | |
//Jump! | |
if (e.which == 38) { | |
if ( Data.getState().isAbleToJump ) { | |
if (jumpSafe) { | |
jumpSafe = false; | |
mergeState({ | |
isAbleToJump: false, | |
verticalBoost: -170 | |
}); | |
} | |
} | |
} | |
}, false); | |
document.addEventListener('keyup', function (e) { | |
if (e.which == 37) { | |
mergeState({ | |
isKeyboardLeftPressed: false | |
}); | |
} | |
if (e.which == 39) { | |
mergeState({ | |
isKeyboardRightPressed: false | |
}); | |
} | |
//Release Jump! | |
if (e.which == 38) { | |
jumpSafe = true; | |
mergeState({ | |
verticalBoost: 0 | |
}); | |
} | |
}, false); | |
} | |
//////////////////////////////////////////////////// | |
function getCharacterPose(state) { | |
const isLeft = state.isFacingLeft; | |
if (state.inAir) { | |
return isLeft ? MegaManPoses.Left_Jump : MegaManPoses.Jump; | |
} | |
if (state.isKeyboardLeftPressed || state.isKeyboardRightPressed) { | |
return isLeft ? MegaManPoses.Left_Run : MegaManPoses.Run; | |
} | |
return isLeft ? MegaManPoses.Left_Stand : MegaManPoses.Stand; | |
} | |
///////////////////////////////////////////////// now to use all this stuff! | |
//Cache references to canvas and context | |
var canvas = document.getElementById("js-canvas"); | |
var ctx = canvas.getContext("2d"); | |
//Init the app | |
Data.init(initialState, canvas, ctx); | |
//Set up assets | |
let assets = { | |
mm: new Image() | |
}; | |
assets.mm.src = `https://s3-us-west-2.amazonaws.com/s.cdpn.io/21542/mm-blue-sprites.png`; | |
/* Draw Loop */ | |
var frameCount=1; | |
var lastTime; | |
var step = function() { | |
var now = Date.now(); | |
var dt = (now - lastTime) / 1000.0; | |
const state = Data.getState(); | |
const prevState = Data.getPrevState(); | |
//Draw the state | |
draw(canvas, ctx, state, assets); | |
//Run Steps - adjust state for next pass | |
runSteps(state, prevState, frameCount, dt); | |
//Track frame count for character animations | |
frameCount += 1; | |
if (frameCount > 64) { frameCount = 1} | |
lastTime = now; | |
requestAnimationFrame(step) | |
}; | |
assets.mm.onload = function() { | |
const state = Data.getState(); | |
/* Inits */ | |
runInits(state); | |
requestAnimationFrame(step); | |
}; |
<script src="https://cdnjs.cloudflare.com/ajax/libs/howler/1.1.29/howler.min.js"></script> |
* { | |
box-sizing: border-box; | |
} | |
body { | |
font-size: 16px; | |
padding: 3em 1em; | |
font-family: monospace; | |
} | |
p { | |
text-align: center; | |
} | |
canvas { | |
margin: 0 auto; | |
display: block; | |
border-radius: 3px; | |
; | |
} |