The District of Joban Difference between revisions of "JCM:Building a Scripted PIDS Preset"

Difference between revisions of "JCM:Building a Scripted PIDS Preset"

From The District of Joban
Line 6: Line 6:
[[File:JCM JS PIDS Tutorial Result.png|none|thumb|413x413px|Top: Default RV PIDS Preset, Bottom: Custom JS-based PIDS Preset]]
[[File:JCM JS PIDS Tutorial Result.png|none|thumb|413x413px|Top: Default RV PIDS Preset, Bottom: Custom JS-based PIDS Preset]]


=== Getting started ===
== Getting started ==
To get started, [https://www.joban.org/archive/misc/JCM_PIDS_Tutorial_Scripting_Pack.zip download the tutorial resource pack here] ,extract the zip and put it in Minecraft's Resource Pack folder.
To get started, [https://www.joban.org/archive/misc/JCM_PIDS_Tutorial_Scripting_Pack.zip download the tutorial resource pack here] ,extract the zip and put it in Minecraft's Resource Pack folder.


This resource pack is specifically set-up for this tutorial, and therefore the JS scripts inside are (mostly) empty. However we can still take a look at how a Scripted PIDS Preset is set-up.
This resource pack is specifically set-up for this tutorial, and therefore the JS scripts inside are (mostly) empty. However we can still take a look at how a Scripted PIDS Preset is set-up.


==== joban_custom_resources.json ====
=== joban_custom_resources.json ===
The file will look like this:
The file will look like this:
<pre>
<pre>
Line 29: Line 29:
Multiple scripts can be used for the same preset as well. However to keep things simple, we are simply going to use 1 script to control the rendering: <code>scripts/pids_tut.js</code>.
Multiple scripts can be used for the same preset as well. However to keep things simple, we are simply going to use 1 script to control the rendering: <code>scripts/pids_tut.js</code>.


==== scripts/pids_tut.js ====
=== scripts/pids_tut.js ===
By opening up '''pids_tut.js''' in the '''scripts''' folder in a text editor (Notepad etc.), we can see the following script:
By opening up '''pids_tut.js''' in the '''scripts''' folder in a text editor (Notepad etc.), we can see the following script:


Line 58: Line 58:
But let's not get ahead of ourselves, and instead start from something very simple: ''A hello world script''.
But let's not get ahead of ourselves, and instead start from something very simple: ''A hello world script''.


==== Hello, World! ====
=== Hello, World! ===
First, let's insert <code>print("Hello World ^^");</code> to the brackets within the <code>create</code> function. This calls the <code>print</code> function with the argument <code>"Hello World ^^"</code>. The <code>print</code> function will output the message in our Minecraft console.
First, let's insert <code>print("Hello World ^^");</code> to the brackets within the <code>create</code> function. This calls the <code>print</code> function with the argument <code>"Hello World ^^"</code>. The <code>print</code> function will output the message in our Minecraft console.


Line 85: Line 85:
So this concludes the Scripted PIDS tutorial, I hope that...
So this concludes the Scripted PIDS tutorial, I hope that...


==== No where are you going! ====
=== No where are you going! ===
ok I am sorry :<
ok I am sorry :<


Line 169: Line 169:
[[File:JCM JS PIDS Tutorial v0.4.png|thumb|407x407px|''<small>(The reason the bottom line appears to be more "left" than the top line is due to Minecraft's imperfection when rendering TTF font, it will work fine if you switch to Minecraft font or with most other text combination)</small>''|none]]
[[File:JCM JS PIDS Tutorial v0.4.png|thumb|407x407px|''<small>(The reason the bottom line appears to be more "left" than the top line is due to Minecraft's imperfection when rendering TTF font, it will work fine if you switch to Minecraft font or with most other text combination)</small>''|none]]


==== Rendering Arrivals ====
=== Rendering Arrivals ===
During the tutorial, you might have noticed the <code>pids</code> object in the create/render/dispose function. This represents our PIDS object, where we can obtain arrivals and more from there.
During the tutorial, you might have noticed the <code>pids</code> object in the create/render/dispose function. This represents our PIDS object, where we can obtain arrivals and more from there.


Line 330: Line 330:
}
}
</pre>
</pre>
=== v1: Light Rail, Light Rail Everywhere! ===
== v1: Not too bad after-all! ==
[TODO v1]
In the last section we've spent all that time rendering some black-on-white text, but still it doesn't really look good.


=== v2: Something is missing ===
The next thing we are going to do is to draw a background alongside the PIDS text. This is very similar to the way we make text, but we use <code>Texture</code> instead, as well as the <code>.texture(id)</code> function to specify the image we would draw:<pre>
[TODO v2 plat number]
function render(ctx, state, pids) {
    for(let i = 0; i < 4; i++) {
        let arrival = pids.arrivals().get(i);
        if(arrival != null) {
            Text.create("Arrival destination")
            .text(TextUtil.cycleString(arrival.destination()))
            .color(0xFFFFFF)
            .pos(0, i*16.75)
            .scale(1.25)
            .draw(ctx);
        }
    }
 
    Texture.create("Background") // <----------
    .texture("jsblock:textures/block/pids/rv_default.png")
    .draw(ctx);
}
</pre><code>jsblock:textures/block/pids/rv_default.png</code> is the default image used for the built-in Railway Vision PIDS that comes with JCM. But of course, you are welcomed to bring your own images as well!
 
Now reload the resource pack and...
[[File:JCM JS PIDS Tutorial v1.0.png|303x303px]]
I don't know how many of you have noticed, but this in-fact is not a background image! Both because it renders in-front of the text, and that it doesn't cover the entire screen.
 
=== Stacking Order ===
In JCM, every elements that gets drawn are rendered with their z-position incremented ever so slightly.
 
Therefore, the 2nd element to be drawn is placed ''in-front'' of the 1st element.
 
So the rule is pretty simple: Whoever gets rendered '''later''' are put '''in-front''', and whoever gets rendered '''earlier''' are '''behind''' the one who are rendered later.
 
With this logic, let's move our background image to the '''top''' of the render function, so that everything afterwards are drawn on in-front of the background.<pre>
function render(ctx, state, pids) {
    Texture.create("Background") // <----------
    .texture("jsblock:textures/block/pids/rv_default.png")
    .draw(ctx);
 
    for(let i = 0; i < 4; i++) {
        let arrival = pids.arrivals().get(i);
        if(arrival != null) {
            Text.create("Arrival destination")
            .text(TextUtil.cycleString(arrival.destination()))
            .color(0xFFFFFF)
            .pos(0, i*16.75)
            .scale(1.25)
            .draw(ctx);
        }
    }
}
</pre>[[File:JCM JS PIDS Tutorial v1.1.png|384x384px]]
Now we just need to resize the texture to stretch it through the entire screen.
 
This can be done through the <code>.size(width, height)</code> function:<pre>
Texture.create("Background")
.texture("jsblock:textures/block/pids/rv_default.png")
.size(80, 80)
.draw(ctx);
</pre>Hmm <code>80, 80</code>? That's a square, not a rectangle like our PIDS screen?
 
That's correct, this code indeed does not cover the entire screen. A RV PIDS uses the size 136w x 76h.
 
While we could just set the size to 136, 76, you can also obtain the size of the PIDS directly with the <code>pids</code> variable passed to our render function, specifically <code>pids.width</code> and <code>pids.height</code><pre>
Texture.create("Background")
.texture("jsblock:textures/block/pids/rv_default.png")
.size(pids.width, pids.height) // <-----
.draw(ctx);
</pre>This is useful both for readability purposes, as well as factoring in different sizes of PIDS. (An LCD PIDS screen is slightly smaller at 133w x 72h)
 
And let's not forget setting our destination text color to black (<code>0x000000</code>, or just remove <code>.color</code> entirely as it's black by default anyway), as the background is now bright.
[[File:JCM JS PIDS Tutorial v1.2.png|333x333px]]
The background image itself appears to include a header bar, which our arrival text has not accounted for.
 
This is a very straight forward fix by just offsetting the text's Y position, and to save you time, you need to offset it by 13 to make it look right:<pre>
Text.create("Arrival destination")
.text(TextUtil.cycleString(arrival.destination()))
.pos(0, 13+(i*16.75)) // <----
.scale(1.25)
.draw(ctx);
</pre>But now things are getting crazy. Imagine if you give this to a friend for a reference, or even just you reading this a year later. Could you tell what that <code>13</code> is for, and why <code>i*16.75</code>?
 
In such case, you should declare a variable with the var/let syntax:<pre>
function render(ctx, state, pids) {
    // ...background
 
    for(let i = 0; i < 4; i++) {
        let rowY = 13 + (i*16.75); // <----
       
        let arrival = pids.arrivals().get(i);
        if(arrival != null) {
            Text.create("Arrival destination")
            .text(TextUtil.cycleString(arrival.destination()))
            .pos(0, rowY) // <----
            .scale(1.25)
            .draw(ctx);
        }
    }
}
</pre>
As for the <code>13</code>, since it won't be changed at runtime, we can use the <code>const</code> keyword to indicate that this is a constant variable and would never change:<pre>
const HEADER_HEIGHT = 13; // <---
 
function create(ctx, state, pids) {
    ...
}
 
function render(ctx, state, pids) {
    // ...background
   
    for(let i = 0; i < 4; i++) {
        let rowY = HEADER_HEIGHT + (i*16.75); // <---
       
        let arrival = pids.arrivals().get(i);
        if(arrival != null) {
            Text.create("Arrival destination")
            .text(TextUtil.cycleString(arrival.destination()))
            .pos(0, rowY)
            .scale(1.25)
            .draw(ctx);
        }
    }
}
 
function dispose(ctx, state, pids) {
    ...
}
</pre>[[File:JCM JS PIDS Tutorial v1.3.png|371x371px]]
Neat!
 
[TODO eta]
 
=== v2: Light Rail, Light Rail Everywhere! ===
[TODO v2 lrt + plat number]


=== v3: It's still missing ===
=== v3: It's still missing ===

Revision as of 21:16, 5 November 2024

Draft

This article is a work in progress.
Informations might not be complete and will be improved overtime.

This is a step-by-step tutorial on building a Scripted PIDS Preset using JavaScript.

By the end of the tutorial, you would have built a Fictional LRT (Light Rail) version of the MTR Railway Vision PIDS, which can adapt to Custom PIDS Messages and handling long string.

Top: Default RV PIDS Preset, Bottom: Custom JS-based PIDS Preset

Getting started

To get started, download the tutorial resource pack here ,extract the zip and put it in Minecraft's Resource Pack folder.

This resource pack is specifically set-up for this tutorial, and therefore the JS scripts inside are (mostly) empty. However we can still take a look at how a Scripted PIDS Preset is set-up.

joban_custom_resources.json

The file will look like this:

{
  "pids_images": [
    {
      "id": "pids_tut",
      "name": "DIY JS Preset",
      "scripts": ["jsblock:scripts/pids_tut.js"]
    }
  ]
}

This is mostly the same from a JSON PIDS Preset, except the scripts property which is a JSON array that points to the scripts used for this preset.

Multiple scripts can be used for the same preset as well. However to keep things simple, we are simply going to use 1 script to control the rendering: scripts/pids_tut.js.

scripts/pids_tut.js

By opening up pids_tut.js in the scripts folder in a text editor (Notepad etc.), we can see the following script:

function create(ctx, state, pids) {
  // Your custom logic here...
}

function render(ctx, state, pids) {
  // Your custom logic here...
}

function dispose(ctx, state, pids) {
  // Your custom logic here...
}

Our custom logic can be placed between each curly brackets for each of the function. As seen, there are a total of 3 functions: create, render, dispose.

The create function is called when our PIDS enters the player's view. So this means that when a player switches the PIDS preset to our preset, or if a player is approaching a station with our PIDS in sight, everything within the curly bracket of the create function will be run.

The render function is called every frame (So 60fps = 60 times per second)*. Notice the asterisk? Because running scripts simply takes time, even if a short amount of time. JCM will try to, whenever possible, call your function every frame. However if there are too many scripts or your script is slow, then it may not be called every frame.

The dispose function is called when a PIDS Preset is switched away from, or the PIDS is no longer in sight for the player. This can be used to do clean-up work in complex PIDS that stores texture etc.

The most useful one we are going to use is the render function, since this is where we can obtain up-to-date information and dynamically render our PIDS.

But let's not get ahead of ourselves, and instead start from something very simple: A hello world script.

Hello, World!

First, let's insert print("Hello World ^^"); to the brackets within the create function. This calls the print function with the argument "Hello World ^^". The print function will output the message in our Minecraft console.

Now, let's insert print("Goodbye World ^^;"); within the dispose function as well.

After that, we can press F3+T to reload our resource pack and look at your game console!

You should the following message:

[Scripting] Hello World ^^

[Scripting] Hello World ^^

As PIDS in JCM (as well as MTR) are constructed in a way where each "side" is drawn separately, a normally placed PIDS will would call the function 2 times. This is normal behavior, you didn't do anything wrong!

Now, try going very far away from the PIDS, to the point where you can visually no longer sees them. (More technically, when the PIDS chunk is no longer loaded). You will notice the following 2 messages shows up in the console:

[Scripting] Goodbye World ^^;

[Scripting] Goodbye World ^^;

There you have it, a very simple Hello World script running in JCM.

(p.s. In case you got lost, run /tp 0 -60 0).

So this concludes the Scripted PIDS tutorial, I hope that...

No where are you going!

ok I am sorry :<

Now let's actually draw the "Hello World" text onto the PIDS, because printing to the game console isn't considered exciting for most players apparently.

We can insert the following code to the render function to draw a text with the content Hello World:

function render(ctx, state, pids) {
    Text.create().text("Hello World").draw(ctx);
}

If this looks confusing, we can also split it line by line in the code:

function render(ctx, state, pids) {
    Text.create()
    .text("Hello World")
    .draw(ctx);
}

This creates a text, setting its text content to Hello World, and then drawing it to ctx (Which is a provided parameter in the render function, more on that later).

A statement is only considered finish by ending with a semicolon (;), so our code will still work after breaking down to different lines. For readability purposes, opening new line is generally preferred.

Anyway now let's save the file, reload with F3+T and see what happens!

*checks date* It's not April Fools yet is it?

Ok our text do actually get rendered, the only problem is that the default text color is black, and as you might have realized, black-on-black may not necessarily be a very visible color combination. So let's change our text to be rendered with the white color instead.

The hex color code for a solid white color is FFFFFF, so we can add the following line to our text:

.color(0xFFFFFF) (0x is used to depict that the number is a hexadecimal number)

So our text block should look something like this:

Text.create()
.text("Hello World")
.color(0xFFFFFF)
.draw(ctx);

Note that .draw() is the final command and you cannot append anything afterwards, so any setting must be appended before .draw().

Now reload our resource pack again and we now see our text!

A white text in our PIDS saying "Hello World"

With this logic, we can append a second Text block to render a 2nd text as well:

function render(ctx, state, pids) {
    Text.create()
    .text("Hello World")
    .color(0xFFFFFF)
    .draw(ctx);
    
    Text.create()
    .text("Joban Client Mod v2!")
    .color(0xFFFFFF)
    .draw(ctx);
}
I mean it technically did render twice...

But it's hard to see, let's move each of them apart. We can use the .pos(x, y) command to set the position of an element.

By default, a text have a height of 9, so we can append .pos(0, 9) to set the text position to 9 unit downwards:

function render(ctx, state, pids) {
    Text.create()
    .text("Hello World")
    .color(0xFFFFFF)
    .draw(ctx);

    Text.create()
    .text("Joban Client Mod v2!")
    .color(0xFFFFFF)
    .pos(0, 9) // <----
    .draw(ctx);
}
(The reason the bottom line appears to be more "left" than the top line is due to Minecraft's imperfection when rendering TTF font, it will work fine if you switch to Minecraft font or with most other text combination)

Rendering Arrivals

During the tutorial, you might have noticed the pids object in the create/render/dispose function. This represents our PIDS object, where we can obtain arrivals and more from there.

We can get a list of arrivals with pids.arrivals(), and then obtain the nth arrival info with the .get(n) function.

So to obtain the first arrival entry, we can do so like this:

let firstRowArrival = pids.arrivals().get(0);

You can obtain a variety of information in the arrival entry, which includes it's route/LRT number, it's arrival and departure time, the route it's running and more. You can refer to the documentation [TODO] for further information.

Here however, we are only interested in getting the destination (i.e. Where the train is going), so we can do that with the destination() function.

let firstRowArrival = pids.arrivals().get(0);
let firstRowDestination = firstRowArrival.destination();

And now we can draw this to our text, by replacing the .text() content with firstRowDestination:

function render(ctx, state, pids) {
    let firstRowArrival = pids.arrivals().get(0);
    let firstRowDestination = firstRowArrival.destination();
    
    Text.create()
    .text(firstRowDestination) // <--- Here
    .color(0xFFFFFF)
    .pos(0, 0)
    .draw(ctx);
    
    Text.create()
    .text("Joban Client Mod v2!")
    .color(0xFFFFFF)
    .pos(0, 9)
    .draw(ctx);
}

Or a one-liner version, without setting variable:

function render(ctx, state, pids) {
    Text.create()
    .text(pids.arrivals().get(0).destination()) // <--- Here
    .color(0xFFFFFF)
    .pos(0, 0)
    .draw(ctx);
    
    Text.create()
    .text("Joban Client Mod v2!")
    .color(0xFFFFFF)
    .pos(0, 9)
    .draw(ctx);
}
JCM JS PIDS Tutorial v0.5.png

However before we continue, there is a few problem with this:

  1. An arrival is not guaranteed to exist. For example if the client is still fetching the arrival info, or there are no arrival on that platform. This would cause the script to error out (Which you may have spotted in the console, but JCM will try to re-run the script every 4 second)
  2. Imagine a script filled with Text.create(), how would you be able to easily tell which text is which?

For the latter, you could add JS comments with //. However JCM also reserved a slot for comment, and that is within the Text.create(string) function. So as an example, you can do the following:

Text.create("1st row destination") // <----
.text(firstRowDestination)
.color(0xFFFFFF)
.pos(0, 0)
.draw(ctx);

The 1st row destination text is purely cosmetic and you can write any string within that, so this could be served as a comment. However whether this is preferable depends on your personal preferences. Now for the former arrival issue, we can add a null check to ensure that the arrival exists first:

function render(ctx, state, pids) {
    let firstRowArrival = pids.arrivals().get(0);
    if(firstRowArrival != null) { // <-- Check if arrival exists first
        Text.create("1st row destination")
        .text(firstRowArrival.destination())
        .color(0xFFFFFF)
        .pos(0, 0)
        .draw(ctx);
    }
    ...
}

Now this might be fine for rendering 1 arrival, but imagine we have to display 4 arrivals, duplicating this code 4 times is cumbersome. Instead we can change this to a for-loop:

function render(ctx, state, pids) {
    for(let i = 0; i < 4; i++) { // Set i to 0. i+1 if i < 4, otherwise don't run this anymore
        let arrival = pids.arrivals().get(i); // <---- Obtain nth row arrival
        if(arrival != null) {
            Text.create("Arrival destination")
            .text(arrival.destination())
            .color(0xFFFFFF)
            .pos(0, i*9) // <--- nth row * text height
            .draw(ctx);
        }
    }
}

The runs everything within the for loop 4 times, setting the i variable to the nth time our code is executed.

As such, we can use i to determine our Y position as well as which arrival to obtain.

JCM JS PIDS Tutorial v0.6.png

Text size & Language Cycle

To resize a text, we can use the .scale(factor) function to scale the text to be larger or smaller.

In our case, we will scale it by 1.25x:

Text.create("Arrival destination")
.text(arrival.destination())
.color(0xFFFFFF)
.pos(0, i*9)
.scale(1.25) // <---- Scale the text by 1.25x
.draw(ctx);
JCM JS PIDS Tutorial v0.7.png

The text does seems to be larger! But they are now too close to each other, and are really uncomfortable to look at.

This is because earlier, we set the position of the text to be .pos(0, i*9). However since our text is scaled, the text height is no longer 9, but instead 9*1.25 (our scale) = 11.25.

While we could just change it to 11.25, let's leave even more padding between each row for a better-look. Let's say each row should go 16.75 unit downwards:

Text.create("Arrival destination")
.text(arrival.destination())
.color(0xFFFFFF)
.pos(0, i*16.75) // <----
.scale(1.25)
.draw(ctx);
JCM JS PIDS Tutorial v0.8.png

Much better! The last thing we have to deal with is handling different languages. Currently it's just displayed with everything including the pipe character.

To cycle the string, we can wrap our destination string with the TextUtil.cycleString(str) function.

TextUtil is a utility method provided our of the box for us, you can check the Utilities page for more helper functions like these.

Text.create("Arrival destination")
.text(TextUtil.cycleString(arrival.destination())) // <---
.color(0xFFFFFF)
.pos(0, i*16.75)
.scale(1.25)
.draw(ctx);

And there we go, now the text cycles!

JCM JS PIDS Tutorial v0.10.png

So to re-cap, our code should look similar to this:

function create(ctx, state, pids) {
    print("Hello World ^^");
}

function render(ctx, state, pids) {
    for(let i = 0; i < 4; i++) {
        let arrival = pids.arrivals().get(i);
        if(arrival != null) {
            Text.create("Arrival destination")
            .text(TextUtil.cycleString(arrival.destination()))
            .color(0xFFFFFF)
            .pos(0, i*16.75)
            .scale(1.25)
            .draw(ctx);
        }
    }
}

function dispose(ctx, state, pids) {
    print("Goodbye World ^^;");
}

v1: Not too bad after-all!

In the last section we've spent all that time rendering some black-on-white text, but still it doesn't really look good.

The next thing we are going to do is to draw a background alongside the PIDS text. This is very similar to the way we make text, but we use Texture instead, as well as the .texture(id) function to specify the image we would draw:

function render(ctx, state, pids) {
    for(let i = 0; i < 4; i++) {
        let arrival = pids.arrivals().get(i);
        if(arrival != null) {
            Text.create("Arrival destination")
            .text(TextUtil.cycleString(arrival.destination()))
            .color(0xFFFFFF)
            .pos(0, i*16.75)
            .scale(1.25)
            .draw(ctx);
        }
    }

    Texture.create("Background") // <----------
    .texture("jsblock:textures/block/pids/rv_default.png")
    .draw(ctx);
}

jsblock:textures/block/pids/rv_default.png is the default image used for the built-in Railway Vision PIDS that comes with JCM. But of course, you are welcomed to bring your own images as well!

Now reload the resource pack and... JCM JS PIDS Tutorial v1.0.png I don't know how many of you have noticed, but this in-fact is not a background image! Both because it renders in-front of the text, and that it doesn't cover the entire screen.

Stacking Order

In JCM, every elements that gets drawn are rendered with their z-position incremented ever so slightly.

Therefore, the 2nd element to be drawn is placed in-front of the 1st element.

So the rule is pretty simple: Whoever gets rendered later are put in-front, and whoever gets rendered earlier are behind the one who are rendered later.

With this logic, let's move our background image to the top of the render function, so that everything afterwards are drawn on in-front of the background.

function render(ctx, state, pids) {
    Texture.create("Background") // <----------
    .texture("jsblock:textures/block/pids/rv_default.png")
    .draw(ctx);

    for(let i = 0; i < 4; i++) {
        let arrival = pids.arrivals().get(i);
        if(arrival != null) {
            Text.create("Arrival destination")
            .text(TextUtil.cycleString(arrival.destination()))
            .color(0xFFFFFF)
            .pos(0, i*16.75)
            .scale(1.25)
            .draw(ctx);
        }
    }
}

JCM JS PIDS Tutorial v1.1.png

Now we just need to resize the texture to stretch it through the entire screen.

This can be done through the .size(width, height) function:

Texture.create("Background")
.texture("jsblock:textures/block/pids/rv_default.png")
.size(80, 80)
.draw(ctx);

Hmm 80, 80? That's a square, not a rectangle like our PIDS screen?

That's correct, this code indeed does not cover the entire screen. A RV PIDS uses the size 136w x 76h.

While we could just set the size to 136, 76, you can also obtain the size of the PIDS directly with the pids variable passed to our render function, specifically pids.width and pids.height

Texture.create("Background")
.texture("jsblock:textures/block/pids/rv_default.png")
.size(pids.width, pids.height) // <-----
.draw(ctx);

This is useful both for readability purposes, as well as factoring in different sizes of PIDS. (An LCD PIDS screen is slightly smaller at 133w x 72h)

And let's not forget setting our destination text color to black (0x000000, or just remove .color entirely as it's black by default anyway), as the background is now bright. JCM JS PIDS Tutorial v1.2.png The background image itself appears to include a header bar, which our arrival text has not accounted for.

This is a very straight forward fix by just offsetting the text's Y position, and to save you time, you need to offset it by 13 to make it look right:

Text.create("Arrival destination")
.text(TextUtil.cycleString(arrival.destination()))
.pos(0, 13+(i*16.75)) // <----
.scale(1.25)
.draw(ctx);

But now things are getting crazy. Imagine if you give this to a friend for a reference, or even just you reading this a year later. Could you tell what that 13 is for, and why i*16.75? In such case, you should declare a variable with the var/let syntax:

function render(ctx, state, pids) {
    // ...background

    for(let i = 0; i < 4; i++) {
        let rowY = 13 + (i*16.75); // <----
        
        let arrival = pids.arrivals().get(i);
        if(arrival != null) {
            Text.create("Arrival destination")
            .text(TextUtil.cycleString(arrival.destination()))
            .pos(0, rowY) // <----
            .scale(1.25)
            .draw(ctx);
        }
    }
}

As for the 13, since it won't be changed at runtime, we can use the const keyword to indicate that this is a constant variable and would never change:

const HEADER_HEIGHT = 13; // <---

function create(ctx, state, pids) {
    ...
}

function render(ctx, state, pids) {
    // ...background
    
    for(let i = 0; i < 4; i++) {
        let rowY = HEADER_HEIGHT + (i*16.75); // <---
        
        let arrival = pids.arrivals().get(i);
        if(arrival != null) {
            Text.create("Arrival destination")
            .text(TextUtil.cycleString(arrival.destination()))
            .pos(0, rowY)
            .scale(1.25)
            .draw(ctx);
        }
    }
}

function dispose(ctx, state, pids) {
    ...
}

JCM JS PIDS Tutorial v1.3.png

Neat!

[TODO eta]

v2: Light Rail, Light Rail Everywhere!

[TODO v2 lrt + plat number]

v3: It's still missing

[TODO v3 header bar]

v4: Go ham!

[TODO v4 overflow adjustment]

v5: It's coming together