Program-A-Boss Tutorial For the Coding Novice (Mega Engine / MaGMML2)
#1
List of posts

[spoiler="0. Introduction Post"]
Hi!

So, several people have enquired about help programming a boss fight. I was thinking of writing up a Make-A-Boss tutorial in order to help people create bosses for their mega man fan-games (in particular, to create robot-master style bosses).

I already have some parts of the tutorial written even, and more parts planned out. The tutorial will be aimed at people who have some minimal programming experience (I.e. you know what an 'if' statement does and what variables are but not much more than that. If you can't program your way out of a locked room, that's completely fine.)

The tutorial will be written specifically for the Mega Engine / MaGMML2 devkit as it is currently released. If and when an updated version of the engine is released following the release of MaGMML2, the tutorial will be modified or duplicated with whatever changes are needed. However, if you're programming in some other engine, the techniques used will probably still be fairly transferable.

Would people be interested in this? I can also do follow-up tutorials if this is popular (e.g. "Hey your tutorial was okay, but how would you implement feature xyz?" "How do you make air man shoot tornadoes in some random pattern?" etc.)

If you are interested, please suggest ideas for what boss this tutorial should be tailored for. I was thinking of programming Quick Man (without his infamous AI bug) as an example boss, because he runs, jumps, and shoots, and has a predictable pattern.

(Edit: Qualifications. I did the code for some bosses in MaGMML2 if you watched the stream (Cheat Man, Quarantine Woman, Neapolitan Man, Force Man, Alter Man) so if you liked any of those then hopefully this tutorial will up your alley!)
[/spoiler]
1. Running, Jumping, and Shooting (this post)
2. Finishing Quick Man (link)

The Goal

Implement a simplified version of Quick Man in the Mega Engine! This tutorial will go very in-depth, but does assume some basic, basic understanding of Game Maker. If you've been using it for a day or two, you're probably good. Some understanding of GML (code) is necessary, but not more than what an if statement is, what a variable is, etc.

Spritework

You'll want to import your sprites into the game. I haven't drawn sprites, so I'll grab the ones here. Using your favourite image editor, align the sprites to a grid to make it easy to import them into game maker, like I've done with the GIMP:

[spoiler="Aligned sprite sheet"][Image: 2Wp9kig.png][/spoiler]

They should all fit within a grid. You can pick the width and height to be as big as you want to give you as much room as you need, but they shouldn't go out of their bounds. Also, they should be aligned within the grid as well. There is no tried-and-true method for doing this so far as I know, but I like to make sure that the tip of the front foot is in the same position in every frame. If you're animating your boss from scratch, then you'll know how to align your sprites. As an example of what not to do, the following layout still never has Quick Man going out of bounds, but nevertheless is improperly aligned:

[spoiler="Misaligned sprite sheet"][Image: WZZ6NbZ.png][/spoiler]

(awd42 asks: "what's a good graphics editor to use?" I like to use the GIMP, but it's not very user-friendly. I'd recommend anything with the ability to display a grid if you're trying to align existing sprites. If you're drawing your own from scratch, well... ask someone who can draw! This is Sprite's Inc., after all. I've heard good things about paint.net)

Once you've made your grid, import it into game maker like so:

1. create a new sprite
2. click "edit sprite" (not "load sprite"!)
3. go to file->create from strip

[spoiler="Steps 1,2, and 3"][Image: Dubion0.png][/spoiler]

4. Fill in the width and height of the grid and the number of images to import

[spoiler="Step 4"][Image: nqGdWDC.png][/spoiler]

5. If needed, you can rearrange the images. The order doesn't matter but it should make sense to you. In particular, the running animation(s) should be all together in a row, and you should make sure that any duplicate frames are arranged correctly. As you can see, I have duplicated one of the running frames; 8 and 10 are identical. Also, it's good to have the first image be the basic standing sprite so that your robot master shows up in the Game Maker room preview correctly.
6. Set the preview speed to something low like 5; you should see your robot master play through all animations but not jitter around too much.

[spoiler="Steps 5 and 6"][Image: JGP287V.png][/spoiler]

At this point, we'll also want to make sure the robot master is facing to the right, and not the left. The reason the Robot Master should face right is that in game maker, the positive x axis is toward the right. Therefore, if the image_xscale variable, which controls direction, is +1, which means "not flipped," then the robot master should be facing right.

[spoiler="How to flip"][Image: UpdODTk.png][/spoiler]

7. Click "Modify Mask"
8. Set the bounding box to "Manual"
9. Se the shape to "Rectangle"
10. Using your mouse, drag a box over your robot master. You can edit the numbers on the side manually for precision. You'll want to make sure the bottom of the rectangle is one pixel above the bottom of the foot -- in Mega Man, robot masters' feet always sink into the floor by one pixel. Err on the side of making your rectangle too small on the top and on the sides so that when Mega Man jumps over the boss he won't clip his toe on thin air and take damage. Finally, make sure the width of the rectangle is an even number.
12. Set the origin y value to be 16 pixels above the bottom of the bounding box, and the origin x value should be in the dead center. (Add the left and right and divide by two to get the average/center.) Any deviation from the center could cause physics errors; the boss could clip into a wall when it turns around.

[spoiler="Steps 7 to 12"][Image: I8SMDSO.png][/spoiler]

Finally, duplicate the sprite and remove everything that isn't in the intro pose. We'll call this sprite sprQuickManPose, but you can call it whatever you want.

[spoiler="Intro Pose"][Image: 6KJNx50.png][/spoiler]

(Aside: you can also split your other sprite up into more sprites if you like (sprQuickManJump, etc.); many of the devkit bosses do this. However, I find it's easiest to work with just one sprite and just vary the subimage in code. The engine requires we do separate the pose as a minimum, though.)

Coding framework

Alright, now you're going to want to create the actual object containing the code for your boss. Create a new object; we'll be calling ours objQuickMan but you can call yours whatever you want.

Set the object's parent to be prtBoss (in Boss/DontPlaceInLevel) and the sprite to be the one you created (not the "Pose" version). Then open the create event and put this code in:

Code:
event_inherited();

healthIndex = 1;
healthpointsStart = 28;
healthpoints = healthpointsStart;

reflectProjectiles = false;
reflectProjectilesRight = false;
reflectProjectilesLeft = false;

ground = false;

pose = sprQuickManPose;
poseImgSpeed = 12/60;

contactDamage = 4;

//stores the weapon damage values
wpndmg[0] = 1; //Mega Buster
wpndmg[1] = 1; //Mega Buster Charge1
wpndmg[2] = 3; //Mega Buster Charge2
wpndmg[3] = 1; //Weapon Explosion
wpndmg[4] = 1; //HornetChaser
wpndmg[5] = 1; //JewelSatellite
wpndmg[6] = 1; //GrabBuster
wpndmg[7] = 1; //TripleBlade
wpndmg[8] = 1; //WheelCutter
wpndmg[9] = 1; //SlashClaw
wpndmg[10] = 1; //Sakugarne
wpndmg[11] = 1; //SuperArrow
wpndmg[12] = 1; //WireAdapter

You can tweak the weapon damage table, as well as the contactDamage and pose image speed as you desire. Don't worry too much about the other variables here; they are just required by the engine as defaults. Be sure to set the pose sprite to whatever you named your sprite, and not sprQuickManPose!

Then in the step event, toss this in:

Code:
event_inherited();

if !global.frozen && !global.flashStopper
{
    if isFight {
    
        image_speed = 0;
        sprite_index = sprQuickMan;

        checkGround();
        gravityCheckGround();
        
        generalCollision();
        
        x += xspeed;
        y += yspeed;
    }
}

(Again, be sure to swap sprQuickMan for your boss' sprite!)

This code is all you need to have basic physics for the boss. It will fall down, collide with walls, etc. (If you're coding a boss that doesn't obey gravity, get rid of the gravityCheckGround(), and if you want your boss to be able to phase through walls, get rid of generalCollision().)

For testing, make a small test room with an objBossController in it. Put this in the objBossController's creation code:

You should see something like this:

[spoiler="Quick Man chilling out, not doing anything."][Image: 5spuncS.png][/spoiler]



Now that we've set up the basic boss framework, we can really get to the fun stuff!

The Fun Stuff

AI! Most bosses have some kind of attack pattern, which may be predictable, or may be random, but regardless can almost always be broken up into distinct phases,a specific, simple move to perform. For example, Quick Man has two simple phases: phase one is "perform a leap." The second phase is "turn to face Mega Man and then run in a straight line." The more finely you can chop the fight up into phases, the easier it will be. Some bosses can have tens of phases.

Once you know what phases are in the fight, you'll then want a pattern to define how the boss switches from phase to phase. For Quick man, this is simple enough: perform 1 (jump) three times, then do 2, then repeat. However, you can get more complicated: "loop phase 1-3, but sometimes do phase 4 instead of phase 2 randomly, and if the boss is hit during phase 1 switch to phase 5 instead," etc. Drawing a flowchart can help a lot to visualize this.

Note that by 'phase' I mean something different from what you might normally refer to as a multi-phase boss fight such as a Wily machine. That meaning of 'phase' is more like a complete change in the attack pattern.

Here's a quick and easy way to set up phase logic:

create event

Code:
phase = 0;

step event

Code:
checkGround()
gravityCheckGround()

switch phase {
  case 0: // jumping
    // (TODO)
    break;
  case 1: // running
    // (TODO)
    break;
}

generalCollision()

x += xspeed
y += yspeed

As you can see, at each step, Quick Man checks what phase he's currently executing and then performs the code in the relevant case of the switch statement. You should put in as many phases here for your boss as you desire.

Currently, we lack a way to switch phases. Most of the time, phase switching tends to occur either after a certain amount of time has elapsed or when the boss reaches a certain position on the screen, e.g. after landing. For now, we'll put the phases on a timer (which we shall call phaseTimer).

Recall that there are 60 frames in a second in the Mega Engine. In order to switch to phase 2 after one second, we'd do this:

Code:
if phaseTimer > 60
  phase = 2;

I like to have the boss code automatically check when the phase has been changed, and reset a phaseTimer variable when that happens, like so:

create event
Code:
phase = 0;
phaseTimer = 0; // the number of frames passed since the start of the phase

step event

Code:
// keep track of phase at start of the step in case it changes
startPhase = phase;

switch phase {
  case 0: // jump for 0.5 seconds:
    // TODO
    if phaseTimer > 30
      phase = 1;
    break;
  case 1: // Run for 1 second:
    // (TODO)
    if phaseTimer > 60
      phase = 0;
    break;
}

// check if phase has changed and reset some variables

phaseTimer += 1;

if phase != startPhase {
  phaseTimer = 0;
}

At this point, we could either code in running or jumping first. Determining jump arcs is a little bit tricky, so let's start with the run.

Running

Fortunately, running is a fairly simple thing to do in the Mega Engine. Just set the variable xspeed to a certain value, and Quick Man will move in that direction. The only thing to worry about really is the animation.

Quick Man has a very straightforward (pardon the pun) running AI: he turns to face Mega Man in his first frame, and then runs in a straight line for 1 second. Even if he reaches a wall, he'll just keep running into it, unmoving. So this is what we'll do:

Code:
case 0: // jump for 0.5 seconds:
    // TODO
    xspeed = 0;
    if phaseTimer > 30
      phase = 1;
    break;
  case 1: // Run for 1 seconds:
    if phaseTimer == 0 {
        // turn to face Mega Man:
    if instance_exists(objMegaman) {
          image_xscale = 1;
          if x > objMegaman.x
            image_xscale = -1;
        }
    }
    xspeed = 2*image_xscale
    if phaseTimer > 60 {
      phase = 0;
    }
    break;

As you can see, on the first frame of the jump, Quick Man turns to face Mega Man. We use the code instance_exists(objMegaman) to make sure Mega Man is alive; if we check Mega Man's x coordinates with objMegaman.x when he is dead, then the game will crash.

Also, we've made it so that during the jump phase (which we haven't coded yet), Quick Man just stands still (xspeed = 0).

Finally, the part that makes him move: we set xspeed = 2*image_xscale to make Quick Man move 2 pixels per frame in the direction he's facing. (You might want to swap that 2 out for a variable runSpeed and set runSpeed in the create event so that you can easily edit the speed later.)

If you test the game, you'll see this:

[Image: 9JsT0jm.gif]

He looks a bit wonky without an animation, so let's breathe some life into him.

Animating A Run

There are a few ways to do this. I like to keep track of a 'distance moved' variable, and determine the frame of the animation based on how far the robot master has moved. That way, if the RM is moving faster, its animation will run faster as well. However, if you would like the run animation to depend only on time, you can forgo the following step and instead swap out xDistanceTravelled for phaseTimer. Otherwise, try this:

create event
Code:
xDistanceTravelled = 0; // how far the RM has moved, ever

step event
Code:
xDistanceTravelled += abs(xspeed)

Our running animation is 4 subimages long (fairly typical) and begins on subimage 8. We want the animation to advance to the next subimage every 10 pixels of distance moved. We'll do this:

Code:
image_index = 8 + (xDistanceTravelled div 10)

However, there is a bug: after the Robot Master has travelled more 16 pixels, it will surpass subimage 11 and it will start displaying other animations instead, namely jumping. We need to figure out a way to loop the animation instead! To do this, we can use modular arithmetic. Oficially, the modulo operation performs a divison and then returns the remainder, but I like to think of it as 'looping'. For example: x mod 3 evaluates to x but "looped" back to 0 every 3. For example:

- 0 mod 3 == 0
- 1 mod 3 == 1
- 2 mod 3 == 2
- 3 mod 3 == 0
- 4 mod 3 == 1
- 5 mod 3 == 2
- 6 mod 3 == 0

And so on. To apply this to our 4-frame loop, we would do this:

Code:
image_index = 8 + (xDistanceTravelled div 10) mod 4

So the phase logic should look like this:

Code:
case 1:
  if phaseTimer == 0 {
      // turn to face Mega Man
      if instance_exists(objMegaman) {
          image_xscale = 1;
      if x > objMegaman.x
          image_xscale = -1;
      }
  }
  xspeed = 2*image_xscale
  
  // running animation
  xDistanceTravelled += abs(xspeed)
  image_index = 8 + (xDistanceTravelled div 10) mod 4
  if phaseTimer > 60
    phase = 0;

[Image: IOU4wum.gif]

[spoiler="An aside about having multiple running phases"]
You might want to extract the xDistanceTravelled part and setting the image index to somewhere outside the phase logic if running happens in more than just one phase. That way, you don't have to have duplicate code across your phases. You might have something like this, after the phase logic ends:

Code:
if running {
  xDistanceTravelled += abs(xspeed)
  image_index = 8 + (xDistanceTravelled div 10) mod 4
}

Then just set running to true in the phases where your Robot Master is running, and false otherwise.
[/spoiler]

Now let's see how to code in a jump!

Jumping

Jumping logic is a bit tricky, because we need to calculate the jump arc. The jump arc depends on a lot of things, and the exact way to go about it depends on precisely the way you want your boss to jump. There are more specifics to a jump than just "jump" -- how high do you want it to be? How fast? Where should the boss end up?

The way jumping will work is that, at the start of the phase, we calculate the jump arc and start moving, and after that there will be nothing to do except for animation and checking when the phase ends.

However, before we can determine the jump arc, Quick Man needs to know where he is in a meaningful way. I like to have all of this information stored in variables:

- position of Quick Man (Game Maker stores these in the variables x and y)
- x position of center of the arena
- radius of the arena (usually a little smaller than half of the screen width)
- y position of RM when on the ground
- height of ceiling
- x and y position of Mega Man (in the Mega Engine, these are: objMegaman.x and objMegaman.y. Be careful, though! If Mega Man has died, accessin these variables will throw an error.)

Quick Man only cares about where he is and where Mega Man is, and couldn't care less about the arena he's in. This will make it easy for us to code. In comparison, some Robot Masters, such as Air Man, don't care where Mega Man is and just jump back and forth across the arena.

Now, it's important to figure out which direction to face. This is stored in the image_xscale variable, and can be either -1 (left) or 1 (right). There are usually three different pieces of information that I like to use to determine which direction to face: (a) which side of the screen the RM is on, (b) which side of Mega Man the RM is on, and © which way the RM was facing before.

Quick Man turns to face Mega Man at the start of his jump, so we'll quickly code that in at the beginning of phase 0:

Code:
case 0: // jump at Mega Man
    if phaseTimer == 0 {
      // determine direction
      image_xscale = 1;
      if instance_exists(objMegaman)
          if x > objMegaman.x
              image_xscale = -1;
    }
    break;

Next we need to figure out the jump arc. There are a number of different ways one might reason about how to plan a jump. In particular, it's good to think about all the variables, and which one you want to be independent and which ones to be dependent. In other words, which variables you actually care about specifically and which variables you want to be calculated automatically because you don't care.

Furthermore, among the independent variables you might want to think about which variables you're free to set (e.g. the height of the jump) and which variables are given (e.g. where the jump begins; this is wherever the robot master happened to be at the start of the phase). Also, among the dependent variables, you should think about which ones you will need the AI to explicitly calculate and which ones don't need to be calculated. (For example, you might need the AI to calculate the initial speed of the jump, but the total amount of airtime doesn't matter and doesn't need to be explicitly calculated; it will be whatever it will be.)

Here are the variables that are usually involved in a jump:

- coordinates of the start of the jump
- coordinates of the end of the jump
- distance and direction travelled along the x axis over the course of the jump
- height of the jump
- length of time of the jump (airtime)
- x and y velocities at the start of the jump

Which variables are dependent and which are independent depends on your AI, and they won't always be the same. Let's work through the process of figuring this out for Quick Man. Quick Man actually has three different types of jumps (low, medium, and high) which he selects between randomly, but I'll just code in the medium jump for now and come back to the other jumps in another post.

- We can't explicitly set the position of the start of the jump; we have to work with wherever he happens to be at the beginning of the phase. So this is an independent variable.
- We want Quick Man to land directly on Mega Man; since we care about setting this explicitly, this is an independent variable.
- Once we have locked in the start position and end position, the distance travelled along the x axis cannot be changed. There's no wiggle room here (and we don't really care anyway), so this is a dependent variable
- Quick Man's medium jump is always 96 pixels high, so this is independent
- The airtime of a jump depends on the height of the jump and the force of gravity, so this is dependent.
- The x and y velocities depend on the height of the jump, the distance travelled, and the airtime of the jump, so these are dependent variables. We'll need to calculate these, however, in order to actually make Quick Man move.

