How to make a responsive UI using Tyrian
Responsive UI Using Tyrian
There tend to be two primary types of UI in games: In-game UI (such as health over a player), and overlay (such as a HUD or dialog box). In-game UI tends not to worry too much about the screen size, and is dealt with primarily by the magnification configuration within Indigo. But what about overlays and HUD's? These tend to scale with the size of the screen and the position of such elements may change depending on the capabilities of the display. Many game engines provide a way to create overlay UI elements that scale in some way to the space they're in using a separate UI system. In Indigo we're exporting to JavaScript and a browser (even in Electron), so depending on how we want the UI to look, one option is to employ more standard web technologies to do this work for us, i.e. HTML and CSS.
Using Tyrian we can utilise HTML and CSS to ensure our overlay UI scales no matter what screen size we use.
In this guide we'll take the basic hello-indigo
example and add a button and
a counter that scales with the size of the screen. It's a pretty basic example,
but it should give you an idea of what can be achieved.
Overview
This is a fairly involved guide with many parts to it that need to be implemented
in order to get everything working. However the concept is pretty simple - we'll
be using the Tyrian Bridge as a communication layer between Indigo and our
index.html
(which is controlled by Tyrian).
These are the steps we're going to take:
- Set up the environment - e.g. clone
hello-indigo
and set up ParcelJS - Modify our build files - e.g. update our
build.sbt
orbuild.sc
files - Create our static files - e.ge create our html, js, css and other static content
- Update Indigo with a new subsystem and Initialise our Tyrian app
- Get Tyrian publishing messages to Indigo (and Indigo to subscribe to them)
- Get Indigo publishing messages to Tyrian (and Tyrian to subscribe to them)
- And finally creating a responsive UI in CSS
Setup
Setting up the Environment
For this guide we'll be using the hello-indigo
example in either
mill or
sbt.
In the existing implementation clicking on the game window will create a new dot
that then rotates around the canvas. We'll modify this so that clicking a button
does this job instead.
You'll want to upgrade hello-indigo
to use IndigoGame
. To do this either
follow this guide
or replace HelloIndigo.scala
with
this
import indigo.*
import indigo.scenes.*
import tyrian.TyrianSubSystem
import cats.effect.IO
final case class HelloIndigo(tyrianSubSystem: TyrianSubSystem[IO, Int]) extends IndigoGame[Unit, Unit, Model, Unit] {
val magnification = 3
val config: GameConfig =
GameConfig.default.withMagnification(magnification)
val assetName = AssetName("dots")
val assets: Set[AssetType] =
Set(
AssetType.Image(AssetName("dots"), AssetPath("assets/dots.png"))
)
def initialScene(bootData: Unit): Option[SceneName] =
None
def scenes(bootData: Unit): NonEmptyList[Scene[Unit, Model, Unit]] =
NonEmptyList(Scene.empty)
val eventFilters: EventFilters =
EventFilters.Permissive
def boot(flags: Map[String, String]): Outcome[BootResult[Unit]] =
Outcome(
BootResult
.noData(config)
.withAssets(assets)
.withSubSystems(tyrianSubSystem)
)
def setup(
bootData: Unit,
assetCollection: AssetCollection,
dice: Dice
): Outcome[Startup[Unit]] =
Outcome(Startup.Success(()))
def initialModel(startupData: Unit): Outcome[Model] =
Outcome(
Model.initial(
config.viewport.giveDimensions(magnification).center
)
)
def updateModel(
context: FrameContext[Unit],
model: Model
): GlobalEvent => Outcome[Model] = {
case MouseEvent.Click(pt) =>
val adjustedPosition = pt - model.center
Outcome(
model.addDot(
Dot(
Point.distanceBetween(model.center, pt).toInt,
Radians(
Math.atan2(
adjustedPosition.x.toDouble,
adjustedPosition.y.toDouble
)
)
)
)
)
case FrameTick =>
Outcome(model.update(context.delta))
case _ =>
Outcome(model)
}
def initialViewModel(startupData: Unit, model: Model): Outcome[Unit] =
Outcome(())
def updateViewModel(
context: FrameContext[Unit],
model: Model,
viewModel: Unit
): GlobalEvent => Outcome[Unit] =
_ => Outcome(())
def present(
context: FrameContext[Unit],
model: Model,
viewModel: Unit
): Outcome[SceneUpdateFragment] =
Outcome(
SceneUpdateFragment(
Graphic(Rectangle(0, 0, 32, 32), 1, Material.Bitmap(assetName)) ::
drawDots(model.center, model.dots)
)
)
def drawDots(
center: Point,
dots: Batch[Dot]
): Batch[Graphic[_]] =
dots.map { dot =>
val position = Point(
(Math.sin(dot.angle.toDouble) * dot.orbitDistance + center.x).toInt,
(Math.cos(dot.angle.toDouble) * dot.orbitDistance + center.y).toInt
)
Graphic(Rectangle(0, 0, 32, 32), 1, Material.Bitmap(assetName))
.withCrop(Rectangle(16, 16, 16, 16))
.withRef(8, 8)
.moveTo(position)
}
}
case class Model(center: Point, dots: Batch[Dot]) {
def addDot(dot: Dot): Model =
this.copy(dots = dot :: dots)
def update(timeDelta: Seconds): Model =
this.copy(dots = dots.map(_.update(timeDelta)))
}
object Model {
def initial(center: Point): Model = Model(center, Batch.empty)
}
case class Dot(orbitDistance: Int, angle: Radians) {
def update(timeDelta: Seconds): Dot =
this.copy(angle = angle + Radians.fromSeconds(timeDelta))
}
Although Indigo builds and exports to Electron natively, for this project we'll export directly to HTML and use Yarn to run our web server, with ParcelJS to copy and package up our HTML, JS, and CSS.
You can use NPM to install Yarn with the command npm install yarn
, and then
install ParcelJS with yarn add parcel --dev
.
Setting up the Build
Next you'll need to update either your
build.sc
(if using mill) to with this:
def buildGame() = T.command {
T {
compile()
fastOpt()
- indigoBuild()()
}
}
- def runGame() = T.command {
- T {
- compile()
- fastOpt()
- indigoRun()()
- }
- }
-
val indigoVersion = "0.12.1"
def ivyDeps =
Agg(
+ ivy"io.indigoengine::tyrian::0.3.1",
+ ivy"io.indigoengine::tyrian-indigo-bridge::0.3.1",
ivy"io.indigoengine::indigo-json-circe::$indigoVersion",
ivy"io.indigoengine::indigo::$indigoVersion",
ivy"io.indigoengine::indigo-extras::$indigoVersion"
)
or build.sbt
(if using sbt) to this:
libraryDependencies ++= Seq(
"io.indigoengine" %%% "indigo" % "0.12.1",
+ "io.indigoengine" %%% "tyrian" % "0.3.1",
+ "io.indigoengine" %%% "tyrian-indigo-bridge" % "0.3.1",
)
-addCommandAlias("buildGame", ";compile;fastOptJS;indigoBuild")
+addCommandAlias("buildGame", ";compile;fastOptJS")
-addCommandAlias("runGame", ";compile;fastOptJS;indigoRun")
What we've done here is add Tyrian and the Tyrian Indigo Bridge to our build.
Tyrian will deal with all of our HTML and the Indigo Bridge will deal with
communication between Tyrian and Indigo. We've also removed runGame
as this
would usually run Electron.
Setting up the HTML and ParcelJS
Usually Indigo will generate all of our HTML for us and run it through Electron, but for this example we're going to generate our own HTML so that we can inject CSS.
Firstly we'll create an app.js
file that simply loads Tyrian and then launches
it for our page:
Note: The path in this example is from a Mill build, sbt's output will live in the target
directory.
import {
TyrianApp
} from './out/HelloIndigo/fastOpt.dest/out.js';
TyrianApp.launch("main");
We'll also need an index.html
to hold our basic HTML data:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello Indigo</title>
<link rel="stylesheet" href="css/main.css" />
<script type="module" defer src="app.js"></script>
</head>
<body>
<div id="main"></div>
</body>
</html>
Tyrian currently uses snabbdom
for adding and removing elements in a web page.
To do this we will need to add a dependency to snabbdom
by running
yarn add snabbdom
.
You'll notice we're using the direct JS output from our build here, which may feel odd. What will happen when we run ParcelJS through Yarn is that the HTML will be copied to a build directory along with the JS, CSS and any dependant static files (such as images) that may be needed, and everything will be correctly linked by Parcel.
Now generate an empty css/main.css
.
Next we'll need to update the package.json
so that ParcelJS will run when yarn
starts and copies the static files for Indigo to use. To do this add the
following to the top of package.json
:
"scripts": {
"start": "parcel index.html --open --no-cache"
}
We'll need to add to tell ParcelJS about our static files by adding the
following to package.json
:
"staticFiles": {
"staticPath": "assets",
"staticOutPath": "assets",
"watcherGlob": "**"
}
We need to add the dependency for static files by running
yarn add parcel-reporter-static-files-copy --dev
from the command line.
Finally, we need to configure ParcelJS so that it knows how to copy our static
files. Add a .parcelrc
file and add the following:
{
"extends": ["@parcel/config-default"],
"reporters": ["...", "parcel-reporter-static-files-copy"]
}
Update HelloIndigo.scala
We'll be using the
TyrianSubSystem
as a way of communicating between Indigo and Tyrian. Add
import tyrian.TyrianSubSystem
to the imports in HelloIndigo.scala
, and then
update the object
to be a case class
so that we can pass in the subsystem as an argument. We can also remove the top level export, as we'll no longer need it:
- import scala.scalajs.js.annotation.JSExportTopLevel
+ import tyrian.TyrianSubSystem
+ import cats.effect.IO
- @JSExportTopLevel("IndigoGame")
- object HelloIndigo extends IndigoGame[Unit, Unit, Model, Unit] {
+ final case class HelloIndigo(tyrianSubSystem: TyrianSubSystem[IO, Int]) extends IndigoGame[Unit, Unit, Model, Unit] {
We'll also need to tell Indigo to use the new sub-system, which we can do by
adding an entry to the boot
method like so:
def boot(flags: Map[String, String]): Outcome[BootResult[Unit]] =
Outcome(
BootResult
.noData(config)
.withAssets(assets)
+ .withSubSystems(tyrianSubSystem)
)
Create a Tyrian App
We'll now develop our initial Tyrian app, which will consist simply of
a canvas (for Indigo), a counter, and a button, wrapped in a few div
elements.
Create a new file called HelloTyrian.scala
inside the helloindigo/src
folder
and add the following contents found below, or
here.
import cats.effect.IO
import tyrian.*
import tyrian.Html.*
import org.scalajs.dom.document
import scala.scalajs.js.annotation.*
enum Msg:
case StartIndigo extends Msg
// @JSExportTopLevel("TyrianApp") // Pandering to mdoc...
object Main extends TyrianApp[Msg, TyrianModel]:
val gameDivId = "game-container"
def init(flags: Map[String, String]): (TyrianModel, Cmd[IO, Msg]) =
(TyrianModel.init, Cmd.Emit(Msg.StartIndigo))
def update(model: TyrianModel): Msg => (TyrianModel, Cmd[IO, Msg]) =
case Msg.StartIndigo =>
(
model,
Cmd.SideEffect {
HelloIndigo(model.bridge.subSystem(IndigoGameId(gameDivId)))
.launch(
gameDivId,
"width" -> "550",
"height" -> "400"
)
}
)
def view(model: TyrianModel): Html[Msg] =
div(`class` := "main")(
div(`class` := "game", id := gameDivId)(),
div(`class`:= "counter")(),
div(`class`:= "btn")(
button()("Click me")
)
)
def subscriptions(model: TyrianModel): Sub[IO, Msg] =
Sub.None
def main(args: Array[String]): Unit =
Tyrian.start(document.getElementById("main"), init(Map()), update, view, subscriptions, 1024)
final case class TyrianModel(bridge: TyrianIndigoBridge[IO, Int])
object TyrianModel:
val init: TyrianModel = TyrianModel(TyrianIndigoBridge())
You can now build the project in mill (mill helloindigo.buildGame
) or sbt (sbt buildGame
) and then run yarn start
to see the HelloIndigo demo running inside a Tyrian website. This is great if you want a
website surrounding your game with no interaction, but it would be much more
useful to get them both talking to each other... this is what we're doing next!
Tyrian to Indigo Communication
The first thing we'll do is get Tyrian to request that Indigo add a new dot. To
do this we'll need to add an OnClick
event to our button, which will tell
Indigo what to do.
In HelloTyrian.scala
, we'll need a new message type. Add a new message type called AddDot like so:
enum Msg:
case StartIndigo extends Msg
+ case AddDot extends Msg
Next we'll need to deal with that message in Tyrian, by adding a case to update
like this:
msg match
+ case Msg.AddDot =>
+ (model, model.bridge.publish(IndigoGameId(gameDivId), 0))
case Msg.StartIndigo =>
The final part on the Tyrian side is to hook the button up to fire the AddDot
message. To do this add an OnClick
attribute to our button like so:
div(`class` := "btn")(
- button()("Click me")
+ button(onClick(Msg.AddDot))("Click me")
)
This will now send an event to Indigo with an integer message, which in this case
we've set to zero. Now we just need to modify Indigo to receive and
process that message. To do this change the MouseEvent.Click
line in
HelloIndigo.scala
with the following:
): GlobalEvent => Outcome[Model] = {
- case MouseEvent.Click(pt) =>
+ case tyrianSubSystem.TyrianEvent.Receive(msg) =>
+ val pt = Point(100, 100)
val adjustedPosition = pt - model.center
We've also added a line below that to set a pt
variable as we now longer have
a mouse position. For now, we've set that to 100,100 like so which is just a
fixed point for the dots to rotate around.
Once more build the project in mill or sbt and then run yarn start
. This time
you'll notice that clicking the game doesn't do anything, but clicking the
button adds a new rotating dot.
We've now successfully gotten Tyrian to talk to Indigo. In the next part we'll be doing the opposite, and adding a counter for the number of dots.
Indigo to Tyrian Communication
In this part we're going to store a count of the number of dots that Indigo is
currently displaying. To do this, first update the model in HelloTyrian.scala
so that it has a count
property which is initialised to zero, like so:
-final case class TyrianModel(bridge: TyrianIndigoBridge[Int])
+final case class TyrianModel(bridge: TyrianIndigoBridge[Int], count: Int)
object TyrianModel:
- val init: TyrianModel
+ val init: TyrianModel =
+ TyrianModel(TyrianIndigoBridge(), 0)
We'll need to display that count on the website. To do this, we simply modify our
counting div
to display what's in the model
like so
- div(`class` := "counter")(),
+ div(`class` := "counter")(model.count.toString)
A new message type is also required as before, so we'll add a new one like so:
enum Msg:
- case StartIndigo extends Msg
+ case StartIndigo extends Msg
- case AddDot extends Msg
+ case AddDot extends Msg
+ case IndigoReceive(msg: Int) extends Msg
We'll need to subscribe to incoming Indigo messages so that we can act on them. This can be done using the bridge subscriptions like so:
def subscriptions(model: TyrianModel): Sub[Msg] =
- Sub.Empty
+ model.bridge.subscribe { case msg =>
+ Some(Msg.IndigoReceive(msg))
+ }
The final part on the Tyrian side is then to update the model once we get a
message from Indigo. Indigo will send an Integer message back to Tyrian, which
we can use directly in our model To do this we simply add the following to our
update
pattern:
msg match
case Msg.AddDot =>
(model, model.bridge.publish(IndigoGameId(gameDivId), 0))
+ case Msg.IndigoReceive(msg) =>
+ (model.copy(count = msg), Cmd.Empty)
case Msg.StartIndigo =>
For the Indigo side we need to modify the updateModel
so that the new model is
assigned to a variable for later use. We can then add a global event through
the Tyrian Subsystem letting the website know how many dots we have. This is done
with the following:
case tyrianSubSystem.TyrianEvent.Receive(msg) =>
val pt = Point(100, 100)
val adjustedPosition = pt - model.center
-
- Outcome(
- model.addDot(
- Dot(
- Point.distanceBetween(model.center, pt).toInt,
- Radians(
- Math.atan2(
- adjustedPosition.x.toDouble,
- adjustedPosition.y.toDouble
- )
- )
- )
- )
- )
+ val newModel = model.addDot(
+ Dot(
+ Point.distanceBetween(model.center, pt).toInt,
+ Radians(
+ Math.atan2(
+ adjustedPosition.x.toDouble,
+ adjustedPosition.y.toDouble
+ )
+ )
+ )
+ )
+
+ Outcome(newModel)
+ .addGlobalEvents(tyrianSubSystem.send(newModel.dots.length))
The important part here is the addGlobalEvents
which sends a subsystem event
to Tyrian.
Now whenever you press the button on the website, the counter will increase by one! This is the basics of communication to and from Tyrian, but we can go further with a little CSS magic. In the next part we'll be dealing purely with CSS to show how we can make a responsive UI.
Responsive UI
One of the benefits of adding UI via Tyrian is that you get full use of the power of CSS and HTML for creating a game that supports all sorts of screen sizes. To do this, we'll be making use of Flexbox and Media Queries.
We'll gloss over the initial setup as it's a lot of boiler-plate. Replace your
css/main.css
with
this.
All of the work in this part takes place in main.css
.
On a desktop that looks pretty good, so we're going to change the layout for mobiles. To do this, we'll first make a media query CSS rule at the end of our CSS like so:
@media (max-width: 767px) {
}
All of the next few parts will now take place within the curly braces of that media query. Firstly we'll change the button to be 100% of the screen width, and increase it's height so it's easier to click on a mobile:
.btn {
align-self: flex-end;
padding-bottom: 0;
}
.btn button {
width: 100vw;
height: 7rem;
}
Now we'll make the counter bigger, so it's easier to read:
.counter {
font-size: 5rem;
}
And finally, we'll reduce the size of the Indigo canvas so that it will fit on a smaller device sizes. It's worth noting that, to date, Indigo won't deal particularly well with mouse positions when the canvas is scaled like this. For our purposes though, this will be fine:
canvas {
transform: scale(0.75);
}
Once more run yarn start
and you'll now be able to scale your browser window
bigger and smaller to see the effects of our changes.
Further Work
Now you know the basics of responsive UI using Tyrian and Indigo you can experiment with some more advanced features. You could, for example, fire an event to Indigo on a canvas resize that will change the size Indigo renders at (which is more efficient that the transform we used here). We could also use CSS to show a dialog box, preventing the player from interacting with the game until they've given feedback.