Skip to content

Instantly share code, notes, and snippets.

@andrewfrench
Last active October 13, 2023 19:17
Show Gist options
  • Select an option

  • Save andrewfrench/110d07ea36a619dcd0a62bafaebee183 to your computer and use it in GitHub Desktop.

Select an option

Save andrewfrench/110d07ea36a619dcd0a62bafaebee183 to your computer and use it in GitHub Desktop.
PID Camera Demo Code
/*
// Compile this Typescript file to Javascript with Webpack.
// Here's the webpack.config.js I used.
const path = require('path');
module.exports = {
entry: './pid-elastic-camera.ts',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
mode: "production",
};
*/
/* Input control */
const KeyRightArrow = "ArrowRight";
const KeyLeftArrow = "ArrowLeft";
const KeyDownArrow = "ArrowDown";
const KeyUpArrow = "ArrowUp";
const KeyA = "KeyA";
const KeyB = "KeyB";
const KeyD = "KeyD";
const KeyR = "KeyR";
const KeyS = "KeyS";
const KeyW = "KeyW";
class Input {
keys: Map<string, boolean>;
constructor() {
this.keys = new Map<string, boolean>();
this.keys.set(KeyRightArrow, false);
this.keys.set(KeyLeftArrow, false);
this.keys.set(KeyDownArrow, false);
this.keys.set(KeyUpArrow, false);
this.keys.set(KeyA, false);
this.keys.set(KeyB, false);
this.keys.set(KeyD, false);
this.keys.set(KeyR, false);
this.keys.set(KeyS, false);
this.keys.set(KeyW, false);
}
rotateCW(): boolean {
return this.keys.get(KeyRightArrow) || this.keys.get(KeyD);
}
rotateCCW(): boolean {
return this.keys.get(KeyLeftArrow) || this.keys.get(KeyA);
}
forward(): boolean {
return this.keys.get(KeyUpArrow) || this.keys.get(KeyW);
}
reverse(): boolean {
return this.keys.get(KeyDownArrow) || this.keys.get(KeyS);
}
reset(): boolean {
return this.keys.get(KeyR);
}
}
/* Camera control */
class Camera {
vector: Vector;
moves: boolean = false;
advance(dt: number, focus: Vector) {
// Do nothing
}
setFocus(focus: Vector) {
this.vector = new Vector(focus.x, focus.y, focus.dx, focus.dy);
}
}
class TrackingCamera extends Camera {
constructor() {
super();
this.moves = true;
}
advance(dt: number, focus: Vector) {
this.vector = new Vector(focus.x, focus.y, focus.dx, focus.dy);
}
}
class ElasticCamera extends Camera {
// PID coefficients.
private cp: number;
private ci: number;
private cd: number;
// Integral accumulators for x and y axes.
private xacc: number = 0;
private yacc: number = 0;
constructor(cp: number, ci: number, cd: number) {
super();
this.moves = true;
this.cp = cp;
this.ci = ci;
this.cd = cd;
}
advance(dt: number, focus: Vector) {
// Calculate the proportional term for x and y axes.
let ptx = this.cp * (focus.x - this.vector.x);
let pty = this.cp * (focus.y - this.vector.y);
// Calculate the integral term for x and y axes.
let itx = this.ci * (this.xacc + focus.x - this.vector.x);
let ity = this.ci * (this.yacc + focus.y - this.vector.y);
// Calculate the differential term for x and y axes.
let dtx = this.cd * (focus.dx - this.vector.dx);
let dty = this.cd * (focus.dy - this.vector.dy);
// Combine terms to calculate dx and dy.
let dx = ptx + itx + dtx;
let dy = pty + ity + dty;
// Add dx and dy to camera vector.
this.vector.dx += dx;
this.vector.dy += dy;
// Calculate new camera location.
this.vector.x += dt * this.vector.dx;
this.vector.y += dt * this.vector.dy;
}
}
/* Game control */
export class Game {
ship: Ship;
input: Input;
gameCanvas: HTMLCanvasElement;
gameCtx: CanvasRenderingContext2D;
parallaxCanvas: HTMLCanvasElement;
gameWidth: number;
gameHeight: number;
camera: Camera;
constructor(gameCanvas: HTMLCanvasElement, parallaxCanvas: HTMLCanvasElement, input: Input, camera: Camera, width: number, height: number) {
this.gameCanvas = gameCanvas;
this.gameCtx = this.gameCanvas.getContext("2d");
this.parallaxCanvas = parallaxCanvas;
this.input = input;
this.gameWidth = width;
this.gameHeight = height;
this.camera = camera;
this.ship = new LightShip(this.gameCanvas.width / 2, this.gameCanvas.height / 2);
this.camera.setFocus(this.ship.vector);
}
draw(dt: number) {
// central coordinates
let leftOffset = 0;
let topOffset = 0;
if (this.camera.moves) {
leftOffset = this.camera.vector.x - (this.gameCanvas.width / 2);
topOffset = this.camera.vector.y - (this.gameCanvas.height / 2);
}
let parallaxOffsetX = -1 * this.gameCanvas.width * this.camera.vector.x / this.gameWidth;
let parallaxOffsetY = -1 * this.gameCanvas.height * this.camera.vector.y / this.gameHeight;
// Clear the canvas
this.gameCtx.clearRect(0, 0, this.gameCanvas.width, this.gameCanvas.height);
// Draw background
this.gameCtx.drawImage(this.parallaxCanvas, parallaxOffsetX, parallaxOffsetY);
// Draw ship
this.ship.draw(this.gameCtx, leftOffset, topOffset, this.input.forward());
}
update(t: number, dt: number) {
if (this.input.rotateCCW()) {
this.ship.rotateCCW(dt);
}
if (this.input.rotateCW()) {
this.ship.rotateCW(dt);
}
if (this.input.forward()) {
this.ship.forward(dt);
}
if (this.input.reverse()) {
this.ship.reverse(dt);
}
if (this.input.reset()) {
this.ship.vector = new Vector(100, 100, 0, 0);
this.camera.setFocus(this.ship.vector);
}
// Update spaceship position
this.ship.vector.x += dt * this.ship.vector.dx;
this.ship.vector.y += dt * this.ship.vector.dy;
// Wrap around screen edges
if (this.ship.vector.x < 0) {
this.ship.vector.x += this.gameWidth;
if (this.camera.moves) {
this.camera.vector.x += this.gameWidth;
}
}
if (this.ship.vector.y < 0) {
this.ship.vector.y += this.gameHeight;
if (this.camera.moves) {
this.camera.vector.y += this.gameHeight;
}
}
if (this.ship.vector.x >= this.gameWidth) {
this.ship.vector.x -= this.gameWidth;
if (this.camera.moves) {
this.camera.vector.x -= this.gameWidth;
}
}
if (this.ship.vector.y >= this.gameHeight) {
this.ship.vector.y -= this.gameHeight;
if (this.camera.moves) {
this.camera.vector.y -= this.gameHeight;
}
}
this.camera.advance(dt, this.ship.vector);
}
}
/* Ship */
class Ship {
heading: Angle;
vector: Vector;
rotationSpeed: number;
thrust: number;
maxSpeed: number;
shapePoints: number[][];
thrusterPoints: number[][];
constructor(x: number, y: number, rotationSpeed: number, thrust: number, maxSpeed: number, shapePoints: number[][], thrusterPoints: number[][]) {
this.heading = new Angle(0);
this.vector = new Vector(x, y, 0, 0);
this.rotationSpeed = rotationSpeed;
this.thrust = thrust;
this.maxSpeed = maxSpeed;
this.shapePoints = shapePoints;
this.thrusterPoints = thrusterPoints;
}
rotateCW(dt: number) {
this.heading = this.heading.add(new Angle(dt * this.rotationSpeed));
}
rotateCCW(dt: number) {
this.heading = this.heading.sub(new Angle(dt * this.rotationSpeed));
}
forward(dt: number) {
this.vector = this.vector.addPolar(this.thrust, this.heading);
// Check and limit the velocity
if (this.vector.magnitude() > this.maxSpeed) {
this.vector = this.vector.withMagnitude(this.maxSpeed);
}
}
reverse(dt: number) {
// rotate until heading is reverse vector angle
let d = (this.heading.radians - this.vector.angle().reverse().radians + TAU) % TAU;
if (Math.abs(d) < 0.2) {
this.heading = this.vector.angle().reverse();
return
}
if (d < Math.PI) {
this.heading = this.heading.sub(new Angle(dt * this.rotationSpeed));
} else {
this.heading = this.heading.add(new Angle(dt * this.rotationSpeed));
}
}
draw(ctx: CanvasRenderingContext2D, leftOffset: number, topOffset: number, thrusting: boolean) {
let x = this.vector.x - leftOffset;
let y = this.vector.y - topOffset;
ctx.save();
ctx.translate(x, y);
ctx.rotate(this.heading.radians);
if (thrusting) {
ctx.beginPath();
for (let i = 0; i < this.thrusterPoints.length; i++) {
ctx.arc(this.thrusterPoints[i][0], this.thrusterPoints[i][1], jitterAbs(4, 0.1), 0, Math.PI * 2);
}
ctx.fillStyle = "rgba(" + jitterAbs(200, 50) + "," + jitterAbs(200, 50) + "," + jitterAbs(230, 26) + "," + jitterAbs(0.8, 0.2) + ")";
ctx.fill();
}
ctx.beginPath();
ctx.moveTo(0, 0);
for (let i = 0; i < this.shapePoints.length; i++) {
ctx.lineTo(this.shapePoints[i][0], this.shapePoints[i][1]);
}
ctx.closePath();
ctx.fillStyle = "white";
ctx.fill();
ctx.restore();
}
}
class LightShip extends Ship {
constructor(x: number, y: number) {
let rotationSpeed = 0.003;
let thrust = 0.01;
let maxSpeed = 0.3;
let shapePoints = [
[15, 0],
[-15, 10],
[-15, -10],
[15, 0],
];
let thrusterPoints = [
[-17, 6],
[-17, -6],
];
super(x, y, rotationSpeed, thrust, maxSpeed, shapePoints, thrusterPoints);
}
}
/* Utils */
export function jitterPct(value: number, deviation: number): number {
return value + (value * ((Math.random() * deviation) - (deviation / 2)))
}
export function jitterAbs(value: number, deviation: number): number {
return value + ((Math.random() * deviation) - (deviation / 2));
}
/* Vector */
const TAU = 2*Math.PI;
class Angle {
radians: number;
constructor(radians: number) {
this.radians = (radians + TAU) % TAU;
}
add(angle: Angle): Angle {
return new Angle(this.radians + angle.radians);
}
sub(angle: Angle): Angle {
return new Angle(this.radians - angle.radians);
}
reverse(): Angle {
return new Angle(this.radians + Math.PI);
}
degrees(): number {
return this.radians * 180 / Math.PI;
}
}
class Vector {
x: number;
y: number;
dx: number;
dy: number;
constructor(x: number, y: number, dx: number, dy: number) {
this.x = x;
this.y = y;
this.dx = dx;
this.dy = dy;
}
magnitude(): number {
return Math.sqrt(this.dx**2 + this.dy**2);
}
angle(): Angle {
// atan2 returns a result from -π to π, and it correctly handles all quadrants
let radians = Math.atan2(this.dy, this.dx);
// Adjust the result to be in the range 0 to 2π
if (radians < 0) {
radians += TAU;
}
return new Angle(radians);
}
addCartesian(dx: number, dy: number): Vector {
return new Vector(this.x, this.y, this.dx + dx, this.dy + dy);
}
addPolar(magnitude: number, angle: Angle): Vector {
let dx = magnitude * Math.cos(angle.radians);
let dy = magnitude * Math.sin(angle.radians);
return new Vector(this.x, this.y, this.dx + dx, this.dy + dy);
}
rotate(angle: Angle): Vector {
return new PolarVector(this.x, this.y, this.magnitude(), this.angle().add(angle));
}
withMagnitude(magnitude: number): Vector {
return new PolarVector(this.x, this.y, magnitude, this.angle());
}
withAngle(angle: Angle): Vector {
return new PolarVector(this.x, this.y, this.magnitude(), angle);
}
}
class PolarVector extends Vector {
constructor(x: number, y: number, magnitude: number, angle: Angle) {
let dx = magnitude * Math.cos(angle.radians);
let dy = magnitude * Math.sin(angle.radians);
super(x, y, dx, dy);
}
}
/* Setup */
// Generate parralax background.
function drawBackground(parallaxCanvas: HTMLCanvasElement, starCanvas: HTMLCanvasElement) {
const STAR_COUNT = 1000; // number of stars in the background
const STAR_COLORS = ["#ffffff", "#ffd2a1", "#ff6622", "#8899ff"];
let parallaxCtx = parallaxCanvas.getContext("2d");
let starCtx = starCanvas.getContext("2d");
// Clear background
parallaxCtx.clearRect(0, 0, parallaxCanvas.width, parallaxCanvas.height);
for (let i = 0; i < STAR_COUNT; i++) {
let x = Math.random() * starCanvas.width;
let y = Math.random() * starCanvas.height;
let radius = Math.random() * 1.6;
// Select a random color
let color = "";
let rval = Math.random();
if (rval < 0.76) {
color = STAR_COLORS[0];
} else if (rval < 0.85) {
color = STAR_COLORS[1];
} else if (rval < 0.95) {
color = STAR_COLORS[2];
} else {
color = STAR_COLORS[3];
}
// Randomize brightness
let brightness = Math.random() * 0.8 + 0.2; // range [0.2, 1]
starCtx.beginPath();
starCtx.arc(x, y, radius, 0, Math.PI * 2);
starCtx.fillStyle = color + Math.round(brightness * 255).toString(16);
starCtx.fill();
}
for (let i = 0; i < 2; i++) {
for (let j = 0; j < 2; j++) {
parallaxCtx.drawImage(starCanvas, i * starCanvas.width, j * starCanvas.height);
}
}
}
// Create an off-screen canvas for the background.
// Re-used by every game instance.
let parallaxCanvas = document.createElement("canvas");
let starCanvas = document.createElement("canvas");
// Initialize input handler.
let input = new Input();
// Get game canvases.
let demoCanvasStaticCamera: HTMLCanvasElement = document.getElementById("gameDemo_staticCamera") as HTMLCanvasElement;
let demoCanvasTrackingCamera: HTMLCanvasElement = document.getElementById("gameDemo_trackingCamera") as HTMLCanvasElement;
let demoCanvasElasticCamera1: HTMLCanvasElement = document.getElementById("gameDemo_elasticCamera1") as HTMLCanvasElement;
let demoCanvasElasticCamera2: HTMLCanvasElement = document.getElementById("gameDemo_elasticCamera2") as HTMLCanvasElement;
let demoCanvasElasticCamera3: HTMLCanvasElement = document.getElementById("gameDemo_elasticCamera3") as HTMLCanvasElement;
// Create demo cameras.
let demoCameraStatic = new Camera();
let demoCameraTracking = new TrackingCamera();
let demoCameraElastic1 = new ElasticCamera(0.0001, 0.0001, 0.005);
let demoCameraElastic2 = new ElasticCamera(0.002, 0.01, 0.05);
let demoCameraElastic3 = new ElasticCamera(0.0001, 0, 0.12);
// Create game instances.
let games: Game[] = [
new Game(demoCanvasStaticCamera, parallaxCanvas, input, demoCameraStatic, demoCanvasStaticCamera.width, demoCanvasStaticCamera.height),
new Game(demoCanvasTrackingCamera, parallaxCanvas, input, demoCameraTracking, 1000, 1000),
new Game(demoCanvasElasticCamera1, parallaxCanvas, input, demoCameraElastic1, 1000, 1000),
new Game(demoCanvasElasticCamera2, parallaxCanvas, input, demoCameraElastic2, 1000, 1000),
new Game(demoCanvasElasticCamera3, parallaxCanvas, input, demoCameraElastic3, 1000, 1000),
];
// Initial setup
function setup() {
starCanvas.width = demoCanvasStaticCamera.width;
starCanvas.height = demoCanvasStaticCamera.height;
parallaxCanvas.width = 2*demoCanvasStaticCamera.width;
parallaxCanvas.height = 2*demoCanvasStaticCamera.height;
// Add key event listeners
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
drawBackground(parallaxCanvas, starCanvas);
}
// Define handler for key presses.
function handleKeyDown(event: KeyboardEvent) {
if (input.keys.has(event.code)) {
input.keys.set(event.code, true);
event.preventDefault();
}
}
// Define handler for key releases.
function handleKeyUp(event: KeyboardEvent) {
if (input.keys.has(event.code)) {
input.keys.set(event.code, false);
event.preventDefault();
}
}
function loop() {
let nt = performance.now()
let dt = nt - t
t = nt
for (let i = 0; i < games.length; i++) {
games[i].update(t, dt);
games[i].draw(dt);
}
// Request next frame
requestAnimationFrame(loop);
}
// Start timer.
let t = performance.now();
// Call setup function.
setup();
// Start game loop.
loop();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment