Demonstrate a top-down camera with a canvas.
WIP: Generating with constant feedback from ChatGPT 4o.
| class Vector2D { | |
| constructor(x, y) { | |
| this.x = x; | |
| this.y = y; | |
| } | |
| // Add another vector to this vector | |
| add(other) { | |
| return new Vector2D(this.x + other.x, this.y + other.y); | |
| } | |
| // Subtract another vector from this vector | |
| subtract(other) { | |
| return new Vector2D(this.x - other.x, this.y - other.y); | |
| } | |
| // Scale the vector by a scalar | |
| scale(scalar) { | |
| return new Vector2D(this.x * scalar, this.y * scalar); | |
| } | |
| // Ensure the vector stays within bounds | |
| clamp(min, max) { | |
| return new Vector2D( | |
| Math.max(min.x, Math.min(this.x, max.x)), | |
| Math.max(min.y, Math.min(this.y, max.y)) | |
| ); | |
| } | |
| } | |
| // GameObject Superclass | |
| class GameObject { | |
| constructor({ position, size, color = "white" }) { | |
| this.position = | |
| position instanceof Vector2D | |
| ? position | |
| : new Vector2D(position.x, position.y); | |
| this.size = size; | |
| this.color = color; | |
| } | |
| isCollidingWith(other) { | |
| return !( | |
| this.position.x + this.size < other.position.x || | |
| this.position.x > other.position.x + other.size || | |
| this.position.y + this.size < other.position.y || | |
| this.position.y > other.position.y + other.size | |
| ); | |
| } | |
| draw(ctx, camera) { | |
| ctx.fillStyle = this.color; | |
| ctx.fillRect( | |
| this.position.x - camera.position.x, | |
| this.position.y - camera.position.y, | |
| this.size, | |
| this.size | |
| ); | |
| } | |
| drawOnMinimap(ctx, minimapScale, minimapX, minimapY) { | |
| const scaledPosition = new Vector2D(minimapX, minimapY).add( | |
| this.position.scale(minimapScale) | |
| ); | |
| const scaledSize = this.size * minimapScale; | |
| ctx.fillStyle = this.color; | |
| ctx.fillRect(scaledPosition.x, scaledPosition.y, scaledSize, scaledSize); | |
| } | |
| } | |
| // Player Class | |
| class Player extends GameObject { | |
| constructor({ position, size }) { | |
| super({ position, size, color: "cyan" }); | |
| this.speed = 5; // Movement speed | |
| } | |
| update(inputHandler, map) { | |
| const movement = new Vector2D(0, 0); | |
| if (inputHandler.isActionActive("moveUp")) movement.y -= this.speed; | |
| if (inputHandler.isActionActive("moveLeft")) movement.x -= this.speed; | |
| if (inputHandler.isActionActive("moveDown")) movement.y += this.speed; | |
| if (inputHandler.isActionActive("moveRight")) movement.x += this.speed; | |
| const newPosition = this.position.add(movement); | |
| // Check collisions with obstacles | |
| if (!map.isCollidingWithObstacles(newPosition, this.size)) { | |
| this.position = newPosition; | |
| } | |
| // Check bounds after updating position | |
| map.checkBounds(this); | |
| } | |
| } | |
| // Obstacle Class | |
| class Obstacle extends GameObject { | |
| constructor({ position, size }) { | |
| super({ position, size, color: "red" }); | |
| } | |
| draw(ctx, camera) { | |
| super.draw(ctx, camera); | |
| } | |
| } | |
| // Camera Class | |
| class Camera extends GameObject { | |
| constructor({ width, height }) { | |
| super({ position: new Vector2D(0, 0), size: 0 }); | |
| this.width = width; | |
| this.height = height; | |
| } | |
| follow(target, mapWidth, mapHeight) { | |
| this.target = target; | |
| this.mapWidth = mapWidth; | |
| this.mapHeight = mapHeight; | |
| } | |
| update() { | |
| if (this.target) { | |
| // Scale width and height by 0.5 (equivalent to dividing by 2), then subtract | |
| const halfSize = new Vector2D(this.width, this.height).scale(0.5); | |
| // Set the camera position by subtracting the scaled vector from the target position | |
| this.position = this.target.position.subtract(halfSize); | |
| // Keep the camera within bounds | |
| this.position = this.position.clamp( | |
| new Vector2D(0, 0), // Minimum position | |
| new Vector2D(this.mapWidth - this.width, this.mapHeight - this.height) // Maximum position | |
| ); | |
| } | |
| } | |
| } | |
| // InputHandler Class | |
| class InputHandler { | |
| constructor({ keymap }) { | |
| this.keys = new Set(); | |
| this.actions = new Set(); | |
| // Create and store the reverse keymap as a property of InputHandler | |
| this.reverseKeymap = this.buildReverseKeymap(keymap); | |
| // Bind keydown and keyup events inline in the constructor | |
| window.addEventListener("keydown", (e) => this.handleKeydown(e)); | |
| window.addEventListener("keyup", (e) => this.handleKeyup(e)); | |
| } | |
| // Helper method to create the reverse keymap | |
| buildReverseKeymap(keymap) { | |
| const reverseKeymap = {}; | |
| Object.entries(keymap).forEach(([action, keys]) => { | |
| keys.forEach((key) => { | |
| reverseKeymap[key] = action; | |
| }); | |
| }); | |
| return reverseKeymap; | |
| } | |
| handleKeydown(e) { | |
| const action = this.reverseKeymap[e.key]; | |
| if (action) { | |
| this.actions.add(action); | |
| } | |
| } | |
| handleKeyup(e) { | |
| const action = this.reverseKeymap[e.key]; | |
| if (action) { | |
| this.actions.delete(action); | |
| } | |
| } | |
| isActionActive(action) { | |
| return this.actions.has(action); | |
| } | |
| } | |
| // Renderer Class | |
| class Renderer { | |
| constructor({ context, minimap }) { | |
| this.ctx = context; | |
| this.minimap = minimap; | |
| } | |
| render(map, player, camera) { | |
| this.ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Draw the map and obstacles | |
| map.draw(this.ctx, camera); | |
| // Draw the player | |
| player.draw(this.ctx, camera); | |
| // Draw the minimap | |
| this.minimap.draw(this.ctx); | |
| } | |
| } | |
| // Map Class | |
| class Map { | |
| constructor({ width, height, obstacles }) { | |
| this.width = width; | |
| this.height = height; | |
| this.obstacles = obstacles; | |
| } | |
| checkBounds(object) { | |
| object.position = object.position.clamp( | |
| new Vector2D(0, 0), | |
| new Vector2D(this.width - object.size, this.height - object.size) | |
| ); | |
| } | |
| isCollidingWithObstacles(position, size) { | |
| return this.obstacles.some((obstacle) => { | |
| return !( | |
| position.x + size < obstacle.position.x || | |
| position.x > obstacle.position.x + obstacle.size || | |
| position.y + size < obstacle.position.y || | |
| position.y > obstacle.position.y + obstacle.size | |
| ); | |
| }); | |
| } | |
| draw(ctx, camera) { | |
| const gridSize = 50; | |
| ctx.fillStyle = "#222"; | |
| ctx.fillRect(0, 0, this.width, this.height); | |
| ctx.strokeStyle = "#444"; | |
| ctx.lineWidth = 1; | |
| for (let x = 0; x < this.width; x += gridSize) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x - camera.position.x, 0 - camera.position.y); | |
| ctx.lineTo(x - camera.position.x, this.height - camera.position.y); | |
| ctx.stroke(); | |
| } | |
| for (let y = 0; y < this.height; y += gridSize) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0 - camera.position.x, y - camera.position.y); | |
| ctx.lineTo(this.width - camera.position.x, y - camera.position.y); | |
| ctx.stroke(); | |
| } | |
| this.obstacles.forEach((obstacle) => obstacle.draw(ctx, camera)); | |
| } | |
| } | |
| // Minimap Class | |
| class Minimap { | |
| constructor({ map, player, size = 64 }) { | |
| this.map = map; | |
| this.player = player; | |
| this.size = size; | |
| } | |
| draw(ctx) { | |
| const minimapX = canvas.width - this.size - 10; | |
| const minimapY = 10; | |
| ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; | |
| ctx.fillRect(minimapX, minimapY, this.size, this.size); | |
| ctx.strokeStyle = "yellow"; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(minimapX - 1, minimapY - 1, this.size + 2, this.size + 2); | |
| const scale = this.size / this.map.width; | |
| this.map.obstacles.forEach((obstacle) => { | |
| obstacle.drawOnMinimap(ctx, scale, minimapX, minimapY); | |
| }); | |
| this.player.drawOnMinimap(ctx, scale, minimapX, minimapY); | |
| } | |
| } | |
| // Scene Class | |
| class Scene { | |
| constructor({ player, map, camera }) { | |
| this.player = player; | |
| this.map = map; | |
| this.camera = camera; | |
| } | |
| update(inputHandler) { | |
| this.player.update(inputHandler, this.map); | |
| this.camera.update(); | |
| } | |
| draw(renderer) { | |
| renderer.render(this.map, this.player, this.camera); | |
| } | |
| } | |
| class Game { | |
| constructor(config) { | |
| const mapWidth = config.map.width || 2000; | |
| const mapHeight = config.map.height || 2000; | |
| const playerConfig = config.player || { | |
| position: new Vector2D(100, 100), | |
| size: 20, | |
| }; | |
| const obstacleCount = config.map.obstacleCount || 10; | |
| this.player = new Player(playerConfig); | |
| this.obstacles = this.generateObstacles(obstacleCount, mapWidth, mapHeight); | |
| this.map = new Map({ | |
| width: mapWidth, | |
| height: mapHeight, | |
| obstacles: this.obstacles, | |
| }); | |
| this.camera = new Camera({ width: canvas.width, height: canvas.height }); | |
| this.camera.follow(this.player, mapWidth, mapHeight); | |
| this.inputHandler = new InputHandler({ keymap: config.keymap }); | |
| this.minimap = new Minimap({ | |
| map: this.map, | |
| player: this.player, | |
| size: config.minimap.size || 64, | |
| }); | |
| this.renderer = new Renderer({ context: ctx, minimap: this.minimap }); | |
| this.scene = new Scene({ | |
| player: this.player, | |
| map: this.map, | |
| camera: this.camera, | |
| }); | |
| } | |
| generateObstacles(count, mapWidth, mapHeight) { | |
| const obstacles = []; | |
| for (let i = 0; i < count; i++) { | |
| const x = Math.random() * (mapWidth - 50); | |
| const y = Math.random() * (mapHeight - 50); | |
| const size = 20 + Math.random() * 40; | |
| obstacles.push(new Obstacle({ position: new Vector2D(x, y), size })); | |
| } | |
| return obstacles; | |
| } | |
| update() { | |
| this.scene.update(this.inputHandler); | |
| } | |
| draw() { | |
| this.scene.draw(this.renderer); | |
| } | |
| gameLoop() { | |
| this.update(); | |
| this.draw(); | |
| requestAnimationFrame(() => this.gameLoop()); | |
| } | |
| start() { | |
| this.gameLoop(); | |
| } | |
| } | |
| // Initialize and start the game | |
| const canvas = document.getElementById("gameCanvas"); | |
| const ctx = canvas.getContext("2d"); | |
| const config = { | |
| player: { | |
| position: new Vector2D(150, 150), | |
| size: 30, | |
| }, | |
| map: { | |
| width: 3000, | |
| height: 3000, | |
| obstacleCount: 20, | |
| }, | |
| minimap: { | |
| size: 64, | |
| }, | |
| keymap: { | |
| moveUp: ["w", "ArrowUp"], | |
| moveLeft: ["a", "ArrowLeft"], | |
| moveDown: ["s", "ArrowDown"], | |
| moveRight: ["d", "ArrowRight"], | |
| }, | |
| }; | |
| const game = new Game(config); | |
| game.start(); |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Pixelated Game</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| height: 100vh; | |
| background-color: black; | |
| } | |
| canvas { | |
| /* Ensure canvas scales uniformly */ | |
| width: 640px; | |
| height: 360px; | |
| /* Make the canvas pixelated */ | |
| image-rendering: optimizeSpeed; /* Older versions of FF */ | |
| image-rendering: -moz-crisp-edges; /* FF */ | |
| image-rendering: -webkit-optimize-contrast; /* Safari */ | |
| image-rendering: -o-crisp-edges; /* Opera */ | |
| image-rendering: pixelated; /* Chrome */ | |
| image-rendering: crisp-edges; /* CSS3 */ | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="gameCanvas" width="320" height="180"></canvas> | |
| <script src="game.js"></script> | |
| </body> | |
| </html> |