James Randall Musings on software development, business and technology.
Empire of Asphalt: WebGL GUI with JSX

As I mentioned in my last entry, and as anyone who has played a city builder will know, these kinds of games have a lot of UI in them. That being the case I need a way of easily putting all this together. A primitive GUI library basically.

I do most of my web work in React or SolidJS and I quite like the JSX approach and so I struck upon the idea of building my UI toolkit based around this.

Happily the TypeScript compiler makes this pretty easy. If you ever look at the code underneath something like React you will find its been transpiled to lots of nested createElement calls - an element factory basically. The TypeScript compiler allows you to use your own factory instead by setting the jsxFactory property in tsconfig.json.

And so I began by updating my config file to look like this:

{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "sourceMap": true,
    "outDir": "build",
    "jsxFactory": "createGuiElement",
    "jsx": "react",
    "experimentalDecorators": true
  },
  "exclude": ["node_modules", "**/*.test.ts"]
}

With that done you can implement the factory function using a compatible signature:

export function createGuiElement<P = {}>(
  type: GuiElementType,
  props?: (Attributes & P) | null,
  ...children: GuiElement[]
): GuiElement {
  if (type === "button") {
    return new Button(props ?? undefined, children)
  } else if (type === "hlayout") {
    return new HLayout(props ?? undefined, children)
  } else if (type === "rect") {
    return new Rect(props ?? undefined, children)
  } else if (type === "image") {
    return new Image(props ?? undefined, children)
  } else if (type === "container") {
    return new Container(props ?? undefined, children)
  } else if (type === "window") {
    return new Window(props ?? undefined, children)
  } else if (type === "raisedbevel") {
    return new RaisedBevel(props ?? undefined, children)
  } else if (type === "bevel") {
    return new RaisedBevel(props ?? undefined, children)
  }
  return new Fragment(props ?? undefined, children)
}

I’ll go into the component implementation at some point in the future but their is one more thing you need to do to get this work without errors. And that’s supply type definitions for your JSX. You do this by popping a TypeScript type file called JSX.d.ts somewhere in your source and declaring an interface called IntrinsicElements:

interface IntrinsicElements {
    hlayout: HLayoutProps
    button: ButtonProps
    image: ImageProps
    rect: RectProps
    container: ContainerProps
    window: WindowProps
    raisedbevel: ChromeProps
    bevel: ChromeProps
}

The props are simply further interface declarations:

interface ImageProps extends GuiElementProps {
    name?: string
}

One thing to take note of - if you want to use a type directly in your code and in your JSX like an enum for horizontal alignment then you can do this but your editor will likely try and add an import to the top of the definition file. Do not let it!!! That will turn the module from an ambient module to a local module. Things will work as is without any import required but to make imports work in an ambient module you use a slightly different syntax:

declare namespace JSX {
    type MouseButton = import("./components/InteractiveElement").MouseButton
    // ...

With all that done here’s a sample of my UI code:

export function testGui(state: Game): GuiElement {
  const bs = 48
  const bp = 8

  return (
    <container>
      <hlayout horizontalAlignment="middle" sizeToFitParent={SizeToFit.Width}>
        <button padding={bp} onClick={(b) => console.log(1)} width={bs} height={bs}>
          <image name="pause" sizeToFitParent={SizeToFit.WidthAndHeight} />
        </button>
        <button padding={bp} onClick={(b) => console.log(2)} width={bs} height={bs}>
          <image name="singlespeed" sizeToFitParent={SizeToFit.WidthAndHeight} />
        </button>
        <button padding={bp} onClick={() => null} width={bs} height={bs}>
          <image name="doublespeed" sizeToFitParent={SizeToFit.WidthAndHeight} />
        </button>
        <rect width={4} height={bs} fill={0xaa000033} />
        <button padding={bp} onClick={() => null} width={bs} height={bs}>
          <image name="bulldozer" sizeToFitParent={SizeToFit.WidthAndHeight} />
        </button>
        <button
          padding={bp}
          onClick={() => (state.gui.windows.zoning.isVisible.value = !state.gui.windows.zoning.isVisible.value)}
          width={bs}
          height={bs}
        >
          <image name="zones" sizeToFitParent={SizeToFit.WidthAndHeight} />
        </button>
        <button padding={bp} onClick={() => null} width={bs} height={bs}>
          <image name="road" sizeToFitParent={SizeToFit.WidthAndHeight} />
        </button>
      </hlayout>
      <window
        title="Zoning"
        isVisible={state.gui.windows.zoning.isVisible}
        left={state.gui.windows.zoning.left}
        top={state.gui.windows.zoning.top}
        width={bs * 6}
        height={bs + constants.window.titleBarHeight}
        padding={0}
        lightChrome={constants.lightGreen}
        midChrome={constants.midGreen}
        darkChrome={constants.darkGreen}
      >
        <hlayout horizontalAlignment={HorizontalAlignment.Left} sizeToFitParent={SizeToFit.Width}>
          <button
            padding={bp}
            width={bs}
            height={bs}
            lightChrome={constants.lightGreen}
            midChrome={constants.midGreen}
            darkChrome={constants.darkGreen}
            onClick={() => (state.gui.currentTool = Tool.LightResidential)}
            isSelected={() => state.gui.currentTool === Tool.LightResidential}
          >
            <image name="lightResidentialZone" sizeToFitParent={SizeToFit.WidthAndHeight} />
          </button>
          <button
            padding={bp}
            width={bs}
            height={bs}
            lightChrome={constants.lightGreen}
            midChrome={constants.midGreen}
            darkChrome={constants.darkGreen}
            onClick={() => (state.gui.currentTool = Tool.DenseResidential)}
            isSelected={() => state.gui.currentTool === Tool.DenseResidential}
          >
            <image name="denseResidentialZone" sizeToFitParent={SizeToFit.WidthAndHeight} />
          </button>
          <button
            padding={bp}
            width={bs}
            height={bs}
            lightChrome={constants.lightGreen}
            midChrome={constants.midGreen}
            darkChrome={constants.darkGreen}
            onClick={() => (state.gui.currentTool = Tool.LightCommercial)}
            isSelected={() => state.gui.currentTool === Tool.LightCommercial}
          >
            <image name="lightCommercialZone" sizeToFitParent={SizeToFit.WidthAndHeight} />
          </button>

          <button
            padding={bp}
            width={bs}
            height={bs}
            lightChrome={constants.lightGreen}
            midChrome={constants.midGreen}
            darkChrome={constants.darkGreen}
            onClick={() => (state.gui.currentTool = Tool.DenseCommercial)}
            isSelected={() => state.gui.currentTool === Tool.DenseCommercial}
          >
            <image name="denseCommercialZone" sizeToFitParent={SizeToFit.WidthAndHeight} />
          </button>

          <button
            padding={bp}
            width={bs}
            height={bs}
            lightChrome={constants.lightGreen}
            midChrome={constants.midGreen}
            darkChrome={constants.darkGreen}
            onClick={() => (state.gui.currentTool = Tool.LightIndustrial)}
            isSelected={() => state.gui.currentTool === Tool.LightIndustrial}
          >
            <image name="lightIndustrialZone" sizeToFitParent={SizeToFit.WidthAndHeight} />
          </button>
          <button
            padding={bp}
            width={bs}
            height={bs}
            lightChrome={constants.lightGreen}
            midChrome={constants.midGreen}
            darkChrome={constants.darkGreen}
            onClick={() => (state.gui.currentTool = Tool.DenseIndustrial)}
            isSelected={() => state.gui.currentTool === Tool.DenseIndustrial}
          >
            <image name="denseIndustrialZone" sizeToFitParent={SizeToFit.WidthAndHeight} />
          </button>
        </hlayout>
      </window>
    </container>
  )
}

In any case you can see my work in progress UI below. Its heavily Transport Tycoon inspired!

There’s a bunch of stuff my toolkit doesn’t do - particularly in regards to optimising change which I might revisit, but only if I need to. We’ll see.

All the code is in the usual place.

https://github.com/JamesRandall/empireOfAsphalt