Here's a quick example of a Quiz game in Scala.
You can run this with scala-cli by pointing it to this gist with
scala-cli --power --enable-markdown https://gist.github.com/JD557/4bd5b7acd53a08e8a9a306a2bde57a97
This project uses the Scala toolkit, InterIm and Minart.
The Scala toolkit requires at least Java 11 for the HTTP requests, so we are using that.
//> using resourceDir ./
//> using jvm "adopt:11"
//> using scala "3.3"
//> using toolkit default
//> using dep "eu.joaocosta::minart::0.6.5"
//> using dep "eu.joaocosta::interim::0.2.0"
import scala.concurrent.Future
import sttp.client4.Response
import sttp.client4.quick.*
import upickle.default.*
import java.util.Base64
import java.text.Normalizer
import eu.joaocosta.interim.*
import eu.joaocosta.interim.InterIm.*
import eu.joaocosta.minart.backend.defaults.given
import eu.joaocosta.minart.graphics.image.*
import eu.joaocosta.minart.graphics.{Color => MinartColor, *}
import eu.joaocosta.minart.input.*
import eu.joaocosta.minart.runtime.*We'll get our quiz questions from the Open Trivia Database.
To do that, we'll query the backend with sttp and parse the response with ujson.
The API returns all strings encoded as Base64, so we add a Question#decode method to handle the decoding.
We'll also get rid of non-ASCII characters, since we don't have those in our font.
val base64Decoder = Base64.getDecoder()
def decodeAndNormalize(str: String): String =
Normalizer.normalize(new String(base64Decoder.decode(str)), Normalizer.Form.NFD).replaceAll("[^\\p{ASCII}]", "");
case class Question(question: String, correct_answer: String, incorrect_answers: List[String]) derives ReadWriter:
def decode =
copy(
question = decodeAndNormalize(question),
correct_answer = decodeAndNormalize(correct_answer),
incorrect_answers = incorrect_answers.map(decodeAndNormalize)
)
val questions: List[Question] =
val response: Response[String] = quickRequest
.get(uri"https://opentdb.com/api.php?amount=48&encode=base64")
.send()
read[List[Question]](
ujson.read(response.body)("results")
).map(_.decode)Next we define the game logic.
First, we have our player model. Each player has a name, a number of correct answers and the total answers.
We also define some helper methods to add a correct or incorrect answer.
case class Player(name: String, correctAnswers: Int = 0, totalAnswers: Int = 0):
def addCorrectAnswer =
copy(correctAnswers = correctAnswers + 1, totalAnswers = totalAnswers + 1)
def addIncorrectAnswer =
copy(totalAnswers = totalAnswers + 1)Then we define our game state, which is just a sequence of questions and players. For simplicity, we'll assume that the current question and current player are always the head of the list.
case class GameState(
questions: List[Question] = questions,
players: List[Player] = Nil
):
val currentQuestion = questions.head.question
val currentOptions =
scala.util.Random.shuffle(questions.map(q => q.correct_answer :: q.incorrect_answers).head.toVector)
val correctAnswer = questions.head.correct_answer
def fillPlayers(numPlayers: Int) =
copy(players = (0 until numPlayers).map(idx => Player(s"Player ${idx + 1}")).toList)
def addCorrectAnswer =
copy(questions.tail :+ questions.head, players.tail :+ players.head.addCorrectAnswer)
def addIncorrectAnswer =
copy(questions.tail :+ questions.head, players.tail :+ players.head.addIncorrectAnswer)
def submitAnswer(answer: String) =
if (answer == correctAnswer) addCorrectAnswer
else addIncorrectAnswerFirst, we define some constants that we need for our user interface.
val windowWidth = 800
val windowHeight = 600
val spleenLarge = Font("spleen-12-24", 24, 12)
val spleenSmall = Font("spleen-8-16", 16, 8)
val uiContext = new UiContext() // Internal state used by InterImNext, we'll have two interfaces:
- If the game state has no players, we want to select the number of players
- Otherwise, we present a question and buttons with the answers
For the player selection UI, we'll show a text asking "How many players?" and 4 buttons (1 to 4 players)
If a button is clicked, we update our state with the required number of players
def playerSelection(inputState: InputState, gameState: GameState) =
ui(inputState, uiContext):
dynamicRows(Rect(0, 0, windowWidth, windowHeight).shrink(10), padding = 0): row ?=>
text(
area = row(128),
color = Color(255, 255, 255),
message = "How many players?",
font = spleenLarge,
horizontalAlignment = centerHorizontally,
verticalAlignment = centerVertically
)
grid(row(maxSize), numRows = 2, numColumns = 2, padding = 5): cells =>
cells.flatten.zipWithIndex
.flatMap: (cell, idx) =>
button(
id = s"answer-$idx",
label = (idx + 1).toString,
skin = skins.ButtonSkin.lightDefault.copy(font = spleenLarge)
)(area = cell)(gameState.fillPlayers(idx + 1))
.headOption
.getOrElse(gameState)During the game, our layout is very similar, but now we show:
- A text with the question
- The player scores
- Buttons with the answers
If a player presses a button, we update the score and go to the next player/question.
def inGame(inputState: InputState, gameState: GameState) =
ui(inputState, uiContext):
dynamicRows(Rect(0, 0, windowWidth, windowHeight).shrink(10), padding = 0): row ?=>
// Question
text(
area = row(128),
color = Color(255, 255, 255),
message = gameState.currentQuestion,
font = spleenLarge,
horizontalAlignment = centerHorizontally,
verticalAlignment = centerVertically
)
// Scores
columns(row(32), numColumns = 4, padding = 5):
gameState.players.zipWithIndex.sortBy(_._1.name)
.foreach: (player, idx) =>
text(
area = summon,
color = if (idx == 0) Color(150, 255, 150) else Color(255, 255, 255),
message = s"${player.name} - Score ${player.correctAnswers} / ${player.totalAnswers}",
font = spleenSmall,
horizontalAlignment = alignLeft,
verticalAlignment = centerVertically
)
// Answers
grid(row(maxSize), numRows = 2, numColumns = 2, padding = 5): cells =>
cells.flatten
.zip(gameState.currentOptions)
.flatMap: (cell, option) =>
button(
id = s"answer-$option",
label = option,
skin = skins.ButtonSkin.lightDefault.copy(font = spleenLarge)
)(cell)(gameState.submitAnswer(option))
.headOption
.getOrElse(gameState)def application(inputState: InputState, gameState: GameState) =
if (gameState.players.isEmpty) playerSelection(inputState, gameState)
else inGame(inputState, gameState)
MinartBackend.run(GameState())(application)For the graphical backend, we use Minart to interpret the operations and run the app loop.
object MinartBackend:
trait MinartFont:
def charWidth(char: Char): Int
def coloredChar(char: Char, color: MinartColor): SurfaceView
case class BitmapFont(file: String, width: Int, height: Int, fontFirstChar: Char = '\u0000') extends MinartFont:
private val spriteSheet = SpriteSheet(Image.loadBmpImage(Resource(file)).get, width, height)
def charWidth(char: Char): Int = width
def coloredChar(char: Char, color: MinartColor): SurfaceView =
spriteSheet.getSprite(char.toInt - fontFirstChar.toInt).map {
case MinartColor(255, 255, 255) => color
case c => MinartColor(255, 0, 255)
}
case class BitmapFontPack(fonts: List[BitmapFont]):
val sortedFonts = fonts.sortBy(_.height)
def withSize(fontSize: Int): MinartFont =
val baseFont = sortedFonts.filter(_.height <= fontSize).lastOption.getOrElse(sortedFonts.head)
if (baseFont.height == fontSize) baseFont
else
val scale = fontSize / baseFont.height.toDouble
new MinartFont:
def charWidth(char: Char): Int = (baseFont.width * scale).toInt
def coloredChar(char: Char, color: MinartColor): SurfaceView = baseFont.coloredChar(char, color).scale(scale)
// Spleen font by Frederic Cambus: https://github.com/fcambus/spleen
private val spleen = BitmapFontPack(List(
BitmapFont("spleen-8-16.bmp", 8, 16, ' '),
BitmapFont("spleen-12-24.bmp", 12, 24, ' ')
))
private def getInputState(canvas: Canvas): InputState = InputState(
canvas.getPointerInput().position.map(_.x).getOrElse(0),
canvas.getPointerInput().position.map(_.y).getOrElse(0),
canvas.getPointerInput().isPressed,
""
)
private def renderUi(canvas: Canvas, renderOps: List[RenderOp]): Unit =
renderOps.foreach {
case RenderOp.DrawRect(Rect(x, y, w, h), color) =>
canvas.fillRegion(x, y, w, h, MinartColor(color.r, color.g, color.b))
case op: RenderOp.DrawText =>
val font = spleen.withSize(op.font.fontSize)
op.asDrawChars.foreach { case RenderOp.DrawChar(Rect(x, y, _, _), color, char) =>
val charSprite = font.coloredChar(char, MinartColor(color.r, color.g, color.b))
canvas
.blit(charSprite, BlendMode.ColorMask(MinartColor(255, 0, 255)))(x, y)
}
case RenderOp.Custom(Rect(x, y, w, h), color, _) =>
canvas.fillRegion(x, y, w, h, MinartColor(color.r, color.g, color.b))
}
val canvasSettings =
Canvas.Settings(
width = windowWidth,
height = windowHeight,
title = "Quiz",
clearColor = MinartColor(80, 110, 120)
)
def run[S](initialState: S)(body: (InputState, S) => (List[RenderOp], S)): Future[S] =
AppLoop
.statefulRenderLoop { (state: S) => (canvas: Canvas) =>
val inputState = getInputState(canvas)
canvas.clear()
val (ops, newState) = body(inputState, state)
renderUi(canvas, ops)
canvas.redraw()
newState
}
.configure(
canvasSettings,
LoopFrequency.hz60,
initialState
)
.run()