diff --git a/assets/campaigns/tutorial.json b/assets/campaigns/tutorial.json new file mode 100644 index 0000000..7c3c1e0 --- /dev/null +++ b/assets/campaigns/tutorial.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "title": "Tutorial Levels", + "author": "kirsle", + "levels": [ + { + "filename": "example1.level", + }, + { + "filename": "example2.level", + }, + { + "filename": "example3.level", + } + ] +} diff --git a/cmd/doodad/commands/edit_doodad.go b/cmd/doodad/commands/edit_doodad.go index 8e9ac6e..67c8c3e 100644 --- a/cmd/doodad/commands/edit_doodad.go +++ b/cmd/doodad/commands/edit_doodad.go @@ -32,7 +32,7 @@ func init() { Usage: "set the doodad author", }, 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.", }, cli.BoolFlag{ diff --git a/dev-assets/doodads/azulian/azulian-red.js b/dev-assets/doodads/azulian/azulian-red.js index ca87ff3..87d7d4c 100644 --- a/dev-assets/doodads/azulian/azulian-red.js +++ b/dev-assets/doodads/azulian/azulian-red.js @@ -1,5 +1,5 @@ function main() { - log.Info("Azulian '%s' initialized!", Self.Doodad().Title); + console.log("Azulian '%s' initialized!", Self.Title); var playerSpeed = 4; var gravity = 4; diff --git a/dev-assets/doodads/buttons/button.js b/dev-assets/doodads/buttons/button.js index 976c2d6..ceac9e7 100644 --- a/dev-assets/doodads/buttons/button.js +++ b/dev-assets/doodads/buttons/button.js @@ -1,5 +1,5 @@ function main() { - console.log("%s initialized!", Self.Doodad().Title); + console.log("%s initialized!", Self.Title); var timer = 0; @@ -26,9 +26,4 @@ function main() { timer = 0; }, 200); }); - - // Events.OnLeave(function(e) { - // console.log("%s has stopped touching %s", e, Self.Doodad().Title) - // Self.Canvas.SetBackground(RGBA(0, 0, 1, 0)); - // }) } diff --git a/dev-assets/doodads/buttons/sticky.js b/dev-assets/doodads/buttons/sticky.js index 8750fad..9d1893e 100644 --- a/dev-assets/doodads/buttons/sticky.js +++ b/dev-assets/doodads/buttons/sticky.js @@ -1,5 +1,5 @@ function main() { - console.log("%s initialized!", Self.Doodad().Title); + console.log("%s initialized!", Self.Title); var pressed = false; diff --git a/dev-assets/doodads/doors/colored-door.js b/dev-assets/doodads/doors/colored-door.js index a221b58..f3e12fc 100644 --- a/dev-assets/doodads/doors/colored-door.js +++ b/dev-assets/doodads/doors/colored-door.js @@ -1,6 +1,6 @@ function main() { - var color = Self.Doodad().Tag("color"); + var color = Self.GetTag("color"); var keyname = "key-" + color + ".doodad"; // Layers in the doodad image. diff --git a/dev-assets/doodads/doors/electric-door.js b/dev-assets/doodads/doors/electric-door.js index ab6dc54..1642890 100644 --- a/dev-assets/doodads/doors/electric-door.js +++ b/dev-assets/doodads/doors/electric-door.js @@ -1,5 +1,5 @@ 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("close", 100, [3, 2, 1, 0]); @@ -9,7 +9,7 @@ function main() { Self.SetHitbox(16, 0, 32, 64); 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 (animating || opened) { diff --git a/dev-assets/doodads/doors/keys.js b/dev-assets/doodads/doors/keys.js index ee7586a..159069e 100644 --- a/dev-assets/doodads/doors/keys.js +++ b/dev-assets/doodads/doors/keys.js @@ -1,9 +1,9 @@ function main() { - var color = Self.Doodad().Tag("color"); + var color = Self.GetTag("color"); Events.OnCollide(function(e) { if (e.Settled) { - e.Actor.AddItem(Self.Doodad().Filename, 0); + e.Actor.AddItem(Self.Filename, 0); Self.Destroy(); } }) diff --git a/dev-assets/doodads/doors/locked-door.js b/dev-assets/doodads/doors/locked-door.js index e1f9f3f..820ae1a 100644 --- a/dev-assets/doodads/doors/locked-door.js +++ b/dev-assets/doodads/doors/locked-door.js @@ -2,7 +2,7 @@ function main() { Self.AddAnimation("open", 0, [1]); var unlocked = false; - var color = Self.Doodad().Tag("color"); + var color = Self.GetTag("color"); Self.SetHitbox(16, 0, 32, 64); diff --git a/dev-assets/doodads/objects/exit-flag.js b/dev-assets/doodads/objects/exit-flag.js index 215ad05..08680a9 100644 --- a/dev-assets/doodads/objects/exit-flag.js +++ b/dev-assets/doodads/objects/exit-flag.js @@ -1,6 +1,6 @@ // Exit Flag. function main() { - console.log("%s initialized!", Self.Doodad().Title); + console.log("%s initialized!", Self.Title); Self.SetHitbox(22+16, 16, 75-16, 86); Events.OnCollide(function(e) { diff --git a/dev-assets/doodads/on-off/state-button.js b/dev-assets/doodads/on-off/state-button.js index 88c06ed..85084d8 100644 --- a/dev-assets/doodads/on-off/state-button.js +++ b/dev-assets/doodads/on-off/state-button.js @@ -4,7 +4,7 @@ var state = false; 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); // When the button is activated, don't keep toggling state until we're not diff --git a/dev-assets/doodads/switches/switch.js b/dev-assets/doodads/switches/switch.js index c5aff2e..c36b2de 100644 --- a/dev-assets/doodads/switches/switch.js +++ b/dev-assets/doodads/switches/switch.js @@ -1,5 +1,5 @@ function main() { - console.log("%s initialized!", Self.Doodad().Title); + console.log("%s initialized!", Self.Title); // Switch has two frames: // 0: Off diff --git a/dev-assets/doodads/trapdoors/down.js b/dev-assets/doodads/trapdoors/down.js index 2ed3907..cc45c41 100644 --- a/dev-assets/doodads/trapdoors/down.js +++ b/dev-assets/doodads/trapdoors/down.js @@ -1,5 +1,5 @@ function main() { - console.log("%s initialized!", Self.Doodad().Title); + console.log("%s initialized!", Self.Title); var timer = 0; diff --git a/dev-assets/doodads/trapdoors/trapdoor.js b/dev-assets/doodads/trapdoors/trapdoor.js index 9e2a27a..fb23362 100644 --- a/dev-assets/doodads/trapdoors/trapdoor.js +++ b/dev-assets/doodads/trapdoors/trapdoor.js @@ -1,6 +1,6 @@ function main() { // What direction is the trapdoor facing? - var direction = Self.Doodad().Tag("direction"); + var direction = Self.GetTag("direction"); console.log("Trapdoor(%s) initialized", direction); var timer = 0; diff --git a/dev-assets/guidebook/pages/DoodadScripts.md b/dev-assets/guidebook/pages/DoodadScripts.md index 6f189d2..7f9a2d8 100644 --- a/dev-assets/guidebook/pages/DoodadScripts.md +++ b/dev-assets/guidebook/pages/DoodadScripts.md @@ -3,20 +3,18 @@ Doodads are programmed using JavaScript which gives them their behavior 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: ```javascript -// The main function is called when the doodad is initialized in Play Mode -// at the start of the level. +// main() is called on level initialization for each +// instance ("actor") of the doodad. 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). - console.log("%s initialized!", Self.Doodad().Title); + console.log("%s initialized!", Self.Title); // If our doodad has 'solid' parts that should prohibit movement, // define the hitbox here. Coordinates are relative so 0,0 is the @@ -37,6 +35,14 @@ function main() { // moving through. 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 @@ -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. -### 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().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 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 -to those found in web browsers. - -```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. +Like in node.js and the web browser, `console.log` and friends are available +for logging from a doodad script. Logs are emitted to the same place as the +game's logs are. ```javascript console.log("Hello world!"); -console.error("The answer is %d!", 42); -console.warn("Actor '%s' has collided with us!", e.Actor.ID()); -console.debug("This only logs when the game is in debug mode!"); +console.log("Interpolate strings '%s' and numbers '%d'", "string", 123); +console.debug("Debug messages shown 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 initializes a Color variable using the game's native Color type. May -be useful for certain game APIs that take color values. - -Example: RGBA(255, 0, 255, 255) creates an opaque magenta color. +Creates a Color type from red, green, blue and alpha values (integers between +0 and 255). ### Point(x, y int) -Returns a Point object which refers to a location in the game world. This -type is required for certain game APIs. +Creates a Point object with X and Y coordinates. + +### 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); + } + }) +} +``` diff --git a/pkg/balance/theme.go b/pkg/balance/theme.go index a4430ed..6a47ca8 100644 --- a/pkg/balance/theme.go +++ b/pkg/balance/theme.go @@ -7,6 +7,14 @@ import ( // Theme and appearance variables. var ( + // Title Screen Font + TitleScreenFont = render.Text{ + Size: 46, + Color: render.Pink, + Stroke: render.SkyBlue, + Shadow: render.Black, + } + // Window and panel styles. TitleConfig = ui.Config{ Background: render.MustHexColor("#FF9900"), diff --git a/pkg/campaign/campaign.go b/pkg/campaign/campaign.go new file mode 100644 index 0000000..e73ae5e --- /dev/null +++ b/pkg/campaign/campaign.go @@ -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") +// } diff --git a/pkg/campaign/files.go b/pkg/campaign/files.go new file mode 100644 index 0000000..0e84a6c --- /dev/null +++ b/pkg/campaign/files.go @@ -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 +} diff --git a/pkg/filesystem/filesystem.go b/pkg/filesystem/filesystem.go index ad37888..9f55533 100644 --- a/pkg/filesystem/filesystem.go +++ b/pkg/filesystem/filesystem.go @@ -26,8 +26,9 @@ const ( // Paths to system-level assets bundled with the application. var ( - SystemDoodadsPath = filepath.Join("assets", "doodads") - SystemLevelsPath = filepath.Join("assets", "levels") + SystemDoodadsPath = filepath.Join("assets", "doodads") + SystemLevelsPath = filepath.Join("assets", "levels") + SystemCampaignsPath = filepath.Join("assets", "campaigns") ) // MakeHeader creates the binary file header. diff --git a/pkg/main_scene.go b/pkg/main_scene.go index e65309b..8050050 100644 --- a/pkg/main_scene.go +++ b/pkg/main_scene.go @@ -50,12 +50,7 @@ func (s *MainScene) Setup(d *Doodle) error { // Main title label s.labelTitle = ui.NewLabel(ui.Label{ Text: branding.AppName, - Font: render.Text{ - Size: 46, - Color: render.Pink, - Stroke: render.SkyBlue, - Shadow: render.Black, - }, + Font: balance.TitleScreenFont, }) s.labelTitle.Compute(d.Engine) @@ -101,6 +96,10 @@ func (s *MainScene) Setup(d *Doodle) error { Name string Func func() }{ + // { + // Name: "Story Mode", + // Func: d.GotoStoryMenu, + // }, { Name: "Play a Level", Func: d.GotoPlayMenu, diff --git a/pkg/menu_scene.go b/pkg/menu_scene.go index 421c473..44fad5c 100644 --- a/pkg/menu_scene.go +++ b/pkg/menu_scene.go @@ -189,9 +189,6 @@ func (s *MenuScene) Loop(d *Doodle, ev *event.State) error { // Draw the pixels on this frame. func (s *MenuScene) Draw(d *Doodle) error { - // Clear the canvas and fill it with white. - d.Engine.Clear(render.White) - // Draw the background canvas. s.canvas.Present(d.Engine, render.Origin) diff --git a/pkg/scripting/js_api.go b/pkg/scripting/js_api.go new file mode 100644 index 0000000..54a57fa --- /dev/null +++ b/pkg/scripting/js_api.go @@ -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, + } +} diff --git a/pkg/scripting/scripting.go b/pkg/scripting/scripting.go index bb91697..388f9ec 100644 --- a/pkg/scripting/scripting.go +++ b/pkg/scripting/scripting.go @@ -1,5 +1,4 @@ -// Package scripting manages the JavaScript VMs for Doodad -// scripts. +// Package scripting manages the JavaScript VMs for Doodad scripts. package scripting import ( diff --git a/pkg/scripting/vm.go b/pkg/scripting/vm.go index ee0f322..0d17511 100644 --- a/pkg/scripting/vm.go +++ b/pkg/scripting/vm.go @@ -2,14 +2,9 @@ package scripting import ( "fmt" - "reflect" "sync" - "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" "github.com/robertkrimen/otto" ) @@ -69,32 +64,7 @@ func (vm *VM) Set(name string, v interface{}) error { // RegisterLevelHooks registers accessors to the level hooks // and Doodad API for Play Mode. func (vm *VM) RegisterLevelHooks() error { - bindings := map[string]interface{}{ - "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, - } + bindings := NewJSProxy(vm) for name, v := range bindings { err := vm.vm.Set(name, v) 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 } diff --git a/pkg/story_scene.go b/pkg/story_scene.go new file mode 100644 index 0000000..35631a2 --- /dev/null +++ b/pkg/story_scene.go @@ -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 +} diff --git a/pkg/uix/canvas_actors.go b/pkg/uix/canvas_actors.go index b73eeb8..ce8f6ad 100644 --- a/pkg/uix/canvas_actors.go +++ b/pkg/uix/canvas_actors.go @@ -55,7 +55,28 @@ func (w *Canvas) InstallScripts() error { for _, actor := range w.actors { 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) if _, err := vm.Run(actor.Doodad().Script); err != nil { diff --git a/pkg/userdir/userdir.go b/pkg/userdir/userdir.go index 89e01c0..2a6ed6b 100644 --- a/pkg/userdir/userdir.go +++ b/pkg/userdir/userdir.go @@ -15,9 +15,10 @@ import ( var ( ConfigDirectoryName = "doodle" - ProfileDirectory string - LevelDirectory string - DoodadDirectory string + ProfileDirectory string + LevelDirectory string + DoodadDirectory string + CampaignDirectory string CacheDirectory string FontDirectory string @@ -34,6 +35,7 @@ func init() { ProfileDirectory = configdir.LocalConfig(ConfigDirectoryName) LevelDirectory = configdir.LocalConfig(ConfigDirectoryName, "levels") DoodadDirectory = configdir.LocalConfig(ConfigDirectoryName, "doodads") + CampaignDirectory = configdir.LocalConfig(ConfigDirectoryName, "campaigns") // Cache directory to extract font files to. CacheDirectory = configdir.LocalCache(ConfigDirectoryName) @@ -44,6 +46,7 @@ func init() { if runtime.GOOS != "js" { configdir.MakePath(LevelDirectory) configdir.MakePath(DoodadDirectory) + configdir.MakePath(CampaignDirectory) configdir.MakePath(FontDirectory) } } @@ -121,6 +124,30 @@ func ListLevels() ([]string, error) { 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. func resolvePath(directory, filename, extension string) string { if strings.Contains(filename, string(filepath.Separator)) {