-
Notifications
You must be signed in to change notification settings - Fork 1
Tutorial
First, make sure you have git and npm installed. You can download the repository by issuing the following commands.
git clone git@github.com:whatgoodisaroad/cablejs.git
cd cablejs
Next, setup the dependencies for examples with these commands. The examples depend on client-side libraries such as jQuery and d3.js, and bower will prepare all of them so that the examples can work.
npm install
cd examples
bower install
Now that cable is downloaded an ready, we can create an HTML document that loads the cable script.
<html>
<head>
<title>Cable</title>
</head>
<body>
<script src="path/to/cable.min.js"></script>
<script>
Cable.define({
// Node definitions go here!
});
</script>
</body>
</html>
As you can see, this document loads up cable followed by a script which executes
a function named Cable.define
with an empty object as argument. What
Cable.define
does is allow us to create nodes in the cable "graph", in
other words, the functionality of our app will be built up just by adding
properties in that empty object.
Let's make a really simple app that takes input in a textbox, and presents a
message based on that input. First, we add <input>
and <span>
elements to
the body.
<input id="input" type="text" value="" />
<span id="output"></span>
Now, we need to create a node in the cable graph to represent the input. This node will represent the stream of data corresponding to the value in the textbox. In cable, this is technically an event node, because it represents the event of there being a new value in the textbox.
We can create an event node by writing a property in the definition object as a
function which accepts one argument named event
. What an event function must
do is configure the DOM or some event system such that it invokes the event
argument with the new value. So, for a textbox, this means binding to the
onchange
and onkeyup
events with a function that invokes the event
argument with the value
property of the textbox.
input:function(event) {
var
e = document.getElementById("input"),
f = function() { event(e.value); };
event.setDefault(e.value);
e.onchange = f;
e.onkeyup = f;
}
We have now incorporated the textbox into the cable graph as an input. But input
isn't useful at all without a way to do output. Let's set it up so that it
outputs the current value of the textbox in the <span>
.
In cable, this will be an effect node. We can write it by making a function
which accepts the name of some other node as argument to be used in producing
the effect. In this case, we want to make use of the node named input
to
display its value, so we'll write a function with an argument named input
.
Cable sets up the input
argument to be a getter for the input value, so we can
produce the output string by invoking it. This informs the cable system to
execute this function every time the value. See below.
output:function(input) {
document.getElementById("output").innerText = (
"You typed '" + input() + "'"
);
}
Putting it all together we have the following HTML file.
<html>
<head>
<title>Cable</title>
</head>
<body>
<input id="input" type="text" value="" />
<span id="output"></span>
<script src="path/to/cable.min.js"></script>
<script>
Cable.define({
input:function(event) {
var
e = document.getElementById("input"),
f = function() { event(e.value); };
event.setDefault(e.value);
e.onchange = f;
e.onkeyup = f;
},
output:function(input) {
document.getElementById("output").innerText = (
"You typed '" + input() + "'"
);
}
});
</script>
</body>
</html>
Now, if I load this page and type "oranges" into the input, the span says:
You typed 'oranges'
This file is a working cable app. It will display whatever you type in the textbox and update the span as the input changes. However, it's a bit boring to display the value in the textbox without changing it. Let's do something more creative by creating a reversed version of the string.
First, think about reversing the string. The reversed version of the string isn't an event and it also isn't really an effect. Instead, it's something that exists between input and output. which, in cable is called a synthetic node because it synthesizes other values into a new one.
In this case, we'll be synthesizing the input into the reversed input. This is
done by creating a function that accepts an argument named input
(so we can
use the value of the textbox), but also accepts an argument named result
. We
can pass a value into result to represent the synthesized data.
reversed:function(input, result) {
result(input().split("").reverse().join(""));
}
Reversed now acts like a stream of data representing the reversed version of the stream of input data.
Now that this is defined, we can display this additional information by
rewriting the output node to use reversed
in a similar way to how input
is
used.
output:function(input, reversed) {
document.getElementById("output").innerText = (
"You typed '" + input() + "' the reversed is '" + reversed() + "'"
);
}
Now, if I type "apples" into the input, the span says:
You typed 'apples' the reversed is 'sellpa'
With these streams, we can define an even more abstract synthetic which detects a whether the input is a palindrome.
isPalindrome:function(input, reversed, result) {
result(input() === reversed());
}
And incorporate this into the result.
output:function(input, isPalindrome) {
document.getElementById("output").innerText = (
"'" + input() + "' is " +
(isPalindrome() ? "" : "not ") +
"a palindrome"
);
}
It's useful that isPalindrome
is somewhat abstract. It's a stream of data
which represents a relationship between two other streams of data. This is a
powerful tool because you can write a synthetic node to represent all sorts of
abstract aspects of your application, and nodes which use such nodes are updated
automatically. These qualities could be things like isUserLoggedIn
,
isGameOver
, or canZoomInFurther
.
Declaring the input and output as we've done in the previous section is conceptually simple, but syntactically verbose. If we add jQuery to our application, cable provides some helpers which make declaring them more convenient.
First, let's add jQuery to cable by declaring it as a library, then, declare the
input and output with cable helper functions. These new versions of input
and
output
work in the same way as before.
Cable.define({
$:Cable.library("path/to/jquery.js"),
input:Cable.textbox("#input"),
output:Cable.template(
"#output",
"You typed '{{input}}' and the reversed is {{reversed}}"
)
});
Now that we have a grasp on how to define cable nodes, let's build a more
complex application, specifically a clone of the popular JavaScript game
2048. The final result of this
tutorial can be found in the examples/2048
directory of the repository.
For the actual rules of the game, we'll use am implementation named 2048.js. This file has nothing to do with cable specifically, it's just a stand-alone, pure-functional implementation in normal JavaScript. The role of cable is a glue-layer to connect this implementation to the DOM.
2048.js provides the following functions:
-
blank()
: Create a blank game state with no numbers. -
initialize()
: Create an initial state for the game (i.e. a blank state plus two numbers spawned). -
move(state, direction)
: Accept a state and a direction (i.e."north"
,"south"
,"east"
or"west"
) and produce a new state created by applying that rule. -
isGameOver(state)
: Accept a state and return a boolean of whether the game is over (i.e. none of the directions are possible). -
isGameWon(state)
: Accept a state and return a boolean of whether the game has been won (i.e. at least one tile is the winning tile -- 2048).
The format of the state values has these properties:
-
score
: The current score -
step
: The number of the most recently applied move. -
grid
: A 2-D array of integers representing thr game-board. 0 is used for blank spaces. -
last.created
:` A list of tiles that were created in the last turn. -
last.moved
:` A list of tiles that shifted in the last turn. -
last.created
:` A list of tiles that were merged into larger ones in the last turn.
We want to connect this set of game functions to a webpage. Let's make an HTML
document and load up cable just like before. Notice, we've made some HTML
elements for the game visuals, namely a <div>
with id grid
to display the
board, and a <div>
with id controls
to house a reset button and display the
score.
<html>
<head>
<title>2048 Cable</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="main">
<div id="grid"></div>
<div id="controls">
<input id="reset" type="button" value="Reset" />
<span id="score"></span>
</div>
</div>
<script src="../../dist/cable.min.js"></script>
<script>
Cable.define({
});
</script>
</body>
</html>
Now that that is set up, the remainder of the work will go in that
Cable.define
argument. All we have to do is create properties in the cable
definition object which describe how to connect the game to the document. The
next step will be to bring in some libraries, namely the 2048.js library and
jQuery.
Cable.define({
$:Cable.library("jquery.min.js"),
game:Cable.library("2048.js")
});
Next, we want some inputs, let's make an event for when the direction keys are
pressed, and another event for when the reset button is pressed. The restart
event uses a helper function built into cable for making events based on
buttons. In the case of the direction, we define the event more manually by
creating an event listener for the onkeyup
event of the document
.
Cable.define({
direction:function($, define) {
define(function(event) {
$(document).on("keyup", function(e) {
switch (e.which) {
case 37: event("west"); break;
case 38: event("north"); break;
case 39: event("east"); break;
case 40: event("south"); break;
}
});
});
},
restart:Cable.button("#reset")
});
We now have our libraries, and we have the inputs to the system, this is all we
need to connect the game mechanics together. In this set of definitions, we
declare a data node named state
to house the game state. Data nodes like this
simply hold some JavaScript value, and update its dependent ndoes whenever it
gets changed.
Cable.define({
state:Cable.data(null),
setup:function(init, restart, game, _state) {
_state(game.initialize());
},
move:function(game, direction, _state) {
if (!game.isGameOver(_state()) && !game.isGameWon(_state())) {
_state(game.move(_state(), direction()));
}
}
});
We create two effects named setup
and move
to setup a new game and to apply
movement commands respectively. setup
listens to two events: the restart
event we created before, and init
. init
is a special event which fires when
cable is loaded, so, this forces the system to setup a new game when the page
loads. It creates a new state using the game.initialize()
function, and sets
the state
node with this value. Notice that the _state
argument has an
underscore in front of it. Putting an underscore before an argument name like
this prevents the function from being updated when state
changes. In this case
we don't want this function to be updated because it is the function that is
making the change.
The move
effect listens to the direction inputs and updates the state by
applying the direction command with the game.move(s, d)
function. As before
with setup
, we prefix the _state
argument name with an underscore because
this effect is making the change to the data node.
With this mechanism in place, we can put together some useful synthetic nodes which map out interesting properties of the state.
Cable.define({
gameOver:function(game, state, result) {
result(game.isGameOver(state()));
},
won:function(game, state, result) {
result(game.isGameWon(state()));
},
score:function(state, result) {
result("" + state().score);
}
});
gameOver
and won
are nodes which represent streams of booleans for whether
the game is over (i.e. failure) or whether it has been won (i.e. the 2048 tile
has been created). score
is a stream of the game's score, converted into a
string.
Now the mechanism is in place, but one important thing remains, we need to
display the game to the player. For organization, we will wrap all of the code
for displaying the game in a scope named render
. In this scope, we'll start
with the simplest thing to display, the game's score, which we can do with a
template definition.
Cable.define({
render:{
showScore:Cable.template("#score", "Score: {{score}}")
}
});
Let's use the Jade templating engine for rendering the grid as an HTML
<table>
. Write the following template in a file named grid.jade
.
table
each row, ri in grid
tr
each col, ci in grid[ri]
td.cell(data-value = grid[ri][ci])
span
Now, to use the template, we can load up the jade rendering libaray, and the
text of this template file, with a couple of resource nodes named jade
and
template
. Finally, putting these together, along with jQuery and the stream of
game state values, write an effect named grid
which renders the table with the
current state, and puts the result inside of the <div>
named grid
. Because
grid
node listens to the state
node, this render happens immediately, every
time that the state changes.
Cable.define({
render:{
jade:Cable.library("jade.js"),
template:Cable.text("grid.jade"),
grid:function($, jade, template, state) {
$("#grid").html(jade.render(template(), state()));
}
}
});
There is only one last detail: we want to render differently when the game is won or lost. This is accomplished with a straightforward use of jQuery.
Cable.define({
render:{
over:function($, gameOver, won) {
if (gameOver() || won()) {
$(".cell").animate({ opacity:0.5 });
alert(gameOver() ? "Game over" : "You win!");
}
else {
$(".cell").animate({ opacity:1 });
}
}
}
});
Putting it all together, we have the following file. In the repository exaples,
this corresponds to examples/2048/v1.html
.
<html>
<head>
<title>2048 Cable</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="main">
<div id="grid"></div>
<div id="controls">
<input id="reset" type="button" value="Reset" />
<span id="score"></span>
</div>
</div>
<script src="cable.min.js"></script>
<script>
Cable.define({
$:Cable.library("jquery.min.js"),
game:Cable.library("2048.js"),
direction:function($, define) {
define(function(event) {
$(document).on("keyup", function(e) {
switch (e.which) {
case 37: event("west"); break;
case 38: event("north"); break;
case 39: event("east"); break;
case 40: event("south"); break;
}
});
});
},
restart:Cable.button("#reset"),
state:Cable.data(null),
setup:function(init, game, _state, restart) {
_state(game.initialize());
},
move:function(game, _state, direction) {
if (!game.isGameOver(_state()) && !game.isGameWon(_state())) {
_state(game.move(_state(), direction()));
}
},
gameOver:function(game, state, result) {
result(game.isGameOver(state()));
},
won:function(game, state, result) {
result(game.isGameWon(state()));
},
score:function(state, result) {
result("" + state().score);
},
render:{
jade:Cable.library("jade.js"),
template:Cable.text("grid.jade"),
grid:function($, jade, template, state) {
$("#grid").html(jade.render(template(), state()));
},
over:function($, gameOver, won) {
if (gameOver() || won()) {
$(".cell").animate({ opacity:0.5 });
alert(gameOver() ? "Game over" : "You win!");
}
else {
$(".cell").animate({ opacity:1 });
}
},
showScore:Cable.template("#score", "Score: {{score}}")
}
});
</script>
</body>
</html>
Viola! The game, 2048.