6
\$\begingroup\$

So I'm making a 2d shoot'em up game as a way of learning canvas, and larger-scale programming than what I'm accustomed to. I'm a purely front-end web dev, I can't say I'm very experienced in this.

Anyway the way I spawn my enemies is like this : at xyz gameTime, a spawner object is pushed into the game. This spawner object add various enemies in various configurations, using that type of interface, I hope the code is self explanatory:

state.enemies.push(enemies[this.enemyType].add({
  pos: [this.pos[0], this.pos[1]],
  angle: this.angle,
  rotation: this.enemyRotation,
  path: this.path
}));

But I'm not here for game design review so let me talk about what I want advice on.

basically you can see my enemy module here: https://github.com/acezard/aceforceone/blob/develop/src/entities/enemies.js

Let me explain it bit by bit. This is the global config object that manages only the default settings of every enemy type. That is, it doesn't contain any vector/rotation/position infos, because these settings are injected at enemy creation.

// Config Object
var enemyConfig = {
  redBomber: {
    url: 'assets/images/enemy-xs-1.svg',
    pos: [0, 0],
    size: [75, 53],
    speed: 100,
    hitpoints: 10,
    ROF: 100,
    score: 100,
    burst: {
      amount: 3,
      delay: 1000,
      counter: 3
    },
  },

  scout: {
    url: 'assets/images/scout.png',
    pos: [0, 0],
    size: [50, 44],
    speed: 500,
    hitpoints: 2,
    score: 50
  },

  rotatingPlat: {
    url: 'assets/images/platpart.png',
    pos: [0, 0],
    size: [150, 44],
    speed: 30,
    hitpoints: 100,
    ROF: 200,
    score: 250,
    burst: {
      amount: 1000,
      delay: 1000,
      counter: 1000
    },
  },

  rogueLeader: {
    url: 'assets/images/rogueleader.svg',
    pos: [0, 0],
    size: [200, 89],
    speed: 100,
    hitpoints: 60,
    score: 300,
    ROF: 500,
  },

  drone: {
    url: 'assets/images/drone.svg',
    pos: [0, 0],
    size: [50, 62],
    speed: 300,
    hitpoints: 5,
    score: 75
  },
};

