Guides updated

This commit is contained in:
José Valim 2023-03-20 12:09:43 +01:00
parent d880b9a073
commit 5d6cb53831
6 changed files with 61 additions and 1026 deletions

View file

@ -86,17 +86,13 @@ defmodule Livebook.Notebook.Learn do
}
},
%{
ref: :kino_reference,
path: Path.join(__DIR__, "learn/kino/reference.livemd")
ref: :kino_intro,
path: Path.join(__DIR__, "learn/kino/intro_to_kino.livemd")
},
%{
ref: :kino_vm_introspection,
path: Path.join(__DIR__, "learn/kino/vm_introspection.livemd")
},
%{
ref: :kino_pong,
path: Path.join(__DIR__, "learn/kino/pong.livemd")
},
%{
ref: :kino_custom_kinos,
path: Path.join(__DIR__, "learn/kino/custom_kinos.livemd")
@ -200,15 +196,14 @@ defmodule Livebook.Notebook.Learn do
@group_configs [
%{
title: "Interactive notebooks with Kino",
title: "Deep dive into Kino",
description:
"Learn more about the Kino package, including the creation of custom UI components.",
cover_url: "/images/kino.png",
notebook_refs: [
:kino_reference,
:kino_intro,
:kino_vm_introspection,
:kino_custom_kinos,
:kino_pong,
:kino_smart_cells
]
}

View file

@ -14,14 +14,14 @@ To do so, we will use Livebook's companion library called
In a nutshell, Kino is a library that you install as part
of your notebooks to make your notebooks interactive.
Kino comes from the Greek prefix of the same name and it
stands for "motion". And, as you learn the library, it
will become clear that this is precisely what it brings to
our notebooks.
Kino comes from the Greek prefix "kino-" and it stands for
"motion". As you learn the library, it will become clear that
this is precisely what it brings to our notebooks.
There is many functionality in the Kino library: it can render
Markdown, animate frames, display tables, manage inputs, and
more. For building notebook applications, we rely on two main
Kino can render Markdown, animate frames, display tables,
manage inputs, and more. It also provides the building blocks
for extending Livebook with charts, smart cells, and much more.
For building notebook applications, we rely on two main
building blocks: `Kino.Control` and `Kino.Frame`.
You can see `kino` listed as a dependency above, so let's run

View file

@ -1,4 +1,4 @@
# Introduction to Kino
# A look into built-in Kinos
```elixir
Mix.install([
@ -8,12 +8,15 @@ Mix.install([
## Introduction
In this notebook we will explore the possibilities that
[`kino`](https://github.com/elixir-nx/kino) brings
into your notebooks. Kino can be thought of as Livebook's
friend that instructs it how to render certain widgets
and interact with them. You can see `kino` listed as a
dependency above, let's run the setup cell and get started!
Throughout the Learning section, we have used Kino several times.
Sometimes we use built-in Kinos, such as using `Kino.Control` and
`Kino.Frame` to deploy applications](/learn/notebooks/deploy-apps),
other times we used custom Kinos tailired for
[data exploration](/learn/notebooks/intro-to-explorer) or
[plotting](/learn/notebooks/intro-to-vega-lite).
In this notebook, we will explore several of the built-in Kinos.
`kino` is already listed as a dependency, so let's get started.
<!-- livebook:{"branch_parent_index":0} -->
@ -37,6 +40,25 @@ IO.puts("Hello, #{Kino.Input.read(name)}!")
There are multiple types of inputs, such as text areas,
color dialogs, selects, and more. Feel free to explore them.
One important feature of inputs is that they are shared.
Once someone changes an input, it will reflect on all users
currently seeing the notebook.
<!-- livebook:{"branch_parent_index":0} -->
## Kino.Control
The [`Kino.Control`](https://hexdocs.pm/kino/Kino.Control.html)
module represents forms, buttons, and other interactive controls.
Opposite to `Kino.Input`, each user has their own control and
the main way to interact with controls is by listening to their
events.
```elixir
Kino.Control.button("Click me!")
|> Kino.listen(fn event -> IO.inspect(event) end)
```
<!-- livebook:{"branch_parent_index":0} -->
## Kino.Markdown
@ -170,7 +192,7 @@ Kino.Tree.new(data)
<!-- livebook:{"branch_parent_index":0} -->
## Kino.render/1
## Kino.render/1 and Kino.Frame
As we saw, Livebook automatically recognises widgets returned
from each cell and renders them accordingly. However, sometimes
@ -190,66 +212,22 @@ Kino.render(Kino.Markdown.new("**Hello world**"))
"Cell result 🚀"
```
<!-- livebook:{"branch_parent_index":0} -->
## Kino.Frame and animations
`Kino.Frame` allows us to render an empty frame and update it
as we progress. Let's render an empty frame:
The `Kino.Frame` construct we used when deploying our chat app is a
generalization of `Kino.render`, which gives us more control over when
to update, append, or clear the output:
```elixir
frame = Kino.Frame.new()
```
Now, let's render a random number between 1 and 100 directly
in the frame:
By default, a frame will update in place. Try running the cell below
several times:
```elixir
Kino.Frame.render(frame, "Got: #{Enum.random(1..100)}")
```
Notice how every time you reevaluate the cell above it updates
the frame. You can also use `Kino.Frame.append/2` to append to
the frame:
```elixir
Kino.Frame.append(frame, "Got: #{Enum.random(1..100)}")
```
Appending multiple times will always add new contents. The content
can be reset by calling `Kino.Frame.render/2` or `Kino.Frame.clear/1`.
By using loops, you can use `Kino.Frame` to dynamically add contents
or animate your livebooks. In fact, there is a convenience function
called `Kino.animate/2` to be used exactly for this purpose:
```elixir
Kino.animate(100, fn i ->
Kino.Markdown.new("**Iteration: `#{i}`**")
end)
```
The above example renders new Markdown output every 100ms.
You can use the same approach to render regular output
or images too!
There's also `Kino.animate/3`, in case you need to accumulate state or halt the animation at certain point!
```elixir
button = Kino.Control.button("Click") |> Kino.render()
button
|> Kino.Control.stream()
|> Kino.animate(0, fn _event, counter ->
new_counter = counter + 1
md = Kino.Markdown.new("**Clicks: `#{new_counter}`**")
{:cont, md, new_counter}
end)
```
Note that this time, instead of refreshing the animation every 100ms, we use an event stream. This way we refresh the animation whenever the button is clicked.
Finally, there's `Kino.listen/{2,3}`, that allows you to consume a stream the same way, but doesn't render anything on its own.
but you can use `append` and `clear` to get different results.
<!-- livebook:{"branch_parent_index":0} -->
@ -322,15 +300,9 @@ to inspect, toggle, and swap each operation along the way:
|> dbg()
```
## Next steps with custom Kinos
## Next steps
With this, we finished our introduction to Kino. Most the guides
ahead of us will use Kino in one way or the other. You can jump
into [the VegaLite guide](/learn/notebooks/intro-to-vega-lite)
for plotting charts or [the MapLibre guide](/learn/notebooks/intro-to-maplibre)
for rendering maps to learn how other packages extend Livebook
through Kino.
We also have a collection of deep dive guides into Kino in the
[Learn](/learn) page if you want to learn more, including how
to create your custom widgets.
We have learned many new Kinos in this section. In the next guide,
we will put some of our new found knowledge into practice [by rendering
inputs, plotting graphs, and drawing diagrams with information retrieved
from the notebook runtime](/learn/notebooks/vm-introspection).

View file

@ -1,937 +0,0 @@
# Multiplayer pong game from scratch
```elixir
Mix.install([
{:kino, "~> 0.9.0"}
])
```
## Introduction
In this notebook, we are going to learn more about
[`Kino.Control`](https://hexdocs.pm/kino/Kino.Control.html)
and [`Kino.Image`](https://hexdocs.pm/kino/Kino.Image.html).
Specifically, we will be building the the [Pong](https://en.wikipedia.org/wiki/Pong)
game directly in Livebook. Not only that, we will actually
make it multiplayer!
## Painting the scene
The first step in our game is to define our scene. The
scene has a `width`, `height`, and several game objects
that we want to render into the scene. For rendering
the objects, we will use a custom protocol function,
called `Scene.Renderer.to_svg/1`. This will allow us
to define as many game objects as we want and teach the
scene how to render them, without changing the scene code:
```elixir
defmodule Scene do
@moduledoc """
The scene for the game objects.
"""
defstruct [:width, :height, :objects]
def new(width, height) do
%__MODULE__{width: width, height: height, objects: []}
end
def add(scene, object) do
update_in(scene.objects, fn objects -> [object | objects] end)
end
def to_svg(scene) do
svgs = Enum.map(scene.objects, &Scene.Renderer.to_svg(&1))
"""
<svg viewBox="0 0 #{scene.width} #{scene.height}"
xmlns="http://www.w3.org/2000/svg">
#{svgs}
</svg>
"""
end
end
```
To render the scene, we will convert it to `svg` and
use `Kino.Image.new/1` to render it in Livebook:
```elixir
Scene.new(80, 10)
|> Scene.to_svg()
|> Kino.Image.new(:svg)
```
Since our scene has no objects, we only see an empty white canvas.
To address this, let's define two shapes and alongside the
`Scene.Renderer` protocol for them.
Let's first define the protocol:
```elixir
defprotocol Scene.Renderer do
def to_svg(object)
end
```
Our first shape is a rectangle:
```elixir
defmodule Scene.Rect do
defstruct [:x, :y, :width, :height, fill: "black"]
def new(x, y, width, height, opts \\ []) do
struct!(%Scene.Rect{x: x, y: y, width: width, height: height}, opts)
end
defimpl Scene.Renderer do
def to_svg(%Scene.Rect{x: x, y: y, width: width, height: height, fill: fill}) do
"""
<rect x="#{x}" y="#{y}" width="#{width}" height="#{height}" fill="#{fill}" />
"""
end
end
end
```
Our second shape is text:
```elixir
defmodule Scene.Text do
defstruct [:x, :y, :text, font_size: 1, fill: "black"]
def new(x, y, text, opts \\ []) do
struct!(%Scene.Text{x: x, y: y, text: text}, opts)
end
defimpl Scene.Renderer do
def to_svg(%Scene.Text{x: x, y: y, text: text, fill: fill, font_size: font_size}) do
"""
<text x="#{x}"
y="#{y}"
fill="#{fill}"
font-size="#{font_size}px"
text-anchor="middle"
dominant-baseline="hanging">#{text}</text>
"""
end
end
end
```
Now let's paint a new scene with the rectangle and some text:
```elixir
Scene.new(80, 10)
|> Scene.add(Scene.Rect.new(0, 0, 80, 10, fill: "#d4f1f477"))
|> Scene.add(Scene.Text.new(40, 4, "Hello world!", font_size: 3))
|> Scene.to_svg()
|> Kino.Image.new(:svg)
```
Perfect.
## Animating scene objects
So far we are able to paint static objects in the scene.
However, all games have dynamic objects that move on the scene
as time passes. In our case, the dynamic objects are the paddles
and the ball.
For such, we will define an `Animation` protocol with a single
function called `step`. It receives the object, the scene, and
is responsible for animating the object one step at a time.
```elixir
defprotocol Animation do
def step(object, scene)
end
```
Now let's define our first game object, the `Game.Ball` struct.
The ball will have a radius `r`, coordinates `x` and `y`, velocities
`dx` and `dy`, and implement both `Scene.Renderer` and `Animation`
protocols.
```elixir
defmodule Game.Ball do
defstruct [:r, :x, :y, :dx, :dy]
def new(r, x, y, opts \\ []) do
struct!(%Game.Ball{r: r, x: x, y: y}, opts)
end
defimpl Scene.Renderer do
def to_svg(%Game.Ball{x: x, y: y, r: r}) do
"""
<circle cx="#{x}" cy="#{y}" r="#{r}" fill="tomato" />
"""
end
end
defimpl Animation do
# On each step, increase x by dx, and y by dy.
def step(ball, _scene) do
%{ball | x: ball.x + ball.dx, y: ball.y + ball.dy}
end
end
end
```
We can now paint the ball in the scene:
```elixir
Scene.new(80, 10)
|> Scene.add(Game.Ball.new(2, 40, 4))
|> Scene.to_svg()
|> Kino.Image.new(:svg)
```
However, we can also animate the ball over the scene using `Kino.Frame`.
Let's define the frame, the scene, and the ball:
```elixir
# The frame we will render scene images on top of
frame = Kino.Frame.new() |> Kino.render()
# The scene with a static background
scene =
Scene.new(80, 20)
|> Scene.add(Scene.Rect.new(0, 0, 80, 20, fill: "#d4f1f477"))
# The ball which we will animate
ball = Game.Ball.new(2, 2, 4, dx: 1, dy: 1)
```
Now we will instantiate a `Kino.Control.interval/1` and use it to
generate a stream of events, one every 33ms (which gives 30 frames per second).
We will take 20 of those events. On each events, we will paint the
scene, update the frame, and then animate the ball for the next event:
```elixir
Kino.Control.interval(33)
|> Kino.Control.stream()
|> Stream.take(20)
|> Enum.reduce(ball, fn _counter, ball ->
# Build the image
image = scene |> Scene.add(ball) |> Scene.to_svg() |> Kino.Image.new(:svg)
# Update the frame
Kino.Frame.render(frame, image)
# Animate the ball
Animation.step(ball, scene)
end)
```
Feel free to tweak the parameters above and see how it affects the ball
movements.
You may also have noticed that, at the moment, the ball simply moves out
of the scene when it reaches the edges, which is not what we would expect
in practice. We will tackle this later once we add collision detection.
For now, let's learn how to capture the user keyboard and use it to control
the paddles.
<!-- livebook:{"branch_parent_index":0} -->
## Capturing the user keyboard
Each player in a Pong game must use the arrow up and arrow down
keys to control the paddle. We can achieve this by using
`Kino.Control.keyboard/1`. Let's instantiate it:
```elixir
keyboard = Kino.Control.keyboard([:status, :keyup, :keydown])
```
The above renders a keyboard icon. Once you click it, Livebook
will start capturing keyboard events. We are interested in three
different events:
* `:status` - it is emitted whenever the keyboard is activated/deactivated
* `:keydown` - whenever a key is pressed down
* `:keyup` - whenever a key is released up
Let's use `Kino.Control.stream/1` to capture and print those events.
In particular, we want to stream while the keyboard is enabled. Once
the keyboard is disabled (i.e. we get a `:status` event with the
`:enabled` key set to `false`), the stream stops:
```elixir
keyboard
|> Kino.Control.stream()
|> Stream.take_while(fn
%{type: :status, enabled: false} -> false
_event -> true
end)
|> Kino.listen()
```
Once you execute the cell above, it will start consuming
the event stream. Click on the keyboard icon from the previous
cell and watch it print keyboard events as you press different keys.
When you are done, **disable the keyboard control**. Now we know
how to capture keyboard events and how they look like, we are
ready to implement the paddles.
## Controlling the paddle with keypresses
The paddle will be similar to the ball: it is its own struct
that can be rendered and animated. However, the paddle should
also be able to handle keyboard events. In particular, a paddle
only moves up or down, and only while a key is pressed. Since
there is additional logic here, let's first define `Game.Paddle`
and the implement the protocols later:
```elixir
defmodule Game.Paddle do
defstruct [:x, :y, :width, :height, dy: 0]
@dy 4
def new(x, y, width, height) do
%Game.Paddle{x: x, y: y, width: width, height: height}
end
def on_key(paddle, %{key: "ArrowUp", type: :keydown}), do: %{paddle | dy: -@dy}
def on_key(paddle, %{key: "ArrowUp", type: :keyup}), do: %{paddle | dy: 0}
def on_key(paddle, %{key: "ArrowDown", type: :keydown}), do: %{paddle | dy: @dy}
def on_key(paddle, %{key: "ArrowDown", type: :keyup}), do: %{paddle | dy: 0}
def on_key(paddle, _), do: paddle
end
```
The paddle moves up while the "ArrowUp" key is pressed,
and down while the "ArrowDown" key is pressed. We will
render the paddle as a rectangle:
```elixir
defimpl Scene.Renderer, for: Game.Paddle do
def to_svg(%Game.Paddle{x: x, y: y, width: width, height: height}) do
"""
<rect x="#{x}" y="#{y}" width="#{width}" height="#{height}" fill="black" />
"""
end
end
```
And animate it over the `y` dimension, with the addition
that the paddle must never leave the scene:
```elixir
defimpl Animation, for: Game.Paddle do
def step(paddle, scene) do
%{paddle | y: clip(paddle.y + paddle.dy, 0, scene.height - paddle.height)}
end
defp clip(value, min, max), do: value |> max(min) |> min(max)
end
```
We are ready to give it a try!
<!-- livebook:{"branch_parent_index":4} -->
## Mixing control streams
To animate the paddle, we will need two control streams:
the keyboard control and an interval to refresh
the paddle as we keep holding the arrow keys up
and down. Let's put the relevant pieces in place:
```elixir
# The frame we will render scene images on top of
frame = Kino.Frame.new() |> Kino.render()
# The scene with a static background
scene =
Scene.new(200, 50)
|> Scene.add(Scene.Rect.new(0, 0, 200, 50, fill: "#d4f1f477"))
# The keyboard control
keyboard = Kino.Control.keyboard([:status, :keyup, :keydown]) |> Kino.render()
# The refresh interval
interval = Kino.Control.interval(33)
# The paddle which we will animate
paddle = Game.Paddle.new(0, 21, 2, 8)
```
Now we want to capture all keyboard events plus animate
the paddle every 33ms. We can do this by passing a list
of controls to `Kino.Control.stream/1`. As before, we
still want to stop the stream as soon as the keyboard is
disabled:
```elixir
[keyboard, interval]
|> Kino.Control.stream()
|> Stream.take_while(fn
%{type: :status, enabled: false} -> false
_event -> true
end)
|> Kino.listen(paddle, fn event, paddle ->
# Build the image
image = scene |> Scene.add(paddle) |> Scene.to_svg() |> Kino.Image.new(:svg)
# Update the frame
Kino.Frame.render(frame, image)
# Input the event into the paddle and animate it
{:cont,
paddle
|> Game.Paddle.on_key(event)
|> Animation.step(scene)}
end)
```
Press the keyboard icon and you should be able to control
the paddle. In particular, as long as you hold the arrow
up or arrow down keys, the paddle should move, without exiting
the scene. Once you are done, disable the keyboard.
With all of the game objects in place, let's implement
collision detection between the scene, the ball, and the
paddles.
## Collision detection
As the ball moves around, it may collide with different elements.
Let's describe those scenarios below:
* If the ball hits either the top or the bottom of the scene,
it should reverse its `y` direction
* If the ball hits any of the paddles, it should reverse its
`x` direction
* If the ball hits either the left or right side of the scene,
it means the opposite paddle won
For simplicity, we will assume that:
* The paddles are positioned to the absolute left and absolute
right of the scene
* For collision detection with the paddle, we will assume the
ball is a square. In practice this is enough unless the ball
is much bigger than the paddle
With this in mind, let's see the collision code. Our function
will either return `{:cont, ball}` or `{:won, :left | :right}`:
```elixir
defmodule Game.Collision do
def bounce(ball, left_paddle, right_paddle, scene) do
ball
|> bounce_y(scene)
|> bounce_x(left_paddle, right_paddle, scene)
end
defp bounce_y(ball, _scene) when ball.y - ball.r <= 0 do
%{ball | y: ball.r, dy: -ball.dy}
end
defp bounce_y(ball, scene) when ball.y + ball.r >= scene.height do
%{ball | y: scene.height - ball.r, dy: -ball.dy}
end
defp bounce_y(ball, _scene), do: ball
defp bounce_x(ball, left, _right, _scene) when ball.x - ball.r <= left.width do
if collides_vertically?(ball, left) do
{:cont, %{ball | x: ball.r + left.width, dx: -ball.dx}}
else
{:won, :right}
end
end
defp bounce_x(ball, _left, right, scene) when ball.x + ball.r >= scene.width do
if collides_vertically?(ball, right) do
{:cont, %{ball | x: scene.width - ball.r - right.width, dx: -ball.dx}}
else
{:won, :left}
end
end
defp bounce_x(ball, _left, _right, _scene), do: {:cont, ball}
defp collides_vertically?(ball, paddle) do
ball.y - ball.r < paddle.y + paddle.height and ball.y + ball.r > paddle.y
end
end
```
To ensure we got the different scenarios right, let's add some tests:
```elixir
ExUnit.start(autorun: false)
defmodule Game.CollisionTest do
use ExUnit.Case, async: true
@scene Scene.new(200, 100)
# Position the left paddle on the middle
@left Game.Paddle.new(0, 40, 2, 20)
# Position the right paddle on the middle
@right Game.Paddle.new(198, 40, 2, 20)
def bounce(ball), do: Game.Collision.bounce(ball, @left, @right, @scene)
test "no bouncing" do
ball = Game.Ball.new(10, _x = 100, _y = 50, dx: 10, dy: 10)
assert bounce(ball) == {:cont, ball}
end
describe "bounces on top" do
test "with perfect collision" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 100, _y = 10, dy: -10))
assert ball.dy == 10
assert ball.y == 10
end
test "with ball slightly outside of scene" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 100, _y = 5, dy: -10))
assert ball.dy == 10
assert ball.y == 10
end
end
describe "bounces on bottom" do
test "with perfect collision" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 100, _y = 90, dy: 10))
assert ball.dy == -10
assert ball.y == 90
end
test "with ball slightly outside of scene" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 100, _y = 95, dy: 10))
assert ball.dy == -10
assert ball.y == 90
end
end
describe "bounces on left" do
test "with perfect collision on the paddle center" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 2 + 10, _y = 50, dx: -10))
assert ball.dx == 10
assert ball.x == 12
end
test "with ball slightly inside the paddle" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 5, _y = 50, dx: -10))
assert ball.dx == 10
assert ball.x == 12
end
test "with the ball bottom touching the paddle top" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 5, _y = 31, dx: -10))
assert ball.dx == 10
assert ball.x == 12
end
test "with the ball top touching the paddle bottom" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 5, _y = 69, dx: -10))
assert ball.dx == 10
assert ball.x == 12
end
test "with the ball top perfectly missing the paddle bottom" do
assert bounce(Game.Ball.new(10, _x = 5, _y = 70, dx: -10)) == {:won, :right}
end
test "with the ball bottom perfectly missing the paddle top" do
assert bounce(Game.Ball.new(10, _x = 5, _y = 30, dx: -10)) == {:won, :right}
end
end
describe "bounces on right" do
test "with perfect collision on the paddle center" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 200 - 2 - 10, _y = 50, dx: 10))
assert ball.dx == 10
assert ball.x == 188
end
test "with ball slightly inside the paddle" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 195, _y = 50, dx: 10))
assert ball.dx == -10
assert ball.x == 188
end
test "with the ball bottom touching the paddle top" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 195, _y = 31, dx: 10))
assert ball.dx == -10
assert ball.x == 188
end
test "with the ball top touching the paddle bottom" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 195, _y = 69, dx: 10))
assert ball.dx == -10
assert ball.x == 188
end
test "with the ball top perfectly missing the paddle bottom" do
assert bounce(Game.Ball.new(10, _x = 195, _y = 70, dx: 10)) == {:won, :left}
end
test "with the ball bottom perfectly missing the paddle top" do
assert bounce(Game.Ball.new(10, _x = 195, _y = 30, dx: 10)) == {:won, :left}
end
end
end
ExUnit.run()
```
All tests should pass! We are now ready to put all pieces
together. We will do so in two steps. First we will create
the `Game.State`, which will keep all objects and know how
to dispatch events to them. Then we will create the
`Game.Server` that will hold the state, control the frame,
keyboards, and refresh rate.
## Encapsulating the game state
The `Game.State` is a module with functions to start a new
game, receive paddle events, and animate its objects.
The `Game.State` itself should hold the scene, the ball,
and both paddles. It should also hold the score and a
status which control if the game is running or idle.
The game is idle before it starts and also after each
player scores.
```elixir
defmodule Game.State do
@moduledoc """
Represents the paddles game state and rules.
"""
@w 400
@h 200
@ball_r 8
@paddle_w 2
@paddle_h 40
@paddle_y div(@h - @paddle_h, 2)
defstruct [:ball, :left_paddle, :left_score, :right_paddle, :right_score, :status, :scene]
@doc """
Returns initial game state.
"""
def new() do
scene =
Scene.new(@w, @h)
|> Scene.add(Scene.Rect.new(0, 0, @w, @h, fill: "#d4f1f477"))
reset(%__MODULE__{scene: scene, left_score: 0, right_score: 0})
end
defp reset(state) do
%{
state
| status: :idle,
ball: new_ball(),
left_paddle: Game.Paddle.new(0, @paddle_y, @paddle_w, @paddle_h),
right_paddle: Game.Paddle.new(@w - @paddle_w, @paddle_y, @paddle_w, @paddle_h)
}
end
defp new_ball() do
Game.Ball.new(
@ball_r,
div(@w, 2),
div(@h, 2),
# Each new ball goes in a random direction
dx: Enum.random([3, -3]),
dy: Enum.random([2, -2])
)
end
@doc """
Marks the game as running.
"""
def start(state) do
%{state | status: :running}
end
@doc """
Applies the event to the given paddle.
"""
def on_key(state, :left, event) do
update_in(state.left_paddle, &Game.Paddle.on_key(&1, event))
end
def on_key(state, :right, event) do
update_in(state.right_paddle, &Game.Paddle.on_key(&1, event))
end
@doc """
Performs a single step within the game by updating object positions.
"""
def step(%{status: :running} = state) do
%Game.State{
ball: ball,
left_paddle: left_paddle,
right_paddle: right_paddle,
scene: scene
} = state
ball = Animation.step(ball, scene)
left_paddle = Animation.step(left_paddle, scene)
right_paddle = Animation.step(right_paddle, scene)
case Game.Collision.bounce(ball, left_paddle, right_paddle, scene) do
{:cont, ball} ->
%{state | ball: ball, left_paddle: left_paddle, right_paddle: right_paddle}
{:won, :left} ->
reset(update_in(state.left_score, &(&1 + 1)))
{:won, :right} ->
reset(update_in(state.right_score, &(&1 + 1)))
end
end
def step(state), do: state
@doc """
Returns an SVG representation of the game board.
"""
def to_svg(state) do
%Game.State{
ball: ball,
left_paddle: left_paddle,
left_score: left_score,
right_paddle: right_paddle,
right_score: right_score,
scene: scene
} = state
text = Scene.Text.new(div(@w, 2), 4, "#{left_score} : #{right_score}", font_size: 10)
scene
|> Scene.add(ball)
|> Scene.add(left_paddle)
|> Scene.add(right_paddle)
|> Scene.add(text)
|> Scene.to_svg()
end
end
```
The most complex function above is `Game.State.step/1`. It
animates the ball and the paddles. Then it checks for collisions.
Depending on the result of the collision, it resets the game
state and bumps the relevant score.
Let's render the game statically and verify everything is in place:
```elixir
Game.State.new()
|> Game.State.to_svg()
|> Kino.Image.new(:svg)
```
Looks good! Now let's animate it over a frame until one of the paddles score:
```elixir
# The frame we will render scene images on top of
frame = Kino.Frame.new() |> Kino.render()
# The scene with a static background
game = Game.State.new()
# Every 33ms, paint a new frame until it completes a round
Kino.Control.interval(33)
|> Kino.Control.stream()
|> Kino.listen(Game.State.start(game), fn _, game ->
if game.status == :running do
game = Game.State.step(game)
Kino.Frame.render(frame, game |> Game.State.to_svg() |> Kino.Image.new(:svg))
{:cont, game}
else
:halt
end
end)
```
Perfect. Now we are ready to wrap it all up with the `Game.Server`.
## Running the game server
The job of the `Game.Server` is to wire everything together.
It will render the keyboard and the frame. However, there is
one important difference. So far, we have been using streams
to drive our objects and animations. Streams are fine for quick
examples but, now that we have both `state` and `events`, our
code will be cleaner if we organize it inside an Elixir process.
In Elixir, we often implement those processes using an abstraction
called [`GenServer`](https://hexdocs.pm/elixir/GenServer.html).
So that's what we will use below. We won't explain all parts that
make a `GenServer`, but here are the relevant bits:
* A `GenServer` is typically started via its `start_link/2` function.
This function will spawn a new process and then invoke the `init/1`
function as a callback
* On `init`, we should receive both `frame` and `keyboard` control as
options. Then, instead of using `Kino.Control.stream/1`, we will use
`Kino.Control.subscribe/2` to receive the control events as messages
* Each message the process receives will be handled by `handle_info/2`
* To implement the tick, we will configure the process to send itself a
message every 33 milliseconds
Let's see those concepts in place:
```elixir
defmodule Game.Server do
@moduledoc """
The game server, handles rendering, timing, and interactions.
"""
use GenServer
@tick_time_ms 33
@doc """
Starts the server and renders the initial UI.
"""
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
@impl true
def init(opts) do
# Subscribe to keyboard events. All events will be
# wrapped in a tuple with :keyboard as key
Kino.Control.subscribe(opts[:keyboard], :keyboard)
state = %{
frame: opts[:frame],
game: nil,
players: []
}
{:ok, render(state)}
end
@impl true
def handle_info({:keyboard, event}, state) do
{:noreply, handle_keyboard(state, event)}
end
@impl true
def handle_info(:tick, state) do
state = update_in(state.game, &Game.State.step/1)
state =
case state.game.status do
:idle -> state
:running -> schedule_tick(state)
end
{:noreply, render(state)}
end
# A player enabled the keyboard
defp handle_keyboard(state, %{type: :status, enabled: true, origin: origin}) do
case state.players do
# First player joined
[] ->
%{state | players: [%{side: :left, origin: origin}]}
|> render()
# Second player joined
[player] ->
%{state | players: [player, %{side: :right, origin: origin}], game: Game.State.new()}
|> render()
# Someone else tried to join, ignore them!
_ ->
state
end
end
# There is a keyboard event but no game yet, ignore it
defp handle_keyboard(state, _event) when state.game == nil do
state
end
# There is a game and it is idle, a keypress from a player will start the game
defp handle_keyboard(state, %{type: :keydown, origin: origin})
when state.game.status == :idle do
if Enum.any?(state.players, &(&1.origin == origin)) do
update_in(state.game, &Game.State.start/1)
|> schedule_tick()
else
state
end
end
# All other keypress go to one of the paddles based on the player
defp handle_keyboard(state, %{origin: origin} = event) do
if player = Enum.find(state.players, &(&1.origin == origin)) do
update_in(state.game, &Game.State.on_key(&1, player.side, event))
else
state
end
end
# Ignore any event that does not match the clauses above
defp handle_keyboard(state, _event) do
state
end
defp schedule_tick(state) do
Process.send_after(self(), :tick, @tick_time_ms)
state
end
defp render(state) do
output = paint(state)
Kino.Frame.render(state.frame, output)
state
end
defp paint(%{players: []}) do
Kino.Markdown.new("Waiting for players. Enable your keyboard to join **left**!")
end
defp paint(%{players: [_]}) do
Kino.Markdown.new("Waiting for another player. Enable your keyboard to join **right**!")
end
defp paint(state) do
state.game |> Game.State.to_svg() |> Kino.Image.new(:svg)
end
end
```
The server is in place! Let's show the keyboard and the frame,
then start a `Game.Server` passing them as arguments:
```elixir
keyboard = Kino.Control.keyboard([:status, :keydown, :keyup]) |> Kino.render()
frame = Kino.Frame.new() |> Kino.render()
Kino.start_child({Game.Server, keyboard: keyboard, frame: frame})
Kino.nothing() # Do not print anything after
```
We are ready to play! To verify it for yourself, copy and paste
this URL in a separate browser tab and toggle the keyboard mode in
both tabs. It may be challenging to act as two players at the same
time, but let's say it's a part of the game! Once you are done,
remember to toggle off the keyboard mode.
If you want one additional challenge, you will notice that, once you
disable the keyboard, the game continues running. Could you change the
code above to stop the game once any player toggles the keyboard off?
In any case, this was a fun and long ride! The next guide should be
much shorter and we will learn [how to create our own kinos](/learn/notebooks/custom-kinos)
using Elixir and JavaScript.

View file

@ -139,7 +139,7 @@ tree:
supervisor
```
We can go even further!
And we can go even further!
Sometimes a supervisor may be supervised by another supervisor,
which may have be supervised by another supervisor, and so on.
@ -363,5 +363,10 @@ for i <- 1..1_000_000 do
end
```
In the next notebook, we will learn [how to use `Kino.Control`
to build a chat app](/learn/notebooks/chat-app)!
In this notebook, we learned how powerful Kino and Livebook
are together. With them, we can augment existing Elixir
constructs, such as supervisors, with rich information, but
also to create new visualizations such as VegaLite charts.
In the next notebook, we will learn [how to create our custom
Kinos with Elixir and JavaScript](/learn/notebooks/custom-kinos)!

View file

@ -86,7 +86,7 @@ defmodule LivebookWeb.LearnLive do
</p>
</div>
</div>
<div class="mt-4">
<div class="mt-4 mb-20">
<ul>
<li
:for={{notebook_info, number} <- Enum.with_index(@group_info.notebook_infos, 1)}