4 ‐ Pouncing Behavior - abi-noel/pouncing-cat GitHub Wiki

Ticket

Pouncing Behavior

Parent: Rudimentary Behavior

Approach

I can't lie, I have not really thought much about how to accomplish this... or really any of the secondary animations.

Let's say I'm in animate() and want to start the pouncing animation. I call a function called pounce() or startPounce() or something. How does that function continue the animation? Can it have another instance of requestAnimationFrame()? If it did, does these mean we now have two requestAnimationFrame() calls scheduled on the stack? Is that bad? If I'm in pounce(), and the user moves the mouse out of range, can I force an early return from the function to stop the pounce and continue chasing?

I know I said I wanted to try to figure out as much of this project as I could on my own, it's looking like I gotta ask Chat.

Ok, I didn't go into much detail with Chat but looks like I was sort of overthinking it. We should be able to have an enum containing the names of the animations we want to perform. For now, it'll just be 'chase' and 'pounce'. This will take some refactoring of our animate() function to have a switch testing currentAnimation.

Maybe we could do something like this:

  • If the conditions for pouncing are met, set currentAnimation to 'pounce'
  • Else, leave currentAnimation set to 'chase'
  • If the current animation is 'chase', continue chasing by calling chase()
  • If the current animation is 'pounce', continue pouncing by calling pounce()

Man bro my brain is not braining right now.

Patrick Dumb

How are pounce() and chase() going to remember their place in the animation? Wait I take it back, this is not relevant until I start using sprites (Abigail from the Future: Nope). Spoiler alert-- I think I can use a count variable that keeps track of the number of times each method has been called and resets once it renders all the frames of a particular animation.

Requirement 1

The cat (circle) should probably keep track of its current animation. Let's make it a property.

Ok, that went very smoothly. Here's what I have added:

// In circle.ts
export enum AnimationType {
  CHASE = "chase",
  POUNCE = "pounce",
}

export class Circle {
...
  public currentAnimation: AnimationType = AnimationType.CHASE;
...
}

My animation function now looks like this:

public animate = () => {
  // Schedule animate() for the next frame
  requestAnimationFrame(this.animate);

  // Clear canvas
  ctx!.clearRect(0, 0, innerWidth, innerHeight);

  // Enter the relevant animation
  switch (this.circle.currentAnimation) {
    case AnimationType.CHASE:
      this.chase();
      break;
    case AnimationType.POUNCE:
      console.log("pounce");
      break;
    default:
      throw new Error(
        `Invalid Animation Type: ${this.circle.currentAnimation} `
      );
  }
};

All the logic we previous discussed is now in chase(). We also need to update that logic to change currentAnimation when the circle is within the pounce threshhold.

if (distance > this.pounceThreshhold) {
  // Scale down the distance vector and add that to the circle's position
  ...
} else {
    this.circle.currentAnimation = AnimationType.POUNCE;
}

Ok. Now if all goes well, the moment the circle stops moving when it enters the pounce threshhold, we should see the pounce output in the console and the circle should stop moving indefinitely since we aren't resetting currentAnimation anywhere. Let's see...

Yep! All is as expected, except that the circle actually disappears instead of staying frozen because it's not getting drawn at all.

Hm... tiny problem. I've added a pounce() method that just speeds up the movement once it reaches the pounce threshhold. The behavior looks like this:

https://github.com/user-attachments/assets/0216c15a-63e6-423b-87d7-17db4a21c488

The little pounce looks good, but I can't just reset the currentAnimation to 'chase' after that because chase() will simply set it right back to pounce() since it is within the pounce threshhold still after having pounced on the mouse. This is why the circle continues to chase at pounce speed even after pouncing. I need a way to monitor that a pounce has already happened and implement some kind of... cooldown, if you want to call it that.

Before I ask Chat-- here is my prediction: we need to implement frame tracking sooner than I thought. I think this might be the solution because not only do we want to monitor when the animation is over, but we also want to stall the pounce animation for a few frames before it jumps. We need to give it time to do the little shimmy that cats do right before they pounce.

