Units
A unit is anything that has pixels.
Bosses are units, minions are units, shield containers are units, etc.
Info
Config
The config type for units is of the type UnitData
.
The basic structure looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | { "properties": { // custom variables defined here }, "behaviour":".fsm", // references a finite-state-machine in the same config file "baseForm":{ // properties common to each form }, "forms": [ { // - each form specifies where its pixel source & anim files are located // - defines pixel types // - defines parts }, ], "fsm":[ "form0": { // the actions in the behaviour state machine }, ], } |
Example json file of a simple 2-form boss
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 | { // custom variables for this boss "properties": { "loopNum": { "type": "Int", "value": 0 }, // the starting value is already 0 by default, but it's specified here for clarity }, "behaviour": ".fsm", // specify where to find the behaviour state machine definition (the leading "." indicates its inside the same json object) // properties defined in baseForm will be used for all forms unless overridden "baseForm": { "baseHp":18, // default hp for each pixel // when player is hit by a bullet, this handler is called "onPlayerHit":[ { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#octopus.boss.player_hit_bullet", "chance":0.1, }}, ], }, "forms": [ { // FORM 0 CONFIG "pxcSource": "octopus/pxc/boss/form0/source", // the location of the pixel source file for this form "pxcAnimDir": "octopus/pxc/boss/form0", // the directory where the anim files associated with the source file are located // list anim files to use (from the directory specified for pxcAnimDir) "anims": { "idle": { "time": 1.25, // how long each playthrough of the anim is "easingType": "Linear", // how to ease through the anim frames }, }, // pixel properties "pixels": [ { "range": "all", // apply these properties to all pixels "sfxHit": "FleshHit", "sfxDestroy": "FleshDestroy", "sfxDisconnect": "FleshDisconnect", "hitGlow":1.4, "hitFlashTime":1, }, ], // define parts "parts": { "core": { "corePath": "misc/core/octopus/core0", "size": 8, // a size of 8 means this part is an 8x8 square "placements": [ 0 ], "hp": 900, "requires": [ "mid", "side" ], // this part cant be damaged until the required parts are destroyed "onHit": [ { "action": "CallMethod", "target": "stage", "method": "ShakeCamera", "params": { "strength": 0.25, "time": 0.33, "easingType": "QuadOut" }}, ], }, "mid": { "corePath": "misc/core/octopus/smallGun", "size": 4, "placements": [ 73, 89 ], "hp": 500, "onDestroy": [ // spawn a powerup { "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup0Mid", "pos": "partPos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - partPos)", }}, ], }, "side": { "corePath": "misc/core/octopus/smallGun", "size": 3, "placements": [ 64, 105 ], "hp": 500, "onDestroy": [ { "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup0Side", "pos": "partPos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - partPos)", }}, ], } }, // this handler is called each frame "onUpdate": [ // continually re-align the rotation of the boss { "action": "CallMethod", "method": "AimTowards", "params": { "facingAngle":0, "rotPercent":0.125, }}, ], // this handler is called when the form is destroyed "onDestroy": [ { "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup0Core", "pos": "corePos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - corePos)", }}, // controller rumble { "action": "CallMethod", "target": "player", "method": "Vibrate", "params": { "pos":"unitPos", "strength": 1, "time": 0.25, "maxDist": 150, "horizThreshold": 40 }}, // camera shake { "action": "CallMethod", "target": "stage", "method": "ShakeCamera", "params": { "strength": 5, "time": 0.5, "easingType": "QuadOut" }}, // slow time for a moment { "action": "CallMethod", "target": "stage", "method": "AddTimeScale", "params": { "scale": 0.5, "time": 0.5, "easingType": "CubicIn" }}, ], // this handler is called when the player dies while this form is active "onPlayerDie":[ // a chance to show a speech bubble { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#octopus.boss.player_die_0", "chance":0.2, }}, ], }, { // FORM 1 CONFIG "pxcSource": "octopus/pxc/boss/form1/source", "pxcAnimDir": "octopus/pxc/boss/form1", "spawnDelay":2.2, // time to wait before spawning this form "coreSpawnTime":1.75, // time to spawn the core "moveMode":"Target", // unit moves toward target position, instead of using velocity "targetPos":"unitSpawnPos + vec2(sin(stageTime * 0.5f), cos(stageTime * 0.5f)) * 2f", // unit will bob around a central point "posLerpSpeed":0.01, // how fast the unit lerps toward the target position "anims": { "spawn": { "time": 1, "easingType": "QuadIn", "next": "idle" }, "idle": { "time": 1.25, "easingType": "Linear" }, }, "pixels": [ { "range": "all", "sfxHit": "FleshHit", "sfxDestroy": "FleshDestroy", "sfxDisconnect": "FleshDisconnect", "hitGlow":1.4, "hitFlashTime":1, }, { "range": "part('core')", "color": "color(1f, 0f, 0f)", // this property applies to pixels defined in the "core" part below }, { "range": "part('mid')", "color": "color(1f, 0f, 0f)", // this property applies to pixels defined in "mid" parts below }, { "range": "part('side')", "color": "color(1f, 0f, 0f)", }, { "range": "part('bot')", "color": "color(1f, 0f, 0f)", }, { "range": "range(146,389)", "sfxHit": "Thump", "sfxDestroy": "MetalDestroy", "sfxDisconnect": "MetalDisconnect", "damagedColor": "color(0.175f, 1f, 0.35f) * 1.15f", // as the pixel loses hp, it changes toward this color "hp": 40, // override the baseHp for these pixels "hitGlow":1.2, "hitFlashTime":0.5, } ], "parts": { "core": { "corePath": "misc/core/octopus/core1", "size": 8, "placements": [ 0 ], "hp": 1000, "requires": [ "mid", "side", "bot" ], "onHit": [ { "action": "CallMethod", "target": "stage", "method": "ShakeCamera", "params": { "strength": 0.25, "time": 0.33, "easingType": "QuadOut" }}, ], }, "side": { "corePath": "misc/core/octopus/smallGun", "placements": [ { "size": 3, "start": 128 }, { "area": 9, "start": 137 }, // you can specify "area" instead of "size", for irregularly shaped parts ], "hp": 600, "onDestroy": [ { "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup1Side", "pos": "partPos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - partPos)", }}, ], }, "bot": { "corePath": "misc/core/octopus/largeGun", "size": 4, "placements": [ 80, 112 ], "hp": 600, "onDestroy": [{ "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup1Bot", "pos": "partPos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - partPos)", }},], }, "mid": { "corePath": "misc/core/octopus/largeGun", "size": 4, "placements": [ 64, 96 ], "hp": 600, "onDestroy": [{ "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup1Mid", "pos": "partPos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - partPos)", }},], } }, // this handler is called immediately when the unit is spawned "onSpawn": [ { "action": "CallMethod", "target": "stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/effect/bgClouds.fgStart", }}, { "action": "CallMethod", "target": "stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/effect/bgClouds.fgOngoing", }}, { "action": "CallMethod", "target":"stage", "method": "SetGridColor", "params": { "color": "color(0f, 1f, 1f, 0.033f)", "time":1, "easingType":"QuadOut" }}, ], // this handler is called when the pixels finish respawning "onFormRespawn": [ { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#octopus.boss.form_1", "chance":0.25, }}, ], "onUpdate": [ { "action": "CallMethod", "method": "Shake", "params": { "strength":"map(core.HpPercent, 1f, 0f, 0f, 0.175f, 'QuadIn')", "time":0, }}, { "action": "CallMethod", "method": "SetScale", "params": { "scale": "vec2(1f + sin(stageTime * 1.8f) * 0.03f, 1f + sin(stageTime * 1.8f) * 0.03f)" }}, { "action": "CallMethod", "method": "AimTowards", "params": { "facingAngle":0, "rotPercent":0.125, }}, ], // this handler is called when the unit's pixel is hit by a bullet "onPixelHit": [ { "action": "CallMethod", "target": "unit", "method": "Nudge", "params": { "vector":"bullet.FacingDirection * 0.15f", "time":0.25, "easingType":"QuadOut", }}, ], "onPlayerDie":[ { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#octopus.boss.player_die_1", "chance":0.33, }}, ], }, ], // the state machine that handles the boss behaviour "fsm": { "inactive": [ { "action": "Wait", } // wait indefinitely ], "form0": { // the name "form0" is important, when the first form of the unit finishes respawning, its behaviour state will be set to this // FORM 0 "initial delay": [ { "action": "Wait", "time":2, }, ], "0": [ { "action": "CallMethod", "method": "ChargePattern", "params": { "paths": [ "octopus/pattern/lazyBullets.1", "octopus/pattern/lazyBullets.2", "octopus/pattern/lazyBullets.3", "octopus/pattern/lazyBullets.4" ], "pathIndex":"loopNum % 4", "partType": "mid", "chargeTime": 0.75, "dir":"playerPos - partPos", }}, { "action": "Wait", "time": 4, }, ], "1": [ { "action": "SetValue", "name": "loopNum", "value": "loopNum + 1" }, { "action": "Goto", "state": "0" }, ], }, "form1": { // when the 2nd unit form finishes respawning, its behaviour state will be set to "form1" // FORM 1 "initial delay 2": [ { "action": "SetValue", "name": "loopNum", "value": 0 }, { "action": "Wait", "time": 1 }, { "action": "CallMethod", "method": "ChargePattern", "params": { "path": "octopus/pattern/curseMineOrb", "partType": "core", "chargeTime": 1, "loopNum":"loopNum", "dir": "(playerPos + playerVel * 0.75f) - partPos", }}, { "action": "Wait", "time": 1 }, ], "0": [ { "action": "Condition", "condition": "loopNum > 0 && numShotPatterns % 2 == 0", "true": [ { "action": "CallMethod", "method": "ChargePattern", "params": { "paths": [ "octopus/pattern/spread.spokes1", "octopus/pattern/spread.spokes2", "octopus/pattern/spread.spokes3" ], "pathIndex":"numShotPatterns % 3", "partType": "side", "chargeTime": 1, "dir":"(playerPos + playerVel * 0.25f) - partPos", }}, { "action": "Wait", "time": 3, }, ], "false": [ { "action": "Repeat", "count": 2, "delay": 1.5, "inner": [ { "action": "CallMethod", "method": "ChargePattern", "params": { "path": "octopus/pattern/spread.1", "partType": "side", "chargeTime": 1, "dir":"(playerPos + playerVel * 0.25f) - partPos", }}, ]}, { "action": "Wait", "time": 2, }, ],}, ], "1": [ { "action": "SetValue", "name": "loopNum", "value": "loopNum + 1" }, { "action": "Goto", "state": "0" }, ], }, }, // subroutine to show a speech bubble and play a sound "speech.sub": { // the parameters we can pass in to the subroutine "parameters": { "this": { "type": "Unit" }, // this subroutine is intended to be run by Unit objects "message": { "type": "String" }, "chance": { "type": "Float" }, }, "actions": [ { "action": "Condition", "condition": "rand.Float(0f, 1f) < 0.5f", "true": [ { "action": "CallMethod", "target":"unit.GetPart('core')", "method": "SpawnBubble", "ignoreNullRef": true, "params": { "text": "${message}", "partType":"core", "lifetime": 4, }}, { "action": "CallMethod", "target":"stage", "method": "PlaySfx", "params": { "sfxType": "OctopusBossSpeech", "pos":"unitPos", }}, ],}, ] }, } |
Forms
Most of the properties for a unit can be defined for each form.
When one form is destroyed, the unit will respawn in their next form.
The config type for unit forms is of the type FormData
.
Each unit config should contain a list of forms:
1 2 3 4 5 6 7 8 9 10 | { "forms": [ { // form 0 }, { // form 1 }, ], } |
Unit form info
Base Form
Any properties defined for the baseForm
of a unit apply to all of its forms, unless overridden (they're essentially the same thing as the #include
functionality, which can be used instead).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | { "baseForm": { "baseHp":18, "onUpdate":[ // actions ], }, "forms": [ { // form 0 "baseHp":25, }, { // form 1 "onUpdate":[ // different actions ], }, ], } |
form 0
overrides the base pixel health of the base form while keeping its onUpdate
actions, while the next form keeps the base form's baseHp
but overrides its onUpdate
actions.
Warning
Some properties won't show your changes until you completely reload the level - restarting isn't enough.
Press the G key to reload the level.
Pixels
Each form needs to specify the location of the pixel source
file and the directory where its anim
files are located.
Each unit form should have one source file, and one or more anim files.
1 2 3 4 5 6 7 | { "forms":[ "pxcSource":"octopus/pxc/boss/form0/source", "pxcAnimDir":"octopus/pxc/boss/form0", // ... ], } |
Each form should also set some properties for its pixels.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | { "forms":[ // ... "pixels": [ { "range": "all", // these properties will apply to all pixels "hp":20, "sfxHit": "PixelHit", "sfxDestroy": "PixelDestroy", "sfxDisconnect": "PixelDisconnect", }, { "range": "part('core')", // these properties will apply to pixels in the "core" part, and override previous properties "color": "#668855" }, { "range": "range(616, 771)", // these pixels apply to a range of assigned numbers (generally a certain color), and override previous properties "hp":40, "sfxHit": "Thud3", }, { "range": "range(72, 317)", "pixelType":"Invulnerable", "sfxHit": "MetalHit", }, ], // ... ], } |
To find the range
of a certain type of pixel:
1) Load your animation file in the PxcEditor.
2) Decide which pixel type you want to get the range of - in this case, the purple colored pixels are going to be invulnerable.
3) Hold the C key and right click one of the pixels to select all other pixels of the same color.
4) Hold the Shift+C keys and right click one of the other purple pixels to add all pixels of that shade to the selection.
5) Press Middle Mouse Button or the
=
button on the far right of the toolbar to toggle showing pixel assignments.
6) The range (in this case
244, 1923
) should now be copied to your clipboard. Paste it into the pixel config. If your range isn't a neat sequence, you probably didn't assign your pixels on a color-by-color basis.
Pixel Types
When specifying properties for pixels, you can choose between a few fundamental types.
Pixel Type | Summary |
---|---|
Normal |
pixel has a certain amount of hp until it is destroyed |
Explosive |
a small amount of damage will trigger this pixel to explode and destroy adjacent pixels |
Invulnerable |
normal damage won't affect this pixel (though Explosive pixels will) |
Parts
Each kind of part used in your enemy needs to be defined in the form's config.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | { "forms":[ { "parts":{ "core": { "corePath": "misc/core/octopus/core0", "size": 12, "placements": [ 0 ], "hp": 900, "requires": [ "gun", ], }, "gun": { "corePath": "misc/core/octopus/smallGun", "size": 7, "placements": [ 73, 89 ], "hp": 500, }, } }, ], } |
For more info check the Unit Parts page.
Animations
Importing
Animations should be declared in the unit form's anims
property.
These anim
json files should be located in the pxcAnimDir
you specified for the unit form.
1 2 3 4 5 6 7 8 9 10 11 12 | { "forms": [ { // ... "anims": { "spawn": { "time": 0.5, "easingType": "QuadIn", "next": "idle" }, "idle": { "time": 1.25, "easingType": "Linear" }, "idle_2": { "time": 1.5, "easingType": "SineInOut" }, }, }, ], } |
Playback
Unit anims can be played with an Action.
Played anims will be added to the end of a queue, and played when earlier animations have been finished.
1 | { "action": "CallMethod", "target": "unit", "method": "PlayAnim", "params": { "name": "spawn", }}, |
There are a number of optional parameters as well:
1 | public void PlayAnim(string name, bool reverse = false, bool forceChange = false, LoopMode loopMode = LoopMode.None, EasingType easingType = EasingType.None, bool playNext = false) |
Parameter | Summary |
---|---|
reverse |
whether anim should start from the end and play backwards |
forceChange |
if true, end current animation immediately to play new one (may cause issues?!) |
loopMode |
override the loop mode saved with the anim file |
easingType |
override the anim's easing mode specified in the import settings |
playNext |
if true, play new anim next instead of after all queued anims |
Behaviour
The behaviour state machine should be specified in the behaviour
unit property.
1 | "behaviour": ".fsm", |
The unit will begin in the first state, inactive
.
When the first form of the unit finishes spawning, it will try to switch to a state called form0
. In this case, form0
isn't itself a state, but a collection of states, and the first one will be chosen.
When the unit's second form finishes spawning, it will switch to the first state in form1
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | { "fsm": { "inactive": [ { "action": "Wait"} ], "form0": { "state 0": [ { "action": "Wait", "time": 1 }, ], "state 1": [ // attack ], }, "form1": { "state 0": [ { "action": "Wait", "time": 1.5 }, ], "state 1": [ // attack ], }, }, } |
Tip
Holding the H key will show the current state of a unit.
When finishing the last action in a state, the state machine will automatically move to the next state in the group.
When finishing the last state in a group of states, it will loop around to the first state in the group.
For example, when form1.state 1 finishes all its actions, playback will move back to form1.state 0.
Charging Patterns
When you want a unit to shoot a pattern, usually you'll charge it from their core or another part. This will cause the part to glow and look in the pattern direction as it leads up to firing.
1 | { "action": "CallMethod", "method": "ChargePattern", "params": { "path": "octopus/pattern/lazyBullets.1", "partType": "side", "chargeTime": 1, "dir":"playerPos - partPos", }}, |
Let's spread that out for clarity:
1 2 3 4 5 6 7 8 9 10 | { "action": "CallMethod", "method": "ChargePattern", "params": { "path": "octopus/pattern/lazyBullets.1", "partType": "side", "chargeTime": 1, "dir":"playerPos - partPos", } }, |
It can be convenient to choose between multiple patterns in the same ChargePattern
action.
Instead of using the path
parameter, use the paths
parameter to list the options, and pathIndex
to choose which to use.
1 | { "action": "CallMethod", "method": "ChargePattern", "params": { "paths": [ "octopus/pattern/lazyBullets.1", "octopus/pattern/lazyBullets.2", "octopus/pattern/lazyBullets.3", "octopus/pattern/lazyBullets.4" ], "pathIndex":"numShotPatterns % 4", "partType": "side", "chargeTime": 1, "dir":"playerPos - partPos", }}, |
There are some optional ChargePattern
parameters available:
Parameter | Type | Summary |
---|---|---|
path |
PatternData | the path of the pattern to shoot |
paths |
List of PatternData | the path of the pattern to shoot |
pathIndex |
int | which index in paths to use |
partType |
string | which unit part(s) to fire from |
partSelect |
PartSelectMode | how to choose next part of partType |
randomDegrees |
float | change pattern angle by up to this much |
mirrorMode |
MirrorMode | used to create extra mirrored patterns |
flip |
FlipPatternMode | used to make patterns symmetrical across an axis |
chargeTime |
float | how long to charge before shooting |
dir |
Vector2 func | starting pattern direction |
sizeMultiplier |
float | scaling applied to every bullet in pattern |
numVolleysAddition |
int | adjust amount of volleys |
numBulletsAddition |
int | adjust amount of bullets in each volley |
shootDelayModifier |
float | scaling applied to shoot delay time |
maxDist |
float | don't shoot pattern if part is too far from player |
Movement
Similar to how bullets work, units can choose to be moved by velocity or moved toward a target position.
Velocity
1 2 3 4 5 6 7 8 | { "forms":[ { "moveMode":"Velocity", // not necessary to specify since it's the default "accelVector":"normalize(playerPos - unitPos) * 0.5f", // move toward player } ], } |
Target
1 2 3 4 5 6 7 8 9 | { "forms":[ { "moveMode":"Target", "targetPos":"playerPos", // move toward player "posLerpSpeed":0.05, // unit moves 5% of the current distance to player each frame } ], } |
Info
The movement mode and the functions can be set with Actions such as SetMovementModeFunc
, SetAccelVectorFunc
, ClearAccelVectorFunc
, etc.
Avoiding Other Units
Generally you want to prevent units from overlapping eachother. You can define circular hitboxes that repel other units.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | { "forms": [ { "repulsionCircles": [ { "offset":"vec2(0, 7f)", "radius":18, // strength is 1 by default}, { "offset":"vec2(-14.5f, -7f)", "radius":12, "strength":1.5, }, { "offset":"vec2(14.5f, -7f)", "radius":12, "strength":1.5, }, ], // how strongly the unit's circles are repelled by other units "avoidUnitStrength":1, // how strongly the unit's circles repel other units' circles "repelUnitStrength":10, }, ], } |
Tip
Hold the H
key to see the repulsion circles of units.
Level Boundary
By default, a unit does not try to stay in the level bounds, and is removed when it completely leaves the bounds.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | { "disableOutOfBounds":false, // this unit does not disable when it goes out of bounds (default is true) "forms": [ { // define new bounds for this unit "bounds":"rect(-stageWidth * 0.5f, -stageHeight * 0.5f, stageWidth, stageHeight * 0.75f)", // how strongly the unit adds force to stay within its bounds "stayInBoundsStrength":10, }, { // this form only stays in bounds on the left and right sides "stayInBoundsStrengthLeft":10, "stayInBoundsStrengthRight":10, "stayInBoundsStrengthUp":0, "stayInBoundsStrengthDown":0, }, ], } |
Tip
Hold the H
key to see the bounds of units.
Facing
Target
1 2 3 4 5 6 7 8 9 | { "forms":[ { "facingMode":"Target", "targetFacingAngle":"vecToAngle(playerPos - unitPos)", // look at player "facingLerpSpeed":0.05, // rotate 5% of the way to target angle each frame } ], } |
AutoRotate
1 2 3 4 5 6 7 8 | { "forms":[ { "facingMode":"AutoRotate", "rotationSpeed":-100, } ], } |
Handlers
You may want to call Actions at certain points while a unit form is active.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | { "onSpawn":[ // called immediately when unit is spawned { "action": "CallMethod", "target":"stage", "method": "ShakeCamera", "params": { "strength": 3, "time": 0.5, "easingType": "QuadOut" }}, ], "onFormRespawn":[ /* called when form has finished spawning pixels */ ], "onUpdate":[ /* called each frame this unit is active and not dormant or hidden, and on this form*/ ], "onDestroy":[ /* called when form is destroyed */ ], "onHidden":[ /* called when unit has become hidden */ ], "onPixelHit":[ /* called when a pixel of this form is hit by a bullet */ ], "onPixelHitByLaser":[ /* called pixel of this form is hit by a laser */ ], "onPixelDestroyed":[ /* called pixel of this form is destroyed */ ], "onPartHit":[ /* called when a part of this form is hit by a bullet */ ], "onPartHitByLaser":[ /* called when a part of this form is hit by a laser */ ], "onPartDestroyed":[ /* called when a part of this form is destroyed */ ], "onImplodeStart":[ /* called when this form starts imploding (core has taken lethal damage) */ ], "onImplodeUpdate":[ /* called while this form is imploding */ ], "onImplodeFinish":[ /* called when this form finishes imploding (and dies) */ ], "onPlayerHit":[ /* called when player takes damage but does not die */ ], "onPlayerDie":[ /* called when player takes lethal damage */ ], "onPlayerActivateStatus":[ /* called when player uses an item */ ], "onPlayerStatusLevelChanged":[ /* called when a status effect changes level */ ], "onDormantStart":[ /* called when unit becomes dormant (don't simulate) */ ], "onDormantEnd":[ /* called when unit is no longer dormant */ ], "onDormantUpdate":[ /* called while unit is dormant, according to "dormantUpdateInterval" */ ], }, |
Unit Destruction
Anchors
Individual pixels can set the isAnchor
property to true
. When non-anchor pixels or parts are disconnected from all anchor pixels, they will be destroyed.
Cores
Generally, units should declare a part with the name "core", though this isn't absolutely required.
The core part of a unit will automatically set all its pixels as anchors.
Note
If a unit has no core (and no anchor pixels), it will handle destruction differently: whenever pixels become separated, the smaller of the separate pieces will be destroyed.
Imploding
"Imploding" refers to the animation when a unit has received lethal damage to its core, but before it is destroyed.
In order for your unit to display this behaviour, it must define a few handlers in its form config:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | "baseForm": { // the time the imploding effect lasts "implodeTime":1.5, // called once, when the unit core takes lethal damage "onImplodeStart": [ // play sounds, show speech bubbles, initialize variables ], // called each frame between onImplodeStart and onImplodeFinish "onImplodeUpdate": [ // make the unit wiggle around, destroy random pixels, etc ], // called once, after implodeTime has elapsed, and right before the unit is destroyed "onImplodeFinish": [ // play sounds, spawn explosion effects, destroy nearby bullets, etc ], } |
Tip
You can manually call the Implode action on a unit to destroy it at any time, without it needing to take lethal damage to the core.
1 | { "action": "CallMethod", "target":"unit", "method": "Implode", }, |
Custom Variables
Custom variables can be defined in the properties
structure.
1 2 3 4 5 6 7 8 9 | { "properties": { "loopNum": { "type": "Int", }, "pixel": { "type": "PixelData", }, "revengeCounter": { "type": "Int", }, "shootTimer": { "type": "Float", "value":6, }, "hasShot": { "type": "Bool", }, }, } |
These values can be used in any script func by the unit.
1 2 3 | { "action": "Repeat", "count": "revengeCounter", "inner": [ // ... ]}, |
To modify them, use the SetValue method:
1 | { "action": "SetValue", "name": "revengeCounter", "value": "revengeCounter + 1" }, |
Warning
When specifying the starting value of custom properties, you can't use a scriptfunc.
For instance:
1 | "revengeCounter": { "type": "Int", "value":"rand.Int(0, 99)" }, |
6
is:
1 | "revengeCounter": { "type": "Int", "value":6 }, |
Texture
Unit forms have a texture
property, which itself has a single shader
property.
It draws a scrolling wavy texture on the pixels.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | { "baseForm":{ "texture": { "shader": { // the texture to draw (path relative to your campaign folder) "texturePath":"pxc_textures/blurredClouds", // how strong the effect is "intensity": "rand.Float(0.9, 1.1)", // i can't remember what all this stuff does, you just gotta play with it "speedX": "rand.Float(0.8, 1.2)", "speedY": "rand.Float(0.15, 0.25)", "timeFactor": "rand.Float(0.08, 0.12)", "timeFactor2": "rand.Float(0.45, 0.55)", "timeFactor3": "rand.Float(0.65, 0.75)", "freqX": "rand.Float(9, 11)", "freqY": "rand.Float(7, 9)", "depthX": "rand.Float(0.075, 0.125)", "depthY": "rand.Float(0.125, 0.175)", } }, }, } |
The unit on the left has no "shader", while the unit on the right has a subtle cloudy coloring.
Speech Bubbles
Unit's SpawnBubble
creates a speech bubble coming out of a part.
1 2 3 4 5 6 7 8 9 10 11 12 13 | { "action": "CallMethod", "method": "SpawnBubble", "params": { "text": "Hihihi", "partType":"core", "lifetime": 1, "fillColor": "color(0.2f, 0.1f, fastSin(stageTime * PI * 2f) * 0.2f + 0.5f, 0.8f)", "borderColor": "color(0.75f, 0.75f, fastSin((stageTime + 0.5f) * PI * 2f) * 0.05f + 0.95f, 0.66f)", "borderWidth": "fastSin(stageTime * PI * 3f) * 2f + 4f", "textColor":"lerp(color(1f, 1f, 1f), color(0.75f, 0.75f, 1f), 0.5f + fastSin(stageTime * PI * 12f) * 0.5f)", "font":"Quantico", "fontSize":33, } }, |
Tip
In order to print the value of a property or function in text (instead of just displaying the name), format the string with ${ }
surrounding whatever should be evaluated.
1 | "text": "I'm thinking of a number!! It's ${rand.Int(1, 100)}!!!", |
Fonts
Here is an image showing the different fonts available.
String Files
Instead of including speech lines directly in the unit configs, you can list them in a separate string file. This allows for more variety and opens up the possibility of localizing into other languages.
Here is text.json
, the strings file for the example custom campaign:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | { "en": { // english "example": { "title": "Example Stage", "description": "This is an example stage", "boss":{ "welcome": [ "Let's fight", { "value": "I'm just an example boss!", "weight": 0.8 }, { "value": "I will kill you", "weight": 0.3 }, ], "death": [ { "value": "You killed me!", "weight": 1.5 }, { "value": "Aghhhh....", "weight": 0.66 }, ], }, }, } } |
Your plugin.json
file needs to list this file under the strings
property, and it's possible to include more than one.
1 2 3 4 5 6 7 | { "title": "Example Plugin", "description": "Custom campaign example", // ... "strings": [ "text" ], } |
Now we can use those strings in any of our scripts.
The text #example.boss.welcome
will be replaced with a random selection defined for that string, with Let's fight having the greatest chance with a default weight of 1.0
, I'm just an example boss! happening slightly less often, and I will kill you being even more rare.
Script Parameters
These parameters can be used inside any scriptfunc by a Unit.
Info
Debugging
debugVector
: draws a 2d vector from the unit's position
debugText
: displays a string below the unit's position (use ${NAME}
for properties)
Examples
Phobia movement
The Phobia boss script is at redux/hunter/unit/boss.json
.
Form 0 alternates between moving toward the center of the stage, and running from the player.
In properties
, the unit declares the Vector2 property targetPos
to represent where to move, and the boolean variable isGoingToCenter
to keep track of the state:
6 7 | "targetPos": { "type": "Vector2", }, "isGoingToCenter": { "type": "Bool", "value": false }, |
targetPos
defaults to Vector2(0, 0)
, the center of the stage, and we'll keep that inital value during Form 0.
In the definition of the first form, the accelVector
property controls the unit's acceleration:
294 | "accelVector":"normalize(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos)) * map(dist(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos)), 0f, 80f, (isGoingToCenter ? 16f : 3f), (isGoingToCenter ? 30f : 15f), 'SineOut') * select(diffInt, 0.8f, 1f, 1.2f)", |
normalize(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos))
In the above section, (targetPos - unitPos)
represents the vector from the boss to (0, 0), and (unitPos - playerPos)
represents the vector from the player to the boss.
We choose which vector to use depending on whether isGoingToCenter
is true or false, using a ternary operator.
Then we normalize
the vector, which keeps the same direction but sets the length to 1. The normalized vector is multiplied by this next section, which determines the speed based on distance to the target:
map(dist(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos)), 0f, 80f, (isGoingToCenter ? 16f : 3f), (isGoingToCenter ? 30f : 15f), 'SineOut')
dist(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos))
finds the distance to the target, depending on our current state.
The map
function checks how far the distance is from 0f
to 80f
, and returns the corresponding value in the range from (isGoingToCenter ? 16f : 3f)
to (isGoingToCenter ? 30f : 15f)
.
For example, if the distance is 80
or higher, it will return the max side of the range: 30
if isGoingToCenter
is true, and 15
otherwise.
For the final part of accelVector
, the speed is adjusted based on the difficulty:
select(diffInt, 0.8f, 1f, 1.2f)
The select
function checks the first parameter (in this case the difficulty represented as either 0/1/2
for Easy/Normal/Nightmare). It uses the first parameter to decide which of the other parameters to use, for example if diffInt
is 2
, the returned value will be 1.2f
.
In essence, the long accelVector
function can be thought of as: "accelVector":"direction * speed * difficulty modifier"
Also in the the first form, these properties control the unit's rotation:
295 296 297 | "facingMode":"Target", "targetFacingAngle":"vecToAngle(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos))", "facingLerpSpeed":"map(distSqr(targetPos - unitPos), 0f, 100f * 100f, 0.00175f, 0.0066f, 'QuadIn') * select(diffInt, 1f, 1f, 1.5f)", |
To determine targetFacingAngle
, we use vecToAngle
to convert the intended direction into degrees.
For facingLerpSpeed
, we again use a map
function to lerp a value based on distance (though in this case we use distSqr
as it's more performant). The boss rotates faster when they are farther away from the center of the stage.
We also need to change the value of isGoingToCenter
:
391 392 393 | "onUpdate":[ { "action": "Condition", "condition": "isGoingToCenter && distSqr(targetPos - unitPos) < select(diffInt, 2f, 2f, 25f)", "true": [{ "action": "SetValue", "name": "isGoingToCenter", "value": false },],}, |
Every frame Form 0 runs the onUpdate
method. The above Condition checks if isGoingToCenter
is true, and if the boss is close enough to the center of the stage - if so, isGoingToCenter
is set to false.
467 468 469 470 | "onOutOfBoundsLeft":[ { "action": "SetValue", "name": "isGoingToCenter", "value": true }, ], "onOutOfBoundsRight":[ { "action": "SetValue", "name": "isGoingToCenter", "value": true }, ], "onOutOfBoundsDown":[ { "action": "SetValue", "name": "isGoingToCenter", "value": true }, ], "onOutOfBoundsUp":[ { "action": "SetValue", "name": "isGoingToCenter", "value": true }, ], |
These callbacks set the value of isGoingToCenter
to true whenever the boss hits the outer bounds of the stage.
Form 1 simply moves and rotates toward the player, but it rotates slower when it's near the player, and moves faster when it's facing the player or far from the player.
504 505 506 507 | "accelVector":"normalize(playerPos - unitPos) * map(distSqr(playerPos - unitPos), 0f, 100f * 100f, map(dot(unitFacingDir, normalize(playerPos - unitPos)), -1f, 1f, 0f, 1f, 'QuadInOut'), map(dot(unitFacingDir, normalize(playerPos - unitPos)), -1f, 1f, 15f, 75f, 'QuadInOut'), 'Linear') * (0.4f + (fastSin(unitTime * 1f) * 0.33f)) * select(diffInt, 0.8f, 1f, 1.2f)", "facingMode":"Target", "targetFacingAngle":"vecToAngle(playerPos - unitPos)", "facingLerpSpeed":"map(distSqr(playerPos - unitPos), 0f, 75f * 75f, 0f, 0.015f, 'QuadIn')", |
The long accelVector
property for Form 1 involves nested map
functions, so it looks more confusing than it is.
map(distSqr(playerPos - unitPos), 0f, 100f * 100f, map(dot(unitFacingDir, normalize(playerPos - unitPos)), -1f, 1f, 0f, 1f, 'QuadInOut'), map(dot(unitFacingDir, normalize(playerPos - unitPos)), -1f, 1f, 15f, 75f, 'QuadInOut'), 'Linear')
This can be represented as map(distance squared, 0, 100*100, 0-1 based on facing the player, 15-75 based on facing the player)
The dot
function takes two vectors, and returns a value from -1 to 1 depending on how much the vectors point in the same direction. If the vectors are facing in opposite directions, it returns -1, and if they are facing in the exact same direction, it returns 1.
map(dot(unitFacingDir, normalize(playerPos - unitPos)), -1f, 1f, 0f, 1f, 'QuadInOut')
The above map
simply checks how closely the boss is facing the player, and re-maps the lower end of the -1 to 1 range to 0 instead.
(0.4f + (fastSin(unitTime * 1f) * 0.33f))
The above section simply uses fastSin
(a more performant, less precise version of sin
) to multiply by (0.4 - 0.33) to (0.4 + 0.33), depending on time.
Form 2 has the most complex movement behaviour. The boss repeats this same pattern:
1. aim toward the player without moving for 6 seconds
2. move forward until hitting the side of the stage or until a certain amount of time elapses
3. wait for a bit without moving or rotating
These variables are defined in properties
to control the movement of Form 2:
9 10 11 12 | "form2IsRecovering": { "type": "Bool", "value": false }, "form2IsAiming": { "type": "Bool", "value": true }, "form2IsMoving": { "type": "Bool", "value": false }, "form2Timer": { "type": "Float", "value": 0 }, |
In the onUpdate
callback for Form 2, we check multiple Conditions based on the value of the properties.
972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 | "onUpdate":[ { "action": "Condition", "condition": "form2IsRecovering", "true": [ { "action": "SetValue", "name": "form2Timer", "value": "form2Timer + dt", }, { "action": "Condition", "condition": "form2Timer > map(unitPixelPercent, 1f, 0.33f, 10f, 8f, 'QuadIn')", "true": [ { "action": "CallSubroutine", "target":"unit", "path": ".form2StartAiming.sub", }, ],}, ], "false": [ { "action": "Condition", "condition": "form2IsAiming", "true": [ { "action": "SetValue", "name": "form2Timer", "value": "form2Timer + dt", }, { "action": "Condition", "condition": "form2Timer > 6f", "true": [ // charge { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#hunter.charge_2", "chance":"attempts == 0 ? 1f : map(attempts % 5, 0, 4, 0.1f, 0.45f)", }}, { "action": "SetValue", "name": "form2IsAiming", "value": false }, { "action": "SetValue", "name": "form2IsMoving", "value": true }, { "action": "SetValue", "name": "form2Timer", "value": 0 }, { "action": "CallMethod", "method": "SetFacingLerpSpeedFunc", "params": { "func": "0"}}, { "action": "CallMethod", "method": "SetAccelVectorFunc", "params": { "func": "unitFacingDir * 300f * mapReturn(form2Timer, 0f, map(unitDestroyedPartCount, 0, 10, 7.5f, 4.5f) * select(diffInt, 1.25f, 1f, 0.85f), 0f, 1f, 'Linear') * select(diffInt, 0.4f, 1f, 1.2f)"}}, { "action": "SetValue", "name": "form2NumTimesMoved", "value": "form2NumTimesMoved + 1" }, ],}, ],}, ], }, { "action": "Condition", "condition": "form2IsMoving", "true": [ { "action": "SetValue", "name": "form2Timer", "value": "form2Timer + dt", }, { "action": "Condition", "condition": "form2Timer > map(unitDestroyedPartCount, 0, 10, 7.5f, 4.5f) * select(diffInt, 1.25f, 1f, 0.85f)", "true": [ { "action": "CallSubroutine", "target":"unit", "path": ".form2HitEdge.sub", }, ],}, ],}, |
If form2IsRecovering
is true, we wait a certain amount of time before calling the subroutine .form2StartAiming.sub
If form2IsAiming
is true, we wait 6 seconds before setting form2IsMoving
to true, using SetFacingLerpSpeedFunc
to set rotation speed to 0, and using SetAccelVectorFunc
to change the boss' speed.
If form2IsMoving
is true, we increment form2Timer
and call the subroutine .form2HitEdge.sub
if enough time has elapsed. We also call that subroutine if the boss hits the edge of the stage.
1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 | "form2StartAiming.sub": { "actions": [ { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#hunter.aim_2", "chance":"attempts == 0 ? 0.1f : map((attempts + 2) % 7, 0, 6, 0.05f, 0.4f)", }}, { "action": "SetValue", "name": "form2IsRecovering", "value": false }, { "action": "SetValue", "name": "form2IsAiming", "value": true }, { "action": "SetValue", "name": "form2IsMoving", "value": false }, { "action": "SetValue", "name": "form2Timer", "value": 0 }, { "action": "CallMethod", "method": "SetAccelVectorFunc", "params": { "func": "vec2(0,0)"}}, { "action": "CallMethod", "method": "SetFacingLerpSpeedFunc", "params": { "func": "map(distSqr(playerPos - unitPos), 0f, 100f * 100f, 0.0075f, 0.045f, 'QuadIn') * select(diffInt, 0.7f, 0.95f, 1f) * map(form2Timer, 0f, 2f, 0f, 1f, 'QuadIn')"}}, { "action": "Condition", "condition": "distSqr(playerPos - unitPos) < 100f * 100f && !stage.DoesBulletExist('redux/hunter/pattern/draggingVine.chainBullet') && numShotPatterns % 5 != 4", "true": [ { "action": "CallSubroutine", "target":"unit", "path": ".attackSpeech.sub", "params": { "message": "#hunter.attack_1", "chanceIncrease":0 }}, { "action": "CallMethod", "method": "ChargePattern", "params": { "path": "redux/hunter/pattern/draggingVine.1", "partType": "core", "chargeTime":"0.5f * select(diffInt, 1.2f, 1f, 0.8f)", "dir":"playerPos - partPos", }}, { "action": "Wait", "time": "3f * select(diffInt, 1.2f, 1f, 0.8f)" }, ],}, { "action": "Condition", "condition": "form2NumTimesMoved % 4 == 3 || form2NumTimesMoved == 9 || form2NumTimesMoved == 21", "true": [ { "action": "CallMethod", "method": "SetTargetFacingAngleFunc", "params": { "func": "vecToAngle(vec2(0f, 0f) - unitPos) + (fastSin(stageTime * 2f) * 10f)"}},], "false": [ { "action": "CallMethod", "method": "SetTargetFacingAngleFunc", "params": { "func": "vecToAngle(playerPos - unitPos)"}},],}, ] }, "form2HitEdge.sub": { "actions": [ { "action": "CallMethod", "method": "SetAccelVectorFunc", "params": { "func": "vec2(0,0)"}}, { "action": "SetValue", "name": "form2IsRecovering", "value": true }, { "action": "SetValue", "name": "form2IsAiming", "value": false }, { "action": "SetValue", "name": "form2IsMoving", "value": false }, { "action": "SetValue", "name": "form2Timer", "value": 0 }, ] }, |
In form2StartAiming.sub
, we use SetAccelVectorFunc
to set the acceleration to vec2(0, 0)
so the boss doesn't move, and use SetFacingLerpSpeedFunc
so that the boss will aim at the player.
Likewise, in form2HitEdge.sub
we also set the acceleration to 0 so the boss stops moving, and we set the flag form2IsRecovering
to true.