diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a41a45aa..5b5a9d68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/examples/ui_gallery/README.md b/examples/ui_gallery/README.md new file mode 100644 index 00000000..2a8bfe21 --- /dev/null +++ b/examples/ui_gallery/README.md @@ -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. diff --git a/examples/ui_gallery/build_atlas.nim b/examples/ui_gallery/build_atlas.nim new file mode 100644 index 00000000..2aa3f368 --- /dev/null +++ b/examples/ui_gallery/build_atlas.nim @@ -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") diff --git a/examples/ui_gallery/gallery.nim b/examples/ui_gallery/gallery.nim new file mode 100644 index 00000000..91a33596 --- /dev/null +++ b/examples/ui_gallery/gallery.nim @@ -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() diff --git a/src/polyworld/gameuis.nim b/src/polyworld/gameuis.nim index 0447f263..a74af29f 100644 --- a/src/polyworld/gameuis.nim +++ b/src/polyworld/gameuis.nim @@ -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 @@ -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, @@ -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. @@ -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 diff --git a/tests/test_gameuis.nim b/tests/test_gameuis.nim index 84d09767..f15c150d 100644 --- a/tests/test_gameuis.nim +++ b/tests/test_gameuis.nim @@ -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"