diff --git a/2024-01-08-CSSE-oop-game-levels2.md b/2024-01-08-CSSE-oop-game-levels2.md new file mode 100644 index 0000000..2813cf4 --- /dev/null +++ b/2024-01-08-CSSE-oop-game-levels2.md @@ -0,0 +1,107 @@ +--- +layout: base +title: Dynamic Game Levels v2.0 +description: Incorporate student lessons goombas, platforms, parallax backgrounds, settings with local storage, etc. Refactor code introducing GameSetup and SettingsControl. Style is moved into _sass. +type: collab +courses: { csse: {week: 18} } +image: /images/platformer/backgrounds/hills.png +--- + + + + + + + +
+ + +
+ + diff --git a/_config.yml b/_config.yml index 2b0791c..cffdef0 100644 --- a/_config.yml +++ b/_config.yml @@ -19,5 +19,5 @@ plugins: - jekyll-remote-theme future: true header_pages: - - 2023-11-27-CSSE-oop-game-levels.md + - 2024-01-08-CSSE-oop-game-levels2.md - AC_csse.md \ No newline at end of file diff --git a/_sass/minima/custom-styles.scss b/_sass/minima/custom-styles.scss index 0d0abbf..5f34427 100644 --- a/_sass/minima/custom-styles.scss +++ b/_sass/minima/custom-styles.scss @@ -24,4 +24,7 @@ // @import "minima/hamilton/skins/daylight"; // @import "minima/hamilton/skins/midnight"; // @import "minima/hamilton/skins/sunrise"; -// @import "minima/hamilton/skins/sunset" \ No newline at end of file +// @import "minima/hamilton/skins/sunset"; + +// FOR PLATFORMER +@import "minima/platformer-styles.scss" \ No newline at end of file diff --git a/_sass/minima/platformer-styles.scss b/_sass/minima/platformer-styles.scss new file mode 100644 index 0000000..b343649 --- /dev/null +++ b/_sass/minima/platformer-styles.scss @@ -0,0 +1,123 @@ +/// platformer-styles.scss +/// @description This stylesheet is used to style the platformer game. + +/// Coding Syles in SCCS (Sass), from code below: +/// @mixin is a block of CSS declarations that can be reused in other classes, see @mixin dark-mode-color below. +/// @include is used to include a mixin in a class, see @include dark-mode-color below. +/// @extend is used to extend a class with another class, see %button below. +/// $variable is used to define a variable, see $spacing-unit, $high-emph, $dark-grey below. +/// #id is used to define an id, see #gameBegin, #gameOver, #settings below. +/// .class is used to define a class, see .sidebar below. +/// & is used to reference the parent selector, see &:hover below. +/// the double slash comments (//) are considered regular comments in Sass. +/// the triple slash comments (///) are considered documentation comments in Sass. +/// comments are not included in the compiled CSS file. +/// @import is used to import other stylesheets, see @import of this file in "_sass/minima/custom-styles.sccs". +/// see https://sass-lang.com/documentation/syntax for more information. + +/// Score for the game +/// @description This ID is used to style the score for the game, key style is to ensure it is on top of other elements. +#score { + position: relative; + z-index: 2; // z-index position ensures these buttons are on top. + padding: 5px; + color: $dt-red !important; // use the high-emphasis color for the text + } + + /// Game Begin, Game Over, Settings IDs + /// @description These IDs correspond to buttons in game screen. Key styling is to ensure they are on top of other elements. + #gameBegin, #gameOver, #settings, #leaderboard { + position: relative; + z-index: 2; // z-index position ensures these buttons are on top. + } + + /// Color mixin + /// @description This mixin is defined to share colors with classes in this stylesheet. + @mixin dark-mode-color { + color: $high-emph !important; // use the high-emphasis color for the text + background-color: $dark-grey !important; // use the dark grey color for the background + border: 1px solid $dt-blue; // from dracula-highlight.scss + } + + /// Horizontal Submenu + /// @description This class styles the horizontal submenu for score and buttons in the game. It has many style elements (e.g. placing submenu below header). + .submenu { + @include dark-mode-color; // use the dark mode color scheme + border: none; // remove setting as submenu + position: fixed; + z-index: 3; // Position on Top of other elements, as well as ID buttons above + top: $spacing-unit * 1.865; // matches minima ".site-header min-height height", reference: https://github.com/jekyll/minima/blob/master/_sass/minima/_layout.scss + + // Styles for the score and buttons + #score, #gameBegin, #gameOver, #settings, #leaderboard { + display: inline-block; // Make the score and buttons inline + margin-right: $spacing-unit; // Add some margin to the right of each item + } + } + + /// Sidebar class + /// @description This class styles the sidebar for settings in the game. It has many style elements (e.g. placing sidebar below header). + .sidebar { + @include dark-mode-color; // use the dark mode color scheme + border: none; // remove setting as sidebar collapse leaves residue line + position: fixed; + z-index: 3; // Position on Top of other elements, as well as ID buttons above + // left: 0; // set the sidebar to be on the left side of the page + top: 0; // calculated to be below the header and submenu + padding-top: 5px; + padding-bottom: 5px; + overflow-x: hidden; /* Disable horizontal scroll */ + transition: 0.5s; /* 0.5-second transition effect to slide in the sidebar */ + // following are changed by JavaScript + width: 0px; + padding-left: 0px; + padding-right: 0px; + } + + /// Table class + /// @description Key style is to present game levels and make it responsive with hover highlighting. + .table.levels { + tr { + cursor: pointer; // Change the cursor to a pointer when it hovers over a row + td { + border: 1px solid $dt-blue; // from dracula-highlight.scss + } + &:hover { // Change the background when a row is hovered over + background-color: $dt-blue; // from dracula-highlight.scss + + td { + background-color: inherit; // Make the background color of the td elements the same as the tr element + } + } + } + } + + /// Input mixin + /// @description This mixin sets some common styles (e.g. color, height) for the input fields, avoiding duplication of common styles in each input class. + /// @param {Number} width - The width of the input field, how wide it is. + /// @param {Number} height - The height of the input field, how tall it is. + /// @param {Color} border - The border color of the input field, the outline color. + /// @param {Number} border-radius - The border radius of the input field, rounded corners. + /// @param {Number} padding - The padding inside the input field, space between the border and the text. + /// @param {String} text-align - The text alignment inside the input field, left, right, or center. + @mixin input { + @include dark-mode-color; // use the dark mode color scheme + height: 30px; + border-radius: 5px; + padding: 5px; + } + + /// Input class + /// @description This class styles the userID input field, key style is to make it wider. + .input.userID { + @include input; + width: 100px; // customize width of the input field + } + + /// Input class + /// @description This class styles the gameSpeed and gravity input fields, key style is to right-align the text. + .input.gameSpeed, .input.gravity { + @include input; + width: 40px; // customize width of the input field + text-align: right; // right-align input for numbers + } \ No newline at end of file diff --git a/assets/js/platformer2/Background.js b/assets/js/platformer2/Background.js new file mode 100644 index 0000000..0315559 --- /dev/null +++ b/assets/js/platformer2/Background.js @@ -0,0 +1,54 @@ +import GameEnv from './GameEnv.js'; +import GameObject from './GameObject.js'; + +export class Background extends GameObject { + constructor(canvas, image, data) { + super(canvas, image, data); + } + + /* Update uses modulo math to cycle to start at width extent + * x is position in cycle + * speed can be used to scroll faster + * width is extent of background image + */ + update() { + this.x = (this.x - this.speed) % this.width; + } + + /* To draws are used to capture primary frame and wrap around ot next frame + * x to y is primary draw + * x + width to y is wrap around draw + */ + draw() { + this.ctx.drawImage(this.image, this.x, this.y); + this.ctx.drawImage(this.image, this.x + this.width, this.y); + } + + /* Background camvas is set to screen + * the ADJUST contant elements portions of image that don't wrap well + * the GameEnv.top is a getter used to set canvas under Menu + * the GameEnv.bottom is setter used to establish game bottom at offsetHeight of canvas + */ + size() { + // Update canvas size + const ADJUST = 1 // visual layer adjust; alien_planet.jpg: 1.42, try 1 for others + + const canvasWidth = GameEnv.innerWidth; + const canvasHeight = canvasWidth / this.aspect_ratio; + GameEnv.backgroundHeight = canvasHeight; + const canvasLeft = 0; + + this.canvas.width = this.width; + this.canvas.height = this.height; + this.canvas.style.width = `${canvasWidth}px`; + this.canvas.style.height = `${GameEnv.backgroundHeight}px`; + this.canvas.style.position = 'absolute'; + this.canvas.style.left = `${canvasLeft}px`; + this.canvas.style.top = `${GameEnv.top}px`; + + // set bottom of game to new background height + GameEnv.setBottom(); + } +} + +export default Background; \ No newline at end of file diff --git a/assets/js/platformer2/BackgroundHills.js b/assets/js/platformer2/BackgroundHills.js new file mode 100644 index 0000000..54890a2 --- /dev/null +++ b/assets/js/platformer2/BackgroundHills.js @@ -0,0 +1,22 @@ +import GameEnv from './GameEnv.js'; +import Background from './Background.js'; + +export class BackgroundHills extends Background { + constructor(canvas, image, data) { + super(canvas, image, data); + } + + // speed is used to background parallax behavior + update() { + this.speed = GameEnv.backgroundHillsSpeed; + super.update(); + } + + draw() { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + super.draw(); + } + +} + +export default BackgroundHills; \ No newline at end of file diff --git a/assets/js/platformer2/BackgroundMountains.js b/assets/js/platformer2/BackgroundMountains.js new file mode 100644 index 0000000..94b7885 --- /dev/null +++ b/assets/js/platformer2/BackgroundMountains.js @@ -0,0 +1,16 @@ +import GameEnv from './GameEnv.js'; +import Background from './Background.js'; + +export class BackgroundMountains extends Background { + constructor(canvas, image, data) { + super(canvas, image, data); + } + + // speed is used to background parallax behavior + update() { + this.speed = GameEnv.backgroundMountainsSpeed; + super.update(); + } +} + +export default BackgroundMountains; \ No newline at end of file diff --git a/assets/js/platformer2/Character.js b/assets/js/platformer2/Character.js new file mode 100644 index 0000000..1d90b29 --- /dev/null +++ b/assets/js/platformer2/Character.js @@ -0,0 +1,132 @@ +import GameEnv from './GameEnv.js'; +import GameObject from './GameObject.js'; + +class Character extends GameObject { + constructor(canvas, image, data) { + super(canvas, image, data); + + // sprite sizes + this.spriteWidth = data.width; + this.spriteHeight = data.height; + + // scale size + this.scaleSize = data?.scaleSize || 80; + + // sprint frame management + this.minFrame = 0; + this.maxFrame = 0; + this.frameX = 0; // Default X frame of the animation + this.frameY = 0; // Default Y frame of the animation + + // gravity for character enabled by default + this.gravityEnabled = true; + } + + getMinFrame(){ + return this.manFrame; + } + + setMinFrame(minFrame){ + this.minFrame = minFrame; + } + + getMaxFrame(){ + return this.maxFrame; + } + + setMaxFrame(maxFrame){ + this.maxFrame = maxFrame; + } + + getFrameX() { + return this.frameX; + } + + setFrameX(frameX){ + this.frameX = frameX; + } + + getFrameY() { + return this.frameY; + } + + setFrameY(frameY){ + this.frameY = frameY; + } + + /* Draw character object + * Canvas and Context + */ + draw() { + // Set fixed dimensions and position for the Character + this.canvas.width = this.canvasWidth; + this.canvas.height = this.canvasHeight; + this.canvas.style.width = `${this.canvas.width}px`; + this.canvas.style.height = `${this.canvas.height}px`; + this.canvas.style.position = 'absolute'; + this.canvas.style.left = `${this.x}px`; // Set character horizontal position based on its x-coordinate + this.canvas.style.top = `${this.y}px`; // Set character up and down position based on its y-coordinate + + this.ctx.drawImage( + this.image, + this.frameX * this.spriteWidth, + this.frameY * this.spriteHeight, + this.spriteWidth, + this.spriteHeight, + 0, + 0, + this.canvas.width, + this.canvas.height + ); + } + + /* Method should be called on initialization and resize events + * intent is to size character in proportion to the screen size + */ + size() { + // set Canvas scale, 80 represents size of Character height when inner Height is 832px + var scaledCharacterHeight = GameEnv.innerHeight * (this.scaleSize / 832); + var canvasScale = scaledCharacterHeight/this.spriteHeight; + this.canvasHeight = this.spriteHeight * canvasScale; + this.canvasWidth = this.spriteWidth * canvasScale; + + // set variables used in Display and Collision algorithms + this.bottom = GameEnv.bottom - this.canvasHeight; + this.collisionHeight = this.canvasHeight; + this.collisionWidth = this.canvasWidth; + + // calculate Proportional x and y positions based on size of screen dimensions + if (GameEnv.prevInnerWidth) { + const proportionalX = (this.x / GameEnv.prevInnerWidth) * GameEnv.innerWidth; + + // Update the x and y positions based on the proportions + this.setX(proportionalX); + this.setY(this.bottom); + } else { + // First Screen Position + this.setX(0); + this.setY(this.bottom); + } + } + + /* Update cycle check collisions + * override draw for custom update + * be sure to have updated draw call super.update() + */ + update() { + + if (this.bottom > this.y && this.gravityEnabled) + this.y += GameEnv.gravity; + + // Update animation frameX of the object + if (this.frameX < this.maxFrame) { + this.frameX++; + } else { + this.frameX = 0; + } + + this.collisionChecks(); + } +} + +export default Character; \ No newline at end of file diff --git a/assets/js/platformer2/GameControl.js b/assets/js/platformer2/GameControl.js new file mode 100644 index 0000000..348256e --- /dev/null +++ b/assets/js/platformer2/GameControl.js @@ -0,0 +1,151 @@ +/** + * GameControl module. + * @module GameControl + * @description GameControl.js key objective is to control the game loop. + * Usage Notes: + * - call GameControl.gameLoop() to run the game levels. + * - call or add listener to GameControl.startTimer() to start the game timer. + */ +import GameEnv from './GameEnv.js'; + +/** + * GameControl is a singleton object that controls the game loop. + * @namespace GameControl + * + * Coding Style Notes: + * - GameControl is defined as an object literal + * - GameControl is a singleton object, without a constructor. + * - This coding style ensures one instance, thus the term object literal. + * - Informerly, GameControl looks like defining a variable with methods contained inside. + * - The object literal style is a common pattern in JavaScript. + * - Observe, definition style of methods with GameControl.methodName = function() { ... } + * - Example: transitionToLevel(newLevel) { ... } versus transitionToLevel: function(newLevel) { ... } + * - Methods are defined as ES6 shorthand, versus the traditional function() style. + * - The shorthand style is a common pattern in JavaScript, more concise, and readable as it common to other coding languages. + * - But, it does not look like key-value pairs, which is the traditional object literal style. + * - This shorthand is part of ES6, and is supported by all modern browsers. references: https://caniuse.com/#feat=es6, https://www.w3schools.com/js/js_versions.asp + * - Observe, scoping/encapulation of this.inTransition and sharing data between methods. + * - this.inTransition is defined in the object literal scope. + * - this.inTransition is shared between methods. + * - this.inTransition is not accessible outside of the object literal scope. + * - this.inTransition is not a global or static variable. + * + */ +const GameControl = { + /** + * A reference to the interval used for the game timer. + * @type {number} + */ + timerInterval: null, // Variable to hold the timer interval reference + /** + * The start time of the game timer. + * @type {number} + */ + startTime: null, // Variable to hold the start time + + /** + * Updates and displays the game timer. + * @function updateTimer + * @memberof GameControl + */ + updateTimer() { + const id = document.getElementById("gameOver"); + if (id.hidden == false) { + this.stopTimer() + } + + // Calculate elapsed time in seconds + const elapsedTime = (Date.now() - this.startTime) / 1000; + + // Display the updated time in the span element with id 'timeScore' + const timeScoreElement = document.getElementById('timeScore'); + if (timeScoreElement) { + timeScoreElement.textContent = elapsedTime.toFixed(2); // Update the displayed time + } + }, + + /** + * Starts the game timer. + * @function startTimer + * @memberof GameControl + */ + startTimer() { + // Get the current time + this.startTime = Date.now(); + + // Start the timer interval, updating the timer every 0.01 second (10 milliseconds) + this.timerInterval = setInterval(() => this.updateTimer(), 10); + }, + + /** + * Stops the game timer. + * @function stopTimer + * @memberof GameControl + */ + stopTimer() { + clearInterval(this.timerInterval); // Clear the interval to stop the timer + }, + + /** + * Transitions to a new level. Destroys the current level and loads the new level. + * @param {Object} newLevel - The new level to transition to. + */ + async transitionToLevel(newLevel) { + this.inTransition = true; + + // Destroy existing game objects + GameEnv.destroy(); + + // Load GameLevel objects + await newLevel.load(); + GameEnv.currentLevel = newLevel; + + // Update invert property + GameEnv.setInvert(); + + // Trigger a resize to redraw canvas elements + window.dispatchEvent(new Event('resize')); + + this.inTransition = false; + }, + + /** + * The main game control loop. + * Checks if the game is in transition. If it's not, it updates the game environment, + * checks if the current level is complete, and if it is, transitions to the next level. + * If the current level is null, it transitions to the beginning of the game. + * Finally, it calls itself again using requestAnimationFrame to create a loop. + */ + gameLoop() { + // Turn game loop off during transitions + if (!this.inTransition) { + + // Get current level + GameEnv.update(); + const currentLevel = GameEnv.currentLevel; + + // currentLevel is defined + if (currentLevel) { + // run the isComplete callback function + if (currentLevel.isComplete && currentLevel.isComplete()) { + const currentIndex = GameEnv.levels.indexOf(currentLevel); + // next index is in bounds + if (currentIndex !== -1 && currentIndex + 1 < GameEnv.levels.length) { + // transition to the next level + this.transitionToLevel(GameEnv.levels[currentIndex + 1]); + } + } + // currentLevel is null, (ie start or restart game) + } else { + // transition to beginning of game + this.transitionToLevel(GameEnv.levels[0]); + } + } + + // recycle gameLoop, aka recursion + requestAnimationFrame(this.gameLoop.bind(this)); + }, + +}; + +export default GameControl; \ No newline at end of file diff --git a/assets/js/platformer2/GameEnv.js b/assets/js/platformer2/GameEnv.js new file mode 100644 index 0000000..689f69b --- /dev/null +++ b/assets/js/platformer2/GameEnv.js @@ -0,0 +1,164 @@ +/** + * GameEnv.js key purpose is to manage shared game environment data and methods. + * + * @class + * @classdesc GameEnv is defined as a static class, this ensures that there is only one instance of the class. + * Static classes do not have a constructor, cannot be instantiated, do not have instance variables, only singleton/static variables, + * do not have instance methods, only singleton/static methods, is similar in namespace to an object literal, but is a class. + * The benefit is it is similar to other coding languages (e.g. Java, C#), thus is more readable to other developers. + * + * Purpose of GameEnv: + * - stores game objects (e.g. gameObjects, player, levels, etc.) + * - stores game attributes (e.g. gravity, speed, width, height, top, bottom, etc.) + * - defines methods to update, draw, and destroy game objects + * - defines methods to initialize and resize game objects + * + * Usage Notes: + * GameEnv is used by other classes to manage the game environment. + * It is dangerous to use GameEnv directly, it is not protected from misuse. Comments below show locations of usage. + * Here are some methods supported by GameEnv: + * - call GameEnv.initialize() to initialize window dimensions + * - call GameEnv.resize() to resize game objects + * - call GameEnv.update() to update, serialize, and draw game objects + * - call GameEnv.destroy() to destroy game objects + */ +export class GameEnv { + + /** + * @static + * @property {string} userID - localstorage key, used by GameControl + * @property {Object} player - used by GameControl + * @property {Array} levels - used by GameControl + * @property {Object} currentLevel - used by GameControl + * @property {Array} gameObjects - used by GameControl + * @property {boolean} isInverted - localstorage key, canvas filter property, used by GameControl + * @property {number} gameSpeed - localstorage key, used by platformer objects + * @property {number} backgroundHillsSpeed - used by background objects + * @property {number} backgroundMountainsSpeed - used by background objects + * @property {number} gravity - localstorage key, used by platformer objects + * @property {number} innerWidth - used by platformer objects + * @property {number} prevInnerWidth - used by platformer objects + * @property {number} innerHeight - used by platformer objects + * @property {number} top - used by platformer objects + * @property {number} bottom - used by platformer objects + * @property {number} prevBottom - used by platformer objects + * @property {number} time - Initialize time variable, used by timer objects + * @property {number} timerInterval - Variable to hold the interval reference, used by timer objects + */ + static userID = "Guest"; + static player = null; + static levels = []; + static currentLevel = null; + static gameObjects = []; + static isInverted = false; + static gameSpeed = 2; + static backgroundHillsSpeed = 0; + static backgroundMountainsSpeed = 0; + static gravity = 3; + static innerWidth; + static prevInnerWidth; + static innerHeight; + static top; + static bottom; + static prevBottom; + + // Make the constructor throws an error, or effectively make it a private constructor. + constructor() { + throw new Error('GameEnv is a static class and cannot be instantiated.'); + } + + /** + * Setter for Top position, called by initialize in GameEnv + * @static + */ + static setTop() { + // set top of game as header height + const header = document.querySelector('header'); + if (header) { + this.top = header.offsetHeight; + } + } + + /** + * Setter for Bottom position, called by resize in GameEnv + * @static + */ + static setBottom() { + // sets the bottom or gravity 0 + this.bottom = + this.top + this.backgroundHeight; + } + + /** + * Setup for Game Environment, called by transitionToLevel in GameControl + * @static + */ + static initialize() { + // store previous for ratio calculations on resize + this.prevInnerWidth = this.innerWidth; + this.prevBottom = this.bottom; + + // game uses available width and height + this.innerWidth = window.innerWidth; + this.innerHeight = window.innerHeight; + this.setTop(); + //this.setBottom(); // must be called in platformer objects + } + + /** + * Resize game objects, called by resize in GameControl + * @static + */ + static resize() { + GameEnv.initialize(); // Update GameEnv dimensions + + // Call the sizing method on all game objects + for (var gameObject of GameEnv.gameObjects){ + gameObject.size(); + } + } + + /** + * Update, serialize, and draw game objects, called by update in GameControl + * @static + */ + static update() { + // Update game state, including all game objects + for (const gameObject of GameEnv.gameObjects) { + gameObject.update(); + gameObject.serialize(); + gameObject.draw(); + } + } + + /** + * Destroy game objects, called by destroy in GameControl + * @static + */ + static destroy() { + // Destroy objects in reverse order + for (var i = GameEnv.gameObjects.length - 1; i >= 0; i--) { + const gameObject = GameEnv.gameObjects[i]; + gameObject.destroy(); + } + GameEnv.gameObjects = []; + } + + /** + * Set "canvas filter property" between inverted and normal, called by setInvert in GameControl + * @static + */ + static setInvert() { + for (var gameObject of GameEnv.gameObjects){ + if (gameObject.invert && !this.isInverted) { // toggle off + gameObject.canvas.style.filter = "none"; // remove filter + } else if (gameObject.invert && this.isInverted) { // toggle on + gameObject.canvas.style.filter = "invert(100%)"; // remove filter + } else { + gameObject.canvas.style.filter = "none"; // remove filter + } + } + } +} + +export default GameEnv; \ No newline at end of file diff --git a/assets/js/platformer2/GameLevel.js b/assets/js/platformer2/GameLevel.js new file mode 100644 index 0000000..69c2728 --- /dev/null +++ b/assets/js/platformer2/GameLevel.js @@ -0,0 +1,68 @@ +// GameLevel.js key objective is to load and intialize GameObject(s) for a level. +import GameEnv from './GameEnv.js'; + +/** + * The GameLevel class represents a level in the game. + * Each instance of GameLevel contains all the game objects that make up a level, + * and provides methods to load the images for these objects and create instances of them. + */ +class GameLevel { + /** + * Creates a new GameLevel. + * @param {Object} levelObject - An object containing the properties for the level. + */ + constructor(levelObject) { + // The levelObjects property stores the levelObject parameter. + this.levelObjects = levelObject; + // The tag is a friendly name used to identify the level. + this.tag = levelObject?.tag; + // The passive property determines if the level is passive (i.e., not playable). + this.passive = levelObject?.passive; + // The isComplete property is a function that determines if the level is complete. + // build conditions to make determination of complete (e.g., all enemies defeated, player reached the end of the screen, etc.) + this.isComplete = levelObject?.callback; + // The gameObjects property is an array of the game objects for this level. + this.gameObjects = this.levelObjects?.objects || []; + // Each GameLevel instance is stored in the GameEnv.levels array. + GameEnv.levels.push(this); + } + + /** + * Loads the images for the game objects and creates new instances of them. + * If any image fails to load, an error is logged and the game is halted. + */ + async load() { + try { + for (const obj of this.gameObjects) { + if (obj.data.file) { + // Load the image for the game object. + obj.image = await this.loadImage(obj.data.file); + // Create a new canvas for the game object. + const canvas = document.createElement("canvas"); + canvas.id = obj.id; + document.querySelector("#canvasContainer").appendChild(canvas); + // Create a new instance of the game object. + new obj.class(canvas, obj.image, obj.data); + } + } + } catch (error) { + console.error('Failed to load one or more GameLevel objects:', error); + } + } + + /** + * Loads an image from a given source. + * @param {string} src - The source of the image. + * @returns {Promise} A promise that resolves with the loaded image. + */ + async loadImage(src) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.src = src; + image.onload = () => resolve(image); + image.onerror = reject; + }); + } +} + +export default GameLevel; \ No newline at end of file diff --git a/assets/js/platformer2/GameObject.js b/assets/js/platformer2/GameObject.js new file mode 100644 index 0000000..c12c532 --- /dev/null +++ b/assets/js/platformer2/GameObject.js @@ -0,0 +1,188 @@ +import GameEnv from './GameEnv.js'; + +class GameObject { + // container for all game objects in game + constructor(canvas, image, data) { + this.x = 0; + this.y = 0; + this.frame = 0; + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.image = image; + this.width = image.width; // from Image() width + this.height = image.height; // from Image() height + this.collisionWidth = 0; + this.collisionHeight = 0; + this.aspect_ratio = this.width / this.height; + this.speedRatio = data?.speedRatio || 0; + this.speed = GameEnv.gameSpeed * this.speedRatio; + this.invert = true; + this.collisionData = {}; + this.jsonifiedElement = ''; + // Add this object to the game object array so collision can be detected + // among other things + GameEnv.gameObjects.push(this); + } + + // extract change from Game Objects into JSON + serialize() { + this.logElement(); + } + + // log Character element change + logElement() { + var jsonifiedElement = this.stringifyElement(); + if (jsonifiedElement !== this.jsonifiedElement) { + //console.log(jsonifiedElement); + this.jsonifiedElement = jsonifiedElement; + } + } + + // strigify Character key data + stringifyElement() { + var element = this.canvas; + if (element && element.id) { + // Convert the relevant properties of the element to a string for comparison + return JSON.stringify({ + id: element.id, + width: element.width, + height: element.height, + style: element.style.cssText, + position: { + left: element.style.left, + top: element.style.top + }, + filter: element.style.filter + }); + } + } + + // X position getter and setter + getX() { + return this.x; + } + + setX(x) { + this.x = x; + } + + // Y position getter and setter + getY() { + return this.y; + } + + setY(y) { + this.y = y; + } + + /* Destroy Game Object + * remove canvas element of object + * remove object from GameObject array + */ + destroy() { + const index = GameEnv.gameObjects.indexOf(this); + if (index !== -1) { + // Remove the canvas from the DOM + this.canvas.parentNode.removeChild(this.canvas); + GameEnv.gameObjects.splice(index, 1); + } + } + + + /* Default collision action is no action + * override when you extend for custom action + */ + collisionAction(){ + // no action + } + + /* Default floor action is no action + * override when you extend for custom action + */ + floorAction(){ + // no action + } + + /* Collision checks + * uses GameObject isCollision to detect hit + * calls collisionAction on hit + */ + collisionChecks() { + for (var gameObj of GameEnv.gameObjects){ + if (this != gameObj ) { + this.isCollision(gameObj); + if (this.collisionData.hit){ + this.collisionAction(); + } + if (this.collisionData.atFloor) { + this.floorAction(); + } + } + } + } + + /* Collision detection method + * usage: if (player.isCollision(platform)) { // action } + */ + isCollision(other) { + // Bounding rectangles from Canvas + const thisRect = this.canvas.getBoundingClientRect(); + const otherRect = other.canvas.getBoundingClientRect(); + + // Calculate center points of rectangles + const thisCenterX = (thisRect.left + thisRect.right) / 2; + //const thisCenterY = (thisRect.top + thisRect.bottom) / 2; + const otherCenterX = (otherRect.left + otherRect.right) / 2; + //const otherCenterY = (otherRect.top + otherRect.bottom) / 2; + + // Calculate hitbox constants + var widthPercentage = 0.5; + var heightPercentage = 0.5; + if (this.canvas.id === "player" && other.canvas.id === "jumpPlatform") { + heightPercentage = 0.0; + } + if (this.canvas.id === "goomba" && other.canvas.id === "player") { + heightPercentage = 0.2; + } + const widthReduction = thisRect.width * widthPercentage; + const heightReduction = thisRect.height * heightPercentage; + + // Build hitbox by subtracting reductions from the left, right, top, and bottom + const thisLeft = thisRect.left + widthReduction; + const thisTop = thisRect.top + heightReduction; + const thisRight = thisRect.right - widthReduction; + const thisBottom = thisRect.bottom - heightReduction; + + // Determine hit and touch points of hit + this.collisionData = { + hit: ( + thisLeft < otherRect.right && + thisRight > otherRect.left && + thisTop < otherRect.bottom && + thisBottom > otherRect.top + ), + atFloor: (GameEnv.bottom <= this.y), + touchPoints: { + this: { + id: this.canvas.id, + top: thisRect.bottom > otherRect.top, + bottom: (thisRect.bottom <= otherRect.top) && !(Math.abs(thisRect.bottom - otherRect.bottom) <= GameEnv.gravity), + left: thisCenterX > otherCenterX, + right: thisCenterX < otherCenterX, + }, + other: { + id: other.canvas.id, + top: thisRect.bottom < otherRect.top, + bottom: (thisRect.bottom >= otherRect.top) && !(Math.abs(thisRect.bottom - otherRect.bottom) <= GameEnv.gravity), + left: thisCenterX < otherCenterX, + right: thisCenterX > otherCenterX, + x: otherRect.left, + }, + }, + }; + + } + +} + +export default GameObject; \ No newline at end of file diff --git a/assets/js/platformer2/GameSetup.js b/assets/js/platformer2/GameSetup.js new file mode 100644 index 0000000..30d8193 --- /dev/null +++ b/assets/js/platformer2/GameSetup.js @@ -0,0 +1,313 @@ +// GameSehup.js Key objective is to define GameLevel objects and their assets. +import GameEnv from './GameEnv.js'; +import GameLevel from './GameLevel.js'; +// To build GameLevels, each contains GameObjects from below imports +import Background from './Background.js' +import BackgroundHills from './BackgroundHills.js'; +import BackgroundMountains from './BackgroundMountains.js'; +import Platform from './Platform.js'; +import JumpPlatform from './JumpPlatform.js'; +import Player from './Player.js'; +import Tube from './Tube.js'; +import Goomba from './Goomba.js'; + +/* Coding Style Notes + * + * GameSetup is defined as an object literal in in Name Function Expression (NFE) style + * * const GameSetup = function() { ... } is an NFE + * * NFEs are a common pattern in JavaScript, reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function + * + * * Informerly, inside of GameSetup it looks like defining keys and values that are functions. + * * * GameSetup is a singleton object, object literal, without a constructor. + * * * This coding style ensures one instance, thus the term object literal. + * * * Inside of GameSetup, the keys are functions, and the values are references to the functions. + * * * * The keys are the names of the functions. + * * * * The values are the functions themselves. + * + * * Observe, encapulation of this.assets and sharing data between methods. + * * * this.assets is defined in the object literal scope. + * * * this.assets is shared between methods. + * * * this.assets is not accessible outside of the object literal scope. + * * * this.assets is not a global variable. + * + * * Observe, the use of bind() to bind methods to the GameSetup object. + * * * * bind() ensures "this" inside of methods binds to "GameSetup" + * * * * this avoids "Temporal Dead Zone (TDZ)" error... + * + * + * Usage Notes + * * call GameSetup.initLevels() to setup the game levels and assets. + * * * the remainder of GameSetup supports initLevels() + * +*/ + +// Define the GameSetup object literal +const GameSetup = { + + /* ========================================== + * ===== Game Level Methods +++============== + * ========================================== + * Game Level methods support Game Play, and Game Over + * * Helper functions assist the Callback methods + * * Callback methods are called by the GameLevel objects + */ + + /** + * Helper function that waits for a button click event. + * @param {string} id - The HTML id or name of the button. + * @returns {Promise} - A promise that resolves when the button is clicked. + * References: + * * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise + * * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve + */ + waitForButton: function(id) { + // Returns a promise that resolves when the button is clicked + return new Promise((resolve) => { + const waitButton = document.getElementById(id); + // Listener function to resolve the promise when the button is clicked + const waitButtonListener = () => { + resolve(true); + }; + // Add the listener to the button's click event + waitButton.addEventListener('click', waitButtonListener); + }); + }, + + /* ========================================== + * ===== Game Level Call Backs ============== + * ========================================== + * Game Level callbacks are functions that return true or false + */ + + /** + * Start button callback. + * Unhides the gameBegin button, waits for it to be clicked, then hides it again. + * @async + * @returns {Promise} Always returns true. + */ + startGameCallback: async function() { + const id = document.getElementById("gameBegin"); + // Unhide the gameBegin button + id.hidden = false; + + // Wait for the startGame button to be clicked + await this.waitForButton('startGame'); + // Hide the gameBegin button after it is clicked + id.hidden = true; + + return true; + }, + + /** + * Home screen exits on the Game Begin button. + * Checks if the gameBegin button is hidden, which means the game has started. + * @returns {boolean} Returns true if the gameBegin button is hidden, false otherwise. + */ + homeScreenCallback: function() { + // gameBegin hidden means the game has started + const id = document.getElementById("gameBegin"); + return id.hidden; + }, + + /** + * Level completion callback, based on Player off screen. + * Checks if the player's x position is greater than the innerWidth of the game environment. + * If it is, resets the player for the next level and returns true. + * If it's not, returns false. + * @returns {boolean} Returns true if the player's x position is greater than the innerWidth, false otherwise. + */ + playerOffScreenCallBack: function() { + // console.log(GameEnv.player?.x) + if (GameEnv.player?.x > GameEnv.innerWidth) { + GameEnv.player = null; // reset for next level + return true; + } else { + return false; + } + }, + + /** + * Game Over callback. + * Unhides the gameOver button, waits for it to be clicked, then hides it again. + * Also sets the currentLevel of the game environment to null. + * @async + * @returns {Promise} Always returns true. + */ + gameOverCallBack: async function() { + const id = document.getElementById("gameOver"); + id.hidden = false; + + // Wait for the restart button to be clicked + await this.waitForButton('restartGame'); + id.hidden = true; + + // Change currentLevel to start/restart value of null + GameEnv.currentLevel = null; + + return true; + }, + + /* ========================================== + * ======= Data Definitions ================= + * ========================================== + * Assets for the Game Objects defined in nested JSON key/value pairs + * + * * assets: contains definitions for all game objects, images, and properties + * * * 1st level: category (obstacles, platforms, backgrounds, players, enemies) + * * * 2nd level: item (tube, grass, mario, goomba) + * * * 3rd level: property (src, width, height, scaleSize, speedRatio, w, wa, wd, a, s, d) + */ + + assets: { + obstacles: { + tube: { src: "/images/platformer/obstacles/tube.png" }, + }, + platforms: { + grass: { src: "/images/platformer/platforms/grass.png" }, + alien: { src: "/images/platformer/platforms/alien.png" }, + bricks: { src: "/images/platformer/platforms/brick_wall.png" }, + }, + backgrounds: { + start: { src: "/images/platformer/backgrounds/home.png" }, + hills: { src: "/images/platformer/backgrounds/hills.png" }, + avenida: { src: "/images/platformer/backgrounds/night_clouds.jpg" }, //temporary change: just to get the game running + mountains: { src: "/images/platformer/backgrounds/mario_mountains.jpg" }, + planet: { src: "/images/platformer/backgrounds/planet.jpg" }, + castles: { src: "/images/platformer/backgrounds/castles.png" }, + end: { src: "/images/platformer/backgrounds/game_over.png" } + }, + players: { + mario: { + src: "/images/platformer/sprites/mario.png", + width: 256, + height: 256, + scaleSize: 80, + speedRatio: 0.7, + w: { row: 10, frames: 15 }, + wa: { row: 11, frames: 15 }, + wd: { row: 10, frames: 15 }, + a: { row: 3, frames: 7, idleFrame: { column: 7, frames: 0 } }, + s: { row: 12, frames: 15 }, + d: { row: 2, frames: 7, idleFrame: { column: 7, frames: 0 } } + }, + monkey: { + src: "/images/platformer/sprites/monkey.png", + width: 40, + height: 40, + scaleSize: 80, + speedRatio: 0.7, + w: { row: 9, frames: 15 }, + wa: { row: 9, frames: 15 }, + wd: { row: 9, frames: 15 }, + a: { row: 1, frames: 15, idleFrame: { column: 7, frames: 0 } }, + s: { row: 12, frames: 15 }, + d: { row: 0, frames: 15, idleFrame: { column: 7, frames: 0 } } + }, + lopez: { + src: "/images/platformer/sprites/lopezanimation.png", + width: 46, + height: 52.5, + scaleSize: 60, + speedRatio: 0.7, + w: {row: 1, frames: 3}, + wa: {row: 1, frames: 3}, + wd: {row: 2, frames: 3}, + idle: { row: 6, frames: 1, idleFrame: {column: 1, frames: 0} }, + a: { row: 1, frames: 3, idleFrame: { column: 1, frames: 0 } }, // Right Movement + s: {}, // Stop the movement + d: { row: 2, frames: 3, idleFrame: { column: 1, frames: 0 } }, // Left Movement + runningLeft: { row: 5, frames: 3, idleFrame: {column: 1, frames: 0} }, + runningRight: { row: 4, frames: 3, idleFrame: {column: 1, frames: 0} }, + } + }, + enemies: { + goomba: { + src: "/images/platformer/sprites/goomba.png", + width: 448, + height: 452, + scaleSize: 60, + speedRatio: 0.7, + } + } + }, + + /* ========================================== + * ========== Game Level init =============== + * ========================================== + * + * Game Level sequence as defined in code below + * * a.) tag: "start" level defines button selection and cycles to the home screen + * * b.) tag: "home" defines background and awaits "start" button selection and cycles to 1st game level + * * c.) tag: "hills" and other levels before the tag: "end" define key gameplay levels + * * d.) tag: "end" concludes levels with game-over-screen background and replay selections + * + * Definitions of new Object creations and JSON text + * * 1.) "new GameLevel" adds game objects to the game environment. + * * * JSON key/value "tag" is for readability + * * * JSON "callback" contains function references defined above that terminate a GameLevel + * * * JSON "objects" contain zero to many "GameObject"(s) + * * 2.) "GameObject"(s) are defined using JSON text and include name, id, class, and data. + * * * JSON key/value "name" is for readability + * * * JSON "id" is a GameObject classification and may have program significance + * * * JSON "class" is the JavaScript class that defines the GameObject + * * J* SON "data" contains assets and properties for the GameObject + */ + + initLevels: function(path) { // ensure valid {{site.baseurl}} for path + + // Add File location in assets relative to the root of the site + Object.keys(this.assets).forEach(category => { + Object.keys(this.assets[category]).forEach(item => { + this.assets[category][item]['file'] = path + this.assets[category][item].src; + }); + }); + + // Home screen added to the GameEnv ... + new GameLevel( {tag: "start", callback: this.startGameCallback } ); + const homeGameObjects = [ + { name:'background', id: 'background', class: Background, data: this.assets.backgrounds.start } + ]; + // Home Screen Background added to the GameEnv, "passive" means complementary, not an interactive level.. + new GameLevel( {tag: "home", callback: this.homeScreenCallback, objects: homeGameObjects, passive: true } ); + + // Hills Game Level defintion... + const hillsGameObjects = [ + // GameObject(s), the order is important to z-index... + { name: 'mountains', id: 'background', class: BackgroundMountains, data: this.assets.backgrounds.mountains }, + { name: 'hills', id: 'background', class: BackgroundHills, data: this.assets.backgrounds.hills }, + { name: 'grass', id: 'platform', class: Platform, data: this.assets.platforms.grass }, + { name: 'bricks', id: 'jumpPlatform', class: JumpPlatform, data: this.assets.platforms.bricks }, + { name: 'goomba', id: 'goomba', class: Goomba, data: this.assets.enemies.goomba }, + { name: 'mario', id: 'player', class: Player, data: this.assets.players.mario }, + { name: 'tube', id: 'tube', class: Tube, data: this.assets.obstacles.tube }, + ]; + // Hills Game Level added to the GameEnv ... + new GameLevel( {tag: "hills", callback: this.playerOffScreenCallBack, objects: hillsGameObjects } ); + + // Avenida Game Level definition... + const avenidaGameObjects = [ + // GameObject(s), the order is important to z-index... + { name: 'avenida', id: 'background', class: Background, data: this.assets.backgrounds.avenida }, + { name: 'grass', id: 'platform', class: Platform, data: this.assets.platforms.grass }, + { name: 'goomba', id: 'goomba', class: Goomba, data: this.assets.enemies.goomba }, + { name: 'lopez', id: 'player', class: Player, data: this.assets.players.lopez }, + ]; + // Avenida Game Level added to the GameEnv ... + new GameLevel( {tag: "avenida", callback: this.playerOffScreenCallBack, objects: avenidaGameObjects } ); + + // Game Over Level definition... + const endGameObjects = [ + { name:'background', class: Background, id: 'background', data: this.assets.backgrounds.end} + ]; + // Game Over screen added to the GameEnv ... + new GameLevel( {tag: "end", callback: this.gameOverCallBack, objects: endGameObjects } ); + } +} +// Bind the methods to the GameSetup object, ensures "this" inside of methods binds to "GameSetup" +// * * this avoids "Temporal Dead Zone (TDZ)" error... +// * * * * "Cannot access 'GameSetup' before initialization", light reading TDZ (ha ha)... +// * * * * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let#Temporal_Dead_Zone +GameSetup.startGameCallback = GameSetup.startGameCallback.bind(GameSetup); +GameSetup.gameOverCallBack = GameSetup.gameOverCallBack.bind(GameSetup); + +export default GameSetup; \ No newline at end of file diff --git a/assets/js/platformer2/Goomba.js b/assets/js/platformer2/Goomba.js new file mode 100644 index 0000000..48f1d0c --- /dev/null +++ b/assets/js/platformer2/Goomba.js @@ -0,0 +1,49 @@ +import Character from './Character.js'; +import GameEnv from './GameEnv.js'; + +export class Goomba extends Character { + // constructors sets up Character object + constructor(canvas, image, data){ + super(canvas, image, data ); + + //Initial Position of Goomba + this.x = .6 * GameEnv.innerWidth; + } + + update() { + super.update(); + + // Check for boundaries + if (this.x <= 0 || (this.x + this.canvasWidth >= GameEnv.innerWidth) ) { + this.speed = -this.speed; + } + + // Every so often change direction + if (Math.random() < 0.005) { + this.speed = Math.random() < 0.5 ? -this.speed : this.speed; + } + + // Move the enemy + this.x += this.speed; + } + + // Player action on collisions + collisionAction() { + if (this.collisionData.touchPoints.other.id === "tube") { + if (this.collisionData.touchPoints.other.left || this.collisionData.touchPoints.other.right) { + this.speed = -this.speed; + } + } + if (this.collisionData.touchPoints.other.id === "player") { + // Collision: Top of Goomba with Bottom of Player + if (this.collisionData.touchPoints.other.bottom) { + console.log("Bye Bye Goomba"); + this.x = GameEnv.innerWidth + 1; + this.destroy(); + } + } + } + +} + +export default Goomba; \ No newline at end of file diff --git a/assets/js/platformer2/JumpPlatform.js b/assets/js/platformer2/JumpPlatform.js new file mode 100644 index 0000000..f26fca9 --- /dev/null +++ b/assets/js/platformer2/JumpPlatform.js @@ -0,0 +1,42 @@ +import GameEnv from './GameEnv.js'; +import GameObject from './GameObject.js'; + +export class JumpPlatform extends GameObject { + constructor(canvas, image, data) { + super(canvas, image, data); + } + + // Required, but no update action + update() { + } + + // Draw position is always 0,0 + draw() { + this.ctx.drawImage(this.image, 0, 0); + } + + // Set platform position + size() { + // Formula for Height should be on constant ratio, using a proportion of 832 + const scaledHeight = GameEnv.innerHeight * (30/832); + const scaledWidth = GameEnv.innerHeight * .1; // width of jump platform is 1/10 of height + const platformX = GameEnv.innerWidth * .2; + const platformY = (GameEnv.bottom - scaledHeight) * .8; + + // set variables used in Display and Collision algorithms + this.bottom = platformY; + this.collisionHeight = scaledHeight; + this.collisionWidth = scaledWidth; + + //this.canvas.width = this.width; + //this.canvas.height = this.height; + this.canvas.style.width = `${scaledWidth}px`; + this.canvas.style.height = `${scaledHeight}px`; + this.canvas.style.position = 'absolute'; + this.canvas.style.left = `${platformX}px`; + this.canvas.style.top = `${platformY}px`; + + } +} + +export default JumpPlatform; \ No newline at end of file diff --git a/assets/js/platformer2/LocalStorage.js b/assets/js/platformer2/LocalStorage.js new file mode 100644 index 0000000..8482023 --- /dev/null +++ b/assets/js/platformer2/LocalStorage.js @@ -0,0 +1,60 @@ +export class LocalStorage{ + constructor(keys){ + this.keys = keys; + console.log("browser local storage available: "+String(this.storageAvailable)); + } + + get storageAvailable(){ //checks if browser is able to use local storage + let type = "localStorage"; + let storage; + try { + storage = window[type]; + const x = "__storage_test__"; + storage.setItem(x, x); + storage.removeItem(x); + return true; + } catch (e) { + return ( + e instanceof DOMException && + // everything except Firefox + (e.code === 22 || + // Firefox + e.code === 1014 || + // test name field too, because code might not be present + // everything except Firefox + e.name === "QuotaExceededError" || + // Firefox + e.name === "NS_ERROR_DOM_QUOTA_REACHED") && + // acknowledge QuotaExceededError only if there's something already stored + storage && + storage.length !== 0 + ); + } + } + + save(key){ //save a particular key + if(!this.storageAvailable){return}; //check if local storage is possible + window.localStorage.setItem(key,this[key]); + } + + load(key){//load a particular key + if(!this.storageAvailable){return}; //check if local storage is possible + this[key] = window.localStorage.getItem(key); + } + + saveAll(){ //saves data for all keys in this.keys + if(!this.storageAvailable){return}; //check if local storage is possible + Object.keys(this.keys).forEach(key => { + window.localStorage.setItem(key,this[key]); + }); + } + + loadAll(){//loads data from all keys in this.keys + if(!this.storageAvailable){return}; //check if local storage is possible + Object.keys(this.keys).forEach(key => { + this[key] = window.localStorage.getItem(key); + }); + } +} + +export default LocalStorage; \ No newline at end of file diff --git a/assets/js/platformer2/Platform.js b/assets/js/platformer2/Platform.js new file mode 100644 index 0000000..57cc2af --- /dev/null +++ b/assets/js/platformer2/Platform.js @@ -0,0 +1,50 @@ +import GameEnv from './GameEnv.js'; +import GameObject from './GameObject.js'; + +export class Platform extends GameObject { + constructor(canvas, image, data) { + super(canvas, image, data); + } + + /* Update uses modulo math to cycle to start at width extent + * x is position in cycle + * speed can be used to scroll faster + * width is extent of background image + */ + update() { + this.x = (this.x - this.speed) % this.width; + } + + /* To draws are used to capture primary frame and wrap around ot next frame + * x to y is primary draw + * x + width to y is wrap around draw + */ + draw() { + this.ctx.drawImage(this.image, this.x, this.y); + this.ctx.drawImage(this.image, this.x + this.width, this.y); + } + + /* Background camvas is set to screen + * the ADJUST contant elements portions of image that don't wrap well + * the GameEnv.top is a getter used to set canvas under Menu + * the GameEnv.bottom is setter used to establish game bottom at offsetHeight of canvas + */ + size() { + // Update canvas size + const scaledHeight = GameEnv.backgroundHeight / 6; + + const canvasWidth = GameEnv.innerWidth; + const canvasLeft = 0; + GameEnv.platformHeight = scaledHeight; + + this.canvas.width = this.width; + this.canvas.height = this.height; + this.canvas.style.width = `${canvasWidth}px`; + this.canvas.style.height = `${GameEnv.platformHeight}px`; + this.canvas.style.position = 'absolute'; + this.canvas.style.left = `${canvasLeft}px`; + this.canvas.style.top = `${GameEnv.bottom}px`; + } +} + +export default Platform; \ No newline at end of file diff --git a/assets/js/platformer2/Player.js b/assets/js/platformer2/Player.js new file mode 100644 index 0000000..3a5042c --- /dev/null +++ b/assets/js/platformer2/Player.js @@ -0,0 +1,275 @@ +import GameEnv from './GameEnv.js'; +import Character from './Character.js'; + +/** + * @class Player class + * @description Player.js key objective is to eent the user-controlled character in the game. + * + * The Player class extends the Character class, which in turn extends the GameObject class. + * Animations and events are activiated by key presses, collisions, and gravity. + * WASD keys are used by user to control The Player object. + * + * @extends Character + */ +export class Player extends Character{ + // instantiation: constructor sets up player object + constructor(canvas, image, data){ + super(canvas, image, data); + // Player Data is required for Animations + this.playerData = data; + + // Player control data + this.pressedKeys = {}; + this.movement = {up: true, down: true, left: true, right: true}; + this.isIdle = true; + this.directionKey = "d"; // initially facing right + + // Store a reference to the event listener function + this.keydownListener = this.handleKeyDown.bind(this); + this.keyupListener = this.handleKeyUp.bind(this); + + // Add event listeners + document.addEventListener('keydown', this.keydownListener); + document.addEventListener('keyup', this.keyupListener); + + GameEnv.player = this; + } + + /** + * Helper methods for checking the state of the player. + * Each method checks a specific condition and returns a boolean indicating whether that condition is met. + */ + + // helper: player facing left + isFaceLeft() { return this.directionKey === "a"; } + // helper: left action key is pressed + isKeyActionLeft(key) { return key === "a"; } + // helper: player facing right + isFaceRight() { return this.directionKey === "d"; } + // helper: right action key is pressed + isKeyActionRight(key) { return key === "d"; } + // helper: dash key is pressed + isKeyActionDash(key) { return key === "s"; } + + // helper: action key is in queue + isActiveAnimation(key) { return (key in this.pressedKeys) && !this.isIdle; } + // helper: gravity action key is in queue + isActiveGravityAnimation(key) { + var result = this.isActiveAnimation(key) && (this.bottom <= this.y || this.movement.down === false); + + // return to directional animation (direction?) + if (this.bottom <= this.y || this.movement.down === false) { + this.setAnimation(this.directionKey); + } + + return result; + } + + /** + * This helper method that acts like an animation manager. Frames are set according to player events. + * - Sets the animation of the player based on the provided key. + * - The key is used to look up the animation frame and idle in the objects playerData. + * If the key corresponds to a left or right movement, the directionKey is updated. + * + * @param {string} key - The key representing the animation to set. + */ + setAnimation(key) { + // animation comes from playerData + var animation = this.playerData[key] + // direction setup + if (this.isKeyActionLeft(key)) { + this.directionKey = key; + this.playerData.w = this.playerData.wa; + } else if (this.isKeyActionRight(key)) { + this.directionKey = key; + this.playerData.w = this.playerData.wd; + } + // set frame and idle frame + this.setFrameY(animation.row); + this.setMaxFrame(animation.frames); + if (this.isIdle && animation.idleFrame) { + this.setFrameX(animation.idleFrame.column) + this.setMinFrame(animation.idleFrame.frames); + } + } + + /** + * gameloop: updates the player's state and position. + * In each refresh cycle of the game loop, the player-specific movement is updated. + * - If the player is moving left or right, the player's x position is updated. + * - If the player is dashing, the player's x position is updated at twice the speed. + * This method overrides Character.update, which overrides GameObject.update. + * @override + */ + update() { + // Player moving right + if (this.isActiveAnimation("a")) { + if (this.movement.left) this.x -= this.speed; // Move to left + } + // Player moving left + if (this.isActiveAnimation("d")) { + if (this.movement.right) this.x += this.speed; // Move to right + } + // Player moving at dash speed left or right + if (this.isActiveAnimation("s")) { + const moveSpeed = this.speed * 2; + this.x += this.isFaceLeft() ? -moveSpeed : moveSpeed; + } + // Player jumping + if (this.isActiveGravityAnimation("w")) { + if (this.gravityEnabled) { + this.y -= (this.bottom * .50); // bottom jump height + } else if (this.movement.down===false) { + this.y -= (this.bottom * .30); // platform jump height + } + } + + // Perform super update actions + super.update(); + } + + /** + * gameloop: respoonds to level change and game over destroy player object + * This method is used to remove the event listeners for keydown and keyup events. + * After removing the event listeners, it calls the parent class's destroy player object. + * This method overrides GameObject.destroy. + * @override + */ + destroy() { + // Remove event listeners + document.removeEventListener('keydown', this.keydownListener); + document.removeEventListener('keyup', this.keyupListener); + + // Call the parent class's destroy method + super.destroy(); + } + + /** + * gameloop: performs action on collisions + * Handles the player's actions when a collision occurs. + * This method checks the collision, type of game object, and then to determine action, e.g game over, animation, etc. + * Depending on the side of the collision, it performs player action, e.g. stops movement, etc. + * This method overrides GameObject.collisionAction. + * @override + */ + collisionAction() { + if (this.collisionData.touchPoints.other.id === "tube") { + // Collision with the left side of the Tube + if (this.collisionData.touchPoints.other.left) { + this.movement.right = false; + } + // Collision with the right side of the Tube + if (this.collisionData.touchPoints.other.right) { + this.movement.left = false; + } + // Collision with the top of the player + if (this.collisionData.touchPoints.other.bottom) { + this.x = this.collisionData.touchPoints.other.x; + this.gravityEnabled = false; // stop gravity + // Pause for two seconds + setTimeout(() => { // animation in tube for 2 seconds + this.gravityEnabled = true; + setTimeout(() => { // move to end of screen for end of game detection + this.x = GameEnv.innerWidth + 1; + }, 1000); + }, 2000); + } + } else { + // Reset movement flags if not colliding with a tube + this.movement.left = true; + this.movement.right = true; + } + // Gomba left/right collision + if (this.collisionData.touchPoints.other.id === "goomba") { + // Collision with the left side of the Enemy + if (this.collisionData.touchPoints.other.left) { + // Game over + this.x = GameEnv.innerWidth + 1; + } + // Collision with the right side of the Enemy + if (this.collisionData.touchPoints.other.right) { + // Game over + this.x = GameEnv.innerWidth + 1; + } + } + // Jump platform collision + if (this.collisionData.touchPoints.other.id === "jumpPlatform") { + // Player is on top of the Jump platform + if (this.collisionData.touchPoints.this.top) { + this.movement.down = false; // enable movement down without gravity + this.gravityEnabled = false; + this.setAnimation(this.directionKey); // set animation to direction + } + } + // Fall Off edge of Jump platform + else if (this.movement.down === false) { + this.movement.down = true; + this.gravityEnabled = true; + } + } + + /** + * Handles the keydown event. + * This method checks the pressed key, then conditionally: + * - adds the key to the pressedKeys object + * - sets the player's animation + * - adjusts the game environment + * + * @param {Event} event - The keydown event. + */ + handleKeyDown(event) { + if (this.playerData.hasOwnProperty(event.key)) { + const key = event.key; + if (!(event.key in this.pressedKeys)) { + this.pressedKeys[event.key] = this.playerData[key]; + this.setAnimation(key); + // player active + this.isIdle = false; + } + // dash action on + if (this.isKeyActionDash(key)) { + this.canvas.style.filter = 'invert(1)'; + } + // parallax background speed starts on player movement + if (this.isKeyActionLeft(key)) { + GameEnv.backgroundHillsSpeed = -0.4; + GameEnv.backgroundMountainsSpeed = -0.1; + } else if (this.isKeyActionRight(key)) { + GameEnv.backgroundHillsSpeed = 0.4; + GameEnv.backgroundMountainsSpeed = 0.1; + } + } + } + + /** + * Handles the keyup event. + * This method checks the released key, then conditionally stops actions from formerly pressed key + * * + * @param {Event} event - The keyup event. + */ + handleKeyUp(event) { + if (this.playerData.hasOwnProperty(event.key)) { + const key = event.key; + if (event.key in this.pressedKeys) { + delete this.pressedKeys[event.key]; + } + this.setAnimation(key); + // player idle + this.isIdle = true; + // dash action off + if (this.isKeyActionDash(key)) { + this.canvas.style.filter = 'invert(0)'; + } + // parallax background speed halts on key up + if (this.isKeyActionLeft(key) || this.isKeyActionRight(key)) { + GameEnv.backgroundHillsSpeed = 0; + GameEnv.backgroundMountainsSpeed = 0; + } + } + } + + +} + + +export default Player; \ No newline at end of file diff --git a/assets/js/platformer2/SettingsControl.js b/assets/js/platformer2/SettingsControl.js new file mode 100644 index 0000000..d4cb7b2 --- /dev/null +++ b/assets/js/platformer2/SettingsControl.js @@ -0,0 +1,366 @@ +// SettingsControl.js key purpose is key/value management for game settings. +import LocalStorage from "./LocalStorage.js"; +import GameEnv from "./GameEnv.js"; +import GameControl from "./GameControl.js"; + +/* Coding Style Notes + * + * SettingsControl is defined as a Class + * * SettingsControl contains a constructor. + * * SettingsControl.constructor() is called when SettingsControl is instantiated. + * * SettingsControl is instantiated in SettingsControl.sidebar(). + * * This coding style allows multiple instances of SettingsControl. + * * This coding style is a common pattern in JavaScript and is very similar to Java. + * * Methods are defined as ES6 shorthand + * + * + * * Observe, instantiation/scoping/encapulation of this.keys + * * * The constructor makes an instance of this.keys by calling super(keys). + * * * * Observe the super(keys) call, this calls extended LocalStorage class constructor. + * * * * Review LocalStorage.js for more details. + * + * * SettingsControl manages keys following Model-View-Control (MVC) design pattern. + * * * Model is the LocalStorage class, which enables persistence of settings between sessions. + * * * View is the HTML/CSS sidebar, which displays and stores document elements in the DOM. + * * * Control is the SettingsControl class, which manages exchange of data between Model and View. + * + * + * Usage Notes + * * call SettingsControl.sidebar() to run the settings sidebar. + * * * the remainder of SettingsControl supports the sidebar and MVC design for settings keys/values. + * +*/ + +// define the SettingsControl class +export class SettingsControl extends LocalStorage{ + constructor(){ //default keys for localStorage + var keys = { + userID:"userID", + currentLevel:"currentLevel", + isInverted:"isInverted", + gameSpeed:"gameSpeed", + gravity:"gravity", + }; + super(keys); //creates this.keys + } + + /** + * Note. Separated from constructor so that class can be created before levels are addeda + * + * Initializes the SettingsControl instance. + * Loads all keys from local storage. + * For each key, + * * If it exists in local storage, loads and parses its value. + * * Else when the key does not exist in local storage, sets key to the corresponding GameEnv.js variable. + */ + initialize(){ + // Load all keys from local storage + this.loadAll(); + + /** + * Handles a key by checking if it exists in local storage and parsing its value. + * If the key does not exist in local storage, it sets the key to the current value of the game environment variable. + * + * @param {string} key - The localstorae key. + * @param {*} gameEnvVariable - The corresponding game environment variable. + * @param {function} [parser=(val) => val] - An optional function to parse the value from local storage. + * If no parser parameter/function is provided, (val) => val is unchanged. + * Else if parser is provided, the value is parsed ... e.g.: + * * (val) => vall === "true" parses the value as a boolean + * * (val) => parseFloat(val) parses the value as a floating point number + */ + const handleKey = (key, gameEnvVariable, parser = (val) => val) => { + if (this[this.keys[key]]) { + return parser(this[this.keys[key]]); + } else { + this[this.keys[key]] = gameEnvVariable; + return gameEnvVariable; + } + }; + + /* Call the handleKey function to set up each game environment variable + * The handleKey function takes three parameters: + * * key - the local storage key + * * gameEnvVariable - the corresponding game environment variable + * * parser - an optional function to parse the value extracted from local storage + */ + // 'userID', the value is parsed as a string + GameEnv.userID = handleKey('userID', GameEnv.userID); + // 'currentLevel', the value is parsed as a an index into the GameEnv.levels array + GameEnv.currentLevel = handleKey('currentLevel', GameEnv.levels[Number(this[this.keys.currentLevel])]); + // 'isInverted', the value is parsed to a boolean + GameEnv.isInverted = handleKey('isInverted', GameEnv.isInverted, (val) => val === "true"); + // 'gameSpeed', the value is parsed to a floating point number + GameEnv.gameSpeed = handleKey('gameSpeed', GameEnv.gameSpeed, parseFloat); + // 'gravity', the value is parsed to a floating point number + GameEnv.gravity = handleKey('gravity', GameEnv.gravity, parseFloat); + + // List for th 'userID' update event + window.addEventListener("userID", (e)=>{ + // Update the userID value when a userID event is fired + this[this.keys.userID] = e.detail.userID(); + // Update the userID value in the game environment + GameEnv.userID = this[this.keys.userID]; + // Save the userID value to local storage + this.save(this.keys.userID); + }); + + // Listen for the 'resize' update event + window.addEventListener("resize",()=>{ + // Update the current level index when the level changes + this[this.keys.currentLevel] = GameEnv.levels.indexOf(GameEnv.currentLevel); + // Save the current level index to local storage + this.save(this.keys.currentLevel); + }); + + // Listen for the 'isInverted' update event + window.addEventListener("isInverted", (e)=>{ + // Update the isInverted value when an invert event is fired + this[this.keys.isInverted] = e.detail.isInverted(); + // Update the isInverted value in the game environment + GameEnv.isInverted = this[this.keys.isInverted]; + // Save the isInverted value to local storage + this.save(this.keys.isInverted); + }); + + // Listen for the 'gameSpeed' update event + window.addEventListener("gameSpeed",(e)=>{ + // Update the gameSpeed value when a speed event is fired + this[this.keys.gameSpeed] = e.detail.gameSpeed(); + // Update the gameSpeed value in the game environment + GameEnv.gameSpeed = parseFloat(this[this.keys.gameSpeed]); + // Save the gameSpeed value to local storage + this.save(this.keys.gameSpeed); + }); + + // Listen for the 'gravity' update event + window.addEventListener("gravity",(e)=>{ + // Update the gravity value when a gravity event is fired + this[this.keys.gravity] = e.detail.gravity(); + // Update the gravity value in the game environment + GameEnv.gravity = parseFloat(this[this.keys.gravity]); + // Save the gravity value to local storage + this.save(this.keys.gravity); + }); + + } + + /** + * Getter for the userID property. + * Creates a div with a text input for the user to enter a userID. + * The input's value is bound to the GameEnv's userID string. + * @returns {HTMLDivElement} The div containing the userID input. + */ + get userIDInput() { + const div = document.createElement("div"); + div.innerHTML = "User ID: "; // label + + const userID = document.createElement("input"); // get user defined userID + userID.type = "text"; + userID.value = GameEnv.userID; // GameEnv contains latest userID + userID.maxLength = 10; // set maximum length to 10 characters + userID.className = "input userID"; // custom style in platformer-styles.scss + + userID.addEventListener("change", () => { + // dispatch event to update userID + window.dispatchEvent(new CustomEvent("userID", { detail: {userID:()=>userID.value} })); + }); + + div.append(userID); // wrap input element in div + return div; + } + + /** + * Getter for the levelTable property. + * Creates a table with a row for each game level. + * Each row contains the level number and the level tag. + * Passive levels are skipped and not added to the table. + * @returns {HTMLTableElement} The table containing the game levels. + */ + get levelTable(){ + // create table element + var t = document.createElement("table"); + t.className = "table levels"; + //create table header + var header = document.createElement("tr"); + var th1 = document.createElement("th"); + th1.innerText = "#"; + header.append(th1); + var th2 = document.createElement("th"); + th2.innerText = "Level Tag"; + header.append(th2); + t.append(header); + + // Create table rows/data + for(let i = 0, count = 1; i < GameEnv.levels.length; i++){ + if (GameEnv.levels[i].passive) //skip passive levels + continue; + // add level to table + var row = document.createElement("tr"); + var td1 = document.createElement("td"); + td1.innerText = String(count++); //human counter + row.append(td1); + // place level name in button + var td2 = document.createElement("td"); + td2.innerText = GameEnv.levels[i].tag; + row.append(td2); + // listen for row click + row.addEventListener("click",()=>{ // when player clicks on the row + //transition to selected level + GameControl.transitionToLevel(GameEnv.levels[i]); // resize event is triggered in transitionToLevel + }) + // add level row to table + t.append(row); + } + + return t; //returns element + } + + /** + * Getter for the isInvertedInput property. + * Creates a div with a checkbox input for the user to invert the game controls. + * The checkbox's checked state is bound to the GameEnv's isInverted state. + * @returns {HTMLDivElement} The div containing the isInverted checkbox. + */ + get isInvertedInput() { + const div = document.createElement("div"); + div.innerHTML = "Invert: "; // label + + const isInverted = document.createElement("input"); // get user defined invert boolean + isInverted.type = "checkbox"; + isInverted.checked = GameEnv.isInverted; // GameEnv contains latest isInverted state + + isInverted.addEventListener("change", () => { + //`dispatch event to update isInverted + window.dispatchEvent(new CustomEvent("isInverted", { detail: {isInverted:()=>isInverted.checked} })); + }); + + div.append(isInverted); // wrap input element in div + return div; + } + + /** + * Getter for the gameSpeedInput property. + * Creates a div with a number input for the user to adjust the game speed. + * The input's value is bound to the GameEnv's gameSpeed state. + * @returns {HTMLDivElement} The div containing the gameSpeed input. + */ + get gameSpeedInput() { + const div = document.createElement("div"); + div.innerHTML = "Game Speed: "; // label + + const gameSpeed = document.createElement("input"); // get user defined game speed + gameSpeed.type = "number"; + gameSpeed.min = 1.0; + gameSpeed.max = 8.0; + gameSpeed.step = 0.1; + gameSpeed.default = 2.0; // customed property for default value + gameSpeed.value = GameEnv.gameSpeed; // GameEnv contains latest game speed + gameSpeed.className = "input gameSpeed"; // custom style in platformer-styles.scss + + gameSpeed.addEventListener("change", () => { + // check values are within range + const value = parseFloat(gameSpeed.value).toFixed(1); + gameSpeed.value = (value < gameSpeed.min || value > gameSpeed.max || isNaN(value)) ? gameSpeed.default : value; + // dispatch event to update game speed + window.dispatchEvent(new CustomEvent("gameSpeed", { detail: {gameSpeed:()=>gameSpeed.value} })); + }); + + div.append(gameSpeed); // wrap input element in div + return div; + } + + /** + * Getter for the gravityInput property. + * Creates a div with a number input for the user to adjust the game gravity. + * The input's value is bound to the GameEnv's gravity state. + * @returns {HTMLDivElement} The div containing the gravity input. + */ + get gravityInput() { + const div = document.createElement("div"); + div.innerHTML = "Gravity: "; // label + + const gravity = document.createElement("input"); // get user defined gravity + gravity.type = "number"; + gravity.min = 1.0; + gravity.max = 8.0; + gravity.step = 0.1; + gravity.default = 3.0; // customed property for default value + gravity.value = GameEnv.gravity; // GameEnv contains latest gravity + gravity.className = "input gravity"; // custom style in platformer-styles.scss + + gravity.addEventListener("change", () => { + // check values are within range + const value = parseFloat(gravity.value).toFixed(1); + gravity.value = (value < gravity.min || value > gravity.max || isNaN(value)) ? gravity.default : value; + // dispatch event to update gravity + window.dispatchEvent(new CustomEvent("gravity", { detail: {gravity:()=>gravity.value} })); + }); + + div.append(gravity); // wrap input element in div + return div; + } + + /** + * Static method to initialize the game settings controller and add the settings controls to the sidebar. + * Constructs an HTML table/menu from GameEnv.levels[] and HTML inputs for invert, game speed, and gravity. + * Each input has an event update associated with it. + * All elements are appended to the sidebar. + */ + static sidebar(){ + // Initiliaze Game settings controller + var settingsControl = new SettingsControl(); + settingsControl.initialize(); + + // Get/Construct an HTML input for userID + var userID = settingsControl.userIDInput; + document.getElementById("sidebar").append(userID); + + // Create a new div element to act as a spacer + var spacer = document.createElement("div"); + spacer.style.height = "20px"; // Set the height of the spacer + document.getElementById("sidebar").append(spacer); // Add the spacer to the sidebar + + // Get/Construct an HTML table/menu from GameEnv.levels[] + var levels = settingsControl.levelTable; + document.getElementById("sidebar").append(levels); + + // Get/Construct HTML input and event update for invert + var invertControl = settingsControl.isInvertedInput; + document.getElementById("sidebar").append(invertControl); + + // Get/Construct HTML input and event update for game speed + var gameSpeed = settingsControl.gameSpeedInput; + document.getElementById("sidebar").append(gameSpeed); + + // Get/Construct HTML input and event update for gravity + var gravityInput = settingsControl.gravityInput; + document.getElementById("sidebar").append(gravityInput); + + // Listener, isOpen, and function for sidebar open and close + var isOpen = false; // default sidebar is closed + var submenuHeight = 0; // calculated height of submenu + function sidebarPanel(){ + // toggle isOpen + isOpen = !isOpen; + // open and close properties for sidebar based on isOpen + var sidebar = document.querySelector('.sidebar'); + sidebar.style.width = isOpen?"200px":"0px"; + sidebar.style.paddingLeft = isOpen?"10px":"0px"; + sidebar.style.paddingRight = isOpen?"10px":"0px"; + sidebar.style.top = `calc(${submenuHeight}px + ${GameEnv.top}px)`; + } + // settings-button and event listener opens sidebar + document.getElementById("settings-button").addEventListener("click",sidebarPanel); + // sidebar-header and event listener closes sidebar + document.getElementById("sidebar-header").addEventListener("click",sidebarPanel); + + window.addEventListener('load', function() { + var submenu = document.querySelector('.submenu'); + submenuHeight = submenu.offsetHeight; + }); + } + +} + +export default SettingsControl; \ No newline at end of file diff --git a/assets/js/platformer2/Tube.js b/assets/js/platformer2/Tube.js new file mode 100644 index 0000000..12174ff --- /dev/null +++ b/assets/js/platformer2/Tube.js @@ -0,0 +1,43 @@ +import GameEnv from './GameEnv.js'; +import GameObject from './GameObject.js'; + +export class Tube extends GameObject { + constructor(canvas, image) { + super(canvas, image, 0); + } + + // Required, but no update action + update() { + } + + // Draw position is always 0,0 + draw() { + this.ctx.drawImage(this.image, 0, 0); + } + + // Set Tube position + size() { + // Formula for Height should be on constant ratio, using a proportion of 832 + const scaledHeight = GameEnv.innerHeight * (100 / 832); + // Formula for Width is scaled: scaledWidth/scaledHeight == this.width/this.height + const scaledWidth = scaledHeight * this.aspect_ratio; + const tubeX = .80 * GameEnv.innerWidth; + const tubeY = (GameEnv.bottom - scaledHeight); + + // set variables used in Display and Collision algorithms + this.bottom = tubeY; + this.collisionHeight = scaledHeight; + this.collisionWidth = scaledWidth; + + //this.canvas.width = this.width; + //this.canvas.height = this.height; + this.canvas.style.width = `${scaledWidth}px`; + this.canvas.style.height = `${scaledHeight}px`; + this.canvas.style.position = 'absolute'; + this.canvas.style.left = `${tubeX}px`; + this.canvas.style.top = `${tubeY}px`; + + } +} + +export default Tube; \ No newline at end of file diff --git a/images/platformer/backgrounds/mario_mountains.jpg b/images/platformer/backgrounds/mario_mountains.jpg new file mode 100644 index 0000000..8fc8888 Binary files /dev/null and b/images/platformer/backgrounds/mario_mountains.jpg differ diff --git a/images/platformer/backgrounds/mountains.jpg b/images/platformer/backgrounds/mountains.jpg new file mode 100644 index 0000000..c32e6f7 Binary files /dev/null and b/images/platformer/backgrounds/mountains.jpg differ diff --git a/images/platformer/obstacles/brick_wall.png b/images/platformer/platforms/brick_wall.png similarity index 100% rename from images/platformer/obstacles/brick_wall.png rename to images/platformer/platforms/brick_wall.png