mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-07 13:34:55 +08:00
Guides updated
This commit is contained in:
parent
d880b9a073
commit
5d6cb53831
6 changed files with 61 additions and 1026 deletions
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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).
|
|
@ -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.
|
|
@ -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)!
|
||||
|
|
|
@ -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)}
|
||||
|
|
Loading…
Add table
Reference in a new issue