JSDocs with pc.Scripts

I’ve been experimenting with trying to get more ‘type safety’ in scripts of my personal projects and found that you can intersect types in the JS Docs implementation of Monaco, our current Code Editor.

With that and some boiler plating use in JS Docs, it’s kind of doable.

See the following:

(function () {
    var BoxPlacement = pc.createScript('boxPlacement');

    BoxPlacement.attributes.add('boxEntity', { type: 'entity', description: 'The entity to be spawned after clicking.' });
    BoxPlacement.attributes.add('cameraEntity', { type: 'entity', description: 'The main camera entity in the scene.' });

    /**
     * @typedef $BoxPlacement
     * @property {pc.Entity} boxEntity
     * @property {pc.Entity} cameraEntity
     * @property {function(pc.Vec3)} spawnBox
     * @property {function(Object.<number, number>)} fireRaycast
     * @property {function(pc.MouseEvent)} onMouseDown
     * @property {function(pc.TouchEvent)} onTouchDown
     */


    /** @type {pc.ScriptType & $BoxPlacement} */
    var prototype = BoxPlacement.prototype;

    // initialize code called once per entity
    prototype.initialize = function () {
        if (this.app.touch) {
            this.app.touch.on('touchstart', this.onTouchStart, this);
        }

        this.app.mouse.on('mousedown', this.onMouseDown, this);

        this.on('destroy', function () {
            if (this.app.touch) {
                this.app.touch.off('touchstart', this.onTouchStart, this);
            }

            this.app.mouse.off('mousedown', this.onMouseDown, this);
        }, this);
    };

    prototype.onMouseDown = function (e) {
        this.fireRaycast(e);
    };

    prototype.onTouchStart = function (touchEvent) {
        // For each new touch on screen, create a box
        var changedTouches = touchEvent.changedTouches;

        for (var i = 0; i < changedTouches.length; i++) {
            this.fireRaycast(changedTouches[i]);
        }

        touchEvent.event.preventDefault();
    };

    prototype.fireRaycast = function (event) {
        // Get the vec3 to start from
        var from = this.cameraEntity.getPosition();

        // Convert the 2D screen space coordinates to a 3D point for the end point
        var to = this.cameraEntity.camera.screenToWorld(event.x, event.y, this.cameraEntity.camera.farClip);

        // Cast a ray between the two points
        var raycastResult = this.app.systems.rigidbody.raycastFirst(from, to);

        // If there was a hit, spawn a box at the point
        if (raycastResult) {
            var hitPoint = raycastResult.point;
            this.spawnBox(hitPoint);
        }
    };

    prototype.spawnBox = function (point) {
        // Clone the box entity and add it to the hierarchy
        var newBox = this.boxEntity.clone();
        this.boxEntity.parent.addChild(newBox);

        // Adjust the position up so the box does not clip through the plane
        point.y += 0.5;

        // Move the box to the clicked position
        newBox.rigidbody.teleport(point);

        // Enable the new box
        newBox.enabled = true;
    };
})();

The important parts are:

  1. The whole script is function scoped because we are using some global variables that really should not be exposed
  2. BoxPlacement.prototype is currently a pc.ScriptType so we use a temp variable and the @type property to give it some properties
/**
     * @typedef $BoxPlacement
     * @property {pc.Entity} boxEntity
     * @property {pc.Entity} cameraEntity
     * @property {function(pc.Vec3)} spawnBox
     * @property {function(Object.<number, number>)} fireRaycast
     * @property {function(pc.MouseEvent)} onMouseDown
     * @property {function(pc.TouchEvent)} onTouchDown
     */


    /** @type {pc.ScriptType & $BoxPlacement} */
    var prototype = BoxPlacement.prototype;

    // initialize code called once per entity
    prototype.initialize = function () {

This gives us code completion and linting for the attributes and functions that we define in the typedef making it a bit easier to work with.

Kapture 2021-12-23 at 16.48.20

It’s not perfect as I can’t name params in the functions and the JSDocs for the functions are not near the the functions themselves.

Anyone got any advance on this?

I think this would be a lot easier with ES6 classes but we need to implement a babel transpiler in the publish step to convert to ES5 code for maximum browser compatibility.

An ES6 > ES5 and TS > ES5 transpiles on publish would be amazing!

Necropost time!

Also, disclamer: for me, this only works in local code editor (webstorm in my case)

Approach @yaustar proposed is fine, though it has a downside - pc.script itself being enclosed in scoped function are not being recognized outside scoped function, at least in my local webstorm.

I browsed through jshint docs and came up with another approach, which seems to work well on my local webstorm setup.

Here is an example:

/**
 * @typedef {{
 *     scale : number,
 *     offset : pc.Vec3,
 *     text : boolean
 * }} HPBarPoolSetting
 */

/**
 * @class {HPBarPool} HPBarPool
 * @property {HPBarPoolSetting[]} settings
 */
const HPBarPool = pc.createScript('hpBarPool');
HPBarPool.attributes.add('settings', {type : 'json', schema : [
        {name : 'scale', 'type' : 'number', default : 0.01},
        {name : 'offset', 'type' : 'vec3', default : new pc.Vec3(0, 2.5, 0.18)},
        {name : 'text', 'type' : 'boolean', default : false},
    ], array : true})

HPBarPool.prototype.initialize = function() {
    /**
     * @type {Map<string, HPBarPoolSetting>}
     */
    this.settingsDictionary = new Map()

    for (let i = 0 ; i < this.settings.length; i++) {
        const s = this.settings[i].scale
    }
}

that way, all the type defenitions works both inside the script scope, using this. in script methods, and annotating script and using it from inside other script.