Tighten Doodad JavaScript API, User Documentation

* Tightens up the surface area of API methods available to the
  JavaScript VMs for doodads. Variables and functions are carefully
  passed in one-by-one so the doodad script can only access intended
  functions and not snoop on undocumented APIs.
* Wrote tons of user documentation for Doodad Scripts: documented the
  full surface area of the exposed JavaScript API now that the surface
  area is known and limited.
* Early WIP code for the Campaign JSON
This commit is contained in:
Noah 2020-04-21 23:50:45 -07:00
parent 44788e8032
commit 38614ee280
27 changed files with 916 additions and 137 deletions

View File

@ -0,0 +1,16 @@
{
"version": 1,
"title": "Tutorial Levels",
"author": "kirsle",
"levels": [
{
"filename": "example1.level",
},
{
"filename": "example2.level",
},
{
"filename": "example3.level",
}
]
}

View File

@ -32,7 +32,7 @@ func init() {
Usage: "set the doodad author", Usage: "set the doodad author",
}, },
cli.StringSliceFlag{ cli.StringSliceFlag{
Name: "tag", Name: "tag, t",
Usage: "set a key/value tag on the doodad, in key=value format. Empty value deletes the tag.", Usage: "set a key/value tag on the doodad, in key=value format. Empty value deletes the tag.",
}, },
cli.BoolFlag{ cli.BoolFlag{

View File

@ -1,5 +1,5 @@
function main() { function main() {
log.Info("Azulian '%s' initialized!", Self.Doodad().Title); console.log("Azulian '%s' initialized!", Self.Title);
var playerSpeed = 4; var playerSpeed = 4;
var gravity = 4; var gravity = 4;

View File

@ -1,5 +1,5 @@
function main() { function main() {
console.log("%s initialized!", Self.Doodad().Title); console.log("%s initialized!", Self.Title);
var timer = 0; var timer = 0;
@ -26,9 +26,4 @@ function main() {
timer = 0; timer = 0;
}, 200); }, 200);
}); });
// Events.OnLeave(function(e) {
// console.log("%s has stopped touching %s", e, Self.Doodad().Title)
// Self.Canvas.SetBackground(RGBA(0, 0, 1, 0));
// })
} }

View File