Next we have a global enemy constructor, managing every enemy type as a base. It contains a mix of the default settings shown above, and the what I call active settings, that is, arguments different for every enemy entity even if they are of the same type (like position). This constructor also have the basic methods and a blank shoot method, because each enemy type has his own implementation (doesn't seem right?).

// Base enemy constructor
var EnemyEntity = function(settingsDefault, settingsActive) {
  // Default
  this.active = true;
  this.speed = settingsDefault.speed;
  this.hitpoints = settingsDefault.hitpoints;
  this.lastFire = Date.now();
  this.score = settingsDefault.score;
  this.ROF = settingsDefault.ROF || null;
  this.maxHitpoints = settingsDefault.hitpoints;
  this.rotating = settingsDefault.rotating || null;
  if (settingsDefault.burst) {
    this.burst = {
      amount: settingsDefault.burst.amount,
      delay: settingsDefault.burst.delay,
      counter: settingsDefault.burst.counter
    };
  }
  this.sprite = new Sprite({
    url: settingsDefault.url,
    pos: settingsDefault.pos,
    size: settingsDefault.size,
    rotated: true
  });

  // Active
  this.angle = settingsActive.angle;
  this.pos = settingsActive.pos;
  this.radians = settingsActive.angle * Math.PI / 180;
  this.vector = [Math.cos(this.radians) * this.speed, Math.sin(this.radians) * this.speed];
  this.rotation = settingsActive.rotation || 0;
  this.path = settingsActive.path || null;
};

// Update method
EnemyEntity.prototype.shoot = function() {};

EnemyEntity.prototype.update = function(dt) {
  if (this.outOfBounds()) {
    this.active = false;
    return;
  }

  if (this.path == "angular") {
    paths.angular(this);
  }

  this.pos[0] += this.vector[0] * dt;
  this.pos[1] += this.vector[1] * dt;
};

EnemyEntity.prototype.outOfBounds = function() {
  return this.pos[1] > canvas.height || this.pos[0] < 0 || this.pos[0] > canvas.width;
};

// Draw method
EnemyEntity.prototype.render = function() {
    canvas.ctx.save();
    canvas.ctx.translate(this.pos[0], this.pos[1]);
    canvas.ctx.translate(this.sprite.size[0] / 2, this.sprite.size[1] / 2);
    canvas.ctx.rotate(Math.PI / 180 * this.rotation);
    this.sprite.render(canvas.ctx);
    canvas.ctx.restore();
};

This part is another constructor, used to create enemy factories. Basically its only role is to share an ADD method, used by every enemy factory.

// Factory constructor
var EnemyFactory = function() {};

EnemyFactory.prototype.add = function(settings) { 
  return new this.type(settings);
};

Now this is an example of what a specific enemy factory looks like. I put two so you can see how much WET code this is. Basically, we first create a constructor, by calling the EnemyEntity constructor with this, and with the active settings. We also get enemyentity methods with calling it as a prototype. The shoot function is different for each enemy.

Then we have the enemy factory per se, basically it's constructed from the EnemyFactory constructor, and giving the specific Enemy constructor as a type, so we then can use myEnemy.add(activeSettings);

// RedBomber
var RedBomber = function(settings) {
  EnemyEntity.call(this, enemyConfig.redBomber, settings);
};

RedBomber.prototype = Object.create(EnemyEntity.prototype);

RedBomber.prototype.shoot = function() {
  var now = Date.now();

  // If the enemy can shoot
  if (this.pos[1] > 0 && this.burst.counter && now - this.lastFire > this.ROF) {
    var x = this.pos[0] + this.sprite.size[0] / 2;
    var y = this.pos[1] + this.sprite.size[1] / 2;

    state.ebullets.push(weapons.red.addMissile({x: x, y: y, angle: 90}));
    state.ebullets.push(weapons.red.addMissile({x: x, y: y, angle: 80}));
    state.ebullets.push(weapons.red.addMissile({x: x, y: y, angle: 100}));

    this.burst.counter--;
    this.lastFire = now;
    return;
  } else

  if (!this.burst.counter && now - this.lastFire > this.burst.delay) {
    this.burst.counter = this.burst.amount;
  }
};

function RedBomberFactory () {};
RedBomberFactory.prototype = new EnemyFactory();
RedBomberFactory.prototype.type = RedBomber;

var redBomber = new RedBomberFactory();

// Rogue Leader
var RogueLeader = function(settings) {
  EnemyEntity.call(this, enemyConfig.rogueLeader, settings);
};

RogueLeader.prototype = Object.create(EnemyEntity.prototype);

RogueLeader.prototype.shoot = function() {
  var now = Date.now();

  // If the enemy can shoot
  if (this.pos[1] > 0 && now - this.lastFire > this.ROF) {
    var x = this.pos[0];
    var y = this.pos[1];

    state.ebullets.push(weapons.redRay.addMissile({x: x + this.sprite.size[0] * 0.3, y: y + this.sprite.size[1] * 0.6, angle: this.rotation - 90}));
    state.ebullets.push(weapons.redRay.addMissile({x: x + this.sprite.size[0] * 0.7, y: y + this.sprite.size[1] * 0.6, angle: this.rotation - 90}));

    this.lastFire = now;
  }
};

function RogueLeaderFactory () {};
RogueLeaderFactory.prototype = new EnemyFactory();
RogueLeaderFactory.prototype.type = RogueLeader;

var rogueLeader = new RogueLeaderFactory();

Then we exports our factory functions

module.exports = {
  redBomber: redBomber,
  scout: scout,
  rotatingPlat: rotatingPlat,
  rogueLeader: rogueLeader,
  drone: drone
};

What's the problem then ? As you can see there is a lot of repetition in my code, every new enemy type adds a LOT of code, but I only really need the shoot method. Though I'm not sure right now how I could manage that better and ask for your advice. Any other consideration of the code is of course much appreciated

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

From a medium review;

  • In 2022, I would now strongly encourage the use of the class and extends keywords

  • Object.assign is magic and saves you from mistakes.

  • I do not see the value of the factory for mobs

    • The bulk of the creation should be done in a factory, but this code does that in EnemyEntity
    • EnemyFactory.prototype.add is a lie, this function does not add
  • I do totally see a use in your code for a bullet factory, to reduce and re-use your code

  • This

    state.enemies.push(enemies[this.enemyType].add({
      pos: [this.pos[0], this.pos[1]],
      angle: this.angle,
      rotation: this.enemyRotation,
      path: this.path
    }));
    

    can now be

    state.enemies.push(enemies[this.enemyType].add({pos: {...pos},angle, rotation, path}));
    

Below here is a dumbed-down example of using classes, and skipping the whole factory thing;

var enemyConfig = {
  redBomber: {
    name: 'The red bomber'
  }
};

class EnemyEntity {

  constructor(defaultSettings, activeSettings){
    //This should save a ton of code
    Object.assign(this, defaultSettings);
    Object.assign(this, activeSettings);
    //Do more stuff
  }
  die(){
    console.log((this.name || 'The nameless enemy') + " dies!");
    console.log(this.lastWords || 'Death!');
  }
}

class RedBomber extends EnemyEntity{
  
  constructor(settings){
    super(enemyConfig.redBomber, settings);
  }
  
  shoot(){
    console.log('Pew pew');
  }
}

const state = {enemies:[]};

state.enemies.push(new RedBomber({lastWords: 'Alas!'}));

state.enemies.forEach(enemy => enemy.shoot());
state.enemies.forEach(enemy => enemy.die());

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.