Log In  
Follow
jasondelaat
[ :: Read More :: ]

In my vector library demo cart (https://www.lexaloffle.com/bbs/?tid=141574) I have a main menu of demos each linked to a key press. From within each demo you should be able to return to the menu. On the web player I can get from the main menu to any of the demos but, once I'm in one of the demos, I can't get back to the menu.

I've uploaded 4 versions of the cart with the key to return to the menu as: tab, space, 0 and now m. All of these work for me within my copy of Picotron (0.1.0e) but none of them seem to work in the web player.

No idea what could be going on here unless all those keys have been reserved for some future web player only functionality and I'm just really good at picking the wrong key to bind my menu to.

P#146627 2024-04-14 18:04

[ :: Read More :: ]

This is a general purpose geometry/vector library.

It's based on Geometric Algebra (GA) but you don't need to know what that is to use it as most of the standard vector operations you're probably familiar with are here.

I'd be happy to add additional demos (with credit obviously) so if you make something interesting that you want to include let me know.

Updates

2024-04-21


2024-04-14

  • Added 3D forward kinematics demo
  • Added 4D hypercube rotation demo
  • Switched to [m] to return to menu. *Edit* It works now! You can navigate back to the menu on the web player and switch back and forth among the demos.

2024-04-10

  • Added a 2D forward kinematics demo
  • Changed to 'space' instead of 'tab' to return to the menu (Hopefully it works better with the web player.) *Edit* It does not.
  • Fixed a minor error in rotor_from_to which resulted in things rotating the wrong way. There may be other errors like this lurking so if anyone notices things transforming in ways they don't expect, please let me know!

Cart #gavel_demos-5 | 2024-04-21 | Embed ▽ | License: CC4-BY-NC-SA
21

To get the library code itself, load #gavel_demos and then copy the algebra.lua file from /ram/cart/src to a convenient location.

Cheat sheet

I don't want to dive too deeply into the mathematics of GA here so this doesn't include everything but should include most of what people are probably already familiar with. Feel free to poke around the code and ask about anything that's not clear or that you just want to know more about.

Notes

  • 1 : GA is "component free" meaning you always work with the whole vector and not its individual components. That has a bunch of advantages but the most relevant one here is that you can pick how many dimensions you want---two, three, four, nine...eight hundred---and everything just works.
  • 2 : Multivectors are a GA thing which you can think of as sort a more expansive vector. Technically vectors are also multivectors but with the "extra" components all set to 0. For simple usage you don't really need to know too much about them but it can be useful to know they exist.
  • 3 : All tranformations are automatically reversible without having to build a whole new transformation. So if v.transform(t) rotates v clockwise then v.transform(t, true) will rotate it the same amount but counter-clockwise.
  • 4 : For vectors---as opposed to general multivectors---the dot product and scalar product return the same value but as different types. For vectors u and v, (u..v).scalar == u%v
  • 5 : string representations will look a bit weird. Since you can set any number of dimensions you want vector components are named by number. So print(vector(10, 20)) will display 10e1+20e2. You can think of e1 and e2 as x and y. As a small quality of life feature, you can access those components on a vector using either notation: v.e1 == v.x

There is one obvious thing missing that I should probably address.

Dude, where's my cross product?

GA's component free nature is what allows it to be general enough that you can just plug in a number of dimensions and have everything work. As you're probably aware, the cross product does not generalize to dimensions other than 3 so it's not here. What we have instead is the "wedge product" (__pow or ^ operator.) The wedge product does generalize.

In 3-dimensions the wedge product of two vectors and the cross product of two vectors contain the same information just presented slightly differently.

algebra(3):make_global()

u = vector(1, 2, 3)
v = vector(7, 5, 2)

print(u ^ v) --     -9e12-19e13-11e23

Don't worry, that's not a vector in 23 dimensions, it's what's called a bivector. Compare this result with the cross product of these two vectors:

u × v = (-11, 19, -9)

The order is reversed and the middle term is multiplied by -1. That seems like a tiny difference but it's the difference between a product which only exists in 3-dimensions and one that exists in any number of dimensions.(Although I'm being very hand-wavy here and there's more to it than that.)

You can write yourself a cross product function if you want to but learning to use the wedge product will usually make your code simpler and more general.

Will this actually work with 800 dimensions?

No. But that's just because it would be computationally infeasible not because there's anything wrong with the math. In principle everything would work in 800 dimensions. In practice you'd probably melt your processor. The way I've written it, GAVEL should work for anything up to 9 dimensions. It'll eat up a lot of memory and probably be slow but it should work. It could be expanded beyond 9 dimensions without too much trouble but I'm going to assume that if you're doing something requiring more than 9 dimensions you're (a) probably not doing it in Picotron, and (b) don't need my help.

Resources

Hopefully I'll make a proper geometric algebra tutorial at some point but until then here are some resources if you want to dive deeper into the math.

Geometric Algebra Playlist
<https://www.youtube.com/playlist?list=PLpzmRsG7u_gqaTo_vEseQ7U8KFvtiJY4K>;

Siggraph2019 Projective Geometric Algebra
<https://www.youtube.com/watch?v=tX4H_ctggYo&list=WL&index=3&t=634s&pp=gAQBiAQB>;

P#146152 2024-04-09 22:47 ( Edited 2024-04-21 16:21)

[ :: Read More :: ]

A tool to help with print debugging based on the Python package IceCream. There's already a Lua version of IceCream but it won't work with Pico-8/Picotron as far as I know. I've called it db for debug rather than ic but it's very similar in concept.

You can download the code here or copy/paste below:

db.lua

----------------------------------------------------------------------------
-- print debugging based on IceCream for python
-- pico-8 lua version by jason delaat
do
   local ignore = {}
   local lookup = _ENV
   for k,_ in pairs(_ENV) do
      ignore[k] = true
   end
   local function format_arg(value, env)
      for k,v in pairs(lookup) do
         if v == value and not ignore[k] then
            return 'db: '..k..'='..tostr(v)
         end
      end
      return 'db: '..tostr(value)
   end

   local db_meta = {
      __call=function(self, value, log)
         if db.display and log then
            print(log)
         elseif db.display then
            print(format_arg(value))
         end
         return value
      end
   }
   db = {
      display = true,
      local_env = function(t)
         lookup = setmetatable(t or {}, {__index=_ENV})
         return lookup
      end,
      reset_env = function()
         lookup = _ENV
      end,
      wrap = function(f)
         local fn = sub(split(format_arg(f), '=')[1], 5)
         _ENV[fn] = function(...)
            local log = 'db: '..fn..'('
            local result = f(...)
            for a in all({...}) do
               log ..= tostr(a)..','
            end
            log = sub(log, 1, -2)
            log ..= ') --> '..tostr(result)
            return result, log
         end
      end
   }
   setmetatable(db, db_meta)
end
----------------------------------------------------------------------------

Summary

summary-table.png

Displaying variables

The most basic thing you can do with db is just display values and variables. When displaying variables with db it displays both the variable name and its value.

x = 1
db(2)
db(x)

Output

db: 2
db: x=1
  • Note: Duplicate values
    db creates its output by searching the global environment by key/value pairs looking for a matching value and then displaying it with the corresponding key. This mostly works. But if more than one variable has the same value db may log the wrong one. For example:

    x = 1
    y = 1
    db(x)

    Because the ordering of keys in a table isn't guaranteed this will sometimes (correctly) display db: x=1 but will also sometimes (incorrectly) display db: y=1. db automatically ignores variable/function names defined in the global environment before the debug.lua code—which includes all built-ins—to try to minimize these collisions.

Inline debug output

db returns its input so you can debug variables where they're actually used.

x = 1
y = 2
z = db(x) + db(y)
db(z)

Output

db: x=1
db: y=2
db: z=3

User defined types

Since db is essentially just a fancy print statement it should work properly with any object as long as it has a __tostring meta-method defined.

do
   local vector_meta = {
      __tostring=function(_ENV)
         return 'vec('..x..','..y..')'
      end
   }

   function vector(x, y)
      return setmetatable({x=x, y=y}, vector_meta)
   end
end

v = vector(1, 2)
db(v)

Output

db: v=vec(1,2)

Function calls

Since functions return values, by default db displays a function call as a bare value:

function plus(a, b)
   return a + b
end

db(plus(1, 2))

Output

db: 3

You can use db.wrap to enable functions to output their name, input parameters and output value, like so:

function plus(a, b)
   return a + b
end

db.wrap(plus)
db(plus(1, 2))

Output

db: plus(1,2) --> 3

Selective debug output

The db.display property can be used to turn debug output on and off wherever you want throughout your code. This lets you leave debug statements in place if you're not sure you're done with them yet but only display those parts you're interested in at the moment.

x = 1
y = 2
function plus(a, b)
   return db(x) + db(y)
end

function minus(a, b)
   return db(x) - db(y)
end

db.wrap(plus)
db.wrap(minus)

db.display = false
p = db(plus(x, y))

db.display = true
m = db(minus(x, y))

db(p)
db(m)

Output

db: x=1
db: y=1
db: minus(1,2) --> -1
db: p=3
db: m=-1

In the above example the debug output for plus is not displayed. However, db.display doesn't prevent the plus function from being called or returning a value: You can see via db(p) that p has the correct value.

Debugging functions

The Problem

Consider the following example:

x = 1
y = 2

function plus(a, b)
   local c = 'who am i?'
   db(c)
   return db(a) + db(b)
end

db(plus(x, y))

Output

db: who am i?
db: x=1
db: y=2
db: 3

First notice that the output of the function call is logged simply as db: 3 because the function hasn't been wrapped with db.wrap. But more importantly, db(c) is displaying the string but not the variable name while db(a) and db(b) are displaying as variables x and y respectively. That's becuase a, b and c are all local variables and Lua doesn't give us direct access to those in the same way as globals. Standard Lua has debug tools which can get a hold of those values but we don't have access to those here in Pico-land.

The Solution

If you want/need to debug values within a function use db.local_env and db.reset_env like so:

x = 1
y = 2

function plus(a, b)
   --local _ENV = db.local_env()    -- can be called with no arguments
   local _ENV = db.local_env({a=a, b=b})  -- note the 'local' keyword!
   c = 'who am i?'                       -- note the *lack* of 'local'
   db(c)
   return db(a) + db(b), db.reset_env()
end

db.wrap(plus)
db(plus(x, y))

db(c)
db(x)

Output

db: c=who am i?
db: a=1
db: b=2
db: plus(1,2) --> 3
db: [nil]
db: x=1

db.local_env creates a temporary enviroment and tells db to use that environment for building its output. db.reset_env just tells db to go back to using the global environment. Including the call db.reset_env() as a second return value let's us reset the environment "after" returning from the function.

  • Note: The local keyword
    Importantly _ENV must be defined as local here or you'll overwrite the global environment. Equally importantly, local variables should not be defined with the local keyword or they'll go back to outputting just values without names. These variables are still local though. As you can see in the output db(c), when called outside the function, displays db: [nil]. When you're done debugging you'll probably want to remove that _ENV: be sure to add local where necessary or all your function local variables will suddenly be globals and you'll have whole new errors to hunt down!

Providing a table to db.local_env is optional. It's mainly included to ensure that input parameters display correctly: Since input parameters are actually defined before our temporary environment, they don't exist in it unless we specifically put them there. You can still use those values but they won't display the variable name when logged.

function no_param(a)
   local _ENV = db.local_env()
   return db(a), db.reset_env()
end

no_param(1)

Output

db: 1
P#144207 2024-03-23 12:55

[ :: Read More :: ]

This cart has devkit mode enabled and requires a mouse and keyboard.
It's intended to be run locally; not all feature work correctly in the web player.

A simple path editor for creating animation paths for game objects. The paths created are simple interpolated paths not bezier or similar curves. The path is guaranteed to pass through all of the control points. You don't have as much control over the exact shape of the path but you can still get some pretty nice results.

Cart #animation_path_editor-0 | 2023-12-07 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
3

Features

  • Save and Load path files in a binary format to .p8 files so they can be easily changed later.
  • Export paths and related functions to a .txt file which can be #included in your project.
  • When exported, paths are translated so the first control point is at (0, 0) so you can easily add any offset allowing you to use the same path in multiple locations without having to change the path itself.
  • When you save a path file the filename is saved in persistent cart data and that file will be automatically loaded the next time you start the editor. (Assumes the editor and path file are in the same directory.)
  • Path files contain multiple paths so you can keep all paths for a single project in a single file.
  • Path names are editable so you can give them meaningful names.
  • Create open or closed paths.
  • A timeline allows you to adjust the animation timing of individual sections of the path as well as the total duration (in seconds) of the animation.
  • Import sprite and map data from another cart. (Assumes cart is in the same directory as the editor.)
  • Select sprites to preview path animation. (Currently a single, static, 8x8 sprite can be selected per path. This is just for preview purposes: selected sprites are saved in path files but are not exported.)
  • Adjust map position either by dragging it or via text input. (As with sprites this is just for preview purposes. Each path can have the map displayed at a different position and those postiions are saved in the path file but not exported.)
  • If you don't like my colour choices, the first tab of the .p8.png contains configuration data which you can edit to better suit your needs/tastes. (You can also suppress the help message which pops up when you run the cart from here if you want to.) I'm not sure I made everything configurable so, if I've missed something you'd like to change the colour of, feel free to let me know and I'll add it in.

Command Summary

(also available in the editor by pressing 'h')

Example

A simple example using a single path to animate two squares at different offsets and with different timing.

Code:

-- exported file ------------------------------------------------------------
do
   local function parse(p_str)
      -- parses the exported path string into a table.

      -- paths are accessed by name with dashes (-) and spaces
      -- converted to underscores. for example: 'path-1' in the editor
      -- becomes 'paths.path_1' when exported.

      -- each path is a three element array. the first element of each
      -- path is the animation duration in seconds. the other two
      -- elements are arrays used by p_lerp to interpolate the
      -- position and timing between points.
      local paths = {}
      local pstrs = split(p_str, '|')
      for p in all(pstrs) do
         local vals = split(p, ':')
         paths[ vals[1] ] = {vals [2], split(vals[3]), split(vals[4])}
      end
      return paths
   end

   local function choose(n, k)
      -- combinatorial choose function for real number 'n' and
      -- positive integer 'k'. this function is used by the
      -- interpolation functions.
      local num, den = 1, 1
      for i=1,k do
         num *= n - (i - 1)
         den *= i
      end
      return k == 0 and 1 or num/den
   end

   function p_lerp(path, t)
      -- calculates the position of 'path' at time 't'
      -- where 0 <= t <= duration in seconds. The duration of a path
      -- is stored at index 1 of the path array.
      local _, coefs, times = unpack(path)
      local ti = 0
      for i=1,#times do
         ti = t >= times[i] and i or ti
      end
      local t1 = times[ti] or -times[ti+2]
      local t2 = times[ti + 1] or 2*times[ti] - times[ti-1]
      local x, y = 0, 0
      for i=1,#coefs / 2 do
         local c = choose(ti - 1 + (t - t1) / (t2 - t1), i)
         x += coefs[2*i-1]*c
         y += coefs[2*i]*c
      end
      return x, y
   end

   function p_lerp_norm(path, t)
      -- a wrapper around 'p_lerp'.
      -- 'p_lerp_norm' expects 't' to be normalized
      -- such that 0 <= t <= 1. This is more in line with how
      -- interpolation functions usually work and is the better choice
      -- if you're planning on applying easing functions to the
      -- animation or if you want to use the same path but vary the
      -- durations.
      return p_lerp(path, t*path[1])
   end

   paths=parse"line:1:46,34:0,1|loop:1:-19,31,51,-13,-61,-32,14,82:0,0.3,0.5,0.8,1|curve:1.8:-6,35,66,-49,-134,91:0,0.6,1.2,1.8"
end
-- end exported file --------------------------------------------------------

-- example ------------------------------------------------------------------
ts1 = time()
ts2 = time()
--p = paths.curve
--p = paths.line
p = paths.loop

function _update()
   t1 = time() - ts1
   if t1 >= p[1] then -- p[1] is the animation duration as set in the editor
      ts1 = time()
   end
   x1,y1 = p_lerp(p, t1)
   x1 += 30
   y1 += 30

   t2 = time() - ts2
   if t2 >= 3 then -- make the animation take 3 seconds instead
      ts2 = time()
   end
   x2,y2 = p_lerp_norm(p, t2/3) -- normalize the time so it's between 0 and 1
   x2 += 60
   y2 += 60
end

function _draw()
   cls(13)
   rectfill(x1, y1, x1+7, y1+7, 7)
   rectfill(x2, y2, x2+7, y2+7, 7)
   print('timer 1:'..t1)
   print('timer 2:'..t2)
end
-- end example --------------------------------------------------------------

P#138411 2023-12-08 13:08

[ :: Read More :: ]

Code for combining simple animations together to create more complex animations using + and * operators.

Cart #animation_operators-0 | 2023-09-09 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
3

The example uses four simple animations—each one just a different coloured circle starting in a different corner and moving to the diagonally opposite corner—and combines them in different ways to create the final animation.

For simple animations A and B:

  • The + operators first runs A and, once it's finished, runs B.
  • The * operator runs both A and B at the same time.

As with normal addition and multiplication you can string together as many animations as you want and use parentheses to indicate a particular ordering.

To create animations use new_animation() and then give the animation object an init method. The init method should return a table containing a draw and an update function; update should return true when the animation has finished.

  a = new_animation()
  a.init = function()
     local offset = 0
     return {
        update=function()
           offset += 5
           if offset > 128 then
              return true
           end
        end,
        draw=function()
           circfill(offset, offset, 2, 7)
        end
     }
  end

The animation object then needs to be instantiated to use it.

  anim = a() -- or a.init(), same thing

  function _update()
     anim.update()
  end

  function _draw()
     cls()
     anim.draw()
  end

Having the init function means you can use the same animation multiple times since any internal values, like offset, will be re-initialized each time. You don't need to initialize the animation each time when combining animations, that's handled internally, you only need to initialize the combined animation as a whole.

  -- creates a compound animation which will play the simple animation
  -- three times in a row.
  anim = (a + a + a)() -- or (a + a + a).init(). Again, same thing.

Lua Code (indented 3 spaces)

do
   local ani_meta = {
      __add=function(self, other)
         local an = new_animation()
         an.init = function()
            local children = {self(), other()}
            local i = 1
            return {
               update=function()
                  local a = children[i]
                  if a.update() then
                     i += 1
                     if i > 2 then
                        return true
                     end
                  end
               end,
               draw=function()
                  local a = children[i]
                  a.draw()
               end
            }
         end
         return an
      end,
      __mul=function(self, other)
         local an = new_animation()
         an.init = function()
            local children = {self(), other()}
            return {
               update=function()
                  local done = true
                  for a in all(children) do
                     local d = a.update()
                     done = done and d
                  end
                  return done
               end,
               draw=function()
                  for a in all(children) do
                     a.draw()
                  end
               end
            }
         end
         return an
      end,
      __call=function(self)
         return self.init()
      end,
   }
   function new_animation()
      return setmetatable({}, ani_meta)
   end
end

Lua Code (indented 1 space)

do
 local ani_meta = {
  __add=function(self, other)
   local an = new_animation()
   an.init = function()
    local children = {self(), other()}
    local i = 1
    return {
     update=function()
      local a = children[i]
      if a.update() then
       i += 1
       if i > 2 then
        return true
       end
      end
     end,
     draw=function()
      local a = children[i]
      a.draw()
     end
    }
   end
   return an
  end,
  __mul=function(self, other)
   local an = new_animation()
   an.init = function()
    local children = {self(), other()}
    return {
     update=function()
      local done = true
      for a in all(children) do
       local d = a.update()
       done = done and d
      end
      return done
     end,
     draw=function()
      for a in all(children) do
       a.draw()
      end
     end
    }
   end
   return an
  end,
  __call=function(self)
   return self.init()
  end,
 }
 function new_animation()
  return setmetatable({}, ani_meta)
 end
end

P#134087 2023-09-09 11:10

[ :: Read More :: ]

If I view a post on the BBS and scroll down to the bottom to post a comment, there are two buttons: Submit, on the left and Preview, on the right.

If I click Preview I'm taken to a new screen. The first issue is that I've just clicked a Preview button but I'm not shown a preview just a larger text box. To actually see the preview I have to scroll down and click the Preview button again.

Which brings me to the second issue: The Preview button is now on the left and the submit button, now called Publish Changes, is on the right. I would expect the buttons to still be in the same place and the number of times I've almost posted half a comment because I almost clicked Publish when what I wanted was Preview are many.

Minor issues. Not the end of the world if it never gets fixed—at this point fixing it might cause as many or even more mistakes than not fixing it so maybe it's just not worth doing—but thought I'd point it out anyway.

P#133429 2023-08-23 12:18 ( Edited 2023-08-28 09:11)

[ :: Read More :: ]

This is just something I've been playing with in between working on other things.

Cart #walkinthewoods-0 | 2023-06-07 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
7

I didn't know about sprite stacking until this old post popped up a couple of months ago. Although that example rotates the sprites themselves it turns out you can get some interesting results even without it. I haven't bothered with collision detection so you can walk right through the trees and the rocks. Waking through the water was intentional though. I wanted it to look like actually walking in water.

The space is procedurally generated but persistent so it's the same every time the cart is run. And it's effectively infinite in all directions. Not actually infinite obviously but pretty darn big for no particularly good reason.

P#130640 2023-06-07 12:01

[ :: Read More :: ]

This cart uses devkit mode so you'll need a mouse and keyboard to use it.

Cart #drafting_table-4 | 2023-05-21 | Code ▽ | Embed ▽ | No License
5


Update:


New version updates the s-expression parser and the importer code. The old parser was incredibly slow; the new one is much faster. And the importer now properly handles larger images that don't compress as much which it handled incorrectly before.

This started off as a level editor for the Space Taxi Remake I'm working on. At some point I realized I was going a bit overboard and instead of doing the sensible thing and saying, "well that's good enough for my purposes I guess I'll stop now," I just kinda leaned into it.

Obviously any images you make with this are yours to do with as you please. If you use it and like it credit is, of course, appreciated but not required.

I've posted a new comment in the Space Taxi thread with a few very rough screens made with this if anyone wants to check that out.

Saving and Loading

Saving doesn't work from the BBS embedded player or on Education edition so you'll need to run the cart locally to do that. Files are saved as .p8l files. They're just plain text with a lisp-like syntax describing the points and shapes, etc. of the drawing.

By default files save to the desktop and are overwritten if they have the same name. You can change this behaviour by modifying the variables overwrite and save_to_desktop at the very top of the code (Tab 0.)

Loading is done via dragging the file into the program and that does work on the BBS player! This sample save file loads the drawing of the words "Drafting Table" as seen on the cart image. Just save it locally as a text file then drag it in to load.

You can load more than one file and it'll just keep adding in the objects from each. This is so you can have, for instance, a file with a bunch of different platform types and one with different obstacles, etc. and combine them without having to redraw them every time.

Exporting and Importing

To compress and export (to the clipboard) the image as a string of binary data press the button at the bottom right of the group in the center or press '8'.
[8x8]

To import the image into your own cart, paste the binary string into the cart and copy and uncomment the code from tab 0 of this cart. There are only three functions you need to know about:

store_image(dest, str)
   converts the binary data string 'str' to bytes and 
   stores them in memory beginning at memory address 'dest'.
   This data is still compressed and not the actual image.
   Returns the memory address immediately after the data 
   which you can use to store additional images or other data.

   next = store_image(0x8000, img1) -- where my img1 is a string exported by Drafting Table
   store_image(next, img2)          -- as is img2.

load_image_from_memory(dest, src)
   used in conjunction with 'store_image'. Reads the 
   compressed data from memory address 'src' and writes 
   the decompressed images data to memory address 'dest'. 
   This can then be drawn to the screen with 'memcpy'.

   load_image_from_memory(0x1000, 0x8000)
   memcpy(0x6000, 0x1000, 0x2000) -- draw the image to the screen

load_image_from_string(dest, str)
   you can instead decompress the image directly from 'str' 
   to memory address 'dest' without storing the compressed 
   data to memory.

   load_image_from_string(0x1000, img1)
   memcpy(0x6000, 0x1000, 0x2000)


The rotation tool in action and "Drafting Table" exported as collision demo "level geometry" for my Space Taxi remake.

Some caveats

Available drawing area

The UI at the bottom of Drafting Table and the UI at the bottom of Space Taxi are the same size leaving me exactly enough drawing space for a single level and that's all that gets exported. If anyone thinks that they'd find this useful it shouldn't be too hard to add a shortcut to hide the UI and allow editing and exporting the full screen. Unless/until someone asks though it will stay as it is because it suits my purpose.

Positive and negative rotations

When using numerical input for rotations I've gone with the convention in mathematics: positive angles rotate counterclockwise and negative angles rotate clockwise. Additionally, angles are given as a number between 0 and 1 as in Pico-8 itself. So 0.25 is a 90 degree rotation counterclockwise while -0.125 is a 45 degree rotation clockwise.

All objects rotate around their own center point even if multiple objects are selected at once unless grouped together with 'g' in which case all grouped objects rotate as a unit around their common center point.

Moving objects ignores snap

This is entirely because my sleep deprived brain was having trouble getting it to work. It'll be fixed eventually. For now the workaround is to just get it in approximately the right spot then use the arrow keys to place it precisely.

Polygons are a bit weird

Due to the way I'm drawing/filling/detecting collisions with the various shapes, the free polygon tool sometimes gives unexpected results like not filling the whole thing or, sometimes, making it completely un-selectable except via the 'select all' shortcut. This only happens when the polygon in not a convex polygon.


The "same" non-convex polygon in outline and fill mode.

So to make sure you're getting what you expect, stick to convex polygons (all interior angles less than 180 degrees) and for complex shapes, draw separate parts and group them together.


Same shape as above but constructed from two shapes grouped together.

P#129079 2023-04-30 13:24 ( Edited 2023-05-21 18:19)

[ :: Read More :: ]

The original Space Taxi divided up its 24 levels into three "shifts" of eight levels each corresponding to the easy, medium and hard levels. And it turns out eight levels is about what I'm able to fit on a single cart so I've decided to release the game by shift and then merge them all together into a single multi-cart game when I'm done. It's actually already multi-cart with one for displaying the menu and loading the level data into upper memory and the other for actually running the game.

The first one, Morning Shift, is not quite finished but it is fully playable. The first level is a copy of the first level from the original game as sort of an homage to it but the rest of the levels are original. And they could use some play testing. If anyone is willing I'd be happy to hear any feedback.

Cart #space_taxi-2 | 2023-05-29 | Code ▽ | Embed ▽ | No License
9

In particular I'm interested in hearing:

  1. Are the blinking platform markers helpful?
  2. Would they be more helpful if they blinked a different colour?
  3. Keeping in mind that these are the easy levels—with 16 more levels to come at some point in the future—how does the progression feel?
  4. Would you change the order of any of the levels?
  5. Was there anything that you particularly liked or disliked about the game?

Don't feel like you have to address all (or any) of those and it's by no means an exhaustive list so any other comments are also appreciated.

Things to do

  • Level art :: You'll see that I've spent more time on some of the levels than others. The layouts are, I think, pretty much set but I'm still working on making some of the levels more visually interesting.
  • SFX and Music :: The original had voice synthesis which I don't but I'd like to add some sound effects when passengers appear, get knocked over, etc. as well as other sound effects generally. And while the original didn't have music I'll probably add some.
  • Fix how money (aka: score) tips, etc. work
  • Add saved high scores
  • Add an actual win screen
  • Various tweaks (refuel faster, etc.)

Original Post Below

I don't know if anyone else remembers Space Taxi from the C64 era but I used to love playing it as a kid. I'm not sure why it popped into my mind a while back but I thought I'd take a shot at implementing a Pico-8 version.

This is a work in progress so it only has a few very boring levels for testing out the mechanics. I'm a bit too close to it and have gotten used to the controls so if anybody who isn't me felt like giving it a shot and letting me know how the controls feel I'd be grateful.

Cart #space_taxi-1 | 2023-04-11 | Code ▽ | Embed ▽ | No License
9

There will likely be a second screen under the "Controls" menu option which explains the UI but I haven't implemented it yet. So briefly:

  • The money indicator on the left is your "Earned Money" aka, your score.
  • Above that is your lives
  • The money indicator on the right is your "Tip." Deliver passengers faster, get a bigger tip.
  • The "clock" on the right is the level indicator. (1:00 is level 1, 12:00 is level 12, etc.)
  • The black box at bottom center is where passenger instructions are displayed (where to take them)
  • Above that is the fuel gauge. Fuel usage is tied to how long you're in the air not how much you use the thrusters and fuel resets between levels. The third (and last) sample level has a fuel platform to test out the refuelling mechanic but not all levels have fuel platforms.
  • The gray checked bar above the other UI elements is the landing indicator and flashes either green, yellow or red. Green for a soft landing, yellow for a harder landing and red when you crash. Your tip is reduced (well not yet but it will be) for "yellow" landings.

Once I've got the mechanics dialed in I'll probably throw together a quick level editor and start making some actual levels. Hopefully I've got room for a full 24. I've got a little under 3000 tokens left and about half my character space and I haven't tried to optimize it yet but I suspect I may end up using a second cart for the level data.

Thanks to anybody who tries it out!

P#128419 2023-04-10 19:50 ( Edited 2023-05-29 11:55)

[ :: Read More :: ]

This function creates simple extensible 'types' with a few additional (potentially) handy properties. If you find it useful, help yourself to it. Credit appreciated but not required.

  -- 52 tokens
  function new_type(names)
     local names = split(names, ' ')
     local meta = {}
     return setmetatable(
    meta,
    {
       __call=function(self, ...)
          local obj = {...}
          for i=1,#names do
         obj[names[i]] = obj[i]
          end
          return setmetatable(obj, {__index = meta})
       end
    }
     )
  end

You use it like this:

  -- a 2d point object
  point = new_type('x y') -- argument names separated by spaces

  p = point(1, 2)
  print(p.x) -- prints 1
  print(p.y) -- prints 2

As for the handy properties, the first is that the object's data is stored both by key and as an array. Which means you can access the properties using the dot notation, numerical indexes, or even unpacking the object, all of which can come in handy in different situations.

  p = point(1, 2)

  x, y = unpack(p) -- extract all the data in order

  -- all of these print 1
  print(p.x)
  print(p[1])
  print(x)

  -- and all of these print 2
  print(p.y)
  print(p[2])
  print(y)

The downside of this is that each object is storing the data twice and, therefore, requires twice as much space. So if you have a lot of active objects in your program at once you'll run out of memory about twice as fast. But you can always modify new_type() to only use one or the other of these methods instead of both if you prefer.

The second handy property is that functions created with new_type() aren't actually functions, they're objects which you can add methods to. Those methods are then available to every object of that 'type'. For instance:

  point = new_type('x y')
  point.add = function(a, b)
     return point(a.x + b.x, a.y + b.y)
  end

  p = point(1, 2)
  q = point(2, 3)
  r = p:add(q)

  print(r.x) -- prints 3
  print(r.y) -- prints 5

It even works if the methods are defined after you've already created the objects:

  point.dot_product = function(a, b)
     return a.x * b.x + a.y * b.y
  end

  print(p:dot_product(r)) -- prints 13
P#126877 2023-03-10 01:20

[ :: Read More :: ]

Not sure if this is actually a bug or expected behaviour. Seems like a bug to me. That said, don't know if it's a problem with PICO-8 or just with Lua in general.

Here's the problem. If I define a metatable using the __metatable property then the __tostring method doesn't get called automatically (by print for example) when expected.

m = {}
x = setmetatable(
   {},
   {
      __index=m,
      __metatable=m,
      __tostring=function(self)
     return 'blah blah blah'
      end
   }
)

print(x)
print(tostring(x))

Output:

[table]
blah blah blah

So, I can call the tostring() function explicitly but it's not called automatically by print as I would expect. If I leave out the __metatable property though it works as expected:

m = {}
x = setmetatable(
   {},
   {
      __index=m,
      __tostring=function(self)
     return 'blah blah blah'
      end
   }
)

print(x)
print(tostring(x))

Output:

blah blah blah
blah blah blah

Putting the __tostring function in m also doesn't work:

m = {
   __tostring=function(self)
      return 'blah blah blah'
   end
}
x = setmetatable(
   {},
   {
      __index=m,
      __metatable=m
   }
)

print(x)
print(tostring(x))

Output:

table: 0x30bb9cc
table: 0x30bb9cc

Nor does giving m its own metatable with a __tostring method:

m = setmetatable({}, {
   __tostring=function(self)
      return 'blah blah blah'
   end
})
x = setmetatable(
   {},
   {
      __index=m,
      __metatable=m
   }
)

print(x)
print(tostring(x))

Output:

[table]
table: 0x30bb9cc

The __metatable property doesn't seem to interfere with other metamethods. Haven't checked them all though. Example:

m = {}
x = setmetatable(
   {},
   {
      __index=m,
      __metatable=m,
      __add=function(a, b)
     return 1
      end,
      __tostring=function(self)
     return 'blah blah blah'
      end
   }
)

print(x)
print(x + x)

Output:

[table]
1

This isn't really a major problem, I'm mostly just messing around but it seems weird so might be worth taking a look at at some point.

P#126465 2023-03-01 12:55

[ :: Read More :: ]

[sfx]

I don't know if people are still having problems with the inline music player but here's a cart with the song just in case.

Cart #mibamawojo-0 | 2023-01-30 | Code ▽ | Embed ▽ | No License
2

I've made no attempt to optimize space and used pretty much all the sfx slots. Though some are duplicated just so if you happen to listen on the editor music/sfx tab it's kind of satisfying to watch. To me at least.

P#125018 2023-01-30 11:33

[ :: Read More :: ]

Just a little weekend "I need a break from my projects" project.

Cart #noise_march-0 | 2022-11-26 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
1

Uses the marching squares algorithm to determine tile placement at each step and 3 dimensional value noise to animate it.

P#121419 2022-11-26 17:50

[ :: Read More :: ]

This isn't really anything special, just a little quality of life improvement I've found handy and keep in a file of utility functions I use frequently.

Overloads sub() so it works on arrays as well as strings. I find it useful to be able to slice and dice arrays in the same way as strings and especially now that we can index strings with square bracket notation the difference between them isn't always important. I've got something with a length and I want a sub-section of it. Makes sense to use the same function for both, in my mind at least.

_sub = sub

function sub(lst, i, j)
   if type(lst) == 'string' then
      return _sub(lst, i, j)
   else
      local r = {}
      for n=i,j or #lst do
         add(r, lst[n])
      end
      return r
   end
end

Creates a new array without modifying the original. Doesn't handle negative indices because I've not really needed them so far but it would be easy enough to add.

Potentially a feature suggestion @zep? I don't mind writing the function myself but I'd also be happy not to. But maybe I'm the only one who finds it useful.

P#120538 2022-11-12 13:15

[ :: Read More :: ]

I'm working on a game using procedural generation and although the game is nowhere near done I thought the generation technique itself was interesting enough on its own to post about.

Rather than try to explain it myself I'll just send you to someone who already has, better than I likely could. BorisTheBrave: Graph Rewriting for Procedural Level Generation

Instead, I'll just share some of my initial results. The general idea is to generate the "big picture" details first and then successively refine them to build a fleshed out so the levels generated here are shown at 1/8 scale. Eventually every pixel in these maps will expand to be one tile at full scale with additional generation filling in the fine details along the way.

Cart #nisofunajo-0 | 2022-11-06 | Code ▽ | Embed ▽ | No License
2

The rules I'm using a pretty simple for now; they were mostly intended to help me shake out any bugs in the graph rewriting system itself. The actual rules I end up using will probably be somewhat more complex but even so I think I'm already getting some decent results.

The Cycle

The first thing I do is generate a cycle with a start (green square) and an end (red square.) The idea for each level is you go in, get whatever is waiting for you at the end—treasure, a boss fight, whatever—and then have to get back out. Having a cycle gives you multiple potential routes to accomplish that.

At the moment the cycles go one-way—although it's not shown—with one path leading toward the end and the other path leading from the end back to the start. But you could have two paths heading towards the start where one might be shorter but full of enemies and the other is longer but relatively safe, and all sorts of other variations.

Terrain

Next up is terrain. For this demo I just have two terrain types, stone and water. You can have both types on a single tile giving you wet stone. The rules are written so that stone and water will never touch directly but will always have wet stone in between. This also results in the occasional patch of wet stone bordered by either stone on both sides or water on both sides which helps to vary things up a bit more.

For the actual game I'm thinking I'll just have abstract terrain types: terrain-1, terrain-2, etc. Then each level will have a randomly chosen theme and that theme will give me a terrain palette which will assign actual terrain types. So you could have fire level and ice levels and so on.

Enemies

The enemy rules are fairly straight forward. There are four types:

  1. Large enemies (big X)
  2. Small enemies (small x)
  3. Swarms (clusters of small enemies that attack together)
  4. Guardians (in a white box with a border. When you come into their
    area everything closes off and you can't continue until you defeat
    them. Basically mini-bosses.)

The red ones will be visible as you approach and the yellow ones will be hidden and ambush you. You'll never get two large enemies right next to each other but any other combination of enemies is possible.

Obstacles

Obstacles show up on the map as locks and keys but they may not be actual locked doors requiring keys. They're just anything which requires you to do or obtain something in one part of the level in order to progress in another part. The "key" could be an actual key but it could also be a lever or a puzzle or a new ability. Maybe you need to drain water in one area to reveal a door in another. Whatever.

This, in particular, is an area where I think graph rewriting shines. Since at this stage the whole level is represented as a graph and the path from start to end and back to start is a directed sub-graph, it's relatively easy to ensure that keys never end up behind their associated lock. The lock and key are initially spawned on the same tile with keys optionally then being moved backwards along the path but never past the start.

At the moment there's a 50% chance of a key moving on each iteration of the rules so, on average, keys are found fairly close to their locks but I can tweak the probabilities to have them move either more or less.

Rooms

Rooms are added to empty areas near the main path. Rooms are either big or small and once placed one or more doors are added to the room connecting it to other rooms and/or the main path.

The dark gray bars connecting the rooms to the path are just to show roughly where doors are and how they connect up to the main path. There won't be literal corridors to every room. Either the rooms will be moved closer to the main path or the surrounding terrain will be expanded to the edge of the room.

At the moment the rooms are all just kind of plopped down on top of each other. Whether overlapping rooms merge into a bigger room or where exactly the walls between them will be will be figured out at a later stage.

Next steps

Next is to take this simple set of rules and and basic level layouts and turn them into full-fledged level maps to get the basic infrastructure in place to handle that process.

After that I'll probably take a break to work on actual game mechanics before really diving into the level generation rules.

P#120219 2022-11-06 19:31

[ :: Read More :: ]

A few days back @dw817 posted a thread about the number of possible combinations in 256 bytes in which they asked:

> Is there a way to calculate 256^256 ?

And knowing there are all sorts of tools and languages which can handle such huge numbers I cheekily posted the answer and caused a bit of a derail.

Anyway, I had some time on my hands so I figured I'd implement large number support for Pico-8.

Cart #jd_bignum-1 | 2022-11-05 | Code ▽ | Embed ▽ | No License
4

Edit: Fixed a bug in divide()

Is this likely to be of use to anybody? Probably not. Is it major overkill? You betcha! Though in fairness, at 706 tokens it came out smaller than I was expecting.

Features/Functions

  • bignum(n): Create a 'bignum' with value n which is just a regular Pico-8 number. Most operations require both numbers to be bignums. So bignum(16000)*bignum(1254) but not bignum(16000)*1254.

    bignum() is equivalent to bignum(0)

  • divide(bn): You can use the division and mod operators (/, %) on bignums if you only need one or the other. The divide function returns both the quotient and remainder at once.

    q, r = divide(bignum(3)^16, bignum(2)^15)

  • show(bn): Converts a bignum into its decimal representation as a string.
  • factorial(n): Calculates the factorial of n and returns the result as a bignum. Factorials get very big, very fast so the input to factorial is just a regular number.
  • + - / % ^: Arithmetic operators behave how you would expect. All require both arguments to be bignums with the exception of ^ where only the first argument is a bignum and the second is a regular number. Division is integer division only so bignum(3)/bignum(4)==bignum(0).
  • > >= <= < ==: Comparisons similarly require both arguments to be bignums.

Issues

  • The arithmetic operators are all fairly efficent but probably not optimally so.
  • I wasn't taking negative numbers into account at all so I make no promises about what will happen if you try to use them. Maybe it will work! (It probably won't work.)
  • show is extremely inefficient. I'm sure there are better ways to convert very long binary strings to decimal but I'm not familiar with them so this is what I've got. Large numbers (like 52!) may take a bit of time and very large numbers will crash the program as it runs out of memory. (bignum(256)^256 does this, sadly. It calculates the number fine but crashes when you try to convert it.)
