I’m in the final stretch now, at least as far as project requirements go. It’s time to work on the game loop! Going to start with being able to create new Celestial instances on a user input. To make sure the player can adjust the moon before it’s animated, I need to add in some conditionals on the physics methods (also throwing in some spice for upcoming sections):
// In Celestial.js
class Celestial {
/** ...
* @arg {boolean} [options.physical=false] Can the object be manipulated?
* ...
*/
constructor(options) {
// ...
/** @var {boolean} physical Can the object be manipulated? */
this.physical = options.physical || false;
// ...
// In Physics#updateVelocities and Physics#updatePositions
(obj) =>
obj instanceof Celestial &&
obj.physical &&
// ...
This is only on the velocity and position updates, I want to be able to check
collisions with objects that aren’t Celestial
s. Honestly the rest of this is
going to be really boring, it’s basically one-for-one writing out what’s in the
pseudocode for the game loop regarding mouse events and, in turn, recreating
what’s in the original code for the old project with adaptations to the new
structure. Technically, I did it all in a single line… oops:
// Handle player input
mouse.isDown
? model.isCreating
? mouse.change.magnitude > 18
// Set velocity if mouse outside threshold
? (model.newborn.velocity = mouse.change
.copy()
.scale(Game.SCALE.velocity)
.reflectHorizontal()
.reflectVertical())
// Customize mass if mouse still in threshold
: model.customMassAllowed &&
(model.newborn.mass *= Game.SCALE.mass)
// Create new Celestial on mouse down
: model.health > 0 &&
(model.isCreating = true) &&
(model.newborn = new Celestial({
mass: 1.35e23,
position: this.view.origin
.copy()
.subtract(mouse.position)
.subtract(this.view.offset)
.scale(0 - this.view.scale),
size: 0.54e8,
})) &&
model.scene.push(model.newborn)
// Release newborn Celestial on mouse up
: model.isCreating &&
(model.newborn.physical = true) &&
(model.health -= 1) &&
(model.isCreating = false);
It was at this point that I realized I really, really need to just get this done, so everything from here one was pretty much thrown together in a frenzy and is far from what I want it to be. The rest of this blog entry will just be covering what I did1 to meet the requirements of the assignment in a single night; I will probably continue to work on this project in the future to make it look more like what I want it to be.
While a Celestial is a game object in the scene that acts as a physical object,
there are other objects that will need to exist in the scene to define certain
aspects in game play. Namely, I needed to be able to define some “Area” in the
scene, or view in general, that could be used generally to handle game
mechanics such as restricting the user input zone and setting win conditions if
the player achieves the desired goal. To that end, I wrote a simple class
to define an Area
:
class Area {
/**
* Create a new scene area game object.
* @arg {Point} position Position coordinates in meters.
* @arg {Point} size Size in meters.
* @arg {Object} options Optional parameters.
* @arg {string} [options.name="Unnamed Area"] Display name.
* @arg {string} [options.texture] The path of a texture for rendering.
*/
constructor(position, size, options){
this.name = options.name || "Unnamed Area";
this.birth = +new Date();
this.position = position;
this.size = size;
this.hitBox = "RECTANGLE";
this.collisions = [];
this.texture = options.texture || null;
}
}
Note that Area#size
is a Point
rather than a number
as in a Celestial#
,
because it is a rectangle defined by two points: $P_1$, the top-left corner
(Area#position
), and $P_2$, the bottom-right corner relative to $P_1$
(Area#size
). Since this is quite different from Celestial
instance styling,
I also needed to add in some handling for the different GameData#scene
element types in the Renderer
s like so:
// In Renderer.js
class Renderer {
// ...
getPosition(obj) {
const origin = this.origin,
position = obj.position.copy().scale(1 / this.scale),
offset = obj instanceof Celestial ? obj.size / (this.scale * 2) : 0;
return origin.add(position).subtract(offset);
}
// ...
// In DOMRenderer.js
class DOMRenderer extends Renderer {
// ...
render() {
const scene = this.model.scene;
scene.forEach(
(obj) => {
const elem = obj.element || this.generateElement(obj),
style = elem.style,
position = this.getPosition(obj);
[style.left, style.top, style.width, style.height] =
obj instanceof Celestial
? [
`${position.x}px`,
`${position.y}px`,
`${obj.size / this.scale}px`,
`${obj.size / this.scale}px`,
]
: [
`${position.x}px`,
`${position.y}px`,
`${obj.size.x / this.scale}px`,
`${obj.size.y / this.scale}px`,
];
// ...
generateElement(obj) {
const type = obj instanceof Celestial ? "celestial" : "viewarea",
element = document.createElement(type),
cleanName = obj.name.replace(/\s+/g, "-").toLowerCase();
element.classList.add(`gravity__${type}_${cleanName}`);
// ...
I also needed to add collision detection for something that isn’t a circle… this is where I got really lazy. I decided that, fuck it, I don’t need to check for actual collisions in this instance, I just need to check that the center of the circle is somewhere in a rectangular area that is orthogonal to the axes. Essentially, I made it trivial by making assumptions, this is not a generalized method in the slightest:2
static circleInRectangle(a, b) {
let p1 = a.position.copy(),
p2 = p1.copy().add(a.size),
c = b.position.copy();
return c.x > p1.x && c.x < p2.x && c.y > p1.y && c.y < p2.y;
}
But, hey, it works for what I need it to do. Of course this was then references
as Physics.INTERSECT__CHECKS.RECTANGLE.CIRCLE
and a quick function to reverse
the order of the arguments before calling it was used as ...CIRCLE.RECTANGLE
.
Easy. Though there was some extra debugging at this point to make sure that the
physics would work even if there were objects in the scene that it wasn’t
supposed to be considering, a simple matter of returning the accumulator
without manipulation in Array.reduce()
.
Then I realized that this function would be necessary for checking if the
user’s mouse is in an area, so I generalized it a bit in the opposite
direction: pointInRectangle
and updated the type of the arguments:
/**
* @arg {Point} point
* @arg {Area} rect
* @returns {boolean}
*/
static pointInRectangle(point, rect) {
let p1 = rect.position.copy(),
p2 = p1.copy().add(rect.size);
return point.x > p1.x && point.x < p2.x && point.y > p1.y && point.y < p2.y;
}
Once again, this was just implementing the bare minimum: the game message, the “lives” counter, and a link back to the source code, all simply added into index.html:
<div id="lives">x attempts remaining</div>
<div id="message">loading...</div>
<div id="repo">
<a href="https://github.com/una-ada/gravity">source code</a>
</div>
I did attempt to throw in a little bit of personality by using Una Script
as the typeface for all this.3 Styling wasn’t really worth mentioning beyond
that, just text size and position: absolute
essentially.
Out of desperation, reference to these elements was written in the
GameData
module rather than DOMRenderer
, thus why I wrote the tags out in
the first place. Anyway, they were just cached in the constructor:
/** @var {HTMLElement} healthDisplay Remaining attempts display element. */
this.healthDisplay = document.querySelector("div#health");
/** @var {HTMLElement} message Element displaying game message. */
this.message = document.querySelector("div#message");
and then the health display was updated with a simple template string, since
GameData#health
is already handled:
model.healthDisplay.textContent = `${Math.max(
0,
model.health
)} lives remaining.`;
Though do note here that use of Math.max()
; for some reason the
GameData#health
value ended up being -1
when all the attempts were used up,
which was not ideal but I didn’t have time to go through and debug that in the
game loop, 🙇🏻♀️.
The message display was handled similarly with a simple update of the
cached element’s innerHTML
using a new property called GameData#condition
.
This is also a shortcut, since in the future I’d like the game messages to be
defined per level instead of in general like this. Doing it that way would’ve
also continued along my hope to eventually add in different types of levels,
but anyway, this all looks something like this:
// In GameData.js
class GameData {
constructor() {
// ...
/** @var {string} condition Current game condition. */
this.condition = "PLAY";
// ...
// In DOMRenderer.js
class DOMRenderer extends Renderer {
// ...
render() {
// ...
model.message.innerHTML =
model.condition === "PLAY"
? `Shoot the moon from the
<span class="red">red</span> area into the
<span class="blue">blue</span> area.`
: model.condition === "WIN"
? `You win! Congratulations!`
: `<span class="red">You lost! Oh no!</span>`;
// ...
From there, I just needed to throw together some methods to check for certain
win or loss conditions. I figured, given that it would be a bit of a detour and
not absolutely necessary at this stage to add a a collision handler that would
destroy the smaller Celestial#
after two hit each other, the only loss
condition that absolutely needed to be implemented for this to function as
“minimum viable product” would be a time-based one.4
To make this easier, I first threw some getters into GameData
for the
Area
s that would be game mechanic relevant. This is another instance of not
very scalable methods being added in to make everything work right away:
/** @var {Area} target The level target area. */
get target() {
return this.scene.find(
(obj) => obj instanceof Area && obj.name.toLowercase() === "target"
);
}
/** @var {Area} playArea The level play area. */
get playArea() {
return this.scene.find(
(obj) => obj instanceof Area && obj.name.toLowerCase() === "play area"
);
}
These are pretty simple methods that just use Array.find()
to search the
GameData#scene
for the properly named Area
s. You could see how this would
not work for any sort of deviation in level design from the current setup, but
it’s what works for the moment!5
Restricting the player to only creating a new Celestial
within the “play
area” then was pretty easy to implement. Just add an extra condition that
the mouse position is within the correct area to the player input handling in
Game#loop
:
let scaledClick;
model.condition === "PLAY" &&
(mouse.isDown
? model.isCreating
? mouse.change.magnitude > 18
// ...
: // Create new Celestial on mouse down
model.health > 0 &&
Physics.pointInRectangle(
(scaledClick = this.view.origin
.copy()
.subtract(mouse.position)
.subtract(this.view.offset)
.scale(0 - this.view.scale)),
model.playArea
) &&
// ...
This is saved into the variable scaledClick
because it is checking the same
Point
that would be used to determine the Celestial#position
, so saving it
here means not repeating all of that later.
Finally, I actually added in the condition checks! The loss condition was
already discussed, as far as why it is the way it is, but the way it was
implemented is somewhat interesting, I suppose. Rather than using
something boring like setTimeout
, I used a comparison of the
Celestial#birth
timestamp with a +new Date()
timestamp in the game loop,
with a hardcoded 30 second (30e3
ms) difference:
// Loss conditions
model.condition === "PLAY" &&
model.health <= 0 &&
new Date() - 30e3 >
model.scene.slice().reverse()
.find(
(obj) =>
obj instanceof Celestial && obj.name.toLowerCase() === "played"
).birth &&
(model.condition = "LOSS");
This is only used when the health
value is at or lower than 0
, so the
player has used up all attempts, and reverses a copy (.slice()
) of the
Model#scene
array to find the last Celestial#
created by the player.
The win condition might look somewhat more complicated, but it really isn’t.
All I did was use an Array.reduce()
on the Model#scene
array to
essentially implement the ||
operator over the results of checking if any of
the “played” Celestial
instances have collided with the “target” Area#
:6
// Win conditions
model.condition === "PLAY" &&
model.scene.reduce(
(acc, obj) =>
// Check played Celestial
obj instanceof Celestial && obj.name === "played"
? obj.collisions.find(
(hit) =>
// Check if hit target
hit.who instanceof Area &&
hit.who.name.toLowerCase() === "target"
) || acc
: acc,
false
) &&
(model.condition = "WIN");
With those two, annoyingly simplified, conditions in place, this could then be called a “game.” Thus, this project was complete enough to present in my course, with some fixing up of the README something like 15 minutes before presentations began, aha. Not that I’m happy with it in this state at all! I’m almost definitely going to come back later to add in some more graphics, info displays for mass and velocity, and hopefully level loading as well! Regardless, that’s all for now!
I mean to say that I’m writing this in retrospect, I didn’t have time to write it out while I was working/panicking. ↩︎
I’m actually not sure at this point how useful a more generalized
method for detecting intersections between a rectangle and a circle
would even be. Theoretically, I could say as long as the Celestial
touches the Area
defined as the target then the player wins the
level, but using centers works just fine and doesn’t really contradict
any intuitive understanding of how something like this would function. ↩︎
I also quickly added in some actual use of the Celestial#texture
property, if only to somewhat mirror the style of the original page. ↩︎
This is primarily to not just skip out on handling more collisions, but also for objects that have flown far off screen or are in a somewhat stable orbit. In the future, the lattermost of these instances will be the only reason such a condition would need to exist. Though, in theory, there are alternative ways to handle that as well, probably. ↩︎
Not entirely worth mentioning in the main narrative here, but this is
also terribly handled in the renderer. Since there is absolutely no
Area#texture
implementation, the only way to distinguish between the
target
and the play area
was to have DOMRenderer
style them based
on their names:
obj instanceof Area &&
(obj.name.toLowerCase() === "target"
? (element.style.backgroundColor = `rgba(0, 0, 255, 0.7)`)
: obj.name.toLowerCase() === "play" &&
(element.style.backgroundColor = `rgba(255, 0, 0, 0.7)`));
I’m making a point of listing these object so literally because the
code itself does checks to make sure the “‘played’ Celestial
” is both
an instance of Celestial
and has the name "played"
. Similarly that
the “‘target’ Area#
” is an Area#
and has the name "target"
. ↩︎