Figuring out what is dependent and independent takes a good amount of practice and some intuition with math, so I'll work through some more examples for other robot masters in another post, if that would be helpful. Also, another useful tip is: the ultimate goal is usually to determine the x and y velocities to give to our Robot Master at the beginning of their jump to guarantee their jump arc is as intended. As soon as you have derived these values, you can stop thinking at that point.

Let's code this in:

Code:
case 0: // jump at Mega Man
    if phaseTimer == 0 {
    // set jumping sprite:
        image_index = 13;

        // turn to face Mega Man:
       if instance_exists(objMegaman) {
          image_xscale = 1;
          if x > objMegaman.x
            image_xscale = -1;
        }
        
        var jump_height, x_start, x_end, x_displacement;
        
        // calculate yspeed:
        jump_height = 96;
        
        // velocity required to reach jump height derived with kinematics equations:
        yspeed = -sqrt(abs(2*gravAccel*jump_height))
        airtime = abs(2*yspeed/gravAccel)
        
        // calculate xspeed:
        x_start = x;
        if instance_exists(objMegaman)
            x_end = objMegaman.x
        else
            x_end = x;
        
        x_displacement = x_end - x_start;
        xspeed = x_displacement / airtime;
    }
    break;

Great. Since gravity is already handled by the gravityCheckGround() script, we don't have to worry about it. Quick Man's yspeed variable will increase slightly each frame until he hits the ground.

