diff --git a/lib/livebook/notebook/learn.ex b/lib/livebook/notebook/learn.ex index 27b0a4640..dcc39a7bb 100644 --- a/lib/livebook/notebook/learn.ex +++ b/lib/livebook/notebook/learn.ex @@ -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 ] } diff --git a/lib/livebook/notebook/learn/deploy_apps.livemd b/lib/livebook/notebook/learn/deploy_apps.livemd index a878b59f3..6dbefe9fb 100644 --- a/lib/livebook/notebook/learn/deploy_apps.livemd +++ b/lib/livebook/notebook/learn/deploy_apps.livemd @@ -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 diff --git a/lib/livebook/notebook/learn/kino/reference.livemd b/lib/livebook/notebook/learn/kino/intro_to_kino.livemd similarity index 73% rename from lib/livebook/notebook/learn/kino/reference.livemd rename to lib/livebook/notebook/learn/kino/intro_to_kino.livemd index 0911594aa..693445e38 100644 --- a/lib/livebook/notebook/learn/kino/reference.livemd +++ b/lib/livebook/notebook/learn/kino/intro_to_kino.livemd @@ -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. @@ -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. + + + +## 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) +``` + ## Kino.Markdown @@ -170,7 +192,7 @@ Kino.Tree.new(data) -## 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 🚀" ``` - - -## 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. @@ -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). diff --git a/lib/livebook/notebook/learn/kino/pong.livemd b/lib/livebook/notebook/learn/kino/pong.livemd deleted file mode 100644 index b62da2cc0..000000000 --- a/lib/livebook/notebook/learn/kino/pong.livemd +++ /dev/null @@ -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)) - - """ - - #{svgs} - - """ - 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 - """ - - """ - 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} - """ - 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 - """ - - """ - 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. - - - -## 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 - """ - - """ - 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! - - - -## 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. diff --git a/lib/livebook/notebook/learn/kino/vm_introspection.livemd b/lib/livebook/notebook/learn/kino/vm_introspection.livemd index e084604fa..097b23735 100644 --- a/lib/livebook/notebook/learn/kino/vm_introspection.livemd +++ b/lib/livebook/notebook/learn/kino/vm_introspection.livemd @@ -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)! diff --git a/lib/livebook_web/live/learn_live.ex b/lib/livebook_web/live/learn_live.ex index 8b0975a54..bf9d88c30 100644 --- a/lib/livebook_web/live/learn_live.ex +++ b/lib/livebook_web/live/learn_live.ex @@ -86,7 +86,7 @@ defmodule LivebookWeb.LearnLive do

-
+