Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ jobs:
- run: nim c -d:headless examples/call_to_adventure/cta.nim
- run: nim c -d:headless examples/light_vs_dark/lvd.nim
- run: nim c -d:headless examples/heartleaf/heartleaf.nim
- name: Build the menu example
if: runner.os == 'Linux'
run: |
nim r --parallelBuild:1 examples/ui_gallery/build_atlas.nim
nim c --parallelBuild:1 examples/ui_gallery/gallery.nim
25 changes: 25 additions & 0 deletions examples/ui_gallery/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Game menu example

Run a character panel, settings page, and quest panel:

```sh
nim r examples/ui_gallery/build_atlas.nim
nim r examples/ui_gallery/gallery.nim
```

Run these commands from the Polyworld root after installing dependencies. The
atlas builder uses Silky's example images and fonts from `../silky`; pass another
Silky directory as its first argument if needed. It writes to `tmp/ui-gallery`.

Try typing a name, changing the volume, choosing a theme, and claiming a quest
reward. Resize the window to see the panels scroll. Copy the controls and replace
the example actions with your game's actions.

`gameuis.gameArea` leaves room for the edge spacing you supply and replay
controls. `fitPanel` keeps a panel in that space, shrinking oversized panels.
Supply insets as left, top, right, and bottom, in UI units; cutouts are not
detected automatically. Content taller than the fitted panel needs a scrolling
control.

The example uses the upstream Silky and Windy versions in `nimby.lock`; it does
not require dependency forks.
13 changes: 13 additions & 0 deletions examples/ui_gallery/build_atlas.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Build the gallery from Silky's example assets, without game art packs.
import std/os, pixie, silky/atlas

let silkyRoot = if paramCount() > 0: paramStr(1) else: "../silky"
let data = silkyRoot / "examples/basicwindow/data"
createDir("tmp/ui-gallery")
let builder = newAtlasBuilder(2048, 4)
builder.addDir(data & "/", data & "/")
doAssert builder.addImage("button.disabled.9patch",
readImage(silkyRoot / "examples/the7gui/data/button.disabled.9patch.png"))
builder.addFont(data / "IBMPlexSans-Regular.ttf", "Default", 18)
builder.addFont(data / "IBMPlexSans-Regular.ttf", "H1", 30)
builder.write("tmp/ui-gallery/atlas.png")
131 changes: 131 additions & 0 deletions examples/ui_gallery/gallery.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
## A runnable game UI built from the same Silky controls on desktop and WebGL.
import bumpy, chroma, pixie, vmath, silky
import polyworld/gameuis

type GalleryState* = object
tab*: int
theme*: string
playerName*: string
showHints*: bool
volume*: float32
difficulty*: string
claimed*: bool
gold*: int
inspected*: bool
crossingFound*: bool

proc newGalleryState*(): GalleryState =
GalleryState(theme: "Midnight", playerName: "Mira", showHints: true,
volume: 0.7, difficulty: "Normal")

proc galleryPanels*(size: Vec2): tuple[header, nav, content: GameUiPanel] =
let area = initGameUiLayout(size).gameArea
result.header = area.fitPanel(vec2(24, 20), vec2(size.x - 48, 80))
result.nav = area.fitPanel(vec2(24, 110), vec2(size.x - 48, 54))
result.content = area.fitPanel(vec2(24, 176), vec2(size.x - 48, size.y - 200))

proc drawGallery*(sk: Silky, window: Window, state: var GalleryState) =
let
panels = galleryPanels(window.size.vec2 / sk.uiScale)
paper = state.theme == "Parchment"
background = if paper: rgbx(225, 216, 194, 255) else: rgbx(17, 26, 35, 255)
controlText = rgbx(235, 239, 234, 255)
foreground = if paper: rgbx(42, 46, 45, 255) else: controlText
muted = if paper: rgbx(78, 87, 80, 255) else: rgbx(164, 185, 188, 255)
sk.theme.textColor = foreground
sk.theme.textH1Color = foreground
sk.theme.defaultTextColor = controlText
sk.clearScreen(background)
ui:
group "heading":
box panels.header.origin.x, panels.header.origin.y, panels.header.size.x, panels.header.size.y
text "title":
characters "The Wayfarer's Journal"
font (if panels.header.size.x < 400: "Default" else: "H1")
tint foreground
text "subtitle":
characters "A place for your next adventure."
tint muted
group "navigation":
box panels.nav.origin.x, panels.nav.origin.y, panels.nav.size.x, panels.nav.size.y
layout LeftToRight
itemSpacing 12
button "Character": state.tab = 0
button "Settings": state.tab = 1
button "Quest": state.tab = 2
frame "content":
box panels.content.origin.x, panels.content.origin.y, panels.content.size.x, panels.content.size.y
# Native scrollbars own overflow when the window is shorter than the content.
horizontalPadding 18
verticalPadding 18
itemSpacing 12
case state.tab
of 0:
h1text state.playerName & " / Pathfinder"
text "Explorer of the northern wilds."
group "health":
box 260, 30
layout LeftToRight
icon "heart"
text "Health / 84 of 100"
progressBar 84, 0, 100
text "Equipment"
group "equipment":
hugHeight()
fillWidth()
itemSpacing 8
button "Inspect Wayfinder's Compass": state.inspected = not state.inspected
button "Upgrade (requires 50 gold)", false: discard
if state.inspected:
text "compass detail":
characters "A brass compass.\nIts needle always points home."
text "Portrait alignment":
fillWidth()
characters "EXPLORER / LEVEL 12"
textAlign RightAlign
of 1:
h1text "Your settings"
text "Player name"
sk.theme.textColor = sk.theme.defaultTextColor
textInput "player-name", state.playerName
sk.theme.textColor = foreground
sk.theme.defaultTextColor = foreground
checkBox "Show interaction hints", state.showHints
sk.theme.defaultTextColor = controlText
text "Volume"
scrubber "volume", state.volume, 0'f32, 1'f32, "Volume"
text "Difficulty"
dropDown state.difficulty, ["Easy", "Normal", "Hard"]
text "Appearance"
dropDown state.theme, ["Midnight", "Parchment"]
else:
h1text "The old watchtower"
text "quest description":
characters "Follow the river.\nFind the old watchtower.\nSpeak to its keeper.\nReturn with news."
fillWidth()
text "Objectives"
sk.theme.defaultTextColor = foreground
checkBox "Find the river crossing", state.crossingFound
sk.theme.defaultTextColor = controlText
text "Reward / 25 gold + travel ration"
button "Claim reward", state.crossingFound and not state.claimed:
state.claimed = true
state.gold += 25
if state.claimed:
text "Reward claimed.\nReady for the next adventure."
for name in ["River crossing", "The mossy stair", "Keeper's door", "North road", "Return to camp"]:
text name

when isMainModule:
let window = newWindow("Polyworld UI gallery", ivec2(960, 720), vsync = true)
window.makeContextCurrent()
loadExtensions()
let sk = newSilky(window, "tmp/ui-gallery/atlas.png")
var state = newGalleryState()
window.onFrame = proc() =
sk.beginUI(window, window.size)
sk.drawGallery(window, state)
sk.endUi()
window.swapBuffers()
while not window.closeRequested:
pollEvents()
38 changes: 30 additions & 8 deletions src/polyworld/gameuis.nim
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type
size*: Vec2
margin*: float32
transportHeight*: float32
safeInsets*: Vec4 ## Left, top, right, bottom in UI layout units.

ReplayTransport* = object
playing*: bool
Expand All @@ -31,16 +32,34 @@ type
proc initGameUiLayout*(
size: Vec2,
transportHeight = 0.0'f32,
margin = 10.0'f32
margin = 10.0'f32,
safeInsets = vec4(0)
): GameUiLayout =
## Creates a nine-region layout above an optional bottom transport ribbon.
result.size = max(size, vec2(0))
result.margin = max(margin, 0)
result.transportHeight = clamp(transportHeight, 0, result.size.y)
result.safeInsets.x = clamp(safeInsets.x, 0, result.size.x)
result.safeInsets.y = clamp(safeInsets.y, 0, result.size.y)
result.safeInsets.z = clamp(safeInsets.z, 0, result.size.x - result.safeInsets.x)
result.safeInsets.w = clamp(safeInsets.w, 0, result.size.y - result.safeInsets.y)
result.transportHeight = clamp(transportHeight, 0,
result.size.y - result.safeInsets.y - result.safeInsets.w)

proc gameArea*(layout: GameUiLayout): GameUiPanel =
## Usable game rectangle, excluding safe insets and replay transport.
GameUiPanel(origin: layout.safeInsets.xy, size: vec2(
layout.size.x - layout.safeInsets.x - layout.safeInsets.z,
layout.size.y - layout.safeInsets.y - layout.safeInsets.w - layout.transportHeight))