Finally, we want to check when Quick Man lands on the ground and move on to phase 1. This is easy enough; the checkGround() script which is already called above sets the variable ground to false if our robot master is in the air, and some positive number otherwise:

Code:
if ground
  phase = 1;

[Image: 50h0BSA.gif]

(You may have noticed some errors in the gif above. I can see two. We'll come back to these in another post.)

So now our completed Quick Man AI should look like this:

Code:
case 0: // jump at Mega Man
    if phaseTimer == 0 {
        image_index = 13;
        // turn to face Mega Man:
       if instance_exists(objMegaman) {
          image_xscale = 1;
          if x > objMegaman.x
            image_xscale = -1;
        }
        
        var jump_height, x_start, x_end, x_displacement;
        
        // calculate yspeed:
        jump_height = 96;
        
        // velocity required to reach jump height derived with kinematics equations:
        yspeed = -sqrt(abs(2*gravAccel*jump_height))
        airtime = abs(2*yspeed/gravAccel)
        
        // calculate xspeed:
        x_start = x;
        if instance_exists(objMegaman)
            x_end = objMegaman.x
        else
            x_end = x;
        
        x_displacement = x_end - x_start;
        xspeed = x_displacement / airtime;
    } else if ground {
        phase = 1;
    }
    break;
case 1: // Run for 1 second:
    if phaseTimer == 0 {
        // turn to face Mega Man:
       if instance_exists(objMegaman) {
          image_xscale = 1;
          if x > objMegaman.x
            image_xscale = -1;
        }
    }
    xspeed = 2*image_xscale
    xDistanceTravelled += abs(xspeed)
    image_index = 8 + (xDistanceTravelled div 10) mod 4
    if phaseTimer > 60 {
        phase = 0;
    }
break;

Alright, now let's do shooting.

Shooting

Quick Man shoots at the apex of his jump, but you can of course make your robot master shoot at any time. Import your bullet sprites as you did for your Robot Master:

[Image: SoNU2F6.png]
[Image: 0pMZK50.png]

Then create a new object, which we'll call objQuickManBoomerang; give it the sprite and set the parent to be

prtEnemyProjectile
(which is in Enemies/DontPlaceInLevel). Give it this create code, and this step code:

Create Event

Code:
event_inherited();

contactDamage = 3;
image_speed = 1/6;

Step Event

Code:
event_inherited();

if !global.frozen && !global.flashStopper {
    
    x += xspeed;
    y += yspeed;
}else if global.flashStopper {
    instance_create(x, y, objExplosion);
    instance_destroy();
}

(If the player uses the flash stopper, the projectiles should explode; if you don't want this, then you can remove that block).

Now, we're going to make Quick Man fire the bullet after he is been in the air for half a second. Technically he should fire at the apex of his jump, but that's

a bit confusing to code, so we'll come back to that at end. He also is supposed to fire three boomerangs, but for now we'll just code in one. In the jump phase,

put this code in:

Code:
// fire bullets at Mega Man:
if phaseTimer == 30 && instance_exists(objMegaman) {
    // set shooting sprite:
    image_index = 14;
    
    // fire bullet:
    with instance_create(x,y,objQuickManBoomerang) {
        var dir;
        dir = point_direction(x,y,objMegaman.x,objMegaman.y)
        xspeed = cos(degtorad(dir)) * 4
        yspeed = -sin(degtorad(dir)) * 4
    }
}

We use a little bit of trigonometry to set the x and y speeds of the bullet; multiplying by 4 increases the speed of the projectile.

Awesome, now our barebones Quick Man fight is complete! Let's try it out:

[Image: psRtOLD.gif]

[spoiler="View the complete code]
Create event

Code:
event_inherited();

healthIndex = 1;
healthpointsStart = 28;
healthpoints = healthpointsStart;

reflectProjectiles = false;
reflectProjectilesRight = false;
reflectProjectilesLeft = false;

ground = false;

pose = sprQuickManPose;
poseImgSpeed = 12/60;

contactDamage = 4;

phase = 0;
phaseTimer = 0;
xDistanceTravelled = 0;

//stores the weapon damage values
wpndmg[0] = 1; //Mega Buster
wpndmg[1] = 1; //Mega Buster Charge1
wpndmg[2] = 3; //Mega Buster Charge2
wpndmg[3] = 1; //Weapon Explosion
wpndmg[4] = 1; //HornetChaser
wpndmg[5] = 1; //JewelSatellite
wpndmg[6] = 1; //GrabBuster
wpndmg[7] = 1; //TripleBlade
wpndmg[8] = 1; //WheelCutter
wpndmg[9] = 1; //SlashClaw
wpndmg[10] = 1; //Sakugarne
wpndmg[11] = 1; //SuperArrow
wpndmg[12] = 1; //WireAdapter

Step event
Code:
event_inherited();

if !global.frozen && !global.flashStopper
{
    if isFight {
        image_speed = 0;
        sprite_index = sprQuickMan;
        
        checkGround();
        gravityCheckGround();
        
        startPhase = phase
        
        switch phase {
            case 0: // jump at Mega Man
                if phaseTimer == 0 {
                    image_index = 13;
                    // turn to face Mega Man:
                   if instance_exists(objMegaman) {
                      image_xscale = 1;
                      if x > objMegaman.x
                        image_xscale = -1;
                    }
                    
                    var jump_height, x_start, x_end, x_displacement;
                    
                    // calculate yspeed:
                    jump_height = 96;
                    
                    // velocity required to reach jump height derived with kinematics equations:
                    yspeed = -sqrt(abs(2*gravAccel*jump_height))
                    airtime = abs(2*yspeed/gravAccel)
                    
                    // calculate xspeed:
                    x_start = x;
                    if instance_exists(objMegaman)
                        x_end = objMegaman.x
                    else
                        x_end = x;
                    
                    x_displacement = x_end - x_start;
                    xspeed = x_displacement / airtime;
                } else if ground {
                    phase = 1;
                }
                
                // fire bullets at Mega Man:
                if phaseTimer == 30 && instance_exists(objMegaman) {
                    // set shooting sprite:
                    image_index = 14;
                    
                    // fire bullet:
                    with instance_create(x,y,objQuickManBoomerang) {
                        var dir;
                        dir = point_direction(x,y,objMegaman.x,objMegaman.y)
                        xspeed = cos(degtorad(dir)) * 4
                        yspeed = -sin(degtorad(dir)) * 4
                    }
                }

                break;
            case 1: // Run for 1 second:
                if phaseTimer == 0 {
                    // turn to face Mega Man:
                   if instance_exists(objMegaman) {
                      image_xscale = 1;
                      if x > objMegaman.x
                        image_xscale = -1;
                    }
                }
                xspeed = 2*image_xscale
                xDistanceTravelled += abs(xspeed)
                image_index = 8 + (xDistanceTravelled div 10) mod 4
                if phaseTimer > 60 {
                    phase = 0;
                }
            break;
        }
        
        generalCollision();
        
        x += xspeed;
        y += yspeed;
        
        phaseTimer += 1;
        
        // check if phase has changed and reset some variables        
        if phase != startPhase {
          phaseTimer = 0;
        }
        
    }
}
[/spoiler]

You also have the most basic tools necessary now for creating your own robot master. However, you may have noticed a number of errors and inaccuracies compared to the original Quick Man. In the next post, I'll go into how to make Quick man actually accurate (fix some bugs we spotted, make him jump three times instead of once, have him randomly select his jump height, etc.). We'll look at a variety of useful coding tricks and also game-maker specific tricks involving coding detection. These probably won't all be generally applicable, but you might find it helpful to read anyway if you are curious.

[Image: 09W9dI3.gif]

(Why isn't he moving forward here? Next time!)
Reply
#2
GOOD
Reply
#3
Good luck! This is a service many people will find useful I bet Big Grin
Reply
#4
Yes, good!

I'd appreciate if you covered the game maker side of things as well (e.g. sprites, image_index, masks, etc.). Looking at the devkit bosses, I understood the AI logic fairly well, but the aforementioned stuff not as much. Also I believe some bosses used a bunch of different sprites (possibly with several subimages) for their standing/attacking/jumping etc. poses, while others packed more images into one sprite? Is there a reason other than personal preference?

Also, any good sprite editors? (The one built-in to GM8.1... works, but isn't great.)

I once tried to have my boss spawn other enemies during the fight, but they were immediately destroyed as soon as they attempted to move or attack. Clearly I missed something...

[spoiler=Discussion about my MaGMML2 level's boss (spoilers if you're avoiding the results):]
One of the judge comments said my boss's hitbox was too small, and they were right. I had tried setting the sprite mask to a rectangle that more closely matched the boss (while staying inside of it, to be fair to the player), but then my boss's jumps were different, and worse, it did things like get stuck in the floor or wall or even go out of bounds, so I reverted back to the Pharaoh Man mask (since my boss was based on Pharaoh Man). Better a slightly wrong hitbox than a broken boss! Not sure why it broke it in that way...[/spoiler]
Reply
#5
(22-05-2017, 10:30 PM)awd42 Wrote: I'd appreciate if you covered the game maker side of things as well (e.g. sprites, image_index, masks, etc.). Looking at the devkit bosses, I understood the AI logic fairly well, but the aforementioned stuff not as much. Also I believe some bosses used a bunch of different sprites (possibly with several subimages) for their standing/attacking/jumping etc. poses, while others packed more images into one sprite? Is there a reason other than personal preference?

There is no reason to use more than one sprite for your boss if you don't want to. I think the devkit bosses use multiple sprites because their programmers found it convenient, but I prefer to just use one sprite and vary the image_index (the subframe of the sprite) manually in the code.

However, if you find this too confusing (e.g. you are uncomfortable with the modular arithmetic needed to loop the animation) then you can just change the sprite_index to some sprite containing the animation you want (e.g. the running animation), set the image_speed to the appropriate value for the animation, and let game maker figure out how to loop the animation correctly for you on its own.

(BTW, In the mega engine, you do at least need to set the intro pose of the boss to be in its own sprite, unless it's a fortress boss.)

(22-05-2017, 10:30 PM)awd42 Wrote: masks

Yeah, masks are tricky. You shouldn't rely on pixel-perfect collision detection for your sprite because the Robot Master might clip into the wall if the animation changes or if it turns around. So, to avoid this, there are two options. You can set a "mask" for the sprite, which means that for the purposes of collision detection only, game maker pretends that your robot master's sprite is its mask (but the player when playing the game will never see the mask directly). If the mask is a still image that is horizontally symmetrical, your robot master will never clip into a wall due to the animation playing or due to it turning around.

However, the much easier solution than creating a mask is just to set the bounding box information on your Robot Master's sprite. I'll go into how to do this properly in the tutorial.

(22-05-2017, 10:30 PM)awd42 Wrote: Also, any good sprite editors? (The one built-in to GM8.1... works, but isn't great.)

I use the GIMP, but it's not very user-friendly; I'd recommend not using it unless you are already familiar with it. Notencore suggests using paint.net.

(22-05-2017, 10:30 PM)awd42 Wrote: I once tried to have my boss spawn other enemies during the fight, but they were immediately destroyed as soon as they attempted to move or attack. Clearly I missed something...

I am not sure exactly why that would be. Certainly there is nothing to do with the fact that there is a boss on screen that would affect this. Something to consider would be, perhaps you spawned them partly sunken into the ground? This can cause them to glitch out when they try to move.

Hope that helps!

Reply
#6
Nice tutorial thanks! I look forward to making real bosses now Big Grin
Reply
#7
Finishing Quick Man

Last time, I went into how to make a boss that can run and jump and shoot, using Quick Man as an example. However, the finished result was not quite accurate to the original Quick Man, so I will finish him off. This post doesn't really have a theme, other than going into the specifics of implementation details for Quick Man. That means this post is not a tutorial so much as it is an example of specific features to implement. If this interests you, then read on! (Otherwise, you're presumably reading because you want a particular question answered, in which case, please ask, and maybe I will know of a way to do what you want?)

By the end of this, Quick Man will...

- jump 3 times in a row, rather than just once.
- jump randomly one of three heights
- not stop in front of a small ledge when jumping (as highlighted in the previous post)
- only shoot at the apex of the second jump
- fire three boomerang shots in a spread, and the boomerangs will stop and home in on the player.
- glide over the gap in the middle of the screen when running.
- take damage from the flash stopper

Jumping 3 times
[spoiler="Implementation"]
Let's add a variable to keep track of how many times Quick Man has jumped, and reset it to 0 after he has jumped three times:

Create Event
Code:
jump_count = 0;

Step Event

Code:
case 0: // jump at Mega Man
    // at start of jump, plan the jump arc:
    if phaseTimer == 0 {
        jump_count += 1; // increment jump counter
        image_index = 13;
        
        // turn to face Mega Man:
        if instance_exists(objMegaman) {
            image_xscale = 1;
            if x > objMegaman.x
                image_xscale = -1;
        }
        
        var jump_height, x_start, x_end, x_displacement;
        
        // [omitted code for snippet]
    } else if ground {
        // landed; restart jump (or next phase after three jumps)
        playSFX(sfxLand);
        if jump_count >= 3 {
            // next phase (running)
            jump_count = 0;
            phase = 1;
        } else {
            // restart jump
            startPhase = -1;
        }
    }

At the top of the snippet, we check if it's the first frame of the jump, and if it is, increment the jump counter.

At the end of the code snippet, we check if Quick Man is on the ground; if he is on the ground then we check how many times he's jumped; if he's jumped 3 times, he should switch to running (phase = 1). Otherwise, we want to reset the phase. I used a bit of a hack here; we can't just do phase = 0 to reset the jump because the phase is already 0; setting the phase variable to 0 again won't accomplish anything. Instead, we need to set the phaseTimer = 0 directly, or trip up the phase-change detector by setting startPhase = -1, causing it to detect a change later when it notices the startPhase is not the current phase (-1 vs 0).

Honestly, this is a pretty minor implementation detail, but I did say I'd go into specifics. If you feel like this solution is too hacky then you could consider having a separate "phaseChanged" variable that you can set yourself to trigger a phase change, but I find that rather cumbersome for most cases.
[/spoiler]


Random Jump Height
[spoiler="Implementation"]
Quick Man randomly picks one of three jump heights: 32 pixels, 96 pixels, or 128 pixels high. When he performs the short jump, he will land right before reaching the player. For the tall jump, he will leap just past the player. For the middle jump, he'll actually try to land dead set on the player. Let's encode these values into an array in the create event:

Code:
// how high Quick Man jumps:
jump_height_for[0] = 32;
jump_height_for[1] = 96;
jump_height_for[2] = 128

// affects where Quick Man tries to land next to Mega Man (positive means jumping past Mega Man)
jump_x_offset[0] = -32;
jump_x_offset[1] = 0;
jump_x_offset[2] = 32;

Then, we'll use the irandom() function to pick a random integer from 0 to 2 to determine which type of jump to perform before calculating the jump arc in the step event:

Code:
jump_type = irandom(2); // 0: small, 1: medium, 2: high

var jump_height, x_start, x_end, x_displacement;

// calculate yspeed:
jump_height = jump_height_for[jump_type]

// velocity required to reach jump height derived with kinematics equations:
yspeed = -sqrt(abs(2*gravAccel*jump_height))
airtime = abs(2*yspeed/gravAccel)

// calculate xspeed:
x_start = x;
if instance_exists(objMegaman)
    x_end = objMegaman.x + jump_x_offset[jump_type]*image_xscale
else
    x_end = x;

x_displacement = x_end - x_start;
xspeed = x_displacement / airtime;
[/spoiler]


Jumping Over that Small Ledge
[spoiler="Implementation"]
[Image: 09W9dI3.gif]

As you may have noticed, Quick Man is stymied when attempting to jump over a waist-high block. This is because the generalCollision() script resets the xspeed variable to 0 when Quick Man collides with a wall. This is normally the correct behaviour, but it looks weird here, so we'll circumvent that by storing the desired xspeed separately in a variable we'll call pref_xspeed (for preferred):

Code:
pref_xspeed = x_displacement / airtime;

And every frame in phase 0, we'll also have this to set xspeed to that preferred xspeed value:

Code:
xspeed = pref_xspeed
[/spoiler]


Shooting at Apex
[spoiler="Implementation"]
We can detect when Quick Man has reached the apex of his jump by noticing that his yspeed has flipped from negative (up) to positive (down). I did this by checking if it was greater than or equal to zero, and if it was, firing, and setting a has_fired variable to true so so that we he doesn't keep firing every frame after that while falling.

Code:
case 0: // jump at Mega Man
    if phaseTimer == 0 {
        jump_count += 1;
        image_index = 13;
        
        // turn to face Mega Man:
        if instance_exists(objMegaman) {
            image_xscale = 1;
            if x > objMegaman.x
                image_xscale = -1;
        }
        
        // [omitted code for snippet]
    } else if ground {
        // landed; restart jump (or next phase after three jumps)
        playSFX(sfxLand);
        if jump_count >= 3 {
            // next phase (running)
            jump_count = 0;
            phase = 1;
            has_fired = false;
        } else {
            // restart jump
            startPhase = -1;
        }
    }
    
    // fire bullets at Mega Man:
    if jump_count == 2 && !has_fired && yspeed >= 0 && instance_exists(objMegaman) {
        has_fired = true;
        // set shooting sprite:
        image_index = 14;
        // fire bullet:
        // [code omitted from snippet]
    }

The if statement checking jump_count == 2 && !has_fired && yspeed >= 0 checks, respectively, if this is Quick Man's second jump, if he hasn't fired yet on this jump, and if he's begun falling (i.e. he's reached the apex already).
[/spoiler]


Firing homing bullets in a spread
[spoiler="Implementation"]
First, we're going to want to edit our bullet object so that they, like Quick Man, have phases. Quick Man's boomerangs have three distinct phases: first they move toward the player; then after they've passed the player they stop briefly; then finally they fly at the player again (eventually flying off screen).

We'll give our bullets a default speed of 4.5 pixels per frame, a variable called dist to keep track of how much distance they've travelled so that they know when to stop, and a variable called timer so that they know when to start moving again after they stop.

objQuickManBoomerang Create Event
Code:
event_inherited();

contactDamage = 3;
image_speed = 1/6;

dist = 0;
phase = 0;
timer = 45;

_speed = 4.5;

objQuickManBoomerang Step Event

Code:
event_inherited();

if !global.frozen && !global.flashStopper {
    switch phase {
    case 0:
        x += xspeed;
        y += yspeed;
        dist -= sqrt(xspeed*xspeed+yspeed*yspeed)
        if dist < 0
            phase = 1;
        break;
    case 1:
        timer -= 1;
        if timer <= 0 {
            phase = 2;
            var dir;
            if instance_exists(objMegaman)
                dir = point_direction(x,y,objMegaman.x,objMegaman.y)
            else
                dir = random(360);
            xspeed = cos(degtorad(dir)) * _speed;
            yspeed = -sin(degtorad(dir)) * _speed;
        }
        break;
    case 2:
        x += xspeed;
        y += yspeed;
        break;
    }
} else if global.flashStopper {
    instance_create(x, y, objExplosion);
    instance_destroy();
}

Notice that in phase 0, the distance counter decreases according to pythagoras' law, and when it reaches 0 the boomerang moves on to phase 1. In phase 1, the timer has a similar function; when it reaches 0, it changes course to go toward Mega Man (again employing some trigonometry). The timer variable is set in the create event, but we'll let Quick Man himself set the distance for the bullet to travel when he creates them.

In Quick Man's step event, when the bullets are created (see the previous section on shooting at the apex of the jump), we'll create three bullets using a for loop like so:

Code:
var i;
for (i=0;i<3;i+=1) {
    with instance_create(x,y,objQuickManBoomerang) {
        var dir, target_x, target_y;
        dir = point_direction(x,y,objMegaman.x,objMegaman.y)
        xspeed = cos(degtorad(dir)) * _speed;
        yspeed = -sin(degtorad(dir)) * _speed;
        dist = point_distance(x,y,objMegaman.x,objMegaman.y) + 48
    }
}

We add 48 pixels to the distance (3 blocks) so that the boomerangs go a little past Mega Man before coming to a stop.

However, this creates three bullets going in the same direction -- the player will only think there's one bullet, since the bullets are all overlapping! We can use the variable i, which is a number ranging from 0 to 2 that is different for each of the three bullets, to make them go in different directions. Normally, I'd make bullets spread out by making adjusting the direction they're fired at by 15 degrees or so, like this:

Code:
dir = point_direction(x,y,objMegaman.x,objMegaman.y) + 15*(i - 1)

However, Quick Man's spread shot is actually a bit more bizarre than that. The bullets actually spread out more the farther Mega Man is from Quick Man, ariving such that the closest bullet is half the distance to Mega Man, and the third bullet is 50% farther, like this:

[Image: 7jfO1Pm.png]

This means that if Quick Man is directly above Mega Man, then the bullets won't spread out at all! Here's how I've coded it:

Code:
// fire bullet:
var i;
for (i=0;i<3;i+=1) {
    with instance_create(x,y,objQuickManBoomerang) {
        var dir, target_x, target_y;
        dir = point_direction(x,y,objMegaman.x,objMegaman.y)
        xspeed = cos(degtorad(dir)) * _speed * (0.5+i/2);
        yspeed = -sin(degtorad(dir)) * _speed;
        dist = point_distance(x,y,x + (objMegaman.x - x) * (0.5 + i/2),objMegaman.y) + 48
    }
}

[/spoiler]


Gliding over Gap (Nudging)
[spoiler="Implementation"]

When running, Quick Man should run smoothly over the gap in the center rather than falling in as shown here:

[Image: oGmr5qL.gif]

In comparison, we desire something like this:

[Image: 2oDTFkX.gif]

This is where usage of the place_meeting() function becomes useful. I would say that place_meeting() is probably the most indispensible function in game maker; in underpins almost all reasoning about how to determine what is in your object's immediate surroundings, be it solids, enemies, etc. How place_meeting() works is that you provide it with an x and y position, and the type of object to look for, and it will return true if your instance would collide with that type of object if one were to move it there[i]. For example, one could check if there is a wall in front of your Robot Master by scanning the pixel in front of your RM:

Code:
if place_meeting(x + image_xscale,y,objSolid)
    //...

What we're going to do is let Quick Man fall into the gap a bit as he runs, but when he reaches the far side, he'll check to see if moving himself up a few pixels would rescue his run, and if so, he'll move up a few pixels. Specifically, we're going to need 4 different checks of place_meeting():

Check if:
1. Quick Man is over a gap --> !place_meeting(x,y+yspeed,objSolid)
2. Quick Man has enough room to be nudged up 6 pixels --> !place_meeting(x,y - 6,objSolid)
3. Quick Man would collide with an objSolid next frame --> place_meeting(x+xspeed,y+yspeed,objSolid)
4. Quick Man would not collide with an objSolid if he were to be moved 6 pixels up [i]from the place he'll be next frame
(This is very important; this tells us not only if nudging is possible but if it would even be useful) --> place_meeting(x+xspeed,y+yspeed-6,objSolid)

We'll toss this code into phase 1 (the running phase):

Code:
// hop over small obstacle:
if place_meeting(x,y+yspeed,objSolid) && !place_meeting(x,y-6,objSolid)
    && !place_meeting(x+xspeed,y+yspeed,objSolid) && !place_meeting(x+xspeed,y+yspeed,objSolid) {
    // adjust position slightly:
    y-=6;
}
[/spoiler]


Taking Damage from Flash Stopper
[spoiler="Implementation"]

This is a minor thing, but we want the Flash Stopper to deal damage to Quick Man. The engine doesn't provide a neat way to make that happen (Flash Stopper is not in the damage table, if you'll notice!) -- but never let this stop you. We can hack this in ourselves! We just need to check if the Flash Stopper is active and, if it is, gradually lower Quick Man's hit points. To make things less dull for the player (and we have license to change things since this is the Flash Stopper we're talking about, not the Time Stopper; strangely enough, the Time Stopper is Flash Man's power, not the Flash Stopper -- that's Bright Man), we will make the Flash Stopper run out quicker when it's being used on Quick Man. Toss this at the bottom of the code:

Code:
// flash stopper drains Quick Man's health:
if global.flashStopper &&! global.frozen {
    global.bossHealth[1] -= 1/30
    if global.bossHealth[1] <= 0
        event_user(10);
        
    // flash stopper drains extra fast:
    global.flashStopperTimer -= 3;
}

We check !global.frozen to make sure the game isn't paused when we are draining his health. We run event_user(10) to kill the boss when his HP has run out.
[/spoiler]


Here's the final code:

Create Event
Code:
event_inherited();

healthIndex = 1;
healthpointsStart = 28;
healthpoints = healthpointsStart;

reflectProjectiles = false;
reflectProjectilesRight = false;
reflectProjectilesLeft = false;

ground = false;

pose = sprQuickManPose;
poseImgSpeed = 12/60;
image_index = 13;

contactDamage = 4;

phase = 0;
phaseTimer = 0;
xDistanceTravelled = 0;
has_fired = false;
jump_count = 0;

// tailor these variables at your leisure:

run_speed = 3;
run_time = 60;

// how high Quick Man jumps:
jump_height_for[0] = 32;
jump_height_for[1] = 96;
jump_height_for[2] = 128

// affects where Quick Man tries to land next to Mega Man
jump_x_offset[0] = -32;
jump_x_offset[1] = 0;
jump_x_offset[2] = 32;

//stores the weapon damage values
wpndmg[0] = 2; //Mega Buster
wpndmg[1] = 2; //Mega Buster Charge1
wpndmg[2] = 3; //Mega Buster Charge2
wpndmg[3] = 3; //Weapon Explosion
wpndmg[4] = 1; //HornetChaser
wpndmg[5] = 2; //JewelSatellite
wpndmg[6] = 1; //GrabBuster
wpndmg[7] = 3; //TripleBlade
wpndmg[8] = 2; //WheelCutter
wpndmg[9] = 2; //SlashClaw
wpndmg[10] = 1; //Sakugarne
wpndmg[11] = 1; //SuperArrow
wpndmg[12] = 4; //WireAdapter

Step Event
Code:
event_inherited();

if !global.frozen && !global.flashStopper
{
    if isFight {
        image_speed = 0;
        sprite_index = sprQuickMan;
        
        checkGround();
        gravityCheckGround();
        
        startPhase = phase
        
        switch phase {
            case 0: // jump at Mega Man
                // plan jump arc:
                if phaseTimer == 0 {
                    jump_count += 1;
                    image_index = 13;
                    
                    // turn to face Mega Man:
                    if instance_exists(objMegaman) {
                        image_xscale = 1;
                        if x > objMegaman.x
                            image_xscale = -1;
                    }
                    
                    jump_type = irandom(2); // 0: small, 1: medium, 2: high
                    
                    // doesn't perform small jump if Mega Man is too far away:
                    if instance_exists(objMegaman)
                        if abs(objMegaman.x - x) > 120 && jump_type == 0
                            jump_type = 1;
                    
                    var jump_height, x_start, x_end, x_displacement;
                    
                    // calculate yspeed:
                    jump_height = jump_height_for[jump_type]
                    
                    // velocity required to reach jump height derived with kinematics equations:
                    yspeed = -sqrt(abs(2*gravAccel*jump_height))
                    airtime = abs(2*yspeed/gravAccel)
                    
                    // calculate xspeed:
                    x_start = x;
                    if instance_exists(objMegaman)
                        x_end = objMegaman.x + jump_x_offset[jump_type]*image_xscale
                    else
                        x_end = x;
                    
                    x_displacement = x_end - x_start;
                    pref_xspeed = x_displacement / airtime;
                } else if ground {
                    // landed; restart jump (or next phase after three jumps)
                    playSFX(sfxLand);
                    if jump_count >= 3 {
                        // next phase (running)
                        jump_count = 0;
                        phase = 1;
                        has_fired = false;
                    } else {
                        // restart jump
                        startPhase = -1;
                    }
                }
                
                // fire bullets at Mega Man:
                if jump_count == 2 && !has_fired && yspeed >= 0 && instance_exists(objMegaman) {
                    has_fired = true;
                    // set shooting sprite:
                    image_index = 14;
                    // fire bullet:
                    var i;
                    for (i=0;i<3;i+=1) {
                        with instance_create(x,y,objQuickManBoomerang) {
                            var dir, target_x, target_y;
                            dir = point_direction(x,y,objMegaman.x,objMegaman.y)
                            xspeed = cos(degtorad(dir)) * _speed * (0.5+i/2);
                            yspeed = -sin(degtorad(dir)) * _speed;
                            dist = point_distance(x,y,x + (objMegaman.x - x) * (0.5 + i/2),objMegaman.y) + 48
                        }
                    }
                }
                
                xspeed = pref_xspeed;
                
                break;
            case 1: // Run for 1 second:
                if phaseTimer == 0 {
                    // turn to face Mega Man:
                   if instance_exists(objMegaman) {
                      image_xscale = 1;
                      if x > objMegaman.x
                        image_xscale = -1;
                    }
                }
                xspeed = run_speed*image_xscale
                
                // hop over small obstacle:
                if place_meeting(x+xspeed,y+yspeed,objSolid) && !place_meeting(x,y+yspeed,objSolid) // obstacle in front?
                    && !place_meeting(x+xspeed,y+yspeed-6,objSolid) && !place_meeting(x,y-6,objSolid) { // no obstacle in desired place to move to?
                    // adjust position slightly:
                    y-=6;
                }
                
                xDistanceTravelled += abs(xspeed)
                image_index = 8 + (xDistanceTravelled div 10) mod 4
                if phaseTimer > run_time {
                    phase = 0;
                }
            break;
        }
        
        generalCollision();            
        x += xspeed;
        y += yspeed;
        
        phaseTimer += 1;
        
        // check if phase has changed and reset some variables        
        if phase != startPhase {
          phaseTimer = 0;
        }
        
    }
}

// flash stopper drains Quick Man's health:
if global.flashStopper &&! global.frozen {
    global.bossHealth[1] -= 1/30
    if global.bossHealth[1] <= 0
        event_user(10);
    // flash stopper drains extra fast:
    global.flashStopperTimer -= 3;
}

[spoiler="Final Battle"]
[Image: jOQW3Jp.gif]
[/spoiler]

If you have any other requests / want to know how to do xyz, let me know, and maybe I'll write up something about it! I probably won't go into quite this much detail though.
Reply


Forum Jump:


Users browsing this thread: 1 Guest(s)