No edit summary
 
Line 599: Line 599:


[[Category:Units]]
[[Category:Units]]
[[Category:Modding tutorials]]

Latest revision as of 22:16, 26 November 2023

Notice: Modding tutorials assume you know at least the basics of coding or scripting. Commander Wars uses Javascript as the Scripting Language. In case you want to learn more about Javascript, you can check the tutorial here: Derek Banas' Javascript tutorial , purchase O'Reilly's excellent book on the subject JavaScript: The Definitive Guide, or check out W3 Schools Interactive Tutorials

You may download the files of this tutorial on Discord

Setup

In this tutorial, you will learn how to create a custom Mech Unit with the movement range of Infantry and 6000g cost, with the ability to hide, which we will call 'Night Stalker'. The Night Stalker unit can only be attacked by other Infantry Units while hidden.

To start off, create a folder named nightstalker inside the mods folder of the game, and inside of it the following folder and file structure:

~/mods/nightstalker
├── mod.txt
└── scripts
    ├── battleanimations
       └── BATTLEANIMATION_NIGHT_STALKER.js
    ├── building
       └── factory.js
    └── units
        └── night_stalker.js

This file structure is important as the game detects the folders to make changes to the game.

Inside the mod.txt add the following:

name=Night Stalkers
description=Custom Mech Units, created for the wiki tutorial.
version=1.0.0
compatible_mods=
incompatible_mods=
required_mods=
cosmetic=false

This makes the mod visible to the game under Options>Mods

Initializing the unit

Initialize the code of the Night Stalker by putting this inside the night_stalker.js:

  var Constructor = function()
{
    this.init = function(unit)
    {
        unit.setAmmo1(10);
        unit.setMaxAmmo1(10);
        unit.setWeapon1ID("WEAPON_MECH_MG");

        unit.setAmmo2(3);
        unit.setMaxAmmo2(3);
        unit.setWeapon2ID("WEAPON_BAZOOKA");

        unit.setFuel(70);
        unit.setMaxFuel(70);
        unit.setBaseMovementPoints(3);
        unit.setMinRange(1);
        unit.setMaxRange(1);
		unit.setVision(2);
    };
}

Constructor.prototype = UNIT;
var NIGHT_STALKER = new Constructor();

The setWeapon1ID() and setWeapon2ID() can take a string of any of the available weapons in the game. One can also create a custom weapon, but that will not be covered in this tutorial.

Adding the Army Data and Unit Costs

Up next we add the base costs as well as the Army data of the unit

this.getBaseCost = function()
    {
        return 6000;
    };

    this.armyData = [["os", "os"],
                     ["bm", "bm"],
                     ["ge", "ge"],
                     ["yc", "yc"],
                     ["bh", "bh"],
                     ["bg", "bg"],
                     ["ma", "ma"],
                     ["ac", "ac"],
                     ["bd", "bd"],
                     ["dm", "dm"],
                     ["gs", "gs"],
                     ["pf", "pf"],
                     ["ti", "ti"],];

    this.animationData = [["os", [1]],
                          ["bm", [1]],
                          ["ge", [1]],
                          ["yc", [1]],
                          ["bh", [1]],
                          ["bg", [2]],
                          ["ma", [2]],
                          ["ac", [2]],
                          ["bd", [2]],
                          ["dm", [2]],
                          ["gs", [2]],
                          ["pf", [2]],
                          ["ti", [2]],];

The array entries correspond to the names of the factions of the game. These can be customized as well, but won't be covered in this tutorial.

Loading Sprites

This part of the code loads the sprites of the unit. We will be using the same parameters as the sprites of the Mech unit.

this.loadSprites = function(unit)
    {
        // none neutral player
        var player = unit.getOwner();
        // get army name
        var armyName = Global.getArmyNameFromPlayerTable(player, MECH.armyData);
        // load sprites
        unit.loadSpriteV2("mech+" + armyName +"+mask", GameEnums.Recoloring_Matrix);
        unit.loadSpriteV2("mech+" + armyName, GameEnums.Recoloring_None);
    };

Movement and Actions

This code sets the movement type of the unit. You may also choose among other movement types

 this.getMovementType = function()
    {
        return "MOVE_FEET";
    };
    this.actionList = ["ACTION_STEALTH", "ACTION_UNSTEALTH","ACTION_FIRE", "ACTION_MISSILE", "ACTION_CAPTURE", "ACTION_JOIN", "ACTION_LOAD", "ACTION_WAIT", "ACTION_CO_UNIT_0", "ACTION_CO_UNIT_1"];