proc gameAreaSize*(layout: GameUiLayout): Vec2 =
## Returns the area available to the nine anchored game UI regions.
vec2(layout.size.x, layout.size.y - layout.transportHeight)
layout.gameArea.size

proc fitPanel*(area: GameUiPanel, origin, size: Vec2): GameUiPanel =
## Fits a desired panel to a usable area. Oversized content needs scrolling.
let usableSize = max(area.size, vec2(0))
result.size = min(max(size, vec2(0)), usableSize)
result.origin = clamp(origin, area.origin, area.origin + usableSize - result.size)

proc panel*(
layout: GameUiLayout,
Expand Down Expand Up @@ -79,12 +98,14 @@ proc panel*(
vec2(centerX, bottom)
of GameUiRegion.BottomRight:
vec2(right, bottom)
result.origin = max(result.origin, vec2(0))
result = layout.gameArea.fitPanel(
layout.gameArea.origin + result.origin, panelSize)

proc transportPanel*(layout: GameUiLayout): GameUiPanel =
## Returns the full-width panel reserved below the nine game UI regions.
result.origin = vec2(0, layout.size.y - layout.transportHeight)
result.size = vec2(layout.size.x, layout.transportHeight)
let area = layout.gameArea
result.origin = area.origin + vec2(0, area.size.y)
result.size = vec2(area.size.x, layout.transportHeight)

proc contains*(panel: GameUiPanel, point: Vec2): bool =
## Returns whether a point lies inside a panel.
Expand Down Expand Up @@ -126,9 +147,10 @@ proc layoutFits*(
## Returns whether plates stay inside the layout without overlapping.
if panelsOverlap(plates, gap):
return false
let transport = layout.transportPanel
let area = layout.gameArea
for plate in plates:
if not plate.inside(layout.size) or overlaps(plate, transport):
let local = GameUiPanel(origin: plate.origin - area.origin, size: plate.size)
if not local.inside(area.size):
return false
true

Expand Down
40 changes: 40 additions & 0 deletions tests/test_gameuis.nim
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,44 @@ doAssert transport.tick == 50
transport.rewind()
doAssert transport.tick == 0

block:
echo "Safe insets and transport share one usable rectangle"
let layout = initGameUiLayout(vec2(800, 600), 40, 10, vec4(30, 20, 50, 60))
doAssert layout.gameArea.origin == vec2(30, 20)
doAssert layout.gameArea.size == vec2(720, 480)
doAssert layout.panel(GameUiRegion.TopLeft, vec2(100)).origin == vec2(40, 30)
doAssert layout.panel(GameUiRegion.BottomRight, vec2(100)).origin == vec2(640, 390)
doAssert layout.transportPanel.origin == vec2(30, 500)
doAssert layout.transportPanel.size == vec2(720, 40)
doAssert layout.layoutFits([layout.gameArea])
doAssert not layout.layoutFits([layout.transportPanel])
doAssert not layout.layoutFits([
GameUiPanel(origin: vec2(30, 499), size: vec2(100, 2))])
doAssert not layout.layoutFits([GameUiPanel(origin: vec2(0), size: vec2(10))])
let collapsed = initGameUiLayout(vec2(40, 30), 100, safeInsets = vec4(60, 40, 60, 40))
doAssert collapsed.gameArea.size == vec2(0)
doAssert collapsed.transportPanel.size == vec2(0)

block:
echo "Panels stay within usable bounds"
let area = GameUiPanel(origin: vec2(20, 30), size: vec2(300, 200))
let huge = area.fitPanel(vec2(-100), vec2(600, 400))
doAssert huge.origin == area.origin and huge.size == area.size
for x in [-100'f32, 0, 200, 500]:
for y in [-100'f32, 0, 200, 500]:
let placed = area.fitPanel(vec2(x, y), vec2(80, 60))
doAssert GameUiPanel(origin: placed.origin - area.origin, size: placed.size).inside(area.size)
doAssert placed.contains(placed.origin + placed.size * 0.5)

block:
let empty = GameUiPanel(origin: vec2(20), size: vec2(-10))
let fitted = empty.fitPanel(vec2(-100), vec2(50))
doAssert fitted.origin == empty.origin and fitted.size == vec2(0)

block:
let layout = initGameUiLayout(vec2(160, 120), 30, 10, vec4(10))
for region in GameUiRegion:
doAssert layout.layoutFits([layout.panel(region, vec2(200))]),
"an oversized panel must stay clear of screen edges and replay controls"

echo "Game UI tests passed"