diff --git a/index.css b/index.css index 3f1fcd8..04a6df7 100644 --- a/index.css +++ b/index.css @@ -19,3 +19,10 @@ body { #resources { display: none; } + +.pixelify-sans-regular { + font-family: "Pixelify Sans", sans-serif; + font-optical-sizing: auto; + font-weight: 600; + font-style: normal; +} \ No newline at end of file diff --git a/index.html b/index.html index d8caa7e..8699c7d 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,19 @@ content="width=device-width, initial-scale=1.0" /> Game Engine + + + +
diff --git a/index.js b/index.js index f392821..f3776e6 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,12 @@ -import { GAME_HEIGHT, GAME_WIDTH } from "./modules/constants.js"; +import { GAME_HEIGHT, GAME_WIDTH, TILE_SIZE } from "./modules/constants.js"; import { Camera, CanvasResizer, FpsCounter, GameObject, MapManagement, + Player, + SpriteSheet, } from "./modules/game-objects/index.js"; const backgroundMaps = [ @@ -29,8 +31,41 @@ const foregroundMaps = [ { name: "ocean", imageId: "overworld", elementId: "level2", layer: 2 }, ]; +const foregroundCollisionMaps = [ + { + name: "overworld", + imageId: "overworld", + elementId: "level1", + layer: 2, + selected: true, + collision: true, + }, + { + name: "ocean", + imageId: "overworld", + elementId: "level2", + layer: 2, + collision: true, + }, +]; + const clicableObjects = ["debug", "level1", "level2"]; +const playerAnimations = { + idle: { + left: [51], + right: [17], + top: [34], + bottom: [0], + }, + walk: { + left: [51, 52, 53, 54], + right: [17, 18, 19, 20], + top: [34, 35, 36, 37], + bottom: [0, 1, 2, 3], + }, +}; + class Game extends GameObject { constructor({ canvas }) { super(); @@ -41,13 +76,25 @@ class Game extends GameObject { height: GAME_HEIGHT, percentage: 0.9, }); + const player = new Player({ + speed: 1, + gameObjects: [new SpriteSheet({ imageId: "character", tileHeight: 32 })], + animations: playerAnimations, + x: 6 * TILE_SIZE, + y: 4 * TILE_SIZE, + width: TILE_SIZE, + height: 2 * TILE_SIZE, + }); const camera = new Camera({ gameObjects: [ new MapManagement({ maps: backgroundMaps }), new MapManagement({ maps: foregroundMaps }), + player, + new MapManagement({ maps: foregroundCollisionMaps }), ], + target: player, }); - const fpsCounter = new FpsCounter({ debug: false }); + const fpsCounter = new FpsCounter(); this.gameObjects = [canvasResizer, camera, fpsCounter]; this.canvas = canvas; diff --git a/modules/game-objects/animation.js b/modules/game-objects/animation.js new file mode 100644 index 0000000..d9ac5f1 --- /dev/null +++ b/modules/game-objects/animation.js @@ -0,0 +1,7 @@ +export class Animation extends GameObject { + constructor({ frames = [], name, x = 0, y = 0 }) { + super({ x, y }); + this.frames = frames; + this.name = name; + } +} diff --git a/modules/game-objects/camera.js b/modules/game-objects/camera.js index b2816c6..3aa8e2c 100644 --- a/modules/game-objects/camera.js +++ b/modules/game-objects/camera.js @@ -9,41 +9,23 @@ export class Camera extends GameObject { width = GAME_WIDTH, height = GAME_HEIGHT, speed = 1, + target = { x: 0, y: 0, width: 0, height: 0 }, // A camera that follows a target }) { super({ x, y, gameObjects, width, height }); this.speed = speed; - this.keys = [ - { key: "ArrowUp", pressed: false, value: [0, -1] }, - { key: "ArrowDown", pressed: false, value: [0, 1] }, - { key: "ArrowLeft", pressed: false, value: [-1, 0] }, - { key: "ArrowRight", pressed: false, value: [1, 0] }, - ]; - this.availableKeys = this.keys.reduce( - (acc, item) => ({ ...acc, [item.key]: item }), - {} - ); - this.eventEmitter.on("changeLevel", () => { + this.target = target; + this.eventEmitter.on("levelChanged", () => { this.x = x; this.y = y; }); + this.eventEmitter.on("targetChanged", (target) => { + this.target = target; + }); } update(delta) { - this.keys.forEach((item) => { - if (item.pressed) { - this.moveCamera(...item.value, delta); - } - }); - } - - onKeyPressed(key) { - if (!this.availableKeys[key]) return; - this.availableKeys[key].pressed = true; - } - - onKeyReleased(key) { - if (!this.availableKeys[key]) return; - this.availableKeys[key].pressed = false; + super.update(delta); + this.followTarget(); } render(ctx) { @@ -52,16 +34,19 @@ export class Camera extends GameObject { ); } - moveCamera(dx, dy, delta) { + followTarget() { + const { x, y, width: tWidth, height: tHeight } = this.target; + const centerX = x + tWidth / 2; + const centerY = y + tHeight / 2; const [item] = this.gameObjects; const { height, width } = item.selected ?? item; - this.x = Math.min( - Math.max(this.x + dx * Math.floor(delta * this.speed * 100), 0), - width * TILE_SIZE - this.width + this.x = Math.max( + 0, + Math.min(centerX - this.width / 2, width * TILE_SIZE - this.width) ); - this.y = Math.min( - Math.max(this.y + dy * Math.floor(delta * this.speed * 100), 0), - height * TILE_SIZE - this.height + this.y = Math.max( + 0, + Math.min(centerY - this.height / 2, height * TILE_SIZE - this.height) ); } } diff --git a/modules/game-objects/fps-counter.js b/modules/game-objects/fps-counter.js index 3d4b266..e56bdbb 100644 --- a/modules/game-objects/fps-counter.js +++ b/modules/game-objects/fps-counter.js @@ -1,8 +1,8 @@ import { GameObject } from "./game-object.js"; export class FpsCounter extends GameObject { - constructor({ debug = false }) { - super({ debug }); + constructor() { + super(); this.fps = 0; } @@ -15,7 +15,7 @@ export class FpsCounter extends GameObject { return; } ctx.fillStyle = "Red"; - ctx.font = "normal 12pt Arial"; + ctx.font = "normal 12pt Pixelify Sans"; ctx.fillText(this.fps + " fps", 5, 15); } } diff --git a/modules/game-objects/index.js b/modules/game-objects/index.js index fbeecb0..bae02d1 100644 --- a/modules/game-objects/index.js +++ b/modules/game-objects/index.js @@ -4,3 +4,6 @@ export { FpsCounter } from "./fps-counter.js"; export { GameObject } from "./game-object.js"; export { MapManagement } from "./map-management.js"; export { Map } from "./map.js"; +export { Player } from "./player.js"; +export { Sprite } from "./sprite.js"; +export { SpriteSheet } from "./sprite-sheet.js"; diff --git a/modules/game-objects/map-management.js b/modules/game-objects/map-management.js index a6dd3f7..88c174e 100644 --- a/modules/game-objects/map-management.js +++ b/modules/game-objects/map-management.js @@ -27,6 +27,6 @@ export class MapManagement extends GameObject { } const map = this.gameObjects.find((item) => item.elementId === elementId); this.selected = map.name; - this.eventEmitter.emit("changeLevel"); + this.eventEmitter.emit("levelChanged"); } } diff --git a/modules/game-objects/player.js b/modules/game-objects/player.js new file mode 100644 index 0000000..2af7011 --- /dev/null +++ b/modules/game-objects/player.js @@ -0,0 +1,108 @@ +import { TILE_SIZE } from "../constants.js"; +import { GameObject } from "./game-object.js"; + +export class Player extends GameObject { + constructor({ + gameObjects = [], + animations = {}, + defaultAnimation = { idle: "bottom" }, + x = 0, + y = 0, + speed = 1, + width = TILE_SIZE, + height = TILE_SIZE, + }) { + super({ x, y, gameObjects, width, height }); + this.speed = speed; + this.keys = [ + { key: "ArrowUp", pressed: false, value: [0, -1] }, + { key: "ArrowDown", pressed: false, value: [0, 1] }, + { key: "ArrowLeft", pressed: false, value: [-1, 0] }, + { key: "ArrowRight", pressed: false, value: [1, 0] }, + ]; + this.availableKeys = this.keys.reduce( + (acc, item) => ({ ...acc, [item.key]: item }), + {} + ); + this.eventEmitter.on("levelChanged", (...args) => { + this.x = x; + this.y = y; + }); + + // TODO: Decouple animation into animation class + this.animations = animations; + this.defaultAnimation = defaultAnimation; + this.currentAnimation = defaultAnimation; + this.currentAnimationFrame = 0; + } + + update(delta) { + let idle = true; + this.keys.forEach((item) => { + if (item.pressed) { + this.moveCharacter(delta, ...item.value); + this.updateAnimation(delta, "walk", ...item.value); + idle = false; + } + }); + if (idle) { + this.updateAnimation(delta, "idle"); + } + } + + onKeyPressed(key) { + if (!this.availableKeys[key]) return; + this.availableKeys[key].pressed = true; + } + + onKeyReleased(key) { + if (!this.availableKeys[key]) return; + this.availableKeys[key].pressed = false; + } + + render(ctx, ...args) { + // TODO: Decouple animation into animation class + const [x, y] = args; + const [spriteSheet] = this.gameObjects; + if (this.debug) { + spriteSheet.sprites.forEach((item, index) => { + item.render(ctx, this.x - x + index * TILE_SIZE, this.y - y); + }); + } else { + const [currentAnimationKey] = Object.keys(this.currentAnimation); + const currentAnimationDirection = + this.currentAnimation[currentAnimationKey]; + const frames = + this.animations[currentAnimationKey][currentAnimationDirection]; + if (!frames) { + throw Error("No animation defined for " + this.currentAnimation); + } + const item = frames.map((frame) => spriteSheet.sprites[frame])[ + Math.floor(this.currentAnimationFrame) % frames.length + ]; + item.render(ctx, this.x - x, this.y - y); + } + } + + moveCharacter(delta, dx, dy) { + const speed = Math.floor(delta * this.speed * 100); + this.x = this.x + dx * speed; + this.y = this.y + dy * speed; + // TODO: Check for collisions and stop movement if needed + } + + // TODO: Decouple animation into animation class + updateAnimation(delta, animation, dx, dy) { + if (animation === "idle") { + const [value] = Object.values(this.currentAnimation); + this.currentAnimation = { [animation]: value }; + return; + } + if (dx !== 0) { + this.currentAnimation = { [animation]: dx > 0 ? "right" : "left" }; + } else if (dy !== 0) { + this.currentAnimation = { [animation]: dy > 0 ? "bottom" : "top" }; + } + this.currentAnimationFrame += 10 * delta; + } +} diff --git a/modules/game-objects/sprite-sheet.js b/modules/game-objects/sprite-sheet.js new file mode 100644 index 0000000..747f847 --- /dev/null +++ b/modules/game-objects/sprite-sheet.js @@ -0,0 +1,49 @@ +import { TILE_SIZE } from "../constants.js"; +import { GameObject } from "./game-object.js"; +import { Sprite } from "./sprite.js"; + +export class SpriteSheet extends GameObject { + constructor({ + imageId, + x = 0, + y = 0, + tileWidth = TILE_SIZE, + tileHeight = TILE_SIZE, + offsetX = 0, + offsetY = 0, + }) { + super({ x, y }); + this.image = document.getElementById(imageId); + this.imageWidth = this.image.width; + this.imageHeight = this.image.height; + this.tileWidth = tileWidth; + this.tileHeight = tileHeight; + this.offsetX = offsetX; + this.offsetY = offsetY; + } + + get sprites() { + if (this.gameObjects?.length) { + return this.gameObjects; + } + const sprites = []; + let index = 0; + for (let row = 0; row < this.imageHeight; row += this.tileHeight) { + for (let col = 0; col < this.imageWidth; col += this.tileWidth) { + sprites.push( + new Sprite({ + image: this.image, + index, + x: col + this.offsetX, + y: row + this.offsetY, + width: this.tileWidth, + height: this.tileHeight, + }) + ); + index++; + } + } + + return (this.gameObjects = sprites); + } +} diff --git a/modules/game-objects/sprite.js b/modules/game-objects/sprite.js new file mode 100644 index 0000000..06cd55a --- /dev/null +++ b/modules/game-objects/sprite.js @@ -0,0 +1,30 @@ +import { GameObject } from "./game-object.js"; + +export class Sprite extends GameObject { + constructor({ image, x = 0, y = 0, width = 0, height = 0, index = 0 }) { + super({ x, y, height, width }); + this.index = index; + this.image = image; + this.imageWidth = this.image.width; + this.imageHeight = this.image.height; + } + + render(ctx, destinationX, destinationY) { + ctx.drawImage( + this.image, + this.x, + this.y, + this.width, + this.height, + destinationX, + destinationY, + this.width, + this.height + ); + if (this.debug) { + ctx.fillStyle = "Red"; + ctx.font = "normal 8pt Pixelify Sans"; + ctx.fillText(this.index, destinationX, destinationY + 8); + } + } +} diff --git a/resources/character.png b/resources/character.png new file mode 100644 index 0000000..a50ceb0 Binary files /dev/null and b/resources/character.png differ diff --git a/resources/overworld.json b/resources/overworld.json index 10984dd..a936f24 100644 --- a/resources/overworld.json +++ b/resources/overworld.json @@ -33,17 +33,48 @@ "x":0, "y":0 }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 605, 607, 606, 607, 606, 607, 606, 607, 606, 607, 606, 607, 606, 607, 606, 607, 607, 607, 607, 607, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 367, 528, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 407, 568, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 13, 9, 10, 11, 0, 0, 0, + 446, 446, 446, 446, 446, 446, 446, 446, 446, 447, 568, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 568, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 214, 215, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 568, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 568, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":20, + "id":2, + "name":"Tile Layer 2", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":30, + "x":0, + "y":0 + }, { "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 485, 486, 486, 486, 486, 486, 486, 486, 486, 486, 486, 486, 486, 486, 486, 486, 486, 486, 486, 486, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 525, 526, 526, 526, 526, 526, 526, 526, 526, 526, 526, 526, 526, 526, 526, 526, 526, 526, 526, 526, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 565, 566, 566, 566, 566, 566, 566, 566, 566, 566, 566, 566, 566, 566, 566, 566, 566, 566, 449, 566, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 605, 607, 606, 607, 606, 607, 606, 607, 606, 607, 606, 607, 606, 607, 606, 607, 607, 607, 607, 607, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 367, 528, 0, 0, 0, 0, 0, 0, 0, 0, 0, 522, 523, 523, 523, 523, 523, 523, 523, 523, 524, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 407, 568, 0, 0, 0, 0, 0, 0, 0, 0, 0, 562, 0, 12, 13, 9, 10, 11, 0, 0, 564, - 446, 446, 446, 446, 446, 446, 446, 446, 446, 447, 568, 0, 0, 0, 0, 0, 0, 0, 0, 0, 562, 0, 47, 53, 49, 50, 51, 0, 0, 564, - 486, 486, 486, 486, 486, 486, 486, 486, 486, 487, 568, 0, 0, 0, 0, 0, 0, 0, 0, 0, 562, 0, 87, 88, 89, 90, 91, 214, 215, 564, - 526, 526, 526, 526, 526, 526, 526, 526, 526, 527, 568, 0, 450, 451, 0, 0, 0, 450, 451, 0, 562, 0, 127, 128, 129, 130, 131, 254, 255, 564, - 566, 566, 566, 566, 566, 566, 566, 566, 566, 567, 568, 0, 490, 491, 0, 0, 0, 490, 491, 0, 562, 0, 167, 168, 169, 170, 171, 294, 295, 564, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 522, 523, 523, 523, 523, 523, 523, 523, 523, 524, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 562, 0, 0, 0, 0, 0, 0, 0, 0, 564, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 562, 0, 47, 53, 49, 50, 51, 0, 0, 564, + 486, 486, 486, 486, 486, 486, 486, 486, 486, 487, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 562, 0, 87, 88, 89, 90, 91, 0, 0, 564, + 526, 526, 526, 526, 526, 526, 526, 526, 526, 527, 0, 0, 450, 451, 0, 0, 0, 450, 451, 0, 562, 0, 127, 128, 129, 130, 131, 254, 255, 564, + 566, 566, 566, 566, 566, 566, 566, 566, 566, 567, 0, 0, 490, 491, 0, 0, 0, 490, 491, 0, 562, 0, 167, 168, 169, 170, 171, 294, 295, 564, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 562, 0, 0, 0, 0, 0, 0, 0, 0, 564, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 602, 603, 603, 642, 0, 641, 603, 603, 603, 604, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @@ -55,8 +86,8 @@ 0, 0, 0, 0, 0, 0, 0, 0, 85, 86, 0, 0, 0, 0, 0, 85, 86, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "height":20, - "id":2, - "name":"Tile Layer 2", + "id":6, + "name":"Tile Layer 3", "opacity":1, "type":"tilelayer", "visible":true, @@ -64,7 +95,7 @@ "x":0, "y":0 }], - "nextlayerid":3, + "nextlayerid":8, "nextobjectid":1, "orientation":"orthogonal", "renderorder":"right-down",