Skip to content

Instantly share code, notes, and snippets.

@rmkane
Created October 17, 2024 04:21
Show Gist options
  • Save rmkane/8783c55ae9284dea3ffacfa9357adf59 to your computer and use it in GitHub Desktop.
Save rmkane/8783c55ae9284dea3ffacfa9357adf59 to your computer and use it in GitHub Desktop.
JS Top-Down Camera

JS Top-Down Camera

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment