Lesson: Projectiles
What is a Projectile?
In games, projectiles are objects that move through space, often used to simulate bullets, missiles, magic spells, or any other flying object that interacts with the game world. They typically:
- Have a starting point and a direction.
- Move independently after being launched.
- Collide with objects like enemies, players, or terrain.
- May have behaviors like homing, bouncing, or exploding.
In this lesson, we focus on a homing projectile — a type of projectile that adjusts its direction over time to follow a target. This behavior is commonly used for heat-seeking missiles, guided spells, or tracking arrows in games.
Constructor: constructor(x, y, target, gameEnv, speed = 5, turnRate = 0.08)
This method initializes a homing projectile with the following behavior and attributes:
x
,y
: Starting coordinates of the projectile.target
: An object the projectile will follow (must have aposition
property).gameEnv
: The game environment, used for rendering and object tracking.speed
: How fast the projectile travels.turnRate
: How quickly it can turn toward its target.radius
: Collision detection and visual size.color
: Visual color ("red"
).active
: State flag that tracks whether the projectile should still exist.
Initial velocity is computed by calculating the angle between the projectile and its target using Math.atan2
. This determines the direction the projectile starts moving in.
If the target
is invalid or has no .position
, a warning is logged, and the projectile is marked inactive.
update()
Method
update() {
if (!this.active) {
this.markForRemoval();
return;
}
if (!this.target || !this.target.position) {
this.active = false;
this.markForRemoval();
return;
}
// Verify position is valid
if (isNaN(this.position.x) || isNaN(this.position.y)) {
console.error(`Invalid projectile position: x=${this.position.x}, y=${this.position.y}`);
this.markForRemoval();
return;
}
const dx = this.target.position.x - this.position.x;
const dy = this.target.position.y - this.position.y;
// Check if we've hit the target (collision detection)
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.radius + 20) {
this.active = false;
console.log('Projectile hit target!');
this.markForRemoval();
return;
}
// Homing logic
const targetAngle = Math.atan2(dy, dx);
const currentAngle = Math.atan2(this.velocity.y, this.velocity.x);
let angleDiff = targetAngle - currentAngle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
const newAngle = currentAngle + this.turnRate * angleDiff;
this.velocity.x = Math.cos(newAngle) * this.speed;
this.velocity.y = Math.sin(newAngle) * this.speed;
// Update position
this.position.x += this.velocity.x;
this.position.y += this.velocity.y;
// Check bounds
if (this.gameEnv && (this.position.x < -50 || this.position.x > this.gameEnv.innerWidth + 50 ||
this.position.y < -50 || this.position.y > this.gameEnv.innerHeight + 50)) {
this.active = false;
this.markForRemoval();
return;
}
// Draw the projectile
this.draw();
}
This method should be called on each frame to handle logic and movement.
- State and validity checks:
- If the projectile is inactive, remove it.
- If the target is lost or has no
.position
, deactivate and remove. - If position values become
NaN
, log an error and remove.
- Collision detection:
- Compute the distance to the target.
- If the projectile is within
(this.radius + 20)
units, it “hits” the target and is removed.
- Homing logic:
- Determine the
targetAngle
and current velocity angle usingMath.atan2
. - Calculate
angleDiff
and normalize it to between-π
andπ
. - Adjust the current angle by a small amount (
this.turnRate * angleDiff
). - Update velocity accordingly using
Math.cos
andMath.sin
.
- Determine the
- Movement:
- Add the velocity to the projectile’s current position to move it.
- Bounds check:
- If the projectile goes offscreen (with a margin of 50 pixels), it is removed.
- Rendering:
- Call
this.draw()
to render the projectile on the canvas.
- Call
draw()
Method
draw() {
if (!this.active) {
return;
}
const ctx = this.gameEnv?.ctx;
if (!ctx) {
return;
}
ctx.save();
// Draw the projectile with a visible design
ctx.beginPath();
ctx.arc(this.position.x, this.position.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
// Add a black border
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.stroke();
// Add a white center for better visibility
ctx.beginPath();
ctx.arc(this.position.x, this.position.y, this.radius - 4, 0, Math.PI * 2);
ctx.fillStyle = 'white';
ctx.fill();
ctx.restore();
}
This method handles rendering the projectile using the canvas 2D API.
- Retrieves the canvas context from
this.gameEnv.ctx
. - Draws a red circle at the projectile’s current position.
- Adds a black outline and a white inner circle for visual clarity.
markForRemoval()
Method
markForRemoval() {
this.active = false;
if (this.gameEnv && this.gameEnv.gameObjects) {
const index = this.gameEnv.gameObjects.indexOf(this);
if (index > -1) {
this.gameEnv.gameObjects.splice(index, 1);
}
}
}
shouldRemove()
Method
shouldRemove() {
return !this.active;
}
Returns true
if the projectile is inactive and should be removed by the game loop.
destroy()
Method
Forces the projectile to become inactive. Can be used for manual removal or special effects.
destroy() {
this.active = false;
}
Hack: Add a Speed Boost When the Target Gets Close!
When the projectiles is close to the target, make it ‘lock on’ so it speeds up
A few other files were edited. If you can complete the hacks without asking me for help or the files… YOU GET EXTRA CREDIT!
WILL NOT BE GRADING UNTIL TUESDAY
UNDEDITED CODE
%%js
class HomingProjectile {
constructor(x, y, target, gameEnv, speed = 5, turnRate = 0.08) {
this.gameEnv = gameEnv;
this.target = target;
this.speed = speed;
this.turnRate = turnRate;
this.radius = 10;
this.baseRadius = 10;
this.color = "red";
this.active = true;
this.exploding = false;
this.impactFrames = 0;
this.position = { x: x, y: y };
if (!target || !target.position) {
console.warn('HomingProjectile: Invalid target provided');
this.active = false;
return;
}
const dx = target.position.x - x;
const dy = target.position.y - y;
const angle = Math.atan2(dy, dx);
this.velocity = {
x: Math.cos(angle) * speed,
y: Math.sin(angle) * speed
};
console.log(`Projectile created at (${Math.round(x)}, ${Math.round(y)}) targeting ${target.spriteData?.id || 'Unknown'}`);
}
update() {
if (!this.active && !this.exploding) {
this.markForRemoval();
return;
}
if (this.exploding) {
this.impactFrames++;
if (this.impactFrames > 30) {
this.markForRemoval();
}
this.draw();
return;
}
if (!this.target || !this.target.position) {
this.active = false;
this.markForRemoval();
return;
}
if (isNaN(this.position.x) || isNaN(this.position.y)) {
console.error(`Invalid projectile position: x=${this.position.x}, y=${this.position.y}`);
this.markForRemoval();
return;
}
const dx = this.target.position.x - this.position.x;
const dy = this.target.position.y - this.position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.radius + 20) {
this.active = false;
this.exploding = true;
this.impactFrames = 0;
console.log('Projectile hit target!');
return;
}
const targetAngle = Math.atan2(dy, dx);
const currentAngle = Math.atan2(this.velocity.y, this.velocity.x);
let angleDiff = targetAngle - currentAngle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
const newAngle = currentAngle + this.turnRate * angleDiff;
this.velocity.x = Math.cos(newAngle) * this.speed;
this.velocity.y = Math.sin(newAngle) * this.speed;
this.position.x += this.velocity.x;
this.position.y += this.velocity.y;
if (this.gameEnv && (this.position.x < -50 || this.position.x > this.gameEnv.innerWidth + 50 ||
this.position.y < -50 || this.position.y > this.gameEnv.innerHeight + 50)) {
this.active = false;
this.markForRemoval();
return;
}
this.draw();
}
draw() {
const ctx = this.gameEnv?.ctx;
if (!ctx) return;
ctx.save();
if (this.exploding) {
const alpha = 1 - (this.impactFrames / 30);
const explosionRadius = this.radius + this.impactFrames * 1.5;
ctx.beginPath();
ctx.arc(this.position.x, this.position.y, explosionRadius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 100, 100, ${alpha})`;
ctx.fill();
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.lineWidth = 2;
ctx.stroke();
} else {
// Pulsing effect while moving
const pulse = Math.sin(Date.now() / 100) * 2;
const drawRadius = this.baseRadius + pulse;
ctx.beginPath();
ctx.arc(this.position.x, this.position.y, drawRadius, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(this.position.x, this.position.y, drawRadius - 4, 0, Math.PI * 2);
ctx.fillStyle = 'white';
ctx.fill();
}
ctx.restore();
}
markForRemoval() {
if (this.gameEnv?.gameObjects) {
const index = this.gameEnv.gameObjects.indexOf(this);
if (index > -1) {
this.gameEnv.gameObjects.splice(index, 1);
}
}
}
shouldRemove() {
return !this.active && !this.exploding;
}
destroy() {
this.active = false;
}
}
export default HomingProjectile;