mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-13 16:34:45 +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,
|
ref: :kino_intro,
|
||||||
path: Path.join(__DIR__, "learn/kino/reference.livemd")
|
path: Path.join(__DIR__, "learn/kino/intro_to_kino.livemd")
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
ref: :kino_vm_introspection,
|
ref: :kino_vm_introspection,
|
||||||
path: Path.join(__DIR__, "learn/kino/vm_introspection.livemd")
|
path: Path.join(__DIR__, "learn/kino/vm_introspection.livemd")
|
||||||
},
|
},
|
||||||
%{
|
|
||||||
ref: :kino_pong,
|
|
||||||
path: Path.join(__DIR__, "learn/kino/pong.livemd")
|
|
||||||
},
|
|
||||||
%{
|
%{
|
||||||
ref: :kino_custom_kinos,
|
ref: :kino_custom_kinos,
|
||||||
path: Path.join(__DIR__, "learn/kino/custom_kinos.livemd")
|
path: Path.join(__DIR__, "learn/kino/custom_kinos.livemd")
|
||||||
|
@ -200,15 +196,14 @@ defmodule Livebook.Notebook.Learn do
|
||||||
|
|
||||||
@group_configs [
|
@group_configs [
|
||||||
%{
|
%{
|
||||||
title: "Interactive notebooks with Kino",
|
title: "Deep dive into Kino",
|
||||||
description:
|
description:
|
||||||
"Learn more about the Kino package, including the creation of custom UI components.",
|
"Learn more about the Kino package, including the creation of custom UI components.",
|
||||||
cover_url: "/images/kino.png",
|
cover_url: "/images/kino.png",
|
||||||
notebook_refs: [
|
notebook_refs: [
|
||||||
:kino_reference,
|
:kino_intro,
|
||||||
:kino_vm_introspection,
|
:kino_vm_introspection,
|
||||||
:kino_custom_kinos,
|
:kino_custom_kinos,
|
||||||
:kino_pong,
|
|
||||||
:kino_smart_cells
|
: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
|
In a nutshell, Kino is a library that you install as part
|
||||||
of your notebooks to make your notebooks interactive.
|
of your notebooks to make your notebooks interactive.
|
||||||
Kino comes from the Greek prefix of the same name and it
|
Kino comes from the Greek prefix "kino-" and it stands for
|
||||||
stands for "motion". And, as you learn the library, it
|
"motion". As you learn the library, it will become clear that
|
||||||
will become clear that this is precisely what it brings to
|
this is precisely what it brings to our notebooks.
|
||||||
our notebooks.
|
|
||||||
|
|
||||||
There is many functionality in the Kino library: it can render
|
Kino can render Markdown, animate frames, display tables,
|
||||||
Markdown, animate frames, display tables, manage inputs, and
|
manage inputs, and more. It also provides the building blocks
|
||||||
more. For building notebook applications, we rely on two main
|
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`.
|
building blocks: `Kino.Control` and `Kino.Frame`.
|
||||||
|
|
||||||
You can see `kino` listed as a dependency above, so let's run
|
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
|
```elixir
|
||||||
Mix.install([
|
Mix.install([
|
||||||
|
@ -8,12 +8,15 @@ Mix.install([
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
In this notebook we will explore the possibilities that
|
Throughout the Learning section, we have used Kino several times.
|
||||||
[`kino`](https://github.com/elixir-nx/kino) brings
|
Sometimes we use built-in Kinos, such as using `Kino.Control` and
|
||||||
into your notebooks. Kino can be thought of as Livebook's
|
`Kino.Frame` to deploy applications](/learn/notebooks/deploy-apps),
|
||||||
friend that instructs it how to render certain widgets
|
other times we used custom Kinos tailired for
|
||||||
and interact with them. You can see `kino` listed as a
|
[data exploration](/learn/notebooks/intro-to-explorer) or
|
||||||
dependency above, let's run the setup cell and get started!
|
[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} -->
|
<!-- 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,
|
There are multiple types of inputs, such as text areas,
|
||||||
color dialogs, selects, and more. Feel free to explore them.
|
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} -->
|
<!-- livebook:{"branch_parent_index":0} -->
|
||||||
|
|
||||||
## Kino.Markdown
|
## Kino.Markdown
|
||||||
|
@ -170,7 +192,7 @@ Kino.Tree.new(data)
|
||||||
|
|
||||||
<!-- livebook:{"branch_parent_index":0} -->
|
<!-- livebook:{"branch_parent_index":0} -->
|
||||||
|
|
||||||
## Kino.render/1
|
## Kino.render/1 and Kino.Frame
|
||||||
|
|
||||||
As we saw, Livebook automatically recognises widgets returned
|
As we saw, Livebook automatically recognises widgets returned
|
||||||
from each cell and renders them accordingly. However, sometimes
|
from each cell and renders them accordingly. However, sometimes
|
||||||
|
@ -190,66 +212,22 @@ Kino.render(Kino.Markdown.new("**Hello world**"))
|
||||||
"Cell result 🚀"
|
"Cell result 🚀"
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- livebook:{"branch_parent_index":0} -->
|
The `Kino.Frame` construct we used when deploying our chat app is a
|
||||||
|
generalization of `Kino.render`, which gives us more control over when
|
||||||
## Kino.Frame and animations
|
to update, append, or clear the output:
|
||||||
|
|
||||||
`Kino.Frame` allows us to render an empty frame and update it
|
|
||||||
as we progress. Let's render an empty frame:
|
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
frame = Kino.Frame.new()
|
frame = Kino.Frame.new()
|
||||||
```
|
```
|
||||||
|
|
||||||
Now, let's render a random number between 1 and 100 directly
|
By default, a frame will update in place. Try running the cell below
|
||||||
in the frame:
|
several times:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
Kino.Frame.render(frame, "Got: #{Enum.random(1..100)}")
|
Kino.Frame.render(frame, "Got: #{Enum.random(1..100)}")
|
||||||
```
|
```
|
||||||
|
|
||||||
Notice how every time you reevaluate the cell above it updates
|
but you can use `append` and `clear` to get different results.
|
||||||
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.
|
|
||||||
|
|
||||||
<!-- livebook:{"branch_parent_index":0} -->
|
<!-- livebook:{"branch_parent_index":0} -->
|
||||||
|
|
||||||
|
@ -322,15 +300,9 @@ to inspect, toggle, and swap each operation along the way:
|
||||||
|> dbg()
|
|> dbg()
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next steps with custom Kinos
|
## Next steps
|
||||||
|
|
||||||
With this, we finished our introduction to Kino. Most the guides
|
We have learned many new Kinos in this section. In the next guide,
|
||||||
ahead of us will use Kino in one way or the other. You can jump
|
we will put some of our new found knowledge into practice [by rendering
|
||||||
into [the VegaLite guide](/learn/notebooks/intro-to-vega-lite)
|
inputs, plotting graphs, and drawing diagrams with information retrieved
|
||||||
for plotting charts or [the MapLibre guide](/learn/notebooks/intro-to-maplibre)
|
from the notebook runtime](/learn/notebooks/vm-introspection).
|
||||||
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.
|
|
|
@ -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
|
supervisor
|
||||||
```
|
```
|
||||||
|
|
||||||
We can go even further!
|
And we can go even further!
|
||||||
|
|
||||||
Sometimes a supervisor may be supervised by another supervisor,
|
Sometimes a supervisor may be supervised by another supervisor,
|
||||||
which may have be supervised by another supervisor, and so on.
|
which may have be supervised by another supervisor, and so on.
|
||||||
|
@ -363,5 +363,10 @@ for i <- 1..1_000_000 do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
In the next notebook, we will learn [how to use `Kino.Control`
|
In this notebook, we learned how powerful Kino and Livebook
|
||||||
to build a chat app](/learn/notebooks/chat-app)!
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4 mb-20">
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
:for={{notebook_info, number} <- Enum.with_index(@group_info.notebook_infos, 1)}
|
:for={{notebook_info, number} <- Enum.with_index(@group_info.notebook_infos, 1)}
|
||||||
|
|
Loading…
Add table
Reference in a new issue