@ -1,5 +1,5 @@
function main() { function main() {
console.log("%s initialized!", Self.Doodad().Title); console.log("%s initialized!", Self.Title);
var pressed = false; var pressed = false;

View File

@ -1,6 +1,6 @@
function main() { function main() {
var color = Self.Doodad().Tag("color"); var color = Self.GetTag("color");
var keyname = "key-" + color + ".doodad"; var keyname = "key-" + color + ".doodad";
// Layers in the doodad image. // Layers in the doodad image.

View File

@ -1,5 +1,5 @@
function main() { function main() {
console.log("%s initialized!", Self.Doodad().Title); console.log("%s initialized!", Self.Title);
Self.AddAnimation("open", 100, [0, 1, 2, 3]); Self.AddAnimation("open", 100, [0, 1, 2, 3]);
Self.AddAnimation("close", 100, [3, 2, 1, 0]); Self.AddAnimation("close", 100, [3, 2, 1, 0]);
@ -9,7 +9,7 @@ function main() {
Self.SetHitbox(16, 0, 32, 64); Self.SetHitbox(16, 0, 32, 64);
Message.Subscribe("power", function(powered) { Message.Subscribe("power", function(powered) {
console.log("%s got power=%+v", Self.Doodad().Title, powered); console.log("%s got power=%+v", Self.Title, powered);
if (powered) { if (powered) {
if (animating || opened) { if (animating || opened) {

View File

@ -1,9 +1,9 @@
function main() { function main() {
var color = Self.Doodad().Tag("color"); var color = Self.GetTag("color");
Events.OnCollide(function(e) { Events.OnCollide(function(e) {
if (e.Settled) { if (e.Settled) {
e.Actor.AddItem(Self.Doodad().Filename, 0); e.Actor.AddItem(Self.Filename, 0);
Self.Destroy(); Self.Destroy();
} }
}) })

View File

@ -2,7 +2,7 @@
function main() { function main() {
Self.AddAnimation("open", 0, [1]); Self.AddAnimation("open", 0, [1]);
var unlocked = false; var unlocked = false;
var color = Self.Doodad().Tag("color"); var color = Self.GetTag("color");
Self.SetHitbox(16, 0, 32, 64); Self.SetHitbox(16, 0, 32, 64);

View File

@ -1,6 +1,6 @@
// Exit Flag. // Exit Flag.
function main() { function main() {
console.log("%s initialized!", Self.Doodad().Title); console.log("%s initialized!", Self.Title);
Self.SetHitbox(22+16, 16, 75-16, 86); Self.SetHitbox(22+16, 16, 75-16, 86);
Events.OnCollide(function(e) { Events.OnCollide(function(e) {

View File

@ -4,7 +4,7 @@
var state = false; var state = false;
function main() { function main() {
console.log("%s ID '%s' initialized!", Self.Doodad().Title, Self.ID()); console.log("%s ID '%s' initialized!", Self.Title, Self.ID());
Self.SetHitbox(0, 0, 33, 33); Self.SetHitbox(0, 0, 33, 33);
// When the button is activated, don't keep toggling state until we're not // When the button is activated, don't keep toggling state until we're not

View File

@ -1,5 +1,5 @@
function main() { function main() {
console.log("%s initialized!", Self.Doodad().Title); console.log("%s initialized!", Self.Title);
// Switch has two frames: // Switch has two frames:
// 0: Off // 0: Off

View File

@ -1,5 +1,5 @@
function main() { function main() {
console.log("%s initialized!", Self.Doodad().Title); console.log("%s initialized!", Self.Title);
var timer = 0; var timer = 0;

View File

@ -1,6 +1,6 @@
function main() { function main() {
// What direction is the trapdoor facing? // What direction is the trapdoor facing?
var direction = Self.Doodad().Tag("direction"); var direction = Self.GetTag("direction");
console.log("Trapdoor(%s) initialized", direction); console.log("Trapdoor(%s) initialized", direction);
var timer = 0; var timer = 0;

View File

@ -3,20 +3,18 @@
Doodads are programmed using JavaScript which gives them their behavior Doodads are programmed using JavaScript which gives them their behavior
and ability to interact with the player and other doodads. and ability to interact with the player and other doodads.
Doodad scripts are run during "Play Mode" when a level _containing_ the doodad
is being played. You can install a JavaScript (.js) file into a doodad using
the command-line `doodad` program.
An example Doodad script looks like the following: An example Doodad script looks like the following:
```javascript ```javascript
// The main function is called when the doodad is initialized in Play Mode // main() is called on level initialization for each
// at the start of the level. // instance ("actor") of the doodad.
function main() { function main() {
// Important global variables:
// - Self: information about the current Doodad running this script.
// - Events: handle events raised during gameplay.
// - Message: publish or subscribe to named messages to interact with
// other doodads.
// Logs go to the game's log file (standard output on Linux/Mac). // Logs go to the game's log file (standard output on Linux/Mac).
console.log("%s initialized!", Self.Doodad().Title); console.log("%s initialized!", Self.Title);
// If our doodad has 'solid' parts that should prohibit movement, // If our doodad has 'solid' parts that should prohibit movement,
// define the hitbox here. Coordinates are relative so 0,0 is the // define the hitbox here. Coordinates are relative so 0,0 is the
@ -37,6 +35,14 @@ function main() {
// moving through. // moving through.
return false; return false;
} }
// When movement is finalized, OnCollide is called one final time
// with e.Settled=true; it is only then that a doodad should run
// event handlers for a logical collide event.
if (e.Settled) {
// do something
Message.Publish("power", true);
}
}); });
// OnLeave is called when an actor, who was previously colliding with // OnLeave is called when an actor, who was previously colliding with
@ -47,17 +53,230 @@ function main() {
} }
``` ```
# JavaScript API # Installing a Doodad Script
## Global Variables Use the command-line `doodad` tool to attach a script to your doodad file:
```bash
# Attach the JavaScript at "script.js" to the doodad file "filename.doodad"
doodad install-script script.js filename.doodad
# To view the script currently attached to a doodad
# (prints the script to your terminal)
doodad show --script filename.doodad
```
# Testing Your Script
The best way to test your doodad script is to use it in a level!
Run the game in a console to watch the log output, and you can use functions
like `console.log()` in your script to help debug issues. Drag your custom
doodad into a level and playtest it! Your script's main() function is called
when the level instance of your doodad is initialized.
# JavaScript API
The following global variables are available to all Doodad scripts. The following global variables are available to all Doodad scripts.
### Self ## Self
Self holds data about the current doodad instance loaded inside of a level.
**String attributes:**
* Self.Title: the doodad title.
* Self.Filename: the doodad filename (useful for inventory items).
Methods are below.
### Self.ID() string
Returns the "actor ID" of the doodad instance loaded inside of a level. This
is usually a random UUID string that was saved with the level data.
### Self.GetTag(string name) string
Return a "tag" that was saved with the doodad's file data.
Tags are an arbitrary key/value data store attached to the doodad file.
You can use the `doodad.exe` tool shipped with the game to view and manage tags
on your own custom doodads:
```bash
# Command-line doodad tool usage:
# Show information about a doodad, look for the "Tags:" section.
doodad show filename.doodad
# Set a tag. "-t" for short.
doodad edit-doodad --tag 'color=blue' filename.doodad
# Set the tag to empty to remove it.
doodad edit-doodad -t 'color=' filename.doodad
```
This is useful for a set of multiple doodads to share the same script but
have different behavior depending on how each is tagged.
### Self.Position() Point
Returns the doodad's current position in the level.
Point is an object with .X and .Y integer values.
```javascript
var p = Self.Position()
console.log("I am at %d,%d", p.X, p.Y)
```
### Self.SetHitbox(x, y, w, h int)
Configure the "solid hitbox" of this doodad.
The X and Y coordinates are relative to the doodad's sprite: (0,0) is the top
left pixel of the doodad. The W and H are the width and height of the hitbox
starting at those coordinates.
When another doodad enters the area of your doodad's sprite (for example, the
player character has entered the square shape of your doodad sprite) your script
begins to receive OnCollide events from the approaching actor.
The OnCollide event tells you if the invading doodad is inside your custom
hitbox which you define here (`InHitbox`) making it easy to make choices based
on that status.
Here's an example script for a hypothetical "locked door" doodad that acts
solid but only on a thin rectangle in the middle of its sprite:
```javascript
// Example script for a "locked door"
function main() {
// Suppose the doodad's sprite size is 64x64 pixels square.
// The door is in side profile where the door itself ranges from pixels
// (20, 0) to (24, 64)
Self.SetHitbox(20, 0, 24, 64)
// OnCollide handlers.
Events.OnCollide(function(e) {
// The convenient e.InHitbox tells you if the colliding actor is
// inside the hitbox we defined.
if (e.InHitbox) {
// Return false to protest the collision (act solid).
return false;
}
});
}
```
### Self.SetVelocity(Velocity)
Set the doodad's velocity. Velocity is a type that can be created with the
Velocity() constructor, which takes an X and Y value:
```javascript
Self.SetVelocity( Velocity(3.2, 7.0) );
```
A positive X velocity propels the doodad to the right. A positive Y velocity
propels the doodad downward.
### Self.SetMobile(bool)
Call `SetMobile(true)` if the doodad will move on its own.
This is for mobile doodads such as the player character and enemy mobs.
Stationary doodads like buttons, doors, and trapdoors do not mark themselves
as mobile.
Mobile doodads incur extra work for the game doing collision checking so only
set this to `true` if your doodad will move (i.e. changes its Velocity or
Position).
```javascript
Self.SetMobile(true);
```
### Self.SetGravity(bool)
Set whether gravity applies to this doodad. By default doodads are stationary
and do not fall downwards. The player character and some mobile enemies that
want to be affected by gravity should opt in to this.
```javascript
Self.SetGravity(true);
```
### Self.ShowLayer(index int)
Switch the active layer of the doodad to the layer at this index.
A doodad file can contain multiple layers, or images. The first and default
layer is at index zero, the second layer at index 1, and so on.
```javascript
Self.ShowLayer(0); // 0 is the first and default layer
Self.ShowLayer(1); // show the second layer instead
```
### Self.ShowLayerNamed(name string)
Switch the active layer by name instead of index.
Each layer has an arbitrary name that it can be addressed by instead of needing
to keep track of the layer index.
Doodads created by the command-line `doodad` tool will have their layers named
automatically by their file name. The layer **indexes** will retain the same
order of file names passed in, with 0 being the first file:
```bash
# Doodad tool-created doodads have layers named after their file names.
# example "open-1.png" will be named "open-1"
doodad convert door.png open-1.png open-2.png open-3.png my-door.doodad
```
### Self.AddAnimation(name string, interval int, layers list)
Register a named animation for your doodad. `interval` is the time in
milliseconds before going to the next frame. `layers` is an array of layer
names or indexes to be used for the animation.
Doodads can animate by having multiple frames (images) in the same file.
Layers are ordered (layer 0 is the first, then increments from there) and
each has a name. This function can take either identifier to specify
which layers are part of the animation.
```javascript
// Animation named "open" using named layers, 100ms delay between frames.
Self.AddAnimation("open", 100, ["open-1", "open-2", "open-3"]);
// Animation named "close" using layers by index.
Self.AddAnimation("close", 100, [3, 2, 1]);
```
### Self.PlayAnimation(name string, callback func())
This starts playing the named animation. The callback function will be called
when the animation has completed.
```javascript
Self.PlayAnimation("open", function() {
console.log("I've finished opening!");
// The callback is optional; use null if you don't need it.
Self.PlayAnimation("close", null);
});
```
### Self.IsAnimating() bool
Returns true if an animation is currently being played.
### Self.StopAnimation()
Stops any currently playing animation.
Self holds information about the current doodad. The full surface area of
the Self object is subject to change, but some useful things you can access
from it include:
* Self.Doodad(): a pointer to the doodad's file data. * Self.Doodad(): a pointer to the doodad's file data.
* Self.Doodad().Title: get the title of the doodad file. * Self.Doodad().Title: get the title of the doodad file.
@ -68,63 +287,278 @@ from it include:
* Self.Doodad().GameVersion: the version of {{ app_name }} that was used * Self.Doodad().GameVersion: the version of {{ app_name }} that was used
when the doodad was created. when the doodad was created.
### Events ### Self.Destroy()
### Message This destroys the current instance of the doodad as it appears in a level.
## Global Functions For example, a Key destroys itself when it's picked up so that it disappears
from the level and can't be picked up again. Call this function when the
doodad instance should be destroyed and removed from the active level.
The following useful functions are also available globally: -----
### Timers and Intervals ## Console Logging
Doodad scripts implement setTimeout() and setInterval() functions similar Like in node.js and the web browser, `console.log` and friends are available
to those found in web browsers. for logging from a doodad script. Logs are emitted to the same place as the
game's logs are.
```javascript
// Call a function after 5 seconds.
setTimeout(function() {
console.log("I've been called!");
}, 5000);
```
setTimeout() and setInterval() return an ID number for the timer created.
If you wish to cancel a timer before it has finished, or to stop an interval
from running, you need to pass its ID number into `clearTimeout()` or
`clearInterval()`, respectively.
```javascript
// Start a 1-second interval
var id = setInterval(function() {
console.log("Tick...");
}, 1000);
// Cancel it after 30 seconds.
setTimeout(function() {
clearInterval(id);
}, 30000);
```
### Console Logging
Doodad scripts also implement the `console.log()` and similar functions as
found in web browser APIs. They support "printf" style variable placeholders.
```javascript ```javascript
console.log("Hello world!"); console.log("Hello world!");
console.error("The answer is %d!", 42); console.log("Interpolate strings '%s' and numbers '%d'", "string", 123);
console.warn("Actor '%s' has collided with us!", e.Actor.ID()); console.debug("Debug messages shown when the game is in debug mode");
console.debug("This only logs when the game is in debug mode!"); console.warn("Warning-level messages");
console.error("Error-level messages");
``` ```
-----
## Timers and Intervals
Like in a web browser, functions such as setTimeout and setInterval are
supported in doodad scripts.
### setTimeout(function, milliseconds int) int
setTimeout calls your function after the specified number of milliseconds.
1000ms are in one second.
Returns an integer "timeout ID" that you'll need if you want to cancel the
timeout with clearTimeout.
### setInterval(function, milliseconds int) int
setInterval calls your function repeatedly after every specified number of
milliseconds.
Returns an integer "interval ID" that you'll need if you want to cancel the
interval with clearInterval.
### clearTimeout(id int)
Cancels the timeout with the given ID.
### clearInterval(id int)
Cancels the interval with the given ID.
-----
## Type Constructors
Some methods may need data of certain native types that aren't available in
JavaScript. These global functions will initialize data of the correct types:
### RGBA(red, green, blue, alpha uint8) ### RGBA(red, green, blue, alpha uint8)
RGBA initializes a Color variable using the game's native Color type. May Creates a Color type from red, green, blue and alpha values (integers between
be useful for certain game APIs that take color values. 0 and 255).
Example: RGBA(255, 0, 255, 255) creates an opaque magenta color.
### Point(x, y int) ### Point(x, y int)
Returns a Point object which refers to a location in the game world. This Creates a Point object with X and Y coordinates.
type is required for certain game APIs.
### Vector(x, y float64)
Creates a Vector object with X and Y dimensions.
-----
## Global Functions
Some useful globally available functions:
### EndLevel()
This ends the current level, i.e. to be used by the goal flag.
### Flash(message string, args...)
Flash a message on screen to the user.
Flashed messages appear at the bottom of the screen and fade out after a few
moments. If multiple messages are flashed at the same time, they stack from the
bottom of the window with the newest message on bottom.
Don't abuse this feature as spamming it may annoy the player.
### GetTick() uint64
Returns the current game tick. This value started at zero when the game was
launched and increments every frame while running.
### time.Now() time.Time
This exposes the Go standard library function `time.Now()` that returns the
current date and time as a Go time.Time value.
### time.Add(t time.Time, milliseconds int64) time.Time
Add a number of milliseconds to a Go Time value.
--------
## Event Handlers
Doodad scripts can respond to certain events using functions on the global
`Events` variable.
### Events.OnCollide( func(event) )
OnCollide is called when another actor is colliding with your doodad's sprite
box. The function is given a CollideEvent object which has the following
attributes:
* Actor: the doodad which is colliding with your doodad.
* Overlap (Rect): a rectangle of where the two doodads' boxes are overlapping,
relative to your doodad sprite's box. That is, if the Actor was moving in from
the left side of your doodad, the X value would be zero and W would be the
number of pixels of overlap.
* InHitbox (bool): true if the colliding actor's hitbox is intersecting with
the hitbox you defined with SetHitbox().
* Settled (bool): This is `false` when the game is trying to move the colliding
doodad and is sussing out whether or not your doodad will act solid and
protest its movement. When the game has settled the location of the colliding
doodad it will call OnCollide a final time with Settled=true. If your doodad
has special behavior when touched (i.e. a button that presses in), you should
wait until Settled=true before running your handler for that.
### Events.OnLeave( func(event) )
Called when an actor that _was_ colliding with your doodad is no longer
colliding (or has left your doodad's sprite box).
### Events.RunKeypress( func(event) )
Handle a keypress. `event` is an `event.State` from the render engine.
TODO: document that.
-----
## Pub/Sub Communication
Doodads in a level are able to send and receive messages to other doodads,
either those that they are **linked** to or those that listen on a more
'broadcast' frequency.
> **Linking** is when the level author connected two doodads together with
> the Link Tool. The two doodads' scripts can communicate with each other
> in-game over that link.
For example, if the level author links a Button to an Electric Door, the button
can send a "power" event to the door so that it can open when a player touches
the button.
Doodads communicate in a "publisher/subscriber" model: one doodad publishes an
event with a name and data, and other doodads subscribe to the named event to
receive that data.
### Official, Standard Pub/Sub Messages
The following message names and data types are used by the game's default
doodads. You're free to use these in your own custom doodads.
If extending this list with your own custom events, be careful to choose a
unique namespace to prevent collision with other users' custom doodads and
their custom event names.
| Name | Data Type | Description |
|------|-----------|--------------|
| power | boolean | Communicates a "powered" (true) or "not powered" state, as in a Button to an Electric Door. |
| broadcast:state-change | boolean | An "ON/OFF" button was hit and all state blocks should flip. |
### Message.Publish(name string, data...)
Publish a named message to all of your **linked** doodads.
`data` is a list of arbitrary arguments to send with the message.
```javascript
// Example button doodad that emits a "power" (bool) state to linked doodads
// that subscribe to this event.
function main() {
// When an actor collides with the button, emit a powered state.
Events.OnCollide(function(e) {
Message.Publish("power", true);
});
// When the actor leaves the button, turn off the power.
Events.OnLeave(function(e) {
Message.Publish("power", false);
})
}
```
### Message.Subscribe(name string, function)
Subscribe to a named message from any **linked** doodads.
The function receives the data that was passed with the message. Its data type
is arbitrary and will depend on the type of message.
```javascript
// Example electronic device doodad that responds to power from linked buttons.
function main() {
// Boolean to store if our electric device has juice.
var powered = false;
// Do something while powered
setInterval(function() {
if (powered) {
console.log("Brmm...")
}
}, 1000);
// Subscribe to the `power` event by a linked button or other power source.
Message.Subscribe("power", function(boolValue) {
console.log("Powered %s!", boolValue === true ? "on" : "off");
powered = boolValue;
});
}
```
### Message.Broadcast(name string, data...)
This publishes a named message to **every** doodad in the level, whether it
was linked to the broadcaster or not.
For example the "ON/OFF" button globally toggles a boolean state in every
state block that subscribes to the `broadcast:state-change` event.
If you were to broadcast an event like `power` it would activate every single
power-sensitive doodad on the level.
```javascript
// Example two-state block that globally receives the state-change broadcast.
function main() {
var myState = false;
Message.Subscribe("broadcast:state-change", function(boolValue) {
// Most two-state blocks just flip their own state, ignoring the
// boolValue passed with this message.
myState = !myState;
});
}
// Example ON/OFF block that emits the state-change broadcast. It also
// subscribes to the event to keep its own state in sync with all the other
// ON/OFF blocks on the level when they get hit.
function main() {
var myState = false;
// Listen for other ON/OFF button activations to keep our state in
// sync with theirs.
Message.Subscribe("broadcast:state-change", function(boolValue) {
myState = boolValue;
});
// When collided with, broadcast the state toggle to all state blocks.
Events.OnCollide(function(e) {
if (e.Settled) {
myState = !!myState;
Message.Broadcast("broadcast:state-change", myState);
}
})
}
```

View File

@ -7,6 +7,14 @@ import (
// Theme and appearance variables. // Theme and appearance variables.
var ( var (
// Title Screen Font
TitleScreenFont = render.Text{
Size: 46,
Color: render.Pink,
Stroke: render.SkyBlue,
Shadow: render.Black,
}
// Window and panel styles. // Window and panel styles.
TitleConfig = ui.Config{ TitleConfig = ui.Config{
Background: render.MustHexColor("#FF9900"), Background: render.MustHexColor("#FF9900"),

62
pkg/campaign/campaign.go Normal file
View File

@ -0,0 +1,62 @@
// Package campaign contains types and functions for the single player campaigns.
package campaign
// Campaign structure for the JSON campaign files.
type Campaign struct {
Version int `json:"version"`
Title string `json:"title"`
Author string `json:"author"`
Levels []Level `json:"levels"`
}
// Level is the "levels" object of the JSON file.
type Level struct {
Filename string `json:"filename"`
}
// LoadFile reads a campaign file from disk, checking a few locations.
// func LoadFile(filename string) (*Level, error) {
// // Search the system and user paths for this level.
// filename, err := filesystem.FindFile(filename)
// if err != nil {
// return nil, err
// }
//
// // Do we have the file in bindata?
// if jsonData, err := bindata.Asset(filename); err == nil {
// log.Info("loaded from embedded bindata")
// return FromJSON(filename, jsonData)
// }
//
// // WASM: try the file from localStorage or HTTP ajax request.
// if runtime.GOOS == "js" {
// if result, ok := wasm.GetSession(filename); ok {
// log.Info("recall level data from localStorage")
// return FromJSON(filename, []byte(result))
// }
//
// // Ajax request.
// jsonData, err := wasm.HTTPGet(filename)
// if err != nil {
// return nil, err
// }
//
// return FromJSON(filename, jsonData)
// }
//
// // Try the binary format.
// if level, err := LoadBinary(filename); err == nil {
// return level, nil
// } else {
// log.Warn(err.Error())
// }
//
// // Then the JSON format.
// if level, err := LoadJSON(filename); err == nil {
// return level, nil
// } else {
// log.Warn(err.Error())
// }
//
// return nil, errors.New("invalid file type")
// }

60
pkg/campaign/files.go Normal file
View File

@ -0,0 +1,60 @@
// Package campaign contains types and functions for the single player campaigns.
package campaign
import (
"io/ioutil"
"path/filepath"
"runtime"
"sort"
"git.kirsle.net/apps/doodle/pkg/bindata"
"git.kirsle.net/apps/doodle/pkg/filesystem"
"git.kirsle.net/apps/doodle/pkg/userdir"
)
// List returns the list of available campaign JSONs.
//
// It searches in:
// - The embedded bindata for built-in scenarios
// - Scenarios on disk at the assets/campaigns folder.
// - User-made scenarios at ~/doodle/campaigns.
func List() ([]string, error) {
var names []string
// List built-in bindata campaigns.
if files, err := bindata.AssetDir("assets/campaigns"); err == nil {
names = append(names, files...)
}
// WASM: only built-in campaigns, no filesystem access.
if runtime.GOOS == "js" {
return names, nil
}
// Read system-level doodads first. Ignore errors, if the system path is
// empty we still go on to read the user directory.
files, _ := ioutil.ReadDir(filesystem.SystemCampaignsPath)
for _, file := range files {
name := file.Name()
if filepath.Ext(name) == ".json" {
names = append(names, name)
}
}
// Append user campaigns.
userFiles, err := userdir.ListCampaigns()
names = append(names, userFiles...)
// Deduplicate names.
var uniq = map[string]interface{}{}
var result []string
for _, name := range names {
if _, ok := uniq[name]; !ok {
uniq[name] = nil
result = append(result, name)
}
}
sort.Strings(result)
return result, err
}

View File

@ -28,6 +28,7 @@ const (
var ( var (
SystemDoodadsPath = filepath.Join("assets", "doodads") SystemDoodadsPath = filepath.Join("assets", "doodads")
SystemLevelsPath = filepath.Join("assets", "levels") SystemLevelsPath = filepath.Join("assets", "levels")
SystemCampaignsPath = filepath.Join("assets", "campaigns")
) )
// MakeHeader creates the binary file header. // MakeHeader creates the binary file header.

View File

@ -50,12 +50,7 @@ func (s *MainScene) Setup(d *Doodle) error {
// Main title label // Main title label
s.labelTitle = ui.NewLabel(ui.Label{ s.labelTitle = ui.NewLabel(ui.Label{
Text: branding.AppName, Text: branding.AppName,
Font: render.Text{ Font: balance.TitleScreenFont,
Size: 46,
Color: render.Pink,
Stroke: render.SkyBlue,
Shadow: render.Black,
},
}) })
s.labelTitle.Compute(d.Engine) s.labelTitle.Compute(d.Engine)
@ -101,6 +96,10 @@ func (s *MainScene) Setup(d *Doodle) error {
Name string Name string
Func func() Func func()
}{ }{
// {
// Name: "Story Mode",
// Func: d.GotoStoryMenu,
// },
{ {
Name: "Play a Level", Name: "Play a Level",
Func: d.GotoPlayMenu, Func: d.GotoPlayMenu,

View File

@ -189,9 +189,6 @@ func (s *MenuScene) Loop(d *Doodle, ev *event.State) error {
// Draw the pixels on this frame. // Draw the pixels on this frame.
func (s *MenuScene) Draw(d *Doodle) error { func (s *MenuScene) Draw(d *Doodle) error {
// Clear the canvas and fill it with white.
d.Engine.Clear(render.White)
// Draw the background canvas. // Draw the background canvas.
s.canvas.Present(d.Engine, render.Origin) s.canvas.Present(d.Engine, render.Origin)

55
pkg/scripting/js_api.go Normal file
View File

@ -0,0 +1,55 @@
package scripting
import (
"time"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/physics"
"git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/go/render"
)
// JSProxy offers a function API interface to expose to Doodad javascripts.
// These methods safely give the JS access to important attributes and functions
// without exposing unintended API surface area in the process.
type JSProxy map[string]interface{}
// NewJSProxy initializes the API structure for JavaScript binding.
func NewJSProxy(vm *VM) JSProxy {
return JSProxy{
// Console logging.
"console": map[string]interface{}{
"log": log.Info,
"debug": log.Debug,
"warn": log.Warn,
"error": log.Error,
},
// Type constructors.
"RGBA": render.RGBA,
"Point": render.NewPoint,
"Vector": physics.NewVector,
// Useful types and functions.
"Flash": shmem.Flash,
"GetTick": func() uint64 {
return shmem.Tick
},
"time": map[string]interface{}{
"Now": time.Now,
"Add": func(t time.Time, ms int64) time.Time {
return t.Add(time.Duration(ms) * time.Millisecond)
},
},
// Bindings into the VM.
"Events": vm.Events,
"setTimeout": vm.SetTimeout,
"setInterval": vm.SetInterval,
"clearTimeout": vm.ClearTimer,
"clearInterval": vm.ClearTimer,
// Self for an actor to inspect themselves.
"Self": vm.Self,
}
}

View File

@ -1,5 +1,4 @@
// Package scripting manages the JavaScript VMs for Doodad // Package scripting manages the JavaScript VMs for Doodad scripts.
// scripts.
package scripting package scripting
import ( import (

View File

@ -2,14 +2,9 @@ package scripting
import ( import (
"fmt" "fmt"
"reflect"
"sync" "sync"
"time"
"git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/physics"
"git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/go/render"
"github.com/robertkrimen/otto" "github.com/robertkrimen/otto"
) )
@ -69,32 +64,7 @@ func (vm *VM) Set(name string, v interface{}) error {
// RegisterLevelHooks registers accessors to the level hooks // RegisterLevelHooks registers accessors to the level hooks
// and Doodad API for Play Mode. // and Doodad API for Play Mode.
func (vm *VM) RegisterLevelHooks() error { func (vm *VM) RegisterLevelHooks() error {
bindings := map[string]interface{}{ bindings := NewJSProxy(vm)
"log": log.Logger,
"Flash": shmem.Flash,
"RGBA": render.RGBA,
"Point": render.NewPoint,
"Vector": physics.NewVector,
"Self": vm.Self, // i.e., the uix.Actor object
"Events": vm.Events,
"GetTick": func() uint64 {
return shmem.Tick
},
"TypeOf": reflect.TypeOf,
"time": map[string]interface{}{
"Now": time.Now,
"Add": func(t time.Time, ms int64) time.Time {
return t.Add(time.Duration(ms) * time.Millisecond)
},
},
// Timer functions with APIs similar to the web browsers.
"setTimeout": vm.SetTimeout,
"setInterval": vm.SetInterval,
"clearTimeout": vm.ClearTimer,
"clearInterval": vm.ClearTimer,
}
for name, v := range bindings { for name, v := range bindings {
err := vm.vm.Set(name, v) err := vm.vm.Set(name, v)
if err != nil { if err != nil {
@ -103,15 +73,6 @@ func (vm *VM) RegisterLevelHooks() error {
) )
} }
} }
// Alias the console.log functions to the logger.
vm.vm.Run(`
console = {};
console.log = log.Info;
console.debug = log.Debug;
console.warn = log.Warn;
console.error = log.Error;
`)
return nil return nil
} }

144
pkg/story_scene.go Normal file
View File

@ -0,0 +1,144 @@
package doodle
import (
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/campaign"
"git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/uix"
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/event"
"git.kirsle.net/go/ui"
)
// StoryScene manages the "Story Mode" menu selection screen.
type StoryScene struct {
// Private variables.
d *Doodle
running bool
// Background wallpaper canvas.
canvas *uix.Canvas
// UI widgets.
supervisor *ui.Supervisor
campaignFrame *ui.Frame // Select a Campaign screen
levelSelectFrame *ui.Frame // Select a level in the campaign screen
// Pointer to the currently active frame.
activeFrame *ui.Frame
}
// Name of the scene.
func (s *StoryScene) Name() string {
return "Story"
}
// GotoStoryMenu initializes the story menu scene.
func (d *Doodle) GotoStoryMenu() {
log.Info("Loading Story Scene")
scene := &StoryScene{}
d.Goto(scene)
}
// Setup the play scene.
func (s *StoryScene) Setup(d *Doodle) error {
s.d = d
// Set up the background wallpaper canvas.
s.canvas = uix.NewCanvas(100, false)
s.canvas.Resize(render.NewRect(d.width, d.height))
s.canvas.LoadLevel(d.Engine, &level.Level{
Chunker: level.NewChunker(100),
Palette: level.NewPalette(),
PageType: level.Bounded,
Wallpaper: "notebook.png",
})
s.supervisor = ui.NewSupervisor()
// Set up the sub-screens of this scene.
s.campaignFrame = s.setupCampaignFrame()
s.levelSelectFrame = s.setupLevelSelectFrame()
s.activeFrame = s.campaignFrame
return nil
}
// setupCampaignFrame sets up the Campaign List screen.
func (s *StoryScene) setupCampaignFrame() *ui.Frame {
var frame = ui.NewFrame("List Frame")
frame.SetBackground(render.RGBA(0, 0, 255, 20))
// Title label
labelTitle := ui.NewLabel(ui.Label{
Text: "Select a Story",
Font: balance.TitleScreenFont,
})
labelTitle.Compute(s.d.Engine)
frame.Place(labelTitle, ui.Place{
Top: 120,
Center: true,
})
// Buttons for campaign selection.
{
campaignFiles, err := campaign.List()
if err != nil {
log.Error("campaign.List: %s", err)
}
_ = campaignFiles
// for _, file := range campaignFiles {
//
// }
}
frame.Resize(render.NewRect(s.d.width, s.d.height))
frame.Compute(s.d.Engine)
return frame
}
// setupLevelSelectFrame sets up the Level Select screen.
func (s *StoryScene) setupLevelSelectFrame() *ui.Frame {
var frame = ui.NewFrame("List Frame")
return frame
}
// Loop the story scene.
func (s *StoryScene) Loop(d *Doodle, ev *event.State) error {
s.supervisor.Loop(ev)
// Has the window been resized?
if ev.WindowResized {
w, h := d.Engine.WindowSize()
if w != d.width || h != d.height {
d.width = w
d.height = h
s.canvas.Resize(render.NewRect(d.width, d.height))
s.activeFrame.Resize(render.NewRect(d.width, d.height))
s.activeFrame.Compute(d.Engine)
return nil
}
}
return nil
}
// Draw the pixels on this frame.
func (s *StoryScene) Draw(d *Doodle) error {
// Draw the background canvas.
s.canvas.Present(d.Engine, render.Origin)
// Draw the active screen.
s.activeFrame.Present(d.Engine, render.Origin)
return nil
}
// Destroy the scene.
func (s *StoryScene) Destroy() error {
return nil
}

View File

@ -55,7 +55,28 @@ func (w *Canvas) InstallScripts() error {
for _, actor := range w.actors { for _, actor := range w.actors {
vm := w.scripting.To(actor.ID()) vm := w.scripting.To(actor.ID())
vm.Self = actor
// Security: expose a selective API to the actor to the JS engine.
vm.Self = map[string]interface{}{
"Filename": actor.Doodad().Filename,
"Title": actor.Doodad().Title,
// functions
"ID": actor.ID,
"GetTag": actor.Doodad().Tag,
"Position": actor.Position,
"SetHitbox": actor.SetHitbox,
"SetVelocity": actor.SetVelocity,
"SetMobile": actor.SetMobile,
"SetGravity": actor.SetGravity,
"AddAnimation": actor.AddAnimation,
"IsAnimating": actor.IsAnimating,
"PlayAnimation": actor.PlayAnimation,
"StopAnimation": actor.StopAnimation,
"ShowLayer": actor.ShowLayer,
"ShowLayerNamed": actor.ShowLayerNamed,
"Destroy": actor.Destroy,
}
vm.Set("Self", vm.Self) vm.Set("Self", vm.Self)
if _, err := vm.Run(actor.Doodad().Script); err != nil { if _, err := vm.Run(actor.Doodad().Script); err != nil {

View File

@ -18,6 +18,7 @@ var (
ProfileDirectory string ProfileDirectory string
LevelDirectory string LevelDirectory string
DoodadDirectory string DoodadDirectory string
CampaignDirectory string
CacheDirectory string CacheDirectory string
FontDirectory string FontDirectory string
@ -34,6 +35,7 @@ func init() {
ProfileDirectory = configdir.LocalConfig(ConfigDirectoryName) ProfileDirectory = configdir.LocalConfig(ConfigDirectoryName)
LevelDirectory = configdir.LocalConfig(ConfigDirectoryName, "levels") LevelDirectory = configdir.LocalConfig(ConfigDirectoryName, "levels")
DoodadDirectory = configdir.LocalConfig(ConfigDirectoryName, "doodads") DoodadDirectory = configdir.LocalConfig(ConfigDirectoryName, "doodads")
CampaignDirectory = configdir.LocalConfig(ConfigDirectoryName, "campaigns")
// Cache directory to extract font files to. // Cache directory to extract font files to.
CacheDirectory = configdir.LocalCache(ConfigDirectoryName) CacheDirectory = configdir.LocalCache(ConfigDirectoryName)
@ -44,6 +46,7 @@ func init() {
if runtime.GOOS != "js" { if runtime.GOOS != "js" {
configdir.MakePath(LevelDirectory) configdir.MakePath(LevelDirectory)
configdir.MakePath(DoodadDirectory) configdir.MakePath(DoodadDirectory)
configdir.MakePath(CampaignDirectory)
configdir.MakePath(FontDirectory) configdir.MakePath(FontDirectory)
} }
} }
@ -121,6 +124,30 @@ func ListLevels() ([]string, error) {
return names, nil return names, nil
} }
// ListCampaigns returns a listing of all available campaigns.
func ListCampaigns() ([]string, error) {
var names []string
// WASM: list from localStorage.
if runtime.GOOS == "js" {
return wasm.StorageKeys(CampaignDirectory + "/"), nil
}
files, err := ioutil.ReadDir(CampaignDirectory)
if err != nil {
return names, err
}
for _, file := range files {
name := file.Name()
if filepath.Ext(name) == ".json" {
names = append(names, name)
}
}
return names, nil
}
// resolvePath is the inner logic for LevelPath and DoodadPath. // resolvePath is the inner logic for LevelPath and DoodadPath.
func resolvePath(directory, filename, extension string) string { func resolvePath(directory, filename, extension string) string {
if strings.Contains(filename, string(filepath.Separator)) { if strings.Contains(filename, string(filepath.Separator)) {