I'm trying to build a game in Swift that should run in the browser and build its GUI in Tokamak. I'd like to use a <canvas> element to render the game's 2D graphics [0].
I was able to create a custom view which creates a <div> using Tokamak's DynamicHTML view and place the <canvas> element inside of it. The problem I'm facing is how I can trigger a re-draw of the canvas' content whenever the state of my view changes. I'm trying to find some way to have a function called whenever the view's state changes after any updates have been applied to the DOM.
My current solution is hurting quite bad; I've attached a custom observer to my @ObservedObject containing the state to be drawn. The observer is called during the update so I'm scheduling a call to my paint() function using setTimeout(..., 0). I have to main issues that I'd like to solve:
- It is a hack and I'm expecting it to break in subtle ways.
- I had to manually add an observer to the object holding the game's state which seems like I'm circumventing Tokamak's update logic.
Is there a better way to achieve my goal of having the state managed by Tokamak but customize how updates are applied to the DOM?
This is the complete code I currently have, which works as intended. The code can be tested live by running carton init --template tokamak in a new directory, placing the code in the created main.swift file and running carton dev:
import JavaScriptKit
import TokamakDOM
import CombineShim
let global = JSObject.global
let document = global.document
/// Example game state which simply specifies the darkness of a circle rendered to a `<canvas>` element.
class GameState: ObservableObject {
@TokamakDOM.Published var circleGrayLevel = 0.0
}
struct Canvas: View {
/// State that should be rendered by this Canvas instance.
@ObservedObject var gameState: GameState
/// This property is set while the view is first rendered. It contains a reference to a `<div>` element created through Tokamak into which the `<canvas>` is placed.
@State private var canvasContainer: JSObject!
/// This property is set once the view is first rendered. It contains reference to the `<canvas>` element we create manually.
@State private var canvas: JSObject!
/// Keeps a listener on gameState alive.
@State private var sink: CombineShim.AnyCancellable?
/// Keeps a `JSClosure` reference to `paint()` alive.
@State private var paintClosure: JSClosure!
/// This is a hack which allows `paint()` to be called _after_ an update to `gameState` has been processed. Otherwise `paint()` would not have access to the new state of `gameState`.
private func schedulePaint() {
_ = global.setTimeout!(paintClosure, 0)
}
private func paint() {
var ctx = canvas!.getContext!("2d")
_ = ctx.clearRect(0, 0, canvas.width, canvas.height)
// Convert gray level (darkness) to value (brightness) used for each RGB channel.
let v = (1 - gameState.circleGrayLevel) * 255
ctx.fillStyle = .string("rgb(\(v) \(v) \(v))")
/// Draw a circle with a gray controlled by `gameState`.
_ = ctx.beginPath()
_ = ctx.arc(100, 75, 50, 0, 2 * Double.pi);
_ = ctx.fill()
_ = ctx.stroke()
}
var body: some View {
DynamicHTML(
"div",
[:],
listeners: [:])
._domRef($canvasContainer)
.onAppear(perform: {
/// Create the `<canvas>` element we will draw to. We let Tokamak create a dummy `<div>` element and place our element inside of it. This is necessary because otherwise Tokamak will set the `width` and `height` attributes on the `<canvas>` element on each update. Setting these properties clears the canvas' backing buffer, clearing the drawing.
canvas = document.createElement("canvas").object
canvas.width = 300
canvas.height = 300
/// Workaround.
/// See https://github.com/swiftwasm/JavaScriptKit/issues/132
var style = canvas.style
style.imageRendering = "pixelated"
_ = canvasContainer.appendChild!(canvas)
paintClosure = JSClosure { _ in
self.paint()
return .undefined
}
/// Call `paint()` whenever `gameState` changes.
sink = gameState.objectWillChange.sink { _ in
schedulePaint()
}
/// Initial paint after the `<canvas>` element has been created.
schedulePaint()
})
}
}
struct GameApp: App {
@StateObject private var gameState = GameState()
var body: some Scene {
WindowGroup("Test") {
VStack {
Canvas(gameState: gameState)
Slider(value: $gameState.circleGrayLevel, in: 0...1)
}
}
}
}
GameApp.main()
[0]: The game does not use animations but may need to render a large amount of graphical elements, only part of which need to be redrawn on each update, similiar to some older stategy games like Sim City or Civilization. So I may want to draw 10'000 tiles on screen but only need to re-draw 100 on an update. I could do this by placing a lot of Image views in a grid but I don't want to push such a large tree of views though Tokamak on each update.