Welcome to the introductory player script guide! In this guide, we will cover the fundamental components of a player script and how you can create your own. We will utilize the default ExRumia as an example of a player script, but will take a different approach from usual in this guide and will have you start from scratch - a complete blank slate. Accordingly, we will provide some basic 'graphics' that you can use as a starting point.
Player scripts are incredibly complex in that creating them requires a working knowledge of most of the provided object types in Danmakufu. It is the only cross-cutting script of this sort and as such, we recommend that you be familiar with the following prior to starting: Shot Objects, 2D Sprite Objects, Primitive Objects, Text Objects, Sound Objects, Shotsheets, Events, and Virtual Keys
In order to not overload this guide, a discussion on player design and balancing as well as how *not* to design a shottype will be provided in the next guide, which will be shorter but far more complex in content.
Before we start writing a player script, we must first understand the components that make up a player.
So let's start at the very beginning? What exactly is a player? What are the most fundamental traits that make a player, well, a player?
The first is that it acts as a means for the physical player to interact with the game. It doesn't really matter what the player looks like or sounds like - all that matters is that the physical player be able to provide instructions, and that the player script execute those instructions. In Touhou games, these are commands such as the movement commands, firing, and casting a spell card/bomb. All of these are mapped to virtual keys, which in turn control the player.
Therefore, virtual keys are what control the player in-game. For a refresher on virtual keys and key input, see Lesson 32.
Now, in Touhou style games, the player can do a number of different actions, and can be customized in a variety of different ways. We will split these up into six sections.
First, the basic parameters of the player. Every player has its own speed, graze radius, deathbomb window, etc. In addition, you must render the player in order for the physical player to understand how the player is reacting to their input.
Next is a common aspect of Touhou-style players - the Player Option. Options/Shikigami/Familiars accompany the player and are typically tasked with moving in a specific way and firing shots. These must be rendered, controlled depending on whether the player is moving unfocused or focused, and must fire shots accordingly.
Another component is the player event. Just like any other Danmakufu script, players have an event handler, and can work with a variety of system-triggered events. We will go into more detail in the respective section, but there is much customization that can be done here, in addition to noting some aspects that Danmakufu does not handle by default.
In addition, we have player death. Player death is simple on the surface, but is actually quite a bit more complex. For example, where does the player respawn? How do graphics and familiars adjust to that respawn point? How is deathbombing controlled? Is it controlled automatically? And after the player has respawned, how do you convey to the player that the player is no longer invincible? These are all things that need to be taken into consideration.
In addition to these, players have their own shotsheets and control mechanisms when they are firing. How do these work? How do you handle focused vs. unfocused shottypes?
And finally, player bombs are incredibly complex, with their own special object type. From triggering to rendering and action to completion, it is necessary to understand how they work, how they destroy bullets, and how to avoid unfortunate accidents.
With that introduction out of the way, let's get started.
As noted earlier, we will be working from scratch here instead of starting by analyzing the default player script. We will go section by section, but let's start with our general framework.
First, a player script is like any other Danmakufu script. You have a header, your routines, and your other miscellaneous supporting files, assets, and scripts. Of particular interest is the player shotsheet, which we will get to later on.
First, let's get our Danmakufu Header sorted out.
#TouhouDanmakufu[Player]
#ScriptVersion[3]
#ID["DNHTut"]
#Title["Danmakufu Tutorials - Player Script"]
#Text["Example player script built for the Danmakufu Tutorials"]
#ReplayName["DNHTut"]
Let's take this apart. First, we have our usual fields. We specify that the script is a Player script after #TouhouDanmakufu
, and use most fields as usual. Title and Text are shown here, but you will almost always want an #Image
component - Player scripts are actually one of the few locations where using this is standard since... it's sort of important to know what your player looks like, and is a great way to include artistic style or text-based information that might not fit on the screen with the limited #Text option.
#ID
and #ReplayName
are two important components of a Player script. The former is the ID for the player, which should be unique across all Danmakufu player scripts if you plan to release your player for general use, and which cannot have spaces. This ID is very important and is the primary identifier for your player script. The Replay Name on the other hand is exactly what it sounds like - the name associated with your player script in replays. These two fields can be retrieved in Danmakufu using GetPlayerID()
and GetPlayerReplayName()
.
Now, as usual, please create your routines - @Initialize
, @MainLoop
, @Event
, etc. Now, between the header and @Initialize
, we will define some global variables that will be global across this script.
The primary one you will want is a reference to the player object itself for when the script runs. For this, we can use something along the lines of let objPlayer = GetPlayerObjectID();
. GetPlayerObjectID()
, when run, gets the Object ID associated with the player object - this is a special object that supports ObjMove and ObjRender commands among others and is how we will refer to the player object within the script.
Besides this, you may also want to define a shortcut for GetCurrentScriptDirectory()
, which we will use for all of the assets we load. We will use csd
as our variable name. Do note that in this tutorial, we won't actually cover sound effects - these are up to you to implement. Also note that player scripts in particular should never have absolute paths since they can be moved around quite a bit and it's good to have a player script that you can drop into a completely different directory. This is because most ph3 scripts don't allow the person playing the script to choose from the full selection of players but instead limit them to a specific set of players bundled together with the script.
Now, let's head into @Initialize
and call some tasks. We will want to define these as well. First, a task to draw the player sprite. Please create a png file with a single white pixel in it and name it white.png
- normally you'd want actual sprites but for the purpose of simplicity given that this is a tutorial, this should suffice. For now we'll call the player drawing task DrawPlayer
.
Since we have no animation, we can have a very simple (and very uninspiring) draw loop. However, we will do some player direction checks to show that the player is moving in a direction. We're using a 15 degree rotation here - if you are too lazy to or unable to use a separate set of sprites for a movement animation then a 10-15 degree shift does the job (though it's not optimal).
task DrawPlayer {
let path = csd ~ "white.png";
ObjPrim_SetTexture(objPlayer, path);
ObjSprite2D_SetSourceRect(objPlayer, 0, 0, 15, 15);
ObjSprite2D_SetDestCenter(objPlayer);
loop {
if(GetVirtualKeyState(VK_LEFT) == KEY_PUSH || GetVirtualKeyState(VK_LEFT) == KEY_HOLD) {
ObjRender_SetAngleZ(objPlayer, -15);
} else if(GetVirtualKeyState(VK_RIGHT) == KEY_PUSH || GetVirtualKeyState(VK_RIGHT) == KEY_HOLD) {
ObjRender_SetAngleZ(objPlayer, 15);
} else {
ObjRender_SetAngleZ(objPlayer, 0);
}
yield;
}
}
In this tutorial we won't be covering the animation process since that really depends on your spritesheet. Note that you do NOT need to manually set positions - remember that you are operating directly on the player object when you run the above commands. The infinite loop here ensures that the rendering task runs as long as the script is running.
For the future, please create a PlayerShot
task. Also add a yield;
to @MainLoop
so that tasks can run in this script.
At this point, you should actually be able to run the tutorial player script and do pretty much everything except for bomb. Of course there are many things missing and some things (e.g. movement speed) are clearly set to some default, but we'll get to replacing those next. Do note that the player controls are built into Danmakufu and that you don't need to manually code them in, so movement and other virtual key controlled actions are done for you.
Players have all kinds of parameters. Hitbox size, movement speed, deathbomb window, etc. See Player Functions for a list. We'll go through these one by one.
First, player speed. Each player has two speeds - one for when they are unfocused and one for when they are focused. Balancing these is difficult but depends on what kind of player you are designing.
Next, the player clip. By default, the player can move throughout the entire playing field but no further. However, you can change the player clip so that the player is further restricted. All four LTRB values are relative to the top left of the playing field.
Next, we have lives, spells, and power. The first should never be set in a player script unless you are trying to make an invincible player - SetPlayerLife()
usage should be reserved for the package/stage/plural instead. The second is a little quirky - adding spells based off of a spell extend system should typically be associated with the package/stage/plural if the system is tied to the game's gimmick but should be associated with the player instead if it's a player-specific gimmick. Removal of bombs and what happens to bombs on bombing/player death/deathbombing should be exclusively controlled in the player script unless you have some kind of gameplay gimmick. Finally, power is typically irrelevant since there is no standard system. It's fine to ignore this entirely.
Next we have invincibility frames. SetPlayerInvincibilityFrame()
starts ticking from when it is called so we won't concern ourselves with this at the moment (it primarily comes into play when the player bombs/dies).
Now we have three fields associated with player death and rebirth. Down State Frames determine how long until respawning occurs. Rebirth Frames specify the deathbomb window, with a default of 15 frames (1/4 second). Finally, there are Rebirth Loss Frames, which effectively make it harder to perform consecutive deathbombs by shortening the window over time. This is handled independently from the deathbomb window as far as the raw player attribute goes, which means that the actual stored value of the deathbomb window does not change - only the effective value that the player sees changes. When the player dies, the loss frames reset back to how they were before without the coder having to manually specify so.
Next we have the autocollect line. This is the y coordinate above which all items can be autocollected by the player. It defaults to zero, so if you want to have autocollect enabled, make sure to set this value. Note that in some scripts or games, it may be preferable to control this from the stage/plural if it's tied to a gimmick.
Finally, in the ObjPlayer category, we have the player hitbox. This is set once and persists over time, adjusting its position automatically. ObjPlayer_AddIntersectionCircleA1()
allows for both the hitbox and graze radii to be set. ObjPlayer_AddIntersectionCircleA2()
can be used to create additional independent graze hitboxes. Finally, ObjPlayer_ClearIntersection()
can be used to clear the existing hitboxes.
We will go with the default on all of these for the purpose of this tutorial. Except the intersection - we sort of need a hitbox. In @Initialize
, add the following. It doesn't matter if it's before or after the tasks are called, but we will put it before. ObjPlayer_AddIntersectionCircleA1(objPlayer, 0, 0, 1, 20);
Options, also known as familiars, are an integral part of Touhou-style shmups. They provide incredibly variety by moving in limitless ways, providing all sorts of extra shots, etc. Notable examples of options include those that automatically hone in on enemies, those that stay in place when the player focuses, and those that trail behind the player.
Now, we *could* implement a complicated set of familiars doing crazy logic but for this tutorial we will keep things simple and have two familiars - one on each side of the player. These will have a different distance depending on whether or not the player is focused.
Please define a task CreateOption(dir)
. In @Initialize
, call it twice - once with the parameter -1 and once with the parameter 1. We will use these parameters to determine which option is which.
We will use a typical task for a render object. Each option is a render object with a graphic and a position relative to the player.
// Creates an option to the side of the player
task CreateOption(dir) {
let path = csd ~ "white.png";
let unfocdist = 64; // Distance to the player when unfocused
let focdist = 32; // Distance to the player when focused
let transitionfr = 15; // Number of frames for option transition when focused/unfocused state switches
let currdist = unfocdist;
let objOption = ObjPrim_Create(OBJ_SPRITE_2D);
ObjPrim_SetTexture(objOption, path);
ObjSprite2D_SetSourceRect(objOption, 0, 0, 11, 11);
ObjSprite2D_SetDestCenter(objOption);
loop {
if (GetVirtualKeyState(VK_SLOWMOVE) != KEY_HOLD && GetVirtualKeyState(VK_SLOWMOVE) != KEY_PUSH) { // Unfocused
if (currdist < unfocdist) {
currdist += (unfocdist - focdist)/transitionfr;
}
} else { // Focused
if (currdist > focdist) {
currdist -= (unfocdist - focdist)/transitionfr;
}
}
ObjRender_SetPosition(objOption, GetPlayerX() + currdist*dir, GetPlayerY(), 1);
yield;
}
}
A few things to note. First, the source rect dictates an oddxodd rect. This is important since many options are circular and will want to rotate. Danmakufu ph3 requires oddxodd rects for clean rotation. Next, the way we're handling the 'animation' between the focused and unfocused positions. While crude, it does the job. We specify two distances from the player - one for focused and one for unfocused - and keep track of the current distance. In the while loop, we check the current distance against these and if not within the range, we adjust the distances. Here I also use a transition frame variable to control the speed of the transition between the phases - it's typically not a good idea to have the options simply warp since it has a jarring visual impact. You can set the transition speed to whatever you want, though lower is better (not too low though!)
We will cover having options fire shots when we cover player shots.
So far, we haven't covered the @Event
routine - in fact, with the current player setup, attempting to bomb will crash Danmakufu since it doesn't know what to do if this isn't defined.
Now, there are plenty of different events you can handle in a player script. EV_GET_ITEM
, EV_HIT
, EV_PLAYER_SHOOTDOWN
, EV_PLAYER_REBIRTH
, and EV_REQUEST_SPELL
are some of the most notable, but there are plenty of others - EV_GRAZE
is a great one to handle if you want graze particles/sound effects on your player, for example. Let's start with EV_GET_ITEM
.
To be quite blunt, the only reason to use EV_GET_ITEM
is to have sound effects play when you collect life and bomb items. Using GetEventArgument(0)
to get the event argument will yield the type of the item (e.g. ITEM_1UP, ITEM_SPELL).
Next, let's touch upon EV_GRAZE
- we'll set up a system for graze particles here. Sound is up to you. We'll want a task CreateGrazeParticle
defined somewhere in the script. In this case, we will also use a global constant MAX_GRAZE_PARTICLES and a global variable curr_graze_particles. Since we'll be creating actual objects, we want to make sure that we don't create more than expected and so these two values will help with that. In the task, we'll create a render object that starts at the player, flies off in a random direction, and disappears.
...
case(EV_GRAZE) {
// Add sound here
if (curr_graze_particles < MAX_GRAZE_PARTICLES) {
CreateGrazeParticle;
}
}
...
task CreateGrazeParticle {
let path = csd ~ "white.png";
let objParticle = ObjPrim_Create(OBJ_SPRITE_2D);
let startx = GetPlayerX();
let starty = GetPlayerY();
let angle = rand(0, 360);
let anglecomponents = [cos(angle), sin(angle)];
let speed = rand(0, 4) + rand(0, 8);
let frames = 30; // Frames for the particle to exist
let size = rand(1, 3);
curr_graze_particles += 1;
ObjPrim_SetTexture(objParticle, path);
ObjSprite2D_SetSourceRect(objParticle, 0, 0, size, size);
ObjSprite2D_SetDestCenter(objParticle);
ascent(i in 0..frames) {
ObjRender_SetX(objParticle, startx + anglecomponents[0] * i);
ObjRender_SetY(objParticle, starty + anglecomponents[1] * i);
if (i > frames/2) {
ObjRender_SetAlpha(objParticle, 255 - (i - frames/2)*255/(frames/2));
}
yield;
}
Obj_Delete(objParticle);
curr_graze_particles -= 1;
}
In this example, we splurge a little and make a fancier graphic with variable speed and size. Our strategy for controlling position is a linear control from the base point (player position at the time of graze). Since calling trigonometric functions is expensive and we will be creating many of these particles, we're running the trig once and storing the value (since the angle is not changing).
Note that we're doing some strange logic for the alpha - this is to have the particle only start fading out halfway through its existence time. Hopefully this should suffice as a basic graze particle effect.
There are four more events we'll cover in this tutorial - EV_HIT
, EV_PLAYER_SHOOTDOWN
, EV_PLAYER_REBIRTH
, and EV_REQUEST_SPELL
. We'll do the two related to player misses and death first.
First thing's first. When the player is hit, regardless of whether or not they deathbomb, there must be some kind of visual indication. And then, once they respawn, we need to determine where they do so and will need to showcase invincibility post-respawn.
Starting with when the player is hit, EV_HIT
differs from EV_PLAYER_SHOOTDOWN
in that the former is emitted when the player is hit while the latter is emitted when the player loses a life. There is a distinction here involving deathbombs - if the player deathbombs, only the former will be emitted. Therefore, we use EV_HIT
for the death sound effect but EV_PLAYER_SHOOTDOWN
for the actual death animation.
Let's create a player death explosion effect. Again, sound is up to you. We'll run a single task in the event handler which I am calling DeathExplosion
. Continuing with the slightly-fancier-than-necessary trend, we'll have some fancy inverted expanding squares coupled with some particle effects to reflect the nature of our square player that will 'reform' our player.
task CreateExpandingInvertSquare(angle) {
let path = csd ~ "white.png";
let objSquare = ObjPrim_Create(OBJ_SPRITE_2D);
ObjPrim_SetTexture(objSquare, path);
ObjRender_SetBlendType(objSquare, BLEND_INV_DESTRGB);
ObjSprite2D_SetSourceRect(objSquare, 0, 0, 2048, 2048);
ObjSprite2D_SetDestCenter(objSquare);
ObjRender_SetPosition(objSquare, GetPlayerX(), GetPlayerY(), 0);
ObjRender_SetAngleZ(objSquare, angle);
ascent(i in 0..120) {
// Scale goes from 0 to 1
ObjRender_SetScaleXYZ(objSquare, i/120, i/120, i/120);
yield;
}
Obj_Delete(objSquare);
}
task CreateDeathParticle {
let path = csd ~ "white.png";
let objSquare = ObjPrim_Create(OBJ_SPRITE_2D);
let angle = rand(0, 360);
let anglecomponents = [cos(angle), sin(angle)];
let speed = rand(0, 8);
let accel = speed/100;
let startx = GetPlayerX();
let starty = GetPlayerY();
let currx = startx;
let curry = starty;
ObjPrim_SetTexture(objSquare, path);
ObjSprite2D_SetSourceRect(objSquare, 0, 0, 8, 8);
ObjSprite2D_SetDestCenter(objSquare);
ObjRender_SetPosition(objSquare, startx, starty, 0);
let rotangles = [rand(0, 360), rand(0, 360), rand(0, 360)];
let rotspeeds = [rand(0, 6), rand(0, 6), rand(0, 6)];
let downframes = GetPlayerDownStateFrame();
ascent(i in 0..downframes/2) {
currx += anglecomponents[0] * speed;
curry += anglecomponents[1] * speed;
ObjRender_SetPosition(objSquare, currx, curry, 0);
rotangles[0] = rotangles[0] + rotspeeds[0];
rotangles[1] = rotangles[1] + rotspeeds[1];
rotangles[2] = rotangles[2] + rotspeeds[2];
ObjRender_SetAngleXYZ(objSquare, rotangles[0], rotangles[1], rotangles[2]);
speed -= accel;
yield;
}
// Use respawn location to determine movement back towards it
let dx = GetStgFrameWidth()/2 - currx;
let dy = GetStgFrameHeight() - 32 - curry;
ascent(i in 0..downframes/2) {
currx += dx/(downframes/2);
curry += dy/(downframes/2);
ObjRender_SetPosition(objSquare, currx, curry, 0);
rotangles[0] = rotangles[0] + rotspeeds[0];
rotangles[1] = rotangles[1] + rotspeeds[1];
rotangles[2] = rotangles[2] + rotspeeds[2];
ObjRender_SetAngleXYZ(objSquare, rotangles[0], rotangles[1], rotangles[2]);
yield;
}
Obj_Delete(objSquare);
}
task DeathExplosion {
CreateExpandingInvertSquare(0);
CreateExpandingInvertSquare(45);
loop(30) {
CreateDeathParticle;
}
loop(5) {yield;}
CreateExpandingInvertSquare(0);
CreateExpandingInvertSquare(45);
}
Admittedly, this is probably overkill, but let's dive into this animation. First, we're creating four squares, offset by 5 frames. These are INV_DESTRGB and so we must pair them up to create a 'restoration' effect whereby the one spawned later 'restores' the invert wave created by the first. In order to have a nice transition for when the object disappear, this time-based pairing is necessary, coupled with a final scale that is large enough to surround the entire playing field (hence the 2048x2048). We're using 120 frames for these but you can do whatever you want. Doubling up with the angle offset is just to make things look fancier.
For the death particles, it's similar to the graze particles except they're actually keeping track of their position when flying outwards and also have acceleration on them for when they fly out, proportional to their speed. We're using the player's down state frames in order to time the animation - note that we run GetPlayerDownStateFrame()
once and store the value - this is because the frame it is called is the first frame where the player is dead (so it's at max value) and because we want to use the original value for timing. This allows us to now have to edit a value here were we to change the number of down state frames elsewhere. As for the second half of the animation, we use the Danmakufu default respawn point as a 'target' and have the particles lerp to that point from wherever they are. If you don't like this, you can remove the second ascent loop and have the first one last the full duration of downframes
. The angle rotation is also just for visual effect and is entirely optional.
Now, a few changes to keep things moving smoothly. We will create a playerdead
global variable initially set to false. In our player and option loops, we will add the following (change the object to whichever one is relevant) in order to actually hide the player and options when the player is dead:
if (playerdead) {
ObjRender_SetAlpha(objPlayer, 0);
} else {
ObjRender_SetAlpha(objPlayer, 255);
}
And now, our current event handler:
@Event {
alternative(GetEventType)
case(EV_GRAZE) {
// Add sound here
if (curr_graze_particles < MAX_GRAZE_PARTICLES) {
CreateGrazeParticle;
}
}
case(EV_PLAYER_SHOOTDOWN) {
playerdead = true;
DeathExplosion;
// Optional: run a task to delete bullets in a radius of the player
}
case(EV_PLAYER_REBIRTH) {
playerdead = false;
SetPlayerInvincibilityFrame(240);
SetPlayerSpell(3); // Note: You can also use max(#, GetPlayerSpell) to not destroy extra bombs from prior life
// Optional: run a task to delete bullets in a radius of the player
}
}
Note that I added 240 frames of invincibility. This is because the player should not be vulnerable on respawn, especially if they're respawning to a fixed location. But that does transition into the next part.
Let's target the invincibility... by copy-pasting the magic circle code from Lesson 29
Now, some key differences. First, this magic circle will be created at the start of the player script and will never delete - the current number of invincibility frames will determine if its logic is executed at all. Secondly, the scale of the radius of the magic circle will be based on the amount of invincibility frames left when compared to some arbitrary max.
task MagicCircle {
let imgpath = GetCurrentScriptDirectory() ~ "./u3l29sample.png";
let NUM_VERTEX = 32;
let MC_RADIUS = 96;
let scale = 1;
let listRadius = [];
loop(NUM_VERTEX) {
listRadius = listRadius ~ [0];
}
let objCirc = ObjPrim_Create(OBJ_PRIMITIVE_2D);
ObjPrim_SetTexture(objCirc, imgpath);
ObjPrim_SetPrimitiveType(objCirc, PRIMITIVE_TRIANGLESTRIP);
ObjPrim_SetVertexCount(objCirc, NUM_VERTEX);
ObjRender_SetScaleXYZ(objCirc, scale, scale, 1);
ascent(iVert in 0..NUM_VERTEX / 2){
let left = iVert * 240/NUM_VERTEX;
let indexVert = iVert * 2;
ObjPrim_SetVertexUVT(objCirc, indexVert + 0, left, 125);
ObjPrim_SetVertexUVT(objCirc, indexVert + 1, left, 141);
}
let angleRender = 0;
let frameInvOld = 0;
loop { // Magic circle never deletes
let frameInv = GetPlayerInvincibilityFrame();
if (frameInv <= 0) { // Not Invincible
Obj_SetVisible(objCirc, false);
} else {
Obj_SetVisible(objCirc, true);
if (frameInv > 240) { // We will use an arbitrary cutoff of 240 frames for our 'max' scale
frameInv = 240;
}
scale = frameInv/240;
ObjRender_SetScaleXYZ(objCirc, scale, scale, 1);
angleRender += 360 / NUM_VERTEX / 8;
ascent(iVert in 0..NUM_VERTEX / 2) {
let indexVert = iVert * 2;
let angle = 360 / (NUM_VERTEX / 2 - 1) * iVert;
let vx1 = listRadius[indexVert] * cos(angle);
let vy1 = listRadius[indexVert] * sin(angle);
ObjPrim_SetVertexPosition(objCirc, indexVert + 0, vx1, vy1, 0);
let vx2 = listRadius[indexVert+1] * cos(angle);
let vy2 = listRadius[indexVert+1] * sin(angle);
ObjPrim_SetVertexPosition(objCirc, indexVert + 1, vx2, vy2, 0);
let drOut = (MC_RADIUS - listRadius[indexVert]) / 8;
listRadius[indexVert] = listRadius[indexVert] + drOut;
let rRateIn = 1 - 0.12;
let drIn = (MC_RADIUS * rRateIn - listRadius[indexVert + 1]) / 8;
listRadius[indexVert + 1] = listRadius[indexVert + 1] + drIn;
}
ObjRender_SetPosition(objCirc, GetPlayerX(), GetPlayerY(), 1);
ObjRender_SetAngleZ(objCirc, angleRender);
}
yield;
}
Obj_Delete(objCirc);
}
We'll call the task in @Initialize
. In contrast to the version presented in Lesson 29, we are now checking the current invincibility frames in the loop - if the player is not invincible, there is no reason to perform the magic circle update logic at all, which improves performance. In addition, we use 240 frames as our 'ceiling' here - this value is used to control the maximum size of the circle and in this case it lines up with the invincibility time set out.
And with that, we have some support for player death. However... might as well discuss respawn. By default, the player respawns 32 pixels above the bottom of the playing field, centered. You can override this behavior by manually setting the player's position. Sometimes you may want to keep the player in the same location where they died or have them animate coming back into the screen from below - in these cases, make sure to override the default.
It's time for our player to actually do damage. For the purposes of this tutorial, we will do an incredibly generic shottype where every 6 frames, the player and options fire shots upwards, with some variety for unfocused and focused. Let's revisit the empty PlayerShot
task first. But first... we need a shotsheet.
A player shotsheet is very similar to a standard shotsheet. The only difference is their use, in fact. You can have IDs defined here independent of any other shotsheet of course, because the player shotsheet is only used by (and data is only loaded into) the player. In our case, we will use our friend white.png and will define two shots - a boring square shot and a slightly less boring rectangle shot.
shot_image = "./white.png"
ShotData{
id = 0 //Dummy Shot
rect = (0,0,0,0)
render = ALPHA
alpha=0
collision = 12
}
ShotData{
id = 1
rect = (0, 0, 8, 8)
render = ALPHA
alpha = 64
collision = 12
}
ShotData{
id = 2
rect = (0, 0, 4, 16)
render = ADD_ARGB
alpha = 64
collision = 12
}
Now, usage. We will define a global variable count
and increment it in the main loop. We will also run LoadPlayerShotData(csd ~ "shotdata.txt");
with the path to your shotdata file in @Initialize
.
There are three conditions upon which to fire - these are: player is not dead, shot key is pressed, and firing is enabled. The first two we must check on our own. The last is handled by the engine. After this, we will typically want to only shoot on a steady interval (e.g. every 6 frames). There are clean nice ways to do this where you start a counter when the player taps and there are ways to make the player shoot for a bit even after letting go of the shot key, but we'll use a naive solution for now.
task PlayerShot {
loop {
if (!playerdead && GetVirtualKeyState(VK_SHOT) != KEY_FREE) {
if (count % 6 == 0) {
if (GetVirtualKeyState(VK_SLOWMOVE) != KEY_HOLD && GetVirtualKeyState(VK_SLOWMOVE) != KEY_PUSH) { // Unfocused
} else { // Focused
}
}
}
yield;
}
}
We'll use the above structure. In this loop, we check that the player is alive and that the shot key is being pressed. We use a % 6 (every 6 frames) counter for controlling shots. We also have a focused/unfocused check. A similar structure will end up in the options, but we'll get to that later.
The workhorse of a player script is CreatePlayerShotA1()
. This creates a standard player shot. You can also create custom shot objects, including lasers, which will all be registered as player shots, though you will need to manually specify damage and penetration. These two parameters specify how much damage is done to a boss, and how many times that damage can be applied before the shot deletes itself, respectively.
For now we'll showcase a fairly boring player. We will implement a light spread player with forward-focus options.
task CreateOption(dir) {
...
loop {
...
if (!playerdead && GetVirtualKeyState(VK_SHOT) != KEY_FREE) {
if (count % 12 == 0) {
if (GetVirtualKeyState(VK_SLOWMOVE) != KEY_HOLD && GetVirtualKeyState(VK_SLOWMOVE) != KEY_PUSH) { // Unfocused
CreatePlayerShotA1(ObjRender_GetX(objOption), ObjRender_GetY(objOption), 12, 270, 6, 1, 1);
} else { // Focused
CreatePlayerShotA1(ObjRender_GetX(objOption), ObjRender_GetY(objOption), 6, 270, 6, 2, 2);
}
}
}
yield;
}
}
task PlayerShot {
loop {
if (!playerdead && GetVirtualKeyState(VK_SHOT) != KEY_FREE) {
if (count % 6 == 0) {
CreatePlayerShotA1(GetPlayerX() - 4, GetPlayerY() - 8, 10, 270, 6, 1, 1);
CreatePlayerShotA1(GetPlayerX() + 4, GetPlayerY() - 8, 10, 270, 6, 1, 1);
if (GetVirtualKeyState(VK_SLOWMOVE) != KEY_HOLD && GetVirtualKeyState(VK_SLOWMOVE) != KEY_PUSH) { // Unfocused
CreatePlayerShotA1(GetPlayerX(), GetPlayerY() - 8, 12, 250, 3, 1, 1);
CreatePlayerShotA1(GetPlayerX(), GetPlayerY() - 8, 12, 260, 3, 1, 1);
CreatePlayerShotA1(GetPlayerX(), GetPlayerY() - 8, 12, 280, 3, 1, 1);
CreatePlayerShotA1(GetPlayerX(), GetPlayerY() - 8, 12, 290, 3, 1, 1);
} else { // Focused
CreatePlayerShotA1(GetPlayerX(), GetPlayerY() - 8, 12, 260, 3, 1, 1);
CreatePlayerShotA1(GetPlayerX(), GetPlayerY() - 8, 12, 265, 3, 1, 1);
CreatePlayerShotA1(GetPlayerX(), GetPlayerY() - 8, 12, 275, 3, 1, 1);
CreatePlayerShotA1(GetPlayerX(), GetPlayerY() - 8, 12, 280, 3, 1, 1);
}
}
}
yield;
}
}
It's typical to provide a player with a boring set of two shots going upwards rapidly as a 'base' attack, coupled with some additional attacks. What kind of shottype is given to the options varies wildly but should support the player. It's worth noting that balancing does in fact matter, but that's a topic for a different guide.
Player bombs are an incredibly complex topic with lots of potential variety. In this section, we'll implement two bombs - one focused and one unfocused. Our unfocused bomb will be a series of squares (duh) that rapidly expand from the player's position and cause damage + bullet clears. Our focused bomb will be two 'rings' of squares rotating around the player before flying upwards.
Let's start by filling out our event handler so that Danmakufu stops crashing every time we try to bomb.
case(EV_REQUEST_SPELL) {
let spell = GetPlayerSpell();
if (spell >= 1) {
SetScriptResult(true);
SetPlayerSpell(spell - 1);
if (GetVirtualKeyState(VK_SLOWMOVE) != KEY_HOLD && GetVirtualKeyState(VK_SLOWMOVE) != KEY_PUSH) {
UnfocusedSpell();
} else {
FocusedSpell();
}
} else {
SetScriptResult(false);
}
}
As you can see above, we need to manually check if there are still bombs remaining. We'll be implementing these two spellcards shortly. Create tasks for them.
We'll do the unfocused spellcard first as it's technically simpler due to our design. Let's first fill out the UnfocusedSpell()
task.
task UnfocusedSpell {
SetForbidPlayerShot(true);
let objManage = GetSpellManageObject(); // Start of spell
SetPlayerInvincibilityFrame(240); // Should last duration of spell + some buffer time afterwards
ascent(i in 0..20) {
CreateUnfocusedSpellSquare(i);
loop(6) {yield;}
}
loop(30) {yield;} // Wait for all spell objects to delete before ending spell
Obj_Delete(objManage); // End of spell
SetForbidPlayerShot(false);
}
Note that we surround the invocation of the spellcard with two calls to SetForbidPlayerShot()
. It's typical to prevent the player from shooting their regular attack during a bomb. Of course you can choose to not do this as well - it's up to you.
Also note that we use GetSpellManageObject()
. When we call this, we 'begin' the player spellcard. When we delete these objects, the spellcard 'ends'. Note that all spell objects for the spellcard should be deleted before the spell management object is deleted.
Let's see the implementation of CreateUnfocusedSpellSquare
. This is a task that runs 20 times with 6 frames in between each time.
task CreateUnfocusedSpellSquare(ID) {
let path = csd ~ "white.png";
let rad = 0; // Tracks size
let objspell = ObjSpell_Create;
ObjSpell_Regist(objspell);
ObjPrim_SetPrimitiveType(objspell, PRIMITIVE_TRIANGLEFAN);
ObjPrim_SetVertexCount(objspell, 4);
ObjPrim_SetTexture(objspell, path);
ObjRender_SetBlendType(objspell, BLEND_ADD_ARGB);
ObjPrim_SetVertexUVT(objspell, 0, 0, 0);
ObjPrim_SetVertexUVT(objspell, 1, 512, 0);
ObjPrim_SetVertexUVT(objspell, 2, 512, 512);
ObjPrim_SetVertexUVT(objspell, 3, 0, 512);
ObjPrim_SetVertexPosition(objspell, 0, -rad, -rad, 0);
ObjPrim_SetVertexPosition(objspell, 1, rad, -rad, 0);
ObjPrim_SetVertexPosition(objspell, 2, rad, rad, 0);
ObjPrim_SetVertexPosition(objspell, 3, -rad, rad, 0);
ObjRender_SetAngleZ(objspell, ID*7);
ascent(i in 0..30) {
ObjRender_SetPosition(objspell, GetPlayerX(), GetPlayerY(), 0);
ObjSpell_SetIntersectionCircle(objspell, GetPlayerX(), GetPlayerY(), rad*2^0.5);
ObjSpell_SetDamage(objspell, 2);
ObjPrim_SetVertexPosition(objspell, 0, -rad, -rad, 0);
ObjPrim_SetVertexPosition(objspell, 1, rad, -rad, 0);
ObjPrim_SetVertexPosition(objspell, 2, rad, rad, 0);
ObjPrim_SetVertexPosition(objspell, 3, -rad, rad, 0);
ObjPrim_SetVertexAlpha(objspell, 0, 255 - i*255/30);
ObjPrim_SetVertexAlpha(objspell, 1, 255 - i*255/30);
ObjPrim_SetVertexAlpha(objspell, 2, 255 - i*255/30);
ObjPrim_SetVertexAlpha(objspell, 3, 255 - i*255/30);
rad += 256/30; // Reaches 256 in 30 frames
yield;
}
Obj_Delete(objspell);
}
The first thing to notice is that our spell objects are treated as primitives. Unfortunately, these do not work well with standard sprite manipulation and so you will need to manually set your UV coordinates and vertex positions. In this case we're just using a white square so the actual coordinates don't matter. Based on the ID passed, we also set a different angle - this gives us a series of rotating squares. Also note that spell objects must be registered to come into effect.
In the loop, which lasts 30 frames, we use ObjSpell_SetIntersectionCircle()
to define the hitbox of the spell object (including the radius in which enemy bullets are deleted) and ObjSpell_SetDamage()
to set how much damage the spell object should do. Note that we only need to set the vertices and alpha in the loop because we're having the squares expand while fading out as part of the visuals (and damage box).
Now let's handle our focused spellcard.
task FocusedSpell {
SetForbidPlayerShot(true);
let objManage = GetSpellManageObject(); // Start of spell
SetPlayerInvincibilityFrame(420); // Should last duration of spell + some buffer time afterwards
ascent(i in 0..20) {
CreateFocusedSpellSquare(i, 1);
loop(3) {yield;}
CreateFocusedSpellSquare(i, -1);
loop(3) {yield;}
}
loop(120) {yield;} // Wait for all spell objects to delete before ending spell
Obj_Delete(objManage); // End of spell
SetForbidPlayerShot(false);
}
To make things fancier, we'll use two sets of squares. In addition, the duration is a lot longer than for the unfocused spell - unfocused spells tend to be more for bullet clearing and hitting a large number of enemies (e.g. stages) while focused spells tend to play a larger role in boss battles.
task CreateFocusedSpellSquare(ID, dir) {
let path = csd ~ "white.png";
let rad = 0; // Tracks size
let rotdist = 64;
let objspell = ObjSpell_Create;
ObjSpell_Regist(objspell);
ObjPrim_SetPrimitiveType(objspell, PRIMITIVE_TRIANGLEFAN);
ObjPrim_SetVertexCount(objspell, 4);
ObjPrim_SetTexture(objspell, path);
ObjRender_SetBlendType(objspell, BLEND_ADD_ARGB);
ObjPrim_SetVertexUVT(objspell, 0, 0, 0);
ObjPrim_SetVertexUVT(objspell, 1, 64, 0);
ObjPrim_SetVertexUVT(objspell, 2, 64, 64);
ObjPrim_SetVertexUVT(objspell, 3, 0, 64);
ObjPrim_SetVertexPosition(objspell, 0, -rad, -rad, 0);
ObjPrim_SetVertexPosition(objspell, 1, rad, -rad, 0);
ObjPrim_SetVertexPosition(objspell, 2, rad, rad, 0);
ObjPrim_SetVertexPosition(objspell, 3, -rad, rad, 0);
let currx = GetPlayerX();
let curry = GetPlayerY();
ascent(i in 0..60) {
// Base location + gradual expansion + rotation
currx = GetPlayerX() + rotdist/60*i * cos(270 + 360/60*i*dir);
curry = GetPlayerY() + rotdist/60*i * sin(270 + 360/60*i*dir);
ObjRender_SetPosition(objspell, currx, curry, 0);
ObjSpell_SetIntersectionCircle(objspell, GetPlayerX(), GetPlayerY(), rad*2^0.5);
ObjSpell_SetDamage(objspell, 1);
ObjPrim_SetVertexPosition(objspell, 0, -rad, -rad, 0);
ObjPrim_SetVertexPosition(objspell, 1, rad, -rad, 0);
ObjPrim_SetVertexPosition(objspell, 2, rad, rad, 0);
ObjPrim_SetVertexPosition(objspell, 3, -rad, rad, 0);
ObjRender_SetAngleZ(objspell, ID*7 + 360/60*i);
if (rad < 32) { // Reaches 32 in 60 frames
rad += 32/60;
}
yield;
}
let ascendspeed = 12;
ascent(i in 0..60) {
currx = GetPlayerX() + 32*sin(i*360/30*dir);
curry = GetPlayerY() - rotdist - ascendspeed*i;
ObjRender_SetPosition(objspell, currx, curry, 0);
ObjSpell_SetIntersectionCircle(objspell, currx, curry, rad*2^0.5);
ObjSpell_SetDamage(objspell, 1 + 4/60*i);
ObjPrim_SetVertexPosition(objspell, 0, -rad, -rad, 0);
ObjPrim_SetVertexPosition(objspell, 1, rad, -rad, 0);
ObjPrim_SetVertexPosition(objspell, 2, rad, rad, 0);
ObjPrim_SetVertexPosition(objspell, 3, -rad, rad, 0);
ObjRender_SetAngleZ(objspell, ID*7 + 360/60*i);
if (rad > 16) { // Reaches 16 in 30 frames
rad -= 16/30;
}
yield;
}
Obj_Delete(objspell);
}
We're doing something similar to the earlier unfocused spellcard but with some major twists. First, we're positioning each of the squares more uniquely, with them moving in a growing spiral starting from the player's position and ending rotdist
pixels in front of the player. Each square follows the trajectory, but has a different angle going in, making it more interesting visually (or well, as interesting as you're going to get with white squares). In addition, after they complete one circuit, they move upwards, decreasing in size but increasing in damage and getting a spiral form due to the sine wave attached to the x coordinate.
Of course, most of this is just for fun - but it does result in a situation where to maximize damage with the focused spell, you should stay close to the boss at the start and move backwards over time. Whether or not such a gimmick is warranted depends on whether or not your player is general purpose as well as what kind of enemies it is designed to be good against.
And with that, we have a player... except that if you've ever played a Touhou game after Touhou 6, there's something missing, isn't there...
To close off this guide, we'll cover some miscellaneous aspects of a player script. For now we'll stick with the hitbox visualization when focused. Now, you will normally want an actual graphic for this, but for the same of demonstration, we'll use a... red square. Add a call to RenderHitbox
in @Initialize
We'll handle the hitbox by creating two objects that will only be visible if the player is alive and focusing (otherwise they will be invisible).
task RenderHitbox {
let path = csd ~ "white.png";
let visible = false;
let objHitbox = ObjPrim_Create(OBJ_SPRITE_2D);
ObjPrim_SetTexture(objHitbox, path);
ObjSprite2D_SetSourceRect(objHitbox, 0, 0, 5, 5);
ObjSprite2D_SetDestRect(objHitbox, -5, -5, 5, 5);
ObjRender_SetColor(objHitbox, 255, 0, 0);
Obj_SetRenderPriority(objHitbox, 0.31); // Player renders at 30
let objHitbox2 = ObjPrim_Create(OBJ_SPRITE_2D);
ObjPrim_SetTexture(objHitbox2, path);
ObjSprite2D_SetSourceRect(objHitbox2, 0, 0, 5, 5);
ObjSprite2D_SetDestRect(objHitbox2, -3, -3, 3, 3);
Obj_SetRenderPriority(objHitbox2, 0.31); // Player renders at 30
loop {
if (visible && (playerdead || GetVirtualKeyState(VK_SLOWMOVE) != KEY_HOLD)) { // Shift to invisible
visible = false;
Obj_SetVisible(objHitbox, false);
Obj_SetVisible(objHitbox2, false);
} else if (!visible && !playerdead && GetVirtualKeyState(VK_SLOWMOVE) == KEY_HOLD) { // Shift to visible
visible = true;
Obj_SetVisible(objHitbox, true);
Obj_SetVisible(objHitbox2, true);
}
ObjRender_SetPosition(objHitbox, GetPlayerX(), GetPlayerY(), 1);
ObjRender_SetPosition(objHitbox2, GetPlayerX(), GetPlayerY(), 1);
yield;
}
}
For this demonstration we use two squares. Note that we store the 'visible' flag as a variable - this is so that instead of performing the full 'is player alive and focusing' check (and the opposite as well), we only need to check for a change in the variable. It saves a little bit of speed in exchange for an extra variable.
In the future, more things may be added here, but for now... here's our player script in its entirety.
N/A