The video for this lesson is my first ph3 script, Death Match in the Skies, chosen simply because it makes use of many very basic techniques combined with others.
Please note that the contents of this page are partially based off of the sample script first shown in Lesson 3. However, it is to be used as a reference - all of the actual code construction will be done here in the tutorial. Helepolis also has a video tutorial that covers roughly the same material as this tutorial.
So now let's begin! First, enter your Danmakufu's script directory and create a folder - it's up to you what the name will be, but something along the lines of 'Tutorial' is best for now. Inside, create a blank text file (or .dnh file if you're using the Sublime Text syntax highlighter). Name it whatever you want as long as there is only English in the title - no accent marks, other diacritics, etc. In your Text Editor (e.g. Notepad/TextEdit/Gedit/etc.), make sure that smart quotes are OFF. Also, turn autocorrect OFF if it is enabled, as it gets very annoying very fast. However, you will want your text editor to catch spelling mistakes - things like CreaateShotA1
can and will happen and it is better to see the error before you try to run the code. As long as your text editor does not automatically correct the 'errors', it is fine and potentially helpful.
Now we will want to write our script! First, paste a near-blank header into your file.
#TouhouDanmakufu[Single]
#ScriptVersion[3]
#Title[]
#Text[]
After this, put the following below the header:
@Event{
}
@Initialize{
}
@MainLoop{
}
Look familiar? Make sure there are some line breaks between the header and @Event - we will be putting stuff there. However, note that mkm, the creator of Danmakufu, does the following:
@Event
{
}
@Initialize
{
}
@MainLoop
{
}
This is a simple matter of style and convention - they are functionally equivalent when run. There are also conventions for whether you put spacing before braces, but few people in the Danmakufu community pay attention to that particular detail). Danmakufu does not see a difference.
Right now, our script looks pretty empty, right? But now we will fill it in.
OK. So we have a Single script and we are using Script Version 3. Now, #Title and #Text.
It's really up to you what to put here. However, remember that they take strings, so everything needs to be in double quotes. For now, let's put the following in #Title: "My First Danmakufu Script!"
and the following in #Text: "Code by < Your Name Here >[r]My First Bullet!"
Now we can do stuff with the other header items if we want to. #Image and #BGM are the easiest to use, as they require a filepath and not much else. I will explain #BGM because it will make testing your script far more pleasant than with no bgm at all. Take a music file (.mp3 or .ogg) and dump it in the same directory as the script, and then in #BGM, put the following: "./nameofbgm.ogg"
or "./nameofbgm.mp3"
, whichever you are using.
If you do not know much about OGG Vorbis sound files now, that is fine. Just be aware that .ogg is the standard for sound in Danmakufu among scripters of all skill levels. I highly recommend using .ogg for all bgm in Danmakufu. I will go over how to use Audacity to export a .mp3 to an .ogg in Lesson 15.
Now that the header is out of the way, it is time to begin filling our script with things that will allow it to run.
At this stage, a complete explanation of @Event will most likely fly straight over your heads, but it is important to know that when certain processes happen. @Event is automatically called by Danmakufu, and depending on the arguments there when it is called, it will do various things such as spawning the boss magic circle, etc. Below is a sample @Event.
@Event{
alternative(GetEventType())
case(EV_REQUEST_LIFE){
SetScriptResult(500);
}
case(EV_REQUEST_TIMER){
SetScriptResult(60);
}
case(EV_REQUEST_SPELL_SCORE){
SetScriptResult(1000000);
}
}
EV_REQUEST_LIFE is the boss's life - this event is called once automatically at the start of the Single, and will set the boss's life to 500. EV_REQUEST_TIMER is the timer for how long the attack will run for, and EV_REQUEST_SPELL_SCORE is called when you declare a spellcard (we will get into this later). It sets the starting Spellcard Bonus.
As of now, we have no boss! Therefore, we must remedy this.
Firstly, between the header and @Event, we will want to create a global variable called objBoss. Basically, write let objBoss;
on a line between the header and @Event. After this, we will turn to @Initialize.
Remember that @Initialize runs only once at the start of a script. Also recall that anything declared as a global variable can be accessed by anything in the script. Therefore, at the top of @Initialize, we will write the following:
@Initialize{
objBoss = ObjEnemy_Create(OBJ_ENEMY_BOSS);
ObjEnemy_Regist(objBoss);
The first line assigns the variable objBoss with a value - an object ID belonging to a brand new Enemy object with type boss. The next line registers the Enemy object. You must register certain objects, such as enemies and custom shots, before they can be used.
However, we do not have any graphic for the boss! From the sample folder that comes with Danmakufu, copy ExRumia.png to the current folder. Below the Enemy Register code, place the following:
let imgExRumia = GetCurrentScriptDirectory() ~ "ExRumia.png";
ObjPrim_SetTexture(objBoss, imgExRumia);
ObjSprite2D_SetSourceRect(objBoss, 64, 1, 127, 64);
ObjSprite2D_SetDestCenter(objBoss);
We will go into animations later, but for now, we will use a default image. The first line assigns the path to the graphic to a new variable called imgExRumia. GetCurrentScriptDirectory()
is a function that returns a string containing the path to the current directory, and it is concatenated to whatever comes after the ~, as discussed in an earlier lesson. After this, ObjPrim_SetTexture()
assigns the image in that path to the Boss object, essentially giving it a graphic. ObjSprite2D_SetSourceRect()
states which part of the texture to use (note that it is not 64, 0, 128, 64 because the top and right edges behave differently from the other two. We will revisit this problem when we deal with image rendering in Lesson 13). !!!FACT CHECK NEEDED!!! Finally, ObjSprite2D_SetDestCenter()
tells Danmakufu to center the graphic for the Boss object.
However, our boss has been spawned at (0,0), the top left corner of the playing field. Now, how do we move it?
In Danmakufu, an important thing to note is that objects can be acted upon by multiple object functions. For example, an enemy object can be acted upon by ObjMove, ObjRender, etc. functions. As such, we are not limited to only one set of functions, and we will be using ObjMove functions to move the boss. The boss graphic will automatically stay on the boss object's location.
In the end, it will be up to you to learn the various ObjMove functions. There are, of course, a few important things to note. Firstly, the default playing screen is 384 by 448 pixels, the values returned by GetStgFrameWidth()
and GetStgFrameHeight()
by default, respectively. Also, the origin is the top left corner of the playing field for objects whose render priorities are between 0.20 and 0.80, not including 0.80. Perhaps the hardest thing to get used to is that the y value increases as you go from the top to the bottom of the screen. Just keep that in mind, because it is a common source of infuriation. We will discuss this once more in Lesson 11.
Please put these two functions somewhere in your script, preferably immediately after @MainLoop. Also add wait()
from Lesson 5, because it will be helpful.
function GetCenterX(){
return GetStgFrameWidth() / 2;
}
function GetCenterY(){
return GetStgFrameHeight() / 2;
}
These two functions are very useful for positioning objects on the playing field. Now we will move the boss.
While it is possible to simply use ObjMove_SetX()
and ObjMove_SetY()
(or ObjMove_SetPosition()
), be aware that this will effectively teleport the boss, and the player will have to move to a new location in order to continue dealing damage. And no, warping every 30 frames is annoying, not fun. Thankfully, there is ObjMove_SetDestAtFrame()
.
After setting the starting graphic for your boss but before the end of @Initialize, include the following:
ObjMove_SetDestAtFrame(objBoss, GetCenterX(), 60, 60);
This code will tell the boss object to move to the location (GetCenterX()
, 60) in 60 frames, which is approximately one second. This should look significantly better than teleporting the boss.
This is our script as of right now, for future reference:
#TouhouDanmakufu[Single]
#ScriptVersion[3]
#Title["My First Danmakufu Script!"]
#Text["Code by Sparen[r]My First Bullet!"]
#BGM["./necrofantasia.ogg"]
let objBoss;
@Event{
alternative(GetEventType())
case(EV_REQUEST_LIFE){
SetScriptResult(500);
}
case(EV_REQUEST_TIMER){
SetScriptResult(60);
}
case(EV_REQUEST_SPELL_SCORE){
SetScriptResult(1000000);
}
}
@Initialize{
objBoss = ObjEnemy_Create(OBJ_ENEMY_BOSS);
ObjEnemy_Regist(objBoss);
let imgExRumia = GetCurrentScriptDirectory() ~ "ExRumia.png";
ObjPrim_SetTexture(objBoss, imgExRumia);
ObjSprite2D_SetSourceRect(objBoss, 64, 1, 127, 64);
ObjSprite2D_SetDestCenter(objBoss);
ObjMove_SetDestAtFrame(objBoss, GetCenterX(), 60, 60);
}
@MainLoop{
}
function GetCenterX(){
return GetStgFrameWidth() / 2;
}
function GetCenterY(){
return GetStgFrameHeight() / 2;
}
function wait(n){
loop(n){yield;}
}
So now we have quite a bit of code! However, our @MainLoop is empty - we will now begin to fill it, first by giving the boss a hitbox.
In Danmakufu, there are two types of enemy hitboxes - one registers collision with the player, and the other registers collisions with player shots. You can also manually create hitboxes for other types of collisions. The primary two functions you will use are the following: ObjEnemy_SetIntersectionCircleToShot()
and ObjEnemy_SetIntersectionCircleToPlayer()
. Their names basically describe what they do. They require 4 arguments each - the first is the object ID of the enemy to damage. The next two are the x and y positions to set the hitbox - yes, this means you can have a hitbox somewhere other than directly on top of the boss. If you want to use the boss's position, use ObjMove_GetX(objBoss)
and ObjMove_GetY(objBoss)
. The final argument is the radius of the hitbox, measured in pixels. 32 and 24 for shot and player respectively are the hitbox sizes shown in the sample scripts. You can do whatever you want, but just keep in mind that you will usually want the player collision hitbox to be smaller than the shot collision hitbox.
Put these two functions inside @MainLoop. Enemy collision hitboxes reset after each frame, and by putting this code in @MainLoop, the hitboxes will move themselves to the enemy's position as long as you are using the ObjMove_GetX()
and ObjMove_GetY()
functions.
As a last note, put a yield; at the end of your @MainLoop. Tasks will not run if you do not have a yield; in @MainLoop.
ObjEnemy_SetIntersectionCircleToShot()
?Right now our script does practically nothing since all it does is create a boss and move it to a location (and then allow it to be shot down). However, we need to be able to close the script in order to finish writing a solid complete framework for our script.
In the sample scripts that come with Danmakufu, there is a portion of @MainLoop that basically runs only when the boss's life falls below or equal to 0. However, that adds clutter to @MainLoop. Therefore, we will call a task TFinalize; at the end of @Initialize.
Note: Keeping the control code in @MainLoop is a perfectly fine method. TFinalize is just one of many possible ways to handle the end of a script.
task TFinalize {
while(ObjEnemy_GetInfo(objBoss, INFO_LIFE) > 0){yield;}
Obj_Delete(objBoss);
DeleteShotAll(TYPE_ALL, TYPE_IMMEDIATE);
SetAutoDeleteObject(true);
CloseScript(GetOwnScriptID());
return;
}
While the boss's life is greater than 0, this task will do nothing. However, the moment the boss's HP drops to 0 or below, the boss object will delete, every enemy shot on screen will be deleted, and the script will close. The use of return;
at the end does nothing and is not needed, although it is in the sample scripts.
There is also SetAutoDeleteObject()
, which defaults to false. When true, all objects spawned in the script will be deleted at the very end. Since many function libraries assume that it is set to true (and indeed, most scripters in the western Danmakufu community set it to true), I recommend that you use it. There are issues with using it, of course, but we will get into that in a much later tutorial.
With this, our script can now end. So now... it's time to shoot our bullets!
Raiko tries to move her drum down towards the middle of the screen. However, it flies from (0,0) upwards.
ObjMove_SetDestAtFrame(objDrum, GetCenterX(), -60, 60);
1) Why does the drum fly upwards?
2) What code will fix the issue?
Now Raiko cannot seem to get her hitbox to work.
@Initialize{
objBoss = ObjEnemy_Create(OBJ_ENEMY_BOSS);
ObjEnemy_Regist(objBoss);
let imgRaiko = GetCurrentScriptDirectory() ~ "Raiko.png";
ObjPrim_SetTexture(objBoss, imgRaiko);
ObjSprite2D_SetSourceRect(objBoss, 64, 1, 127, 64);
ObjSprite2D_SetDestCenter(objBoss);
ObjMove_SetDestAtFrame(objBoss, GetCenterX(), 60, 60);
ObjEnemy_SetIntersectionCircleToShot(objBoss, ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 32);
ObjEnemy_SetIntersectionCircleToPlayer(objBoss, ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 24);
}
@MainLoop{
}
3) Why does the hitbox not spawn?
It's what you've all been waiting for! Creating bullets! However... we are now entering an area where coding style comes into play.
There are multiple ways to do what we want to do here. There is a tasking style (very commonly used), a @MainLoop style (less commonly used), and countless others. I will show the tasking style, the @MainLoop style, and my own style. It is important to be able to read and understand how a variety of different styles work to accomplish the same or similar goals.
We will begin with the most generic bullet function, CreateShotA1(x, y, speed, angle, graphic, delay)
. The x and y positions are the starting coordinates for the bullet. You may also use let obj = CreateShotA1();
to transform the bullet later, but that will be covered later. For now, we will discuss spawning a bullet every thirty frames, first in tasking style. But first, add the following below your Danmakufu Header:
#include "script/default_system/Default_ShotConst.txt"
This will include the default shotsheet with the script so that we can have bullet graphics. Don't worry about the include now - just be aware that the file contains a list of shot constants that will make it easier to figure out which bullet we are firing. I personally do not use shot constants, preferring to simply load a shotsheet and use numbers, but for this lesson, I will demonstrate with the shot constants.
Remember TFinalize? Now we will create a task called MainTask; and call it in @Initialize. In MainTask, we will first insert a while(ObjEnemy_GetInfo(objBoss,INFO_LIFE) > 0){
}
Inside the braces we will do the following:
while(ObjEnemy_GetInfo(objBoss,INFO_LIFE) > 0){
let angleT = GetAngleToPlayer(objBoss);
CreateShotA1(ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 2, angleT, DS_BALL_S_RED, 5);
wait(30);
}
In this while loop, we first declare a variable angleT to hold the angle from the boss to the player, given by the function GetAngleToPlayer()
. After this, we fire a shot from the boss's position with speed 2, angle towards the player. Its graphic is DS_BALL_S_RED, a small red ball, and there are 5 delay frames before the bullet materializes and can damage the player. After this, the task waits 30 frames before checking the boss's life and looping through this again.
Now I will show the @MainLoop style. First, you will want to define a global variable count (or frame, but I prefer count) after the shotsheet include, and set it to 0. Then, in @MainLoop, you will add the following after the hitbox declaration and before the yield;
:
if(count == 30){
let angleT = GetAngleToPlayer(objBoss);
CreateShotA1(ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 2, angleT, DS_BALL_S_RED, 5);
count = 0;
}
count++;
Basically, you increment your counter until it hits 30, then execute the bullet spawning and reset the counter
Note that in plural scripts and the like, this will need to be changed slightly to account for the fact that the bullets will still shoot if the boss is dead. I will describe a countermeasure below.
Finally, I will discuss my coding method, which is similar to the @MainLoop method. The main difference is that I do not reset the counter and instead use a modulus. The @MainLoop code I insert between the hitboxes and yield; is similar to the following:
if(count % 30 == 0){
let angleT = GetAngleToPlayer(objBoss);
CreateShotA1(ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 2, angleT, DS_BALL_S_RED, 5);
}
count++;
When the count is a multiple of 30, the code inside the braces will execute. All three methods are functionally similar. However, I will now describe good habits and how to robustify the code. For the tasking code, there is a check after the wait for whether or not the boss is alive, but this is not the case for the other two methods. Therefore, we will do the following:
First, we will create a task called fire;. It will contain the following:
task fire{
if(ObjEnemy_GetInfo(objBoss, INFO_LIFE) <= 0){return;} //Default kill to prevent (0,0) spawning
let angleT = GetAngleToPlayer(objBoss);
CreateShotA1(ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 2, angleT, DS_BALL_S_RED, 5);
}
We will call fire; instead of the two lines defining the angleT local variable and calling the bullet. Now, the bullets will not spawn if the boss happens to be dead and the script is still running due to the return; following a check for the boss's life. You will see this line of code plastered everywhere in my scripts - it is very much my most often copy-pasted line of code in ph3. We will have an in-depth discussion of this in Lesson 16.
Well, now we have a boss that fires a bullet. To conclude this lesson, I will show the scripts we have devised in their entirety.
Tasking style
#TouhouDanmakufu[Single]
#ScriptVersion[3]
#Title["My First Danmakufu Script!"]
#Text["Code by Sparen[r]My First Bullet!"]
#BGM["./necrofantasia.ogg"]
#include "script/default_system/Default_ShotConst.txt"
let objBoss;
@Event{
alternative(GetEventType())
case(EV_REQUEST_LIFE){
SetScriptResult(500);
}
case(EV_REQUEST_TIMER){
SetScriptResult(60);
}
case(EV_REQUEST_SPELL_SCORE){
SetScriptResult(1000000);
}
}
@Initialize{
objBoss = ObjEnemy_Create(OBJ_ENEMY_BOSS);
ObjEnemy_Regist(objBoss);
let imgExRumia = GetCurrentScriptDirectory() ~ "ExRumia.png";
ObjPrim_SetTexture(objBoss, imgExRumia);
ObjSprite2D_SetSourceRect(objBoss, 64, 1, 127, 64);
ObjSprite2D_SetDestCenter(objBoss);
ObjMove_SetDestAtFrame(objBoss, GetCenterX(), 60, 60);
TFinalize;
MainTask;
}
@MainLoop{
ObjEnemy_SetIntersectionCircleToShot(objBoss, ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 32);
ObjEnemy_SetIntersectionCircleToPlayer(objBoss, ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 24);
yield;
}
task MainTask{
while(ObjEnemy_GetInfo(objBoss,INFO_LIFE) > 0){
let angleT = GetAngleToPlayer(objBoss);
CreateShotA1(ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 2, angleT, DS_BALL_S_RED, 5);
wait(30);
}
}
task TFinalize {
while(ObjEnemy_GetInfo(objBoss, INFO_LIFE) > 0){yield;}
Obj_Delete(objBoss);
DeleteShotAll(TYPE_ALL, TYPE_IMMEDIATE);
SetAutoDeleteObject(true);
CloseScript(GetOwnScriptID());
return;
}
function GetCenterX(){
return GetStgFrameWidth() / 2;
}
function GetCenterY(){
return GetStgFrameHeight() / 2;
}
function wait(n){
loop(n){yield;}
}
@MainLoop style
#TouhouDanmakufu[Single]
#ScriptVersion[3]
#Title["My First Danmakufu Script!"]
#Text["Code by Sparen[r]My First Bullet!"]
#BGM["./necrofantasia.ogg"]
#include "script/default_system/Default_ShotConst.txt"
let objBoss;
let count = 0;
@Event{
alternative(GetEventType())
case(EV_REQUEST_LIFE){
SetScriptResult(500);
}
case(EV_REQUEST_TIMER){
SetScriptResult(60);
}
case(EV_REQUEST_SPELL_SCORE){
SetScriptResult(1000000);
}
}
@Initialize{
objBoss = ObjEnemy_Create(OBJ_ENEMY_BOSS);
ObjEnemy_Regist(objBoss);
let imgExRumia = GetCurrentScriptDirectory() ~ "ExRumia.png";
ObjPrim_SetTexture(objBoss, imgExRumia);
ObjSprite2D_SetSourceRect(objBoss, 64, 1, 127, 64);
ObjSprite2D_SetDestCenter(objBoss);
ObjMove_SetDestAtFrame(objBoss, GetCenterX(), 60, 60);
TFinalize;
}
@MainLoop{
ObjEnemy_SetIntersectionCircleToShot(objBoss, ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 32);
ObjEnemy_SetIntersectionCircleToPlayer(objBoss, ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 24);
if(count == 30){
fire;
count = 0;
}
count++;
yield;
}
task fire{
if(ObjEnemy_GetInfo(objBoss, INFO_LIFE) <= 0){return;} //Default kill to prevent (0,0) spawning
let angleT = GetAngleToPlayer(objBoss);
CreateShotA1(ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 2, angleT, DS_BALL_S_RED, 5);
}
task TFinalize {
while(ObjEnemy_GetInfo(objBoss, INFO_LIFE) > 0){yield;}
Obj_Delete(objBoss);
DeleteShotAll(TYPE_ALL, TYPE_IMMEDIATE);
SetAutoDeleteObject(true);
CloseScript(GetOwnScriptID());
return;
}
function GetCenterX(){
return GetStgFrameWidth() / 2;
}
function GetCenterY(){
return GetStgFrameHeight() / 2;
}
function wait(n){
loop(n){yield;}
}
Sparen style
#TouhouDanmakufu[Single]
#ScriptVersion[3]
#Title["My First Danmakufu Script!"]
#Text["Code by Sparen[r]My First Bullet!"]
#BGM["./necrofantasia.ogg"]
#include "script/default_system/Default_ShotConst.txt"
let objBoss;
let count = 0;
@Event{
alternative(GetEventType())
case(EV_REQUEST_LIFE){
SetScriptResult(500);
}
case(EV_REQUEST_TIMER){
SetScriptResult(60);
}
case(EV_REQUEST_SPELL_SCORE){
SetScriptResult(1000000);
}
}
@Initialize{
objBoss = ObjEnemy_Create(OBJ_ENEMY_BOSS);
ObjEnemy_Regist(objBoss);
let imgExRumia = GetCurrentScriptDirectory() ~ "ExRumia.png";
ObjPrim_SetTexture(objBoss, imgExRumia);
ObjSprite2D_SetSourceRect(objBoss, 64, 1, 127, 64);
ObjSprite2D_SetDestCenter(objBoss);
ObjMove_SetDestAtFrame(objBoss, GetCenterX(), 60, 60);
TFinalize;
}
@MainLoop{
ObjEnemy_SetIntersectionCircleToShot(objBoss, ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 32);
ObjEnemy_SetIntersectionCircleToPlayer(objBoss, ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 24);
if(count % 30 == 0){
fire;
}
count++;
yield;
}
task fire{
if(ObjEnemy_GetInfo(objBoss, INFO_LIFE) <= 0){return;} //Default kill to prevent (0,0) spawning
let angleT = GetAngleToPlayer(objBoss);
CreateShotA1(ObjMove_GetX(objBoss), ObjMove_GetY(objBoss), 2, angleT, DS_BALL_S_RED, 5);
}
task TFinalize {
while(ObjEnemy_GetInfo(objBoss, INFO_LIFE) > 0){yield;}
Obj_Delete(objBoss);
DeleteShotAll(TYPE_ALL, TYPE_IMMEDIATE);
SetAutoDeleteObject(true);
CloseScript(GetOwnScriptID());
return;
}
function GetCenterX(){
return GetStgFrameWidth() / 2;
}
function GetCenterY(){
return GetStgFrameHeight() / 2;
}
function wait(n){
loop(n){yield;}
}
ObjMove_SetDestAtFrame()
for smooth movementCreateShotA1()
[ 弾幕風 PH3 Tutorial ] Introduction to Danmakufu (Helepolis)
-->Highly recommended if you have not yet watched it
[ 弾幕風 PH3 Tutorial ] Improving our boss and danmaku (Helepolis)
-->Covers things that were also covered in this tutorial.