P#120151 2022-11-05 13:35 ( Edited 2022-11-05 20:45)

[ :: Read More :: ]

I know a lot of people, myself included, usually write their pico~8 code a little off the cuff tinkering with it until it works. Which tends to be more fun in my experience. But it can also be incredibly frustrating if I'm working on sometime more complex where every time I change something I break something else. And in those cases planning out some formal tests helps me maintain my sanity and get the thing actually working much faster than I probably would otherwise. And since I'm working on something fairly complex at the moment, I took a bit of a detour and put together a little test framework and thought I'd make it available for anybody else who might find it useful.

The code is on github: https://github.com/jasondelaat/pico8-tools/tree/release/testo-8
Or you can just copy it here:

--------------------------------
-- testo-8: testing framework
-- copyright (c) 2022 jason delaat
-- mit license: https://github.com/jasondelaat/pico8-tools/blob/release/license
--------------------------------
do
   local all_tests = {}

   local function smt(t, mt)
      return setmetatable(t, {__index=mt})
   end

   local function shallow_copy(lst)
      local copy = {}
      for l in all(lst) do
         add(copy, l)
      end
      return copy
   end

   local function filter(f, lst)
      local results = {}
      for l in all(lst) do
         if f(l) then
            add(results, l)
         end
      end
      return results
   end

   local execute_meta = {
      execute=function(self)
         local result = self[4](self[3](self[2]()))
         if self._cleanup[1] then
            self._cleanup[1]()
         end
         return {
            result,
            self[1]..self.when_txt..self.result_txt
         }
      end
   }

   local when_result_meta
   local result_meta = {
      result=function(self, txt, fn)
         local t = shallow_copy(self)
         t.when_txt = self.when_txt
         t.result_txt = 'result '..txt..'\n'
         t._cleanup = self._cleanup
         add(t, fn)
         add(all_tests, smt(t, execute_meta))
         return smt(self, when_result_meta)
      end
   }

   local _cleanup
   local when_meta = {
      when=function(self, txt, fn)
         _cleanup = {}
         local t = shallow_copy(self)
         t.when_txt = 'when '..txt..'\n'
         t[3] = fn
         t._cleanup = _cleanup
         return smt(t, result_meta)
      end
   }

   when_result_meta = {
      when=when_meta.when,
      result=result_meta.result,
      cleanup=function(self, f)
         add(_cleanup, f)
         return self
      end
   }

   local given_meta = {
      given=function(self, txt, fn)
         local msg = self[1]..'given '..txt..'\n'
         return smt({msg, fn}, when_meta)
      end
   }
   function test(name)
      _cleanup = {}
      local t = smt({name..':\n', _cleanup=_cleanup}, given_meta)
      return t
   end

   local function run_tests()
      cls()
      cursor(0, 7)
      local results = {}
      for t in all(all_tests) do
         add(results, t:execute())
      end
      local failures =
         results and filter(function(r) return not r[1] end, results) or 0
      if #failures == 0 then
         print('all '..#all_tests..' tests passed!', 0, 0, 11)
      else
         for f in all(failures) do
            print(f[2])
         end
         rectfill(0, 0, 127, 6, 0)
         print(#failures..'/'..#all_tests..' tests failed:\n', 0, 0, 8)
         cursor(0, 127)
      end
   end

   function _init()
      run_tests()
   end
end
-- end testo-8 ------------------------------

And here's a cart with some simple functions containing errors and a few tests, most of which fail just to give you an idea of how it works.

Cart #testo8_demo-0 | 2022-10-23 | Code ▽ | Embed ▽ | No License
1

Testo-8

The framework is pretty simple. It's just a single function test which returns an object exposing methods— given, when, result and cleanup —for defining the test.

Testo-8 defines an _init function which automatically runs the tests. Just #include testo-8.lua, write tests and run the cart. If you've defined your own _init you'll probably need to comment it out to get the tests to run.

A very simple test might look something like this:

test('some simple addition')
    :given('the number one', function() return 1 end)
    :when('adding 2', function(n) return n+2 end)
    :result('should equal 3', function(r) return r == 3 end)

The methods given, when and result —which I'll call clauses—all take a string as their first argument and a function as their second, while test takes a string argument only. The strings are used to build the output message if the test fails.

The function arguments taken by the other methods serve different purposes:

  • given should return the object(s) being tested. (1 in the example)
  • when takes the object(s) being tested as input and does something with it returning the result(s) (add 2)
  • result takes the result(s) and should return a boolean, true if the test passes and false if it fails. (does 1+2 == 3?)

Each test has exactly one given clause below which will be one or more when clauses. Each when clause contains one or more result clauses and can optionally be ended with a cleanup clause. More on that later. So an actual test might look something like this:

-- the ...'s are just a placeholders for some appropriate function
test('some test')
    :given('an object to test', ...)
    :when('1st when', ...)
    :result('result 1', ...)
    :result('result 2', ...)
    :result('result 3', ...)

    :when('2nd when', ...)
    :result('result 4', ...)
    :result('result 5', ...)
    :result('result 6', ...)
    :cleanup(...)

    :when('3rd when', ...)
    :result('result 7', ...)
    :result('result 8', ...)

The number of result clauses is the actual number of tests that will be run so the above example would be eight tests. Each result clause is executed as follows: The given clause is executed to generate the object(s) to test. The test object(s) are passed to the when clause which appears above the result and finally the results are passed to the result clause which determines whether the test passes or fails.

So in the above example the given clause will run eight times, once for every result. The first when clause will be called three times and so will the second while the third when clause will only be called twice.

cleanup takes a single function as its argument and is used to clean up after a test if, for instance, the test modifies some global state which needs to be reset in-between tests. The cleanup clause is optional but if it exists will be called after each result clause inside the same when. The cleanup in the above example would therefore be called three times, once after each of results 4, 5 and 6.

For anyone interested in seeing actual tests, here are some I wrote for a simple s-expression parser.

function test_given(s)
   return s, function() return s end
end

function len(n)
   return function(lst)
      return #lst == n
   end
end

function index_eq(n, s)
   return function(lst)
      return lst[n] == s
   end
end

function is_table(r)
   return type(r) == 'table'
end

function is_nil(r)
   return r == nil
end

test('empty s-exp')
   :given(test_given('()'))
   :when('parsed', parse)
   :result('should be a table', is_table)
   :result('should have 0 elements', len(0))

test('single element s-exp')
   :given(test_given('(atom)'))
   :when('parsed', parse)
   :result('should have 1 element', len(1))
   :result('element should be atom', index_eq(1, 'atom'))

test('multi-element s-exp')
   :given(test_given('(a b c d e)'))
   :when('parsed', parse)
   :result('should have 5 elements', len(5))
   :result('1st should be a', index_eq(1, 'a'))
   :result('2nd should be b', index_eq(2, 'b'))
   :result('3rd should be c', index_eq(3, 'c'))
   :result('4th should be d', index_eq(4, 'd'))
   :result('5th should be e', index_eq(5, 'e'))

test('nested s-exp')
   :given(test_given('((atom))'))
   :when('parsed', parse)
   :result('should have 1 element', len(1))
   :result('element should be a table', function(r) return is_table(r[1]) end)
   :result('element should have length 1', function(r) return #r[1] == 1 end)
   :result('nested element should be atom',
           function(r) return r[1][1] == 'atom' end
          )

test('multi-element nested s-exp')
   :given(test_given('((a b) (c d) (e f))'))
   :when('parsed', parse)
   :result('should have 3 elements', len(3))
   :result('each element should be length 2',
           function(r)
              for i in all(r) do
                 if #i != 2 then
                    return false
                 end
              end
              return true
           end
          )
   :result('1st contains a and b',
           function(r)
              return r[1][1] == 'a' and r[1][2] == 'b'
           end
          )
   :result('2nd contains c and d',
           function(r)
              return r[2][1] == 'c' and r[2][2] == 'd'
           end
          )
   :result('3rd contains e and f',
           function(r)
              return r[3][1] == 'e' and r[3][2] == 'f'
           end
          )

test('multiply nested s-exp')
   :given(test_given('((a (b c)))'))
   :when('parsed', parse)
   :result('should have 1 element', len(1))
   :result('element length 2',
           function(r)
              return #r[1] == 2
           end
          )
   :result('element [1][1] == a',
           function(r)
              return r[1][1] == 'a'
           end
          )
   :result('element [1][2] is table',
           function(r)
              return is_table(r[1][2])
           end
          )
   :result('element #[1][2] == 2',
           function(r)
              return #r[1][2] == 2
           end
          )
   :result('element [1][2][1] == b',
           function(r)
              return r[1][2][1] == 'b'
           end
          )
   :result('element [1][2][2] == c',
           function(r)
              return r[1][2][2] == 'c'
           end
          )

test('empty string fails to parse')
   :given(test_given(''))
   :when('parsed', parse)
   :result('should be nil', is_nil)

test('unclosed parens fails to parse')
   :given(test_given('((a b) (c)'))
   :when('parsed', parse)
   :result('should be nil', is_nil)

test('too many closed parens fails to parse')
   :given(test_given('((a b) (c)))'))
   :when('parsed', parse)
   :result('should be nil', is_nil)

test('parsing with newlines')
   :given(test_given('(a \n    (b c))'))
   :when('parsed', parse)
   :result('should have 2 elements', len(2))
   :result('1st element should be a', index_eq(1, 'a'))
   :result('r[2][1] == b', function(r) return r[2][1] == 'b' end)
   :result('r[2][2] == c', function(r) return r[2][2] == 'c' end)

Modules and Testing

I rely heavily on an external editor and spreading all my code around a bunch of files. If that's not how you work this may not be super practical. But here's a quick run-down of how I (currently) work on a project.

Even though Pico-8 Lua doesn't technically have modules I generally try to write things in a modular way and #include with the help of do...end gives me something module-like.

A vastly oversimplified example would be something like this:

-- player.lua
local pos = {x=64, y=64}
local s = 1

local function move(var, dist)
    return function()
        pos[var] += dist
    end
end

move_left = move('x', -2)
move_right = move('x', 2)
move_up = move('y', -2)
move_down = move('y', 2)

function draw_player()
    spr(s, pos.x, pos.y)
end

Which I include inside of a do...end block like so:

Writing modules like this doesn't really cost much extra because:

  1. These are all functions I'd write anyway
  2. The local keyword doesn't use any tokens
  3. The do...end costs just a single token
  4. The added encapsulation given module local variables means I can't accidentally mess of things like the player position from other parts of my code because pos doesn't exist outside of the module.

Importantly, I don't put the surrounding do...end in the module file itself. Because when it come to writing the actual tests, I'll put those in another separate file and then include it inside the same do...end block as before.

This makes the tests part of the same module so they can access and test all the local data and functions. Once I'm sure everything is working properly I can just comment out the #include for the test file and free up all those tokens.

Issues

  1. Since Lua doesn't have exception handling capabilities like try...catch or similar, I'm not able to intercept certain errors and report them as test failures. So things like attempting to index a nil value, etc. will still cause the cart to crash and you'll have to fix those problems before the test will run.
  2. The above can also lead to occasionally cryptic error messages saying that there's an error with testo-8 itself. This is certainly possible but usually it means you've passed nil, or something else, where testo-8 is expecting a function. If you're frequently commenting out parts of your code make sure you haven't commented out a function which you're using in a test.
P#119475 2022-10-23 14:00 ( Edited 2022-10-25 18:45)

[ :: Read More :: ]

Cart #jd_dice_wip-0 | 2022-10-16 | Code ▽ | Embed ▽ | No License

So I've started working on this. It's still very early and I've only got some of the UI elements and the very beginnings of procedural generation in place but I think there's enough to make it worth sharing.

Controls

While moving around:

  • 🅾️ to switch to the ability selector. (Coloured boxed bottom-middle)

In ability selector:

  • 🅾️ to select highlighted item
  • ❎ to cancel

Choosing 'END TURN' pulls up a 'Dice Selector' menu which doesn't do anything yet.
Press either ❎ or 🅾️ to dismiss it.

Notes

  • Actual movement in the game will be tile based but I wanted people to be able to zoom around the big empty levels fairly quickly for now because it's not like there's much to see.
  • At the moment the levels are just a big loop of straight, boring grey hallways surrounded by blackness but eventually room will be able to expand into the available empty spaces, there will be locks, keys, enemies, puzzles, etc.
  • The mini-map may or may not be in the actual game but I wanted to give an overall idea of the shape of the generated levels.
  • The green square in-level is the level entrance and the red one the level exit. Nothing happens yet when you get to them.
  • The stats on the bottom left are hard-coded and don't do/mean anything yet.

I'm trying my hand at procedural generation using graph re-writing. There's a fairly straight-forward summary of the process here for anyone interested: https://www.boristhebrave.com/2021/04/02/graph-rewriting/

This version isn't actually using re-writing yet which is why all the interesting stuff is missing from the level. It's just a graph with a basic loop. I've got the system written and—I think—working as expected so next steps are to start testing some rules for slightly more interesting levels to shake out all the bugs and then take it from there. Oh yeah, and all the actual game mechanics. Those will probably help too.

P#119177 2022-10-16 15:21

[ :: Read More :: ]

So maybe this is old news to everybody but me but I just discovered it by accident. Turns out you can define local functions! It makes sense but it somehow never occurred to me to try before.

Here's a quick example:

do
   local function a()
      print('in a')
   end

   function b()
      print('in b: calling a')
      a()
   end

   print('in local scope')
   print(a)
   print(b)
   a()
   b()
   print('leaving local scope\n\n')
end

print('in global scope')
print(a)
print(b)
b()

Output:

in local scope
[function]
[function]
in a
in b: calling a
in a
leaving local scope

in global scope
[nil]
[function]
in b: calling a
in a

That's it. Just thought it was interesting.

P#118372 2022-10-02 18:10

[ :: Read More :: ]

I had a bit of time to tinker yesterday. I'm not sure what made me think of the old Infocom text adventures but here we are.

If you're familiar with Text Adventures (or Interactive Fiction) you may know that one of—maybe the—most popular tool for creating them has been the Inform language. The current version is Inform 7 but waaaaay back when I was first learning, it is was Inform 6.

Anyway, I decided to throw together a quick little IF authoring API loosely based on Inform 6. It is by no means complete or particularly advanced. Basically, I followed the first tutorial for the game Heidi from The Inform Beginner's Guide and implemented just enough to make it work. But work it does! Mostly. I think...

The command parser is extremely simple so don't expect too much from it. All commands should be in the form:

verb [noun] [noun]

Where the nouns are optional depending on what you're trying to do. So east or e to walk to the east; take object to pick up an object; and put object target to put an object on/in something else. There are enough verbs, including synonyms, to complete this game and the API even lets you add new ones but don't try to be too creative with your commands. It was mostly just something to do on a Saturday afternoon so I may or may not develop it further.

Cart #jd_heidi_if-3 | 2022-09-28 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
8

The API clocks in at 926 tokens while the example game itself is only 173.

For simplicity's sake here's the code for just the game itself if you're interested in how it works. You can find the original in Appendix B of The Inform Beginner's Guide linked above if you want to see how the two versions compare.

-->8
-- example game: heidi
-- adapted from:
-- 'the inform beginner's guide'
-- https://ifarchive.org/if-archive/infocom/compilers/inform6/manuals/IBG.pdf
--------------------------------

-- defining the title screen
story(
   'heidi',
   "a simple text adventure written\nby roger firth and sonja\nkesserich.\n\nadapted to pico-8 from\nthe inform beginner's guide.\nby jason delaat.\n\n\npress enter to begin.")

-- rooms and objects
before_cottage = object('in front of a cottage')
    description("you stand outside a cottage.\nthe forest stretches east.\n")
    has(light)

forest = object('deep in the forest')
    description("through the dense foliage you\nglimpse a building to the west.\na track heads to the northeast.")
    has(light)

bird = object('baby bird', forest)
    description("too young to fly, the nestling\ntweets helplessly.")
    name('baby', 'bird', 'nestling')

clearing = object('a forest clearing')
    description("a tall sycamore stands in the\nmiddle of this clearing. the\npath winds southwest through the\ntrees.")
    has(light)

nest = object("bird's nest", clearing)
    description("the nest is carefully woven of \ntwigs and moss.\n ")
    name("bird's", 'nest', 'twigs', 'moss')
    has(container|open)

tree = object('tall sycamore tree', clearing)
    description("standing proud in the middle of \n the clearing, the stout tree \n looks easy to climb.\n ")
    name('tall', 'sycamore', 'tree', 'stout', 'proud')
    has(scenery)

top_of_tree = object('at the top of the tree')
    description("you cling precariously to the \ntrunk.")
    has(light)
    each_turn(function(_ENV)
          if contains(branch.contents, nest) then
             print('you win!')
             stop()
          end
    end)

branch = object('wide firm bough', top_of_tree)
    description("it's flat enough to support a \nsmall object.\n ")
    name('wide', 'firm', 'flat', 'bough', 'branch')
    has(static|supporter)

-- connecting the rooms
before_cottage
   :e_to(forest)

forest
   :w_to(before_cottage)
   :ne_to(clearing)

clearing
   :sw_to(forest)
   :u_to(top_of_tree)

top_of_tree
   :d_to(clearing)

-- initialization
function _init()
   location(before_cottage)
   max_carried = 1
end

Walkthrough


This example game is very short with only four rooms and a handful of objects. The goal is to return the baby bird to its nest in the tree. You can only carry one object at a time—as defined by the max_carried variable in the _init() function—but an object inside of something else counts as a single object.

in front of a cottage
--------------------------------
you stand outside a cottage.
the forest stretches east.

> east

deep in the forest
--------------------------------
through the dense foliage you
glimpse a building to the west.
a track heads to the northeast.

you see a baby bird.

> take bird

you take the baby bird.

> ne

a forest clearing
--------------------------------
a tall sycamore stands in the
middle of this clearing. the
path winds southwest through the
trees.

you see a bird's nest.

> put bird nest

you put the baby bird 
in the bird's nest.

> take nest

you take the bird's nest.

> climb

at the top of the tree
--------------------------------
you cling precariously to the 
trunk.

you see a wide firm bough.

> put nest bough

you win!

Some of the words have multiple aliases. Instead of 'bough' you could type 'put nest branch' and that would also work.

P#117587 2022-09-18 10:44 ( Edited 2022-09-28 09:38)

View Older Posts