Every so often I’ve been experimenting with Synchronet BBS’s Javascript capabilities, as I try to figure out how to make a BBS door game with my daughter.
So far we’ve learned how to create a new character profile, save it in Synchronet’s JSON datastore, and retrieve it. We’ve written a very simple LoRD-style combat routine.
Then we started playing with ANSI graphics. We learned how to use frame.js to put stuff onto the screen. Then we learned how to use sprite.js to move a character around the screen. And, with help from Synchronet js gurus, I figured out how to mask the sprite so the background could show through.
More recently I wondered about going beyond the typical 80×25 dimensions. Many terminal programs are capable of increasing the console’s dimensions, so I mocked up an oversized 132×60 game interface:
Scrolling the background
My two latest experiments involved scrolling the background. Frame.js has a built-in method for scrolling data in a frame, but I wanted to wrap the data so that the background would keep scrolling indefinitely. I ended up writing a new circular scrolling method for frame.js. (Find the code at the bottom of this blog post)
Next I needed art. I scoured Google looking for pixel art examples of characters and forests. Then I fired up PabloDraw and started drawing. Honestly, I surprised myself with the final result.
Beyond scrolling the background, I also needed to animate the character to make it look like she was walking. I found a helpful tutorial, and adapted the sprite to have a two-frame walk.
As I experimented, I realized I needed to fix my sprite in place. The scrolling background behind her would create the illusion of walking. For this reason I am not using sprite.js’s getcmd(), a great routine which moves the sprite in response to keyboard commands. Instead I am manually changing the sprite’s bearing and position properties to create the two-frame walking animation.
Here’s the result of that effort. Remember, this first effort has a single background forest and a single foreground sprite:
Parallax scrolling
I shared this video with different BBS enthusiasts. Since I now knew how to scroll the background continuously, and I knew how to mask frames to allow transparency, I began to wonder about parallax scrolling. Obviously it could be done, but would the low resolution and chunkiness of ANSI text art keep the effect from working? Some folks on Facebook and Twitter encouraged me to try it.
I ended up separating the background into three layers. The top layer consisted of big trees and foliage. The middle layer consisted of lighter trees and a light green canopy. The last layer consisted of light gray and very faint, thin trees.
I realized that for the effect to be most successful, I would need to keep the color schemes of each layer distinct. The top layer employed the darkest colors; the bottom, the lightest. Given the limited 16-color ANSI palette, I made heavy use of shaded blocks to create the illusion of lighter and darker tones.
I used magenta as my mask color. A little function loops over the frame data and changes any magenta solid block into undefined
, making it transparent. It’s the same idea as filming movie actors against a “green screen” which allows them to be easily masked and placed in front of CGI backgrounds.
Now all I had to do was set up each background layer as a frame in the code, mask each one, and set up different scrolling values so that the layers would pass by at different speeds as the player moves left or right.
The result of my labor can be seen in the animated GIF at the top of this blog post.
Next steps
In any case, I will probably add some code to count the steps the sprite takes. In this way, I can programmatically send in bad guys or surprises at regular intervals of steps.
I also plan to play around with using a parallax ratio, similar to what I saw in this tutorial, instead of a hard number. I’m thinking I could then compare the ratio to the step interval to see if the background layer should scroll or not.
After I shared the parallax scrolling video, someone asked: “So you got the trailer done, when’s the movie coming out?”
Good question. As I have explained in previous blog posts, “Jewel Mountain” is sort of a moving target. My daughter has a lot of ideas, but we still haven’t truly nailed down how this thing will work as a game. But after all these experiments, I think I’m stumbling into a direction. Who knows if we’ll ever get it finished, though.
Code
Here are some gists I posted on GitHub. If you are a Synchronet sysop, feel free to incorporate any of this into your own projects. If you come up with new riffs on these ideas, please let me know!
.scrollCircular(x,y) method for frame.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
this.scrollCircular = function(x,y) { | |
var update = false; | |
if (typeof y == "number" && y > 0 && settings.v_scroll) { | |
for (var i=0; i<y; i++) { | |
var rowToMove = properties.data.shift(); | |
properties.data.push(rowToMove); | |
update = true; | |
} | |
} | |
else if (typeof y == "number" && y < 0 && settings.v_scroll) { | |
for (var i=0; i<Math.abs(y); i++) { | |
var rowToMove = properties.data.pop(); | |
properties.data.unshift(rowToMove); | |
update = true; | |
} | |
} | |
else if (typeof x == "number" && x > 0 && settings.h_scroll) { | |
for (var i=0; i<x; i++) { | |
for ( yl = 0; yl < properties.data.length; yl++) { | |
var cellToMove = properties.data[yl].shift(); | |
properties.data[yl].push(cellToMove); | |
} | |
update = true; | |
} | |
} | |
else if (typeof x == "number" && x < 0 && settings.h_scroll) { | |
for (var i=0; i<Math.abs(x); i++) { | |
for ( yl = 0; yl < properties.data.length; yl++) { | |
var cellToMove = properties.data[yl].pop(); | |
properties.data[yl].unshift(cellToMove); | |
} | |
update = true; | |
} | |
} | |
if (update) { | |
this.refresh(); | |
} | |
return update; | |
} |
maskFrame(frame,char,attr) function
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function maskFrame(theFrame,maskChar,maskAttr) { | |
var x, y, xl, yl; | |
xl = theFrame.data.length; | |
for (x=0; x<xl; x++) { | |
yl = theFrame.data[x].length; | |
for (y=0; y<yl; y++) { | |
var theChar = theFrame.data[x][y]; | |
if (theChar.ch == maskChar && theChar.attr == maskAttr) { | |
theFrame.data[x][y].ch = undefined; | |
theFrame.data[x][y].attr = undefined; | |
} | |
} | |
} | |
} |
Sample scrolling routine
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function gamePlay() { | |
player.sprite = new Sprite.Profile("girl-walking", bgFrame, 18, 9, 'e', 'stand'); | |
// Mask the sprite. 219 = solid block. 2 = green | |
maskFrame( player.sprite.frame, ascii(219), 2 ); | |
player.sprite.frame.draw(); | |
var userInput = ''; | |
while( ascii(userInput) != 13 ) { | |
userInput = console.getkey(K_UPPER | K_NOCRLF); | |
// User has pushed a key, but don't anything | |
// unless the sprite is allowed to move. | |
if ( player.sprite.canMove() ) { | |
if ( userInput == KEY_LEFT ) { | |
if (player.sprite.bearing != 'w') { | |
player.sprite.turnTo('w'); | |
} | |
bgFrameBot.scrollCircular(-1,0); | |
bgFrameMid.scrollCircular(-2,0); | |
bgFrameTop.scrollCircular(-3,0); | |
} | |
else if ( userInput == KEY_RIGHT ) { | |
if (player.sprite.bearing != 'e') { | |
player.sprite.turnTo('e'); | |
} | |
bgFrameBot.scrollCircular(1,0); | |
bgFrameMid.scrollCircular(2,0); | |
bgFrameTop.scrollCircular(3,0); | |
} | |
if ( userInput == KEY_LEFT || KEY_RIGHT ) { | |
// update lastMove attribute manually | |
// so I can use sprite.canMove() | |
player.sprite.lastMove = system.timer; | |
if ( player.sprite.position == 'walk' ) { | |
player.sprite.position = 'stand'; | |
player.sprite.frame.draw(); | |
Sprite.cycle(); | |
} | |
else if ( player.sprite.position == 'stand' ) { | |
player.sprite.position = 'walk'; | |
player.sprite.frame.draw(); | |
Sprite.cycle(); | |
} | |
} | |
Sprite.cycle(); | |
} // if player.sprite.canMove | |
} // end while | |
} // gamePlay() | |
Share your thoughts!