The action array is the same as the original Mech unit, however by adding the ACTION_STEALTH and ACTION_UNSTEALTH these actions will be added as well to the unit in game.

Walking animation

We will also copy the Mech's walking animation, this shows when the unit is ordered to moved in game

 this.doWalkingAnimation = function(action, map)
    {
        var unit = action.getTargetUnit();
        var animation = GameAnimationFactory.createWalkingAnimation(map, unit, action);
        // none neutral player
        var player = unit.getOwner();
        // get army name
        var armyName = Global.getArmyNameFromPlayerTable(player, MECH.armyData);
        var data = Global.getDataFromTable(armyName, MECH.animationData);
        animation.loadSpriteV2("mech+" + armyName + "+walk+mask", GameEnums.Recoloring_Matrix, data[0]);
        animation.setSound("moveboots.wav", -2);
        return animation;
    };

You may also choose to set a different sound for the walking animation. These are the available sounds in the game If these are in the game, one only needs to pass the name of the wav file as a parameter.

alternatively, you may create a sounds folder in the root of the mod, and place a wav file there. If you do, you only pass the name of the wav file as well.

Name and unit description

These functions set the name and unit description. The Description uses HTML syntax for highlighting

this.getName = function()
    {
        return qsTr("Night Stalker");
    };

    this.getDescription = function()
    {
        return qsTr("<r>Attack power high. Can </r><div c='#00ff00'>capture </div><r> bases and hide. </r><div c='#00ff00'>Vision +3 </div><r> when on mountains.</r>");
    };

Can move and fire

The following function defines if a unit can attack and move on the same turn. It's set to false for indirect units

this.canMoveAndFire = function()
    {
        return true;
    };

Setting the unit type

This sets the group of units it belongs to, for a list of possible units type check Unit Types

this.getUnitType = function()
    {
        return GameEnums.UnitType_Infantry;
    };

Editor placement sounds

This sets the sound a unit emits when it's put in a map, while using the map editor

this.getEditorPlacementSound = function()
    {
        return "moveboots.wav";
    };

Like with the animation sound, you may use the available sounds in the game or place a sound in a new sounds folder, at the root of the mod.

Setting up the damage the unit receives

In this tutorial, we will make the unit receive the same damage as the vanilla Mech units, to do so we add:

 this.getUnitDamageID = function(unit, map)
    {
        return "MECH";
    };

However it is possible to set the damage the unit receives by creating a csv file. Won't be covered in this tutorial.

Creating the battle animation

The file BATTLEANIMATION_NIGHT_STALKER.js defines the behavior of the battle animations. The definition and setup of a custom battle animation, is beyond the scope of this tutorial. For simplicity, we will copy the contents of the Mech unit and modify it to match the constructor of the custom unit. The code of BATTLEANIMATION_NIGHT_STALKER.js is copied verbatim for reference:

var Constructor = function()
{
    this.getMaxUnitCount = function()
    {
        return 5;
    };
    this.armyData = [["ac", "ac"],
                     ["bd", "bd"],
                     ["bh", "bh"],
                     ["bg", "bg"],
                     ["bm", "bm"],
                     ["dm", "dm"],
                     ["ge", "ge"],
                     ["gs", "gs"],
                     ["ma", "ma"],
                     ["os", "os"],
                     ["pf", "pf"],
                     ["ti", "ti"],
                     ["yc", "yc"],];

    this.animationData = [["ac", ["bazooka_bm", Qt.point(20, 16), Qt.point(-50, 20), -90]],
                          ["bd", ["bazooka_bm", Qt.point(17, 16), Qt.point(-50, 20), -90]],
                          ["bh", ["bazooka_bh", Qt.point(15, 12), Qt.point(-50, 20), -90]],
                          ["bg", ["bazooka_bh", Qt.point(15, 12), Qt.point(-50, 20), -90]],
                          ["bm", ["bazooka_bm", Qt.point(17, 16), Qt.point(-50, 20), -90]],
                          ["dm", ["bazooka_ge", Qt.point(14, 9),  Qt.point(-50, 20), -90]],
                          ["ge", ["bazooka_ge", Qt.point(15, 14), Qt.point(-50, 20), -90]],
                          ["gs", ["bazooka_ge", Qt.point(14, 9),  Qt.point(-50, 20), -90]],
                          ["ma", ["bazooka_os", Qt.point(20, 10), Qt.point(-50, 20), -90]],
                          ["os", ["bazooka_os", Qt.point(18, 17), Qt.point(-50, 20), -90]],
                          ["pf", ["bazooka_os", Qt.point(17, 16), Qt.point(-50, 20), -90]],
                          ["ti", ["bazooka_yc", Qt.point(18, 17), Qt.point(-50, 20), -90]],
                          ["yc", ["bazooka_yc", Qt.point(19, 17), Qt.point(-50, 20), -90]],];

    this.getRiverString = function(unit)
    {
        var terrainId = "PLAINS";
        var terrain = unit.getTerrain();
        if (terrain !== null)
        {
            terrainId = unit.getTerrain().getTerrainID();
        }
        if (terrainId === "RIVER" ||
                terrainId === "DESERT_TRY_RIVER")
        {
            return "+river";
        }
        return "";
    };

    this.isMountain = function(terrainId)
    {
        if (terrainId === "MOUNTAIN" ||
            terrainId === "SNOW_MOUNTAIN" ||
            terrainId === "DESERT_ROCK" ||
            terrainId === "WASTE_MOUNTAIN")
        {
            return true
        }
        return false;
    };

    this.loadMoveInAnimation = function(sprite, unit, defender, weapon)
    {
        if (weapon === 1 || defender === null)
        {
            var count = sprite.getUnitCount(BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount());
            var armyName = Global.getArmyNameFromPlayerTable(unit.getOwner(), BATTLEANIMATION_NIGHT_STALKER.armyData);
            var riverName = BATTLEANIMATION_NIGHT_STALKER.getRiverString(unit);            
            sprite.loadMovingSpriteV2("mech+" + armyName + riverName + "+walk+mask", GameEnums.Recoloring_Matrix, sprite.getMaxUnitCount(), Qt.point(-75, 5),
                                      Qt.point(65, 0), 600, false,
                                      1, 1);
            var spriteId = "mech+" + armyName + riverName + "+walk";
            if (sprite.existResAnim(spriteId))
            {
                sprite.loadMovingSprite(spriteId, false, sprite.getMaxUnitCount(), Qt.point(-75, 5),
                                        Qt.point(65, 0), 600, false,
                                        1, 1);
            }
            for (var i = 0; i < count; i++)
            {
                sprite.loadSound("infantry_move.wav", 5, i * BATTLEANIMATION.defaultFrameDelay);
            }
            BATTLEANIMATION.loadSpotterOrCoMini(sprite, unit, false);
        }
        else
        {
            BATTLEANIMATION_INFANTRY.loadMoveInAnimation(sprite, unit, defender, weapon);
        }
    };

    this.loadStopAnimation = function(sprite, unit, defender, weapon)
    {
        if (weapon === 1 || defender === null)
        {
            var terrainId = unit.getTerrain().getTerrainID();
            if (BATTLEANIMATION_NIGHT_STALKER.isMountain(terrainId))
            {
                BATTLEANIMATION_NIGHT_STALKER.loadStandingAnimation(sprite, unit, defender, weapon);
            }
            else
            {
                BATTLEANIMATION_NIGHT_STALKER.loadSprite(sprite, unit, defender, weapon, "+stop", 1, 0, 1);
            }
        }
        else
        {
            BATTLEANIMATION_INFANTRY.loadStopAnimation(sprite, unit, defender, weapon);
        }
    };

    this.loadSprite = function(sprite, unit, defender, weapon, ending, count, startFrame = 0, endFrame = 0)
    {
        var armyName = Global.getArmyNameFromPlayerTable(unit.getOwner(), BATTLEANIMATION_NIGHT_STALKER.armyData);
        var riverName = BATTLEANIMATION_NIGHT_STALKER.getRiverString(unit);
        var offset = Qt.point(-10, 5);
        sprite.loadSpriteV2("mech+" + armyName + riverName + ending + "+mask", GameEnums.Recoloring_Matrix,
                            BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount(), offset, count, 1, 0, 0,
                            false, false, 100, endFrame, startFrame);
        var spriteId = "mech+" + armyName + riverName + ending;
        if (sprite.existResAnim(spriteId))
        {
            sprite.loadSpriteV2(spriteId, GameEnums.Recoloring_None,
                                BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount(), offset, count, 1, 0, 0,
                                false, false, 100, endFrame, startFrame);
        }
        BATTLEANIMATION.loadSpotterOrCoMini(sprite, unit, false);
    };

    this.loadStandingAnimation = function(sprite, unit, defender, weapon)
    {
        if (weapon === 1 || defender === null)
        {
            BATTLEANIMATION_NIGHT_STALKER.loadSprite(sprite, unit, defender, weapon, "", 1);
        }
        else
        {
            BATTLEANIMATION_INFANTRY.loadStandingAnimation(sprite, unit, defender, weapon);
        }
    };

    this.loadFireAnimation = function(sprite, unit, defender, weapon)
    {
        var count = sprite.getUnitCount(BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount());
        if (weapon === 1)
        {
            BATTLEANIMATION_NIGHT_STALKER.loadSprite(sprite, unit, defender, weapon, "+fire", 1, 0, 2);
            var player = unit.getOwner();
            var armyName = Global.getArmyNameFromPlayerTable(player, BATTLEANIMATION_NIGHT_STALKER.armyData);
            var data = Global.getDataFromTable(armyName, BATTLEANIMATION_NIGHT_STALKER.animationData);
            var weaponRes = data[0];
            var offset = data[1];
            sprite.loadMovingSprite(weaponRes, false, sprite.getMaxUnitCount(), offset,
                                    Qt.point(160, 0), 500, false,
                                    1, 1, -1);
            sprite.loadSprite("rocket_trailing_smoke",  false, BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount(), offset);
            offset = Qt.point(data[1].x - 55, data[1].y - 5);
            sprite.loadSprite("bazooka_launch",  false, BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount(), offset);
            for (var i = 0; i < count; i++)
            {
                sprite.loadSound("baazoka_fire.wav", 1, i * BATTLEANIMATION.defaultFrameDelay);
            }
        }
        else
        {
            BATTLEANIMATION_INFANTRY.loadFireAnimation(sprite, unit, defender, weapon);
        }
    };

    this.getFireDurationMS = function(sprite, unit, defender, weapon)
    {
        if (weapon === 1)
        {
            return 700 + BATTLEANIMATION.defaultFrameDelay * sprite.getUnitCount(BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount());
        }
        else
        {
            return 600 - BATTLEANIMATION.defaultFrameDelay * sprite.getUnitCount(BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount());
        }
    };

    this.loadStandingFiredAnimation = function(sprite, unit, defender, weapon)
    {
        if (weapon === 1)
        {
            BATTLEANIMATION_NIGHT_STALKER.loadSprite(sprite, unit, defender, weapon, "+fire", 1, 2, 2);
        }
        else
        {
            BATTLEANIMATION_INFANTRY.loadStandingAnimation(sprite, unit, defender, weapon);
        }
    };

    this.loadImpactUnitOverlayAnimation = function(sprite, unit, defender, weapon)
    {
        if (weapon === 0)
        {
            sprite.loadColorOverlayForLastLoadedFrame("#969696", 1000, 1, 300);
        }
        else
        {
            sprite.loadColorOverlayForLastLoadedFrame("#969696", 300, 2, 0);
        }
    };

    this.loadImpactAnimation = function(sprite, unit, defender, weapon)
    {
        var count = sprite.getUnitCount(BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount());
        var i = 0;
        if (weapon === 1)
        {
            sprite.loadSprite("cannon_hit",  false, sprite.getMaxUnitCount(), Qt.point(0, 20),
                              1, 1.0, 0, 400, true);
            sprite.addSpriteScreenshake(8, 0.95, 800, 500);
            var player = unit.getOwner();
            var armyName = Global.getArmyNameFromPlayerTable(player, BATTLEANIMATION_NIGHT_STALKER.armyData);
            var data = Global.getDataFromTable(armyName, BATTLEANIMATION_NIGHT_STALKER.animationData);
            var weaponRes = data[0];
            sprite.loadMovingSprite(weaponRes, false, sprite.getMaxUnitCount(), Qt.point(127, 24),
                                    Qt.point(-127, 0), 400, true,
                                    1, 1, 0, 0, true);
            for (i = 0; i < count; i++)
            {
                sprite.loadSound("rocket_flying.wav", 1, 0);
                sprite.loadSound("impact_explosion.wav", 1, 200 + i * BATTLEANIMATION.defaultFrameDelay);
            }
        }
        else
        {
            var yOffset = 22;
            if (unit.getUnitType()  === GameEnums.UnitType_Air)
            {
                yOffset = 40
            }
            sprite.loadSprite("mg_hit",  false, sprite.getMaxUnitCount(), Qt.point(0, yOffset),
                              1, 1.0, 0, 0, true);
            BATTLEANIMATION.playMgImpactSound(sprite, unit, defender, weapon, count);
        }
    };

    this.getImpactDurationMS = function(sprite, unit, defender, weapon)
    {
        if (weapon === 0)
        {
            return 400 - BATTLEANIMATION.defaultFrameDelay + BATTLEANIMATION.defaultFrameDelay * sprite.getUnitCount(BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount());
        }
        else
        {
            return 400 - BATTLEANIMATION.defaultFrameDelay + BATTLEANIMATION.defaultFrameDelay * sprite.getUnitCount(BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount());
        }
    };

    this.getPositionOffset = function(sprite, unit, terrain, unitIdx)
    {
        if (terrain !== null)
        {
            if (BATTLEANIMATION_NIGHT_STALKER.isMountain(terrain.getID()))
            {
                if (unitIdx >= 4)
                {
                    return Qt.point(-20 * (6 - unitIdx), 0);
                }
            }
        }
        return Qt.point(0, 0);
    };

    this.hasMoveInAnimation = function(sprite, unit, defender, weapon)
    {
        var terrainId = unit.getTerrain().getTerrainID();
        if (BATTLEANIMATION_NIGHT_STALKER.isMountain(terrainId))
        {
            return false;
        }
        else
        {
            // return true if the unit has an implementation for loadMoveInAnimation
            return true;
        }
    };
    this.getMoveInDurationMS = function()
    {
        return 610;
    };

    this.getStopDurationMS = function(sprite, unit, defender, weapon)
    {
        return 300 + BATTLEANIMATION.defaultFrameDelay * BATTLEANIMATION_NIGHT_STALKER.getMaxUnitCount();
    };

    this.getDyingDurationMS = function(sprite, unit, defender, weapon)
    {
        return 600;
    };

    this.hasDyingAnimation = function()
    {
        return true;
    };

    this.loadDyingAnimation = function(sprite, unit, defender, weapon)
    {
        if (weapon === 1)
        {
            var armyName = Global.getArmyNameFromPlayerTable(unit.getOwner(), BATTLEANIMATION_NIGHT_STALKER.armyData);
            var riverName = BATTLEANIMATION_NIGHT_STALKER.getRiverString(unit);
            var data = Global.getDataFromTable(armyName, BATTLEANIMATION_NIGHT_STALKER.animationData);
            var offset = Qt.point(-10, 5);
            var rotation = data[3];
            var movement = data[2];
            var ending = "";
            if (sprite.getHasFired())
            {
                ending = "+fire";
            }
            sprite.loadDyingMovingSprite("mech+" + armyName + riverName + ending + "+mask",
                                         "mech+" + armyName + "+dying+mask",
                                         GameEnums.Recoloring_Matrix,
                                         offset, movement, rotation, 400, 0, 2);
            var spriteId = "mech+" + armyName + riverName + ending;
            if (sprite.existResAnim(spriteId))
            {
                sprite.loadDyingMovingSprite(spriteId,
                                             "mech+" + armyName + "+dying",
                                             GameEnums.Recoloring_None,
                                             offset, movement, rotation, 400, 0, 2);
            }
            BATTLEANIMATION.loadSpotterOrCoMini(sprite, unit, false);
        }
        else
        {
            BATTLEANIMATION_INFANTRY.loadDyingAnimation(sprite, unit, defender, weapon);
        }
    };
};

Constructor.prototype = BATTLEANIMATION;
var BATTLEANIMATION_NIGHT_STALKER = new Constructor();

Adding the unit to a building

To add the unit to the factory we will do so by adding the Night Stalker to the construction list inside the factory.js, like this:

FACTORY.constructionList.push("NIGHT_STALKER");

In case you wanted to add it to a different type of building, you'd need to create the corresponding Javascript file. The following is a list of the buildings available in the game, from which you can build units:

Available buildings
Building name Building constructor name Javascript file
Airport AIRPORT airport.js
Amphibious factory[1] AMPHIBIOUSFACTORY amphibiousfactory.js
Factory FACTORY factory.js
Harbour HARBOUR harbour.js

Activating the mod

On the main menu of the game, go to Options>Mods then check the box that reads Night Stalkers the game will restart.

Notes

  1. Used to deploy Hovercraft Units