Wobblepaint started as a secret cartridge in my 2019 Advent Calendar entry, but I think it's time for a proper release! This version has some extra controls and nicer, less crinkly wobble.
Instructions
Your brush has a size, colour, pattern and shape that can be adjusted separately. There are 4 presets you can select and modify using keyboard shortcuts, or by clicking and dragging the top menu bar down to reveal a palette of attributes.
Instructions
CTRL-Z, CTRL-Y (or S,F) to undo/redo
CTRL-C, CTRL-V to copy and paste between doodles
W,R to switch between doodles (or use the menu buttons)
TAB to toggle menu
Mouse wheel (or e,d) to change brush size
RMB to pick up a colour
RMB in menu colour palette to select secondary colour (used for patterns)
LMB+RMB in menu colour palette to set the background colour
To save all doodles, use the cartridge icon button in the pull-down menu.
Wobblepaint saves data to itself. To start a new wobble cart, type LOAD #WOBBLEPAINT from inside PICO-8 and then save it as something. The data storage is reasonably efficient so you can get around 20~100 doodles to a cart depending on complexity.
To save a gif to desktop, use the gif button to record a second of looping wobble. If you want to record multiple doodles (e.g. for an animation or story), press tab to hide menu, CTRL-8 to start a gif, W,R to flip through the doodles, and then CTRL-9 to save the gif.
Gamepad controls
Turn off the devkit input in the options menu ("turn off mouse") and use a gamepad:
LRUD to move the cursor
[X] to paint
[O] + L/R to undo/redo
[O] + U/D to adjust brush size
In the menu, [X] and [O] behave the same as LMB,RMB
Using Wobblepaint doodles in your cartridges
CTRL-C copies doodles in a text format that can be pasted into code (or bbs posts)
Paste the code from tab 5 into your cartridge to load and draw them:
wobdat="1f00514302d06ee1179c8d34a74033b359e834319ba6504fa4690ade340000" str_to_mem(wobdat, 0x4300) mywob = wob_load(0x4300) function _draw() cls(mywob.back_col) wob_draw(mywob) end |
Or alternatively, copy the binary data straight out of the spritesheet and use load_library (tab 2) to load all of the doodles into a table.
Changes
v1.5: fixed uneven frame times when recording gif and increased length to 2 seconds (was 1)
+second palette 👀
BTW I had no idea CTRL+key and TAB had their own ORD values. Is there more information about that somewhere? Maybe a list? :)
I wasn't able to record a gif via BBS, it says "saved on desktop" but the file doesn't show up anywhere (I'm using Windows 10)...
Oh hey @machi ! I think you were on sheezyart ?? Not sure. I know we both share a mutual friend (shaddowkarate/newrem) though. I was darkscythe.
Great drawing!
@BoneVolt same here. The gif showed up on the left side of the bbs page, and I had to right click and "Save image as..." to download it. Maybe the save to desktop only works if you run the cart in standalone pico8.
@Lambdanaut
I never heard of Sheezyart, must be someone else, Im afraid :v
i made a little dandelion...
and a fatty bee, why not :3
and just to say, im here cuz of a brazilian youtuber called Goularte, and i loved this site!
Id just like to say that zep, youre such a wonderfull guy. The BBS has helped me so much, its just a great place thriving with creativity and amazing people, a place that you created alongside the community. I just want to say, thank you zep, we all love you! <3
Wow! I really enjoy reading Zep's carts source code because it teaches me some new tricks. But! This one has put me down... I can't figure out how the editor save the states and undo/redo them.
Could someone explain the basics about data compression and everything subject related and implemented in this cart?
@gcuellar The function doing the parsing is wob_load. Inside there, getval is used to extract numbers from memory, bit by bit. It does so by calling peek, which reads Pico-8 internal memory and returns it, but byte by byte. So sometimes the numbers read by getval require several bytes read (for example a number can take the last two bits from 1 byte and then the first 3 bits of the next byte).
Back into wob_load, the main thing it does is using getval to fill up a table called scn.
The first thing it does in order to do this is read the first 16 bits, which indicate the size of the scene in bits (variable dat_len). Then it reads the scene background color (4 bits) and stores it in scn.background_color.
Then it reads a bunch of "curves" using a loop which exists using the aforementioned dat_len var. Each curve has a bunch of properties, encoded as numbers (color, size, shape, etc). Some curves have "segments", which require an extra loop.
Curves are stored in the array part of scn, and segments are stored in the array part of each curve. So what you end up at the end is a scn table which looks like this:
scn = { background_color = 16, [1] = { -- first curve col = 15, shape = 3, [1] = { ... } -- first segment [2] = { ... } -- second segment },... } |
With this in mind, you can probably imagine how to undo works: it takes the last curve from scn, and puts it on another table called undo_stack.
"Redo" does the opposite - move the curve from undo_stack back to scn.
To load from a string, you first put that string into memory with this game's str_to_mem, and then read the memory.
The other interesting function is wob_draw, which is able to read the curves of a scn table and perform the relevant pico-8's functions. There is a table called funcs which is used to select how to draw every "shape", depending on the "shape number" of each curve. For example, shape number "0" (the first one in funcs is a circle). The functions read their parameters from the scn table. Each "curve" and "segment" end up calling the relevant pico-8 function or functions.
wob_draw is also in charge of doing the "wobbly effect" by altering the dots positions by random amounts. Picking the amounts involves doing something tricky with magic numbers, in the nrnd function.
With regards to "data compression"... I would call it "efficiency". Everything is stored as numbers, but they are densely packed to their size, with no wasted space. For example: consider the background color. In pico-8 there are only 16 colors. If you wanted the background color to be number 12, and stored that in plain text, you would need the character "1" followed by "2". That's 2 bytes/16 bits total. But by handling stuff in binary you can dedicate exactly the 4 bits you need for that, which "saves" 12 bits.
I hope it helps!
.@kikito Kudos for you, mate. Your explanation was very useful.
Thanks!
Having an absolute blast with this cart. Thanks, zep!
Maybe a long shot, but perhaps someone with knowledge of the GIF format can help figure out a problem I've found. I haven't figured out the exact circumstances that lead to it, but in this exported GIF, some colors are dropped/replaced when attached to a message in iMessage on iOS. See the attached example. I've sent other exports to friends and colors have been fine, but in this export the colors are consistently dropped. All the colors are rendered correctly on macOS in Preview, just not inline in the actual iMessage thread.
Any clues?
Okay, so after a lot of learning about and experimenting with the GIF file format, I found a partial fix and a workaround. The nature of the fixes provide some clues into what's going on which led me to a further line of investigation related to the macOS Core Graphics framework.
Partial fix
Removing the global color table and adding a local color table to every image (based on the global table) produces a file that works with Messages on macOS but, I interestingly, NOT on iOS. Here's one way I came up with to do it using Go:
package main import ( "image/gif" "os" ) // usage: go run global2local.go input.gif output.gif func main() { inputName := os.Args[1] outputName := os.Args[2] image, err := load(inputName) if err != nil { panic(err) } // Remove the global color table, coercing the encoder into // writing a local table for each image. image.Config.ColorModel = nil err = save(image, outputName) if err != nil { panic(err) } } func load(name string) (*gif.GIF, error) { file, err := os.Open(name) if err != nil { return nil, err } defer file.Close() return gif.DecodeAll(file) } func save(image *gif.GIF, name string) error { file, err := os.Create(name) if err != nil { return err } defer file.Close() return gif.EncodeAll(file, image) } |
Lossy workaround
Resizing the output down 50% with gifsicle produces a working file:
gifsicle --resize 128x128 -o output.gif input.gif |
Further research
Discovering the problem was related to the global color table led me to a Stack Overflow question which describes very similar symptoms indicating a possible weird interaction with global color tables and Core Graphics in certain circumstances. I have no experience with Core Graphics and so haven't investigated further.
Conclusions
Wobblepaint delegates GIF rendering to PICO-8, so any change to the encoding would need to happen there. For reference, replacing the global color table with local tables in my example file adds 5,509 bytes. I'm not an image processing/GIF expert by any stretch so other than output file size, I'm not clear what implications there might be to changing the GIF encoding in PICO-8 to stop using global color tables. I'm not sure whether the optimization global tables represent are considered important in the context of PICO-8.
It's also not sure whether there's some acknowledged bug in macOS/iOS that will ever be fixed.
I need to do more experimentation to understand why the local color table rewriting fix doesn't seem to be effective only on iOS.
Anyway, hope someone finds this info useful. Thanks again, zep!
Color pad button doesnt seem to be working :\
still able to make some things tho...
Took me quite long to realize there are other tools lol
My first time using this
I drew Shellos from memory, I think it turned out pretty good
I was just testing this and made this just to see what it looked like lol
figured out how to change colors, made Mario to see how it would look with a black outline.
I'll occasionally return to see the artworks. I'm looking for a specific thing.
A thing wobdat="ba00d15d021875854da5e9bd6b77ef546a4d4d49ddab6953f5eabeb665cf00a08807a21b5e78c381fb754f5ddbf75635dd7fd31490de6c9afd4f15f6bfe99f665f60b3ffa7dfb490fa679d665b78fbd934dd69617f9f00df7481df3f4d372d69ffdf6830cfbfc04ed31fe0ff07002bb73c0240c20a9ade2e2eb601c069163c00585540872910e7240140b453a0232791079f06200e1ebe3f00a8000bb9090056c0625fc06b0300a1b080240723ce00a0f90cee5b747a0efc0100"
[Please log in to post a comment]