Supreme Leader agrees with my analysis 🎉. Let's get into it.

We need a frameCount variable to hold the number of elapsed frames. This count will be incremented every time animate() is called.

Lets start with setting some arbitrary frame counts for each animation. I did a quick console log test to get the fps for my display, and it was always around 60. In the future we will likely standardize this framerate, but that's not this ticket.

'chase': indefinite
'pounce': 60
'cooldown': 60

Right now, we aren't worried about the little delay before pouncing for the shimmy animation, or the requirement to refrain from pouncing if the mouse has not moved, or really anything other than getting the pounce/chase behavior to be able to reset and cooldown.

So we keep track of the frames with frameCounter... then what? We need a way to monitor the start time of a pounce and the amount of frames that have elapsed since that start. Man I need to write this down somewhere. I'm going to pretend I'm a debugger, monitor the locals in a table (I had to do a Google search to remember what that window was called), and I'll get the Rocketbook notes pasted in here with my findings.

First, let's say we're at iteration 0. We are outside the pounce threshold.

image

Then comes iteration 1. We move inside the pounce threshold, setting the current animation to 'pounce' and storing the current frame as the pounceAnimationStartedFrame.

image

Then, for iterations 2 and 3, we keep track of the number of frames that have passed since the pounce started. This continues through to the end of the pounce.

image image

Let's say it takes 5 frames to reach the cursor. We'd then be at iteration 8 with a framesSincePounce of 7.

image

I could continue these little drawings all the way through the algorithm, but to be honest at this point I started to understand what I needed to do and just jumped into coding, so that's what we're going to do.

Here are the requirements I jotted down on this ticket:

  • Cat should pounce if mouse is within threshhold
  • Cat should stall for a bit before pouncing
  • Cat should not pounce if mouse is out of range
  • Cat should not pounce if mouse is too close
  • Maybe we should have the pouncing behavior start at < 300px and only get cut off if the mouse exits 350px or something.
  • Cat pounce destination should be set once it begins leap (i.e. user should be able to anticipate the pounce and move the mouse out of the way)
  • Cat should not pounce if mouse is not moving
  • Cat pounce velocity should be constant (maybe add acceleration in future iterations)

We've already accomplished the first one-- initiating the pounce. That's where we left off before this little digression.

  • Cat should pounce if mouse is within threshhold

Frame Tracking

Now, before moving on to number two, we need to put in the tracking logic in so we can start and stop animations. Let's make frameCount and pounceAnimationStartedFrame variables and the following readonly variables: POUNCE_THRESHOLD, POUNCE_DURATION, and POUNCE_SPEED. We just want to solve that eternal pouncing bug we saw in the previous video.

Step 1

Increment the frame count on every animation cycle. Do this in the animate function.

Step 2

Store the first frame of the pounce. Do this in the pounce() function with the following check:

if (this.pounceAnimationStartedFrame === 0)
      this.pounceAnimationStartedFrame = this.frameCount;

Just gotta remember to set pounceAnimationStartedFrame back to 0 when we know the animation has ended.

Step 3

Calculate how long we have been in the pounce animation with

let framesSincePounceStart = this.frameCount - this.pounceAnimationStartedFrame;

Step 4

Then, if the animation has not reached its end (if (framesSincePounceStart <= this.POUNCE_DURATION)), pounce. We will do this the same way we chase, but move faster.

const dx = this.mousePosition.x - this.circle.position.x;
const dy = this.mousePosition.y - this.circle.position.y;
this.circle.position.x += dx * this.POUNCE_SPEED;
this.circle.position.y += dy * this.POUNCE_SPEED;

POUNCE_SPEED is defined as 0.1, but this is subject to change in the future depending on how the sprites look.

Step 5

If the animation has reached its end, set the animation type to 'sit'. Yeah, this is a new thing. I was thinking about it, and I think it would look weird if the cat always went from chasing (or standing) to pouncing, even if its not moving. This sit() animation will be used whenever the cursor is too close to pounce, and it currently only has a static circle.draw() as its contents.

Maybe this will be replaced by something in the future, idk.

Ok, let's check it out.

https://github.com/user-attachments/assets/53ac759d-9a0c-4ce2-80e3-a15b47340ea1

Perfect! That's exactly what we expected it to do. We never set the animation back to 'chase' after the pounce, which is why it stays still after the pounce.

Requirements 2-5

Let's start on the second requirement:

  • Cat should stall for a bit before pouncing

It looks awkward for the cat to go right into a pounce from chasing. We want a little pause right before for some shimmying. Now, at first I set a SHIMMY_DURATION of 120 frames, but pretty quickly realized it looked too mechanical. Remember, I want the user to be able to anticipate the pounce and move out of the way. It would be pretty boring if the cat always took the same exact amount of time to pounce. Plus, that's just not natural. Sometimes cats pounce right away, and sometimes they take a bit. Let's reflect that with a randomly generated duration.

We want to calculate this once per pounce. Let's do it on the first frame.

if (this.pounceAnimationStartedFrame === 0) {
  this.pounceAnimationStartedFrame = this.frameCount;
  this.shimmyDuration = this.genRandomNumBetween(
    this.MAX_SHIMMY_DURATION,
    this.MIN_SHIMMY_DURATION
  );
}

MIN_SHIMMY_DURATION and MAX_SHIMMY_DURATION are set, respectively, to 20 and 120. They're admittedly arbitrary, but it looks fine for now.

Now that we have the shimmy duration, what do we want to do with it? I think first, we want to extend the pounce duration by this quantity. So we can update the condition that checks if the pounce has ended:

// If we haven't finished the pounce (includes the pounce duration and shimmy duration)
if (framesSincePounceStart <= this.POUNCE_DURATION + this.shimmyDuration) {

Then what? Well, we want to actually do something for those 'shimmy' frames.

Now, admittedly, I'm showing you the end iteration of this, which I wasn't supposed to do. I was supposed to write down my failings and developments iteratively in here, but it's been a week since this got implemented and its getting foggy.

if (!this.tryShimmy(distance)) {
  framesSincePounceStart = 0;
  this.pounceAnimationStartedFrame = 0;
}

Originally, it was called shimmy, not tryShimmy, and it was not in an if statement to test its execution. I can't remember exactly why I ended up structuring it this way, but I know there was some kind of chink in the original implementation that led me here. But this block is the first thing inside that previous if statement we just discussed that is the primary entrance to pouncing.

tryShimmy() looks like this:

public tryShimmy(distance: number): boolean {
  // If the cursor is out of range (use the buffer to prevent starting and stopping),
  // set the animation to chase and return false to stop the shimmy.
  if (distance > this.POUNCE_THRESHOLD + this.POUNCE_BUFFER) {
    this.circle.currentAnimation = AnimationType.CHASE;
    return false;
  }
  // If the cursor is too close, set the animation to sit and return false
  else if (distance < this.POUNCE_THRESHOLD - this.POUNCE_BUFFER) {
    this.circle.currentAnimation = AnimationType.SIT;
    return false;
  }
  // If all is well, draw the circle and return true
  this.circle.draw();
  return true;
}

There are three sections to this function.

First Two Sections

We want to be able to leave the pounce animation early if the cursor no longer satisfies the requirements for pouncing, but we don't want this condition to mirror the one that enters us into the pounce animation; i.e., we need a buffer distance alongside POUNCE_THRESHOLD to prevent a choppy, rapid 'enter pounce, exit pounce, enter pounce, exit pounce', and so forth.

So first, if the cursor goes out of range, break the pounce and start chasing. Or, if the cursor is too close, break the pounce and sit. Both of these sections utilize the POUNCE_BUFFER.

Third Section

Until we have a spritesheet, the shimmy "animation" will simply be the same as the sit "animation"-- just draw the circle, keeping it static. So if the first two conditions to fail the pounce are not met, continue shimmy, thus continuing its parent: pounce.

Let's check out the visual changes.

https://github.com/user-attachments/assets/aeae5a2e-8577-46a1-8517-1d8e361cc517

See the little pause? See how it resumes chasing when the cursor goes out of range? It's looking great! But do you also see how the circle unnaturally follows the mouse during its period of heightened speed (the pounce)? Let's fix that next.

Hey, we knocked out four in one!

  • Cat should stall for a bit before pouncing
  • Cat should not pounce if mouse is out of range
  • Cat should not pounce if mouse is too close
  • Maybe we should have the pouncing behavior start at < 300px and only get cut off if the mouse exits 350px or something.

Requirement 6

Now on to

  • Cat pounce destination should be set once it begins leap (i.e. user should be able to anticipate the pounce and move the mouse out of the way)

This should be rather simple. When do we want to store the destination? Well, at the very beginning of the pounce motion. It is important to note that I use "pounce" to describe to different motions of different scope. The wider scope includes shimmy. The narrower scope refers solely to the period of heighted velocity, the actual leap. I am using the latter definition here. We want to store the destination on the first frame of the leap. How can we do this?

We have a else that represents the end of shimmy. Let's store it in that block. We can use our frame tracking logic for this.

if (framesSincePounceStart === this.shimmyDuration + 1) { ... }

So if this is the first frame after the randomly generated shimmy duration within the pounce, do something. And that something is, of course, store the mouse's coordinates with:

this.pounceDestination = {
  x: this.mousePosition.x,
  y: this.mousePosition.y,
}; 

Now, I will admit, I did hit an interesting little roadblock here that, frankly, I should've seen coming. This roadblock was due to my genRandomNumberBetween helper that I defined originally as:

public genRandomNumBetween(min: number, max: number): number {
  return Math.random() * (max - min) + min;
}

The issue is glaring to me now, but let me take you through how I discovered it. I quickly recognized that the condition framesSincePounceStart === this.shimmyDuration + 1 always evaluated to false. A simple log of the values demonstrated that shimmyDuration was a floating point value. DUH ABIGAIL! Math.random() has never outputted a whole number-- not in Java, not in JavaScript. In Java, you could just cast the randomly generated number to an int, but in JavaScript, the way to rectify this is just to round the output. So I changed the genRandomNumberBetween function to return the floored version of the number. This solved the problem.

Now what do we do with the stored values in pounceDestination? Move the circle towards it.

const dx = this.pounceDestination.x - this.circle.position.x;
const dy = this.pounceDestination.y - this.circle.position.y;
this.circle.position.x += dx * this.POUNCE_SPEED;
this.circle.position.y += dy * this.POUNCE_SPEED;

Bang. While we're at it, we need to go ahead and ensure that chasing can continue from within sit, so that we can have infinite pounces as long as the conditions are met. I said at the very beginning that I was gonna do this, then never did. We accomplish this with a simple

if (distance > this.POUNCE_THRESHOLD - this.POUNCE_BUFFER) {
  this.circle.currentAnimation = AnimationType.CHASE;
}

within sit(). At face value, the use of POUNCE_BUFFER may not be obvious. A person may look at this and think "hey, this tries to chase while within the POUNCE_THRESHOLD instead of chasing only when the cursor exits that threshold", and they are sort of right. But remember, if the cursor happens to be in between the buffer and the pounce threshold, chase() will just set the animation back to 'pounce'. This ensures that a pounce can start without needing the mouse to move out of range and then back in range.

https://github.com/user-attachments/assets/015d0003-d201-4f69-98d1-744ecfe746fa

  • Cat pounce destination should be set once it begins leap (i.e. user should be able to anticipate the pounce and move the mouse out of the way)

Requirements 7 and 8

Alright, we've checked off all of the requirements except two (we know why the second is stricken through from our previous devlog):

  • Cat should not pounce if mouse is not moving
  • Cat pounce velocity should be constant (maybe add acceleration in future iterations)

But what about the first one? Honestly, I think I'm gonna set it on the shelf for now. I'm not convinced that it is a necessary requirement yet, so I created a new ticket for this that I'll come back to in the future.

But hey, we got some rudimentary behavior up! 🎉

That's three tickets done and dusted.