Jan 30: Moving objects
Objects and classes
Sometimes it makes sense for several pieces of state to be packaged up together as describing a single entity. For example, Processing provides the color() function to collect red, green, and blue values into a single thing that can be stored in a variable of type color. When dealing with moving objects, it's very helpful to be able to do that ourselves -- keep together the x and y coordinates, step sizes, radius, etc. of each object in a sketch.
The class mechanism allows us to do that (see Programming Notes for details). Here's the "random wanderer" sketch from the State lecture restructured that way. We define a new class, called Wanderer; objects of this class will have two fields, storing the current x and y coordinates. When we create a Wanderer object (new), we give the initial coordinates to the constructor method. We then access and update the coordinates with the dot notation, getting the x and y fields of the ball object as ball.x and ball.y.
Wanderer wanderer = new Wanderer(50,50); // Create a Wanderer at the center void setup() { smooth(); noStroke(); background(0); } void draw() { fill(0,3); rect(0,0,width,height); fill(255); ellipse(wanderer.x,wanderer.y,5,5); // Move the position by random steps in x and y wanderer.x = wanderer.x+random(-2,2); wanderer.y = wanderer.y+random(-2,2); }
class Wanderer { float x, y; // the position of the Wanderer // Initialize a Wanderer to be at the given coordinates Wanderer(float x0, float y0) { x = x0; y = y0; } }
[applet]
It's important to distinguish between the declaration of a variable (and creation of a new object) and the definition of a class. Here, a wanderer object is stored in a variable called "wanderer", which is of type "Wanderer". The declaration works the same as we've seen with other types (although we use new to create it, providing some initial values). The class definition simply says what the objects will look like. We don't have to do that for float and int, because Processing already knows. We do for Wanderer, because it's a new type of our own creation.
This first object-oriented sketch is a bit of an improvement over the original sketch in that it keeps the x and y coordinates together (that would be even more helpful in the pulsar sketch, where we have a bunch of aspects of an object's state). It also gives a name to the package, which improves the readability of the code. However, the real power comes in restructuring the code further to define functions (called "methods"), in the class definitions. A method operates on the state of an object; by putting it in the class definition, we're keeping things together (state and functions) that belong together, leading to more coherent code.
Wanderer wanderer = new Wanderer(50,50); // Create a Wanderer at the center void setup() { smooth(); noStroke(); background(0); } void draw() { fill(0,3); rect(0,0,width,height); wanderer.draw(); wanderer.update(); }
class Wanderer { float x, y; // the position of the Wanderer // Initialize a Wanderer to be at the given coordinates Wanderer(float x0, float y0) { x = x0; y = y0; } // Draw a Wanderer, wherever it happens to be now void draw() { fill(255); ellipse(x,y,5,5); } // Update the state of a Wanderer void update() { x+=random(-2,2); y+=random(-2,2); } }
[applet]
Here we separate out the draw() method and the update() method, to emphasize that they're doing conceptually different things. The methods and their names is totally up to us -- we don't have to call a draw() method from the draw() function, it just makes things clear. Note that within the bodies of these methods, we don't have to use the dot notation to access the fields. When we call wanderer.update(), within the body of update(), "x" automatically refers to wanderer.x. This makes sense when considering both the fields and the methods to be part of the object. We could extend this sketch to keep other aspects of the state (colors, sizes, etc.) in the fields, and update them in the update() method. We can keep arrays of objects, much like we keep arrays of floats, and will do so later.
Bouncing
Okay, now that we've got the state of our moving object nicely packaged up, let's start considering how it should move. We've already seen several examples where we update the current x and y coordinates by steps in those directions (rather than wandering aimlessly). Let's put those in our new framework, and add a little twist: bouncing off a wall.
void setup() { size(400,300); smooth(); noStroke(); background(0); ball = new Ball(width/2,height/2, 5, 5); } void draw() { fill(0,10); rect(0,0,width,height); ball.draw(); ball.update(); }
class Ball { float x, y; // position float r; // radius // the following also provide initial values for the fields, // equivalent to putting that in the constructor float s = 5; // speed, intialized to 5 int dx = 1, dy = 1; // direction, initialized to diagonal // Initialize a Ball at position (x0,y0), with speed s0 and radius r0 Ball(float x0, float y0, float s0, float r0) { x = x0; y = y0; s = s0; r = r0; } void draw() { fill(255); ellipse(x,y,r*2,r*2); } void update() { // Move in the appropriate direction by the step size x+=s*dx; y+=s*dy; // If too close to the wall (consider the radius of the ball), // move back and change direction if (x > width-r) { x = width-r; dx = -1; } else if (x < r) { x = r; dx = 1; } if (y > height-r) { y = height-r; dy = -1; } else if (y < r) { y = r; dy = 1; } } }
[applet]
When the ball touches one of the boundaries of the window (the distance from the center to the wall is the radius of the ball), it reverses the appropriate direction. The ball object is created in the setup() function this time, and placed at the center. Note that the initial dx and dy values are predetermined at their declaration, rather than being passed to the constructor. That's a convenient way to handle default values.
We can take this one step further, and check where the ball bounces off the wall. If it's close to a "paddle", we update the color of the ball accordingly. Pong Art!
PaddleBall ball; int paddleR=20, wallR=5; // size of the paddle and wall color paddleColor=color(random(255),random(255),random(255)); void setup() { size(400,300); smooth(); background(0); strokeCap(SQUARE); // so paddles look right ball = new PaddleBall(width/2,height/2, 5, 5); } void draw() { // wall noFill(); stroke(0); rect(0,0,width,height); // paddles strokeWeight(wallR*2); stroke(paddleColor); line(mouseX-paddleR,0,mouseX+paddleR,0); line(mouseX-paddleR,height,mouseX+paddleR,height); line(width,mouseY-paddleR,width,mouseY+paddleR); line(0,mouseY-paddleR,0,mouseY+paddleR); ball.draw(); ball.update(); } void keyPressed() { if (key=='c') // random color paddleColor = color(random(255),random(255),random(255)); else if (key=='Z') // bigger ball.r++; else if (key=='z') { if (ball.r > 1) ball.r--; } else if (key=='S') // faster ball.s++; else if (key=='s') { if (ball.s > 1) ball.s--; } }
class PaddleBall { float x, y; // position float s; // speed int dx = 1, dy = 1; // direction, defaulting to diagonal float r; // radius color c = color(255,255,255); // color, starting black float a = 255; // transparency, starting opaque // Initialize at position (x0,y0), with speed s0 and radius r0 PaddleBall(float x0, float y0, float s0, float r0) { x = x0; y = y0; s = s0; r = r0; } void draw() { fill(c,a); noStroke(); ellipse(x,y,r*2,r*2); } void update() { if (a>100) a--; // fade away until hit paddle again x+=s*dx; y+=s*dy; // Have to account for thickness of wall when checking for bounce if (x > width-wallR-r) { x = width-wallR-r; dx = -1; // See if bounced off paddle if (abs(y-mouseY) < paddleR) paddle(); } else if (x < r+wallR) { x = r+wallR; dx = 1; if (abs(y-mouseY) < paddleR) paddle(); } else if (y > height-wallR-r) { y = height-wallR-r; dy = -1; if (abs(x-mouseX) < paddleR) paddle(); } else if (y < r+wallR) { y = r+wallR; dy = 1; if (abs(x-mouseX) < paddleR) paddle(); } } // When hit paddle, update the color, and go back to opaque void paddle() { c = paddleColor; a = 255; } }
[applet]
Or we can combine the wall-bouncing idea with the orbiting-ellipse idea, keeping extra fields for the orbit. Packaging these fields up into an object helps make clear how they all fit together. Note that it's our decision as to what parameters to put in the constructor for initializing the various fields, and what to just give default values to when declaring the fields.
Orbiter ball; void setup() { size(400,300); smooth(); noStroke(); background(0); ball = new Orbiter(width/2,height/2); } void draw() { fill(0,10); rect(0,0,width,height); ball.draw(); ball.update(); } void keyPressed() { if (key=='a') { // orbit angle step size if (ball.da > 5) ball.da-=5; } else if (key=='A') ball.da+=5; else if (key=='o') { // orbit size if (ball.o > 5) ball.o-=5; } else if (key=='O') ball.o+=5; }
class Orbiter { float cx, cy; // position of moving center float x, y; // actual position of ball (revolving around center) float s=5; // speed int dx = 1, dy = 1; // direction float r=5; // radius of ball float o = 20; // radius of orbit float a = 0; // orbit angle int da = 25; // angle step (degrees) // New orbiter with center at (cx0, cy0) Orbiter(float cx0, float cy0) { cx = cx0; cy = cy0; } void draw() { fill(255); noStroke(); ellipse(x,y,r*2,r*2); } void update() { // Move the center and the orbit angle cx += 5*dx; cy += 5*dy; a += da; // Compute the actual position of the orbiting object, // according to the center and angle x = cx+o*cos(radians(a)); y = cy+o*sin(radians(a)); // Check for bounce if (x > width-r || x < r) { dx = -dx; da = -da; } if (y > height-r || y < r) { dy = -dy; da = -da; } } }
[applet]
Finally, we can have spiralling motion by having the update method increase both the current angle and the current radius. By changing dr and da, we can control the tightness of the spiral.
Pinwheel pinwheel; void setup() { size(400,400); smooth(); background(0); noStroke(); pinwheel = new Pinwheel(width/2,height/2); } void draw() { fill(0,5); rect(0,0,width,height); pinwheel.draw(); pinwheel.update(); } void mousePressed() { pinwheel.moveTo(mouseX, mouseY); }
class Pinwheel { float cx, cy; // position float r = 0; // radius float a = 0; // angle float dr = 1, da = radians(57); // steps Pinwheel(float cx0, float cy0) { cx = cx0; cy = cy0; } void draw() { fill(255); noStroke(); ellipse(cx+r*cos(a),cy+r*sin(a),20,20); } void update() { // Expand both the radius and the angle r += dr; a += da; // When too big or too small, switch direction if (r>width/2 || r>height/2 || r<=0) { dr = -dr; da = -da; } } // Change the center of the pinwheel void moveTo(float cx0, float cy0) { cx = cx0; cy = cy0; r = 0; a = 0; dr = abs(dr); da = abs(da); } }
[applet]
Programming notes
- Multiple files
- Processing allows us to store separate parts of a sketch in separate files, which show up as tabs. Create and modify tabs/files using the button on the far right, above the scroll bar (PDE reference). It's not required to put classes in separate tabs, but I think it's good style, from a modularity standpoint.
- Class definition
- A class definition has the form
There can actually be no constructor, or multiple ones as long as they have different types of parameters. The standard rules apply in naming the class; it's traditional to begin the name with an uppercase letter.
class Name { field declarations // same syntax as variable declarations // constructor definition Name(parameters) // same syntax as function definition except no return type { initialization of fieds } method definitions // same syntax as function definitions } - Constructor definition / object creation
- The constructor is defined just like a function, but no return type is given (or you can think of the return type as being the class). An object is created with the new function, as we saw with arrays. Here the form is obj = new Name(parameters) where Name is the name of the class, and the parameters match those of the constructor. Thus the constructor is used to initialize the fields of a new object. The body of the constructor is like that of any method (see below).
- Field definition / access
- A field is defined just like a variable declaration (e.g., "float x;"). An object has a value for each such field (just like a person has a name, an address, etc.). Outside the class definition, a field is accessed with a "dot", e.g., obj.x refers to the x field of the obj object. A field can be treated like any other variable -- used in calculations, assigned to, etc. Object-oriented programming theory says that in general we shouldn't be accessing a field outside the class (but should instead have methods to handle any use of the object's state), but we're not purists here :).
- Method definition / invocation
- A method works much like a function; the key difference is that it "belongs" to an object. Thus we call it with a "dot" and then it can access any of the object's fields without a "dot". For example, we would call a method as "obj.doSomething()", and inside the definition of doSomething(), we could refer to "x" instead of "obj.x".
- Extending
- I probably won't use this, but you'll find in the book and in various on-line examples cases where one class "extends" another. That means the "subclass" has all the stuff from the "superclass", along with some new stuff, and perhaps replacing some of the old stuff. The subclass can be used wherever the superclass can.
Practice problems
- Give the Wanderer a color. First just give a constant color; as a further extension, have it change over time. [hints]
- Modify the Ball class so that it doesn't bounce off the walls. [hints]
- Define a new class Pulsar, which moves around the screen like a Ball, except that its radius also expands and shrinks as it moves. [hints]