Files
realworld/internal/ui/ui.go
T
mr.zero bbc3faf3d7
ci/woodpecker/push/ebitengine Pipeline was successful
tmp backgrounds
2026-08-29 22:30:22 +02:00

236 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
// HUD layout.
//
// 0 394│396 639
// ┌────────────────────────────────────────────────────────────────────┐
// 0 │ ALLEY — paused SRP: 1 tape │ TopBar (20)
// 20 ├────────────────────────────────────────────────────────────────────┤
// │ │
// │ scene — hotspots, characters, SpeechBubble │ Scene (242)
// │ │
// 262├─────────────────────────────────────────┬──────────────────────────┤
// │ Use hook on: floor grate │ DEX │
// │ ┌───┬───┬───┬───┐ ┌───────┐┌────────┐ │ "The hook is a hand │ HUD (118)
// │ ├───┼───┼───┼───┤ │ 1 DEX ││ 2 — │ │ shorter than the gap." │
// 379└─────────────────────────────────────────┴──────────────────────────┘
// InventoryBar TapeSlots TapeChannel
//
// On the resolution: the screen is exactly one painted background. inkwell
// stretches a scene background over the WHOLE screen (core.engine.go), not over
// the region left free by the HUD, so any screen size other than the art's own
// squashes every painting — and the deck is drawn at 640×380
// (games/realworld_screen_assets). Hence 640×380: the art lands pixel for
// pixel, and the HUD is an opaque strip laid over its foot.
//
// That still satisfies what the wiki's "320×200 VGA look" brief was after — a
// low, wide VGA frame at an exact 2× of a 320-wide canvas — and it still keeps
// the engine's fixed 6×16 debug font readable: a 16px glyph is 4% of 380, the
// proportion the design was drawn for. (At 320×190 it would be 8%, rows would
// overlap and the top bar could not hold a line.)
//
// Everything vertical is derived from LineH and from ScreenH below, never from
// the wireframe deck's ratios, so the layout cannot drift out of step with the
// font — or with the art — again.
import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/world"
)
// Screen and layout, in internal (unscaled) pixels.
const (
// The screen is the size of one painted background, because the engine
// scales a background to the full screen: at any other size the art is
// squashed. See the note above.
ScreenW = 640
ScreenH = 380
// LineH is one text row: the glyph cell plus leading. Every text-bearing
// box is sized as a multiple of this, so rows can never overlap.
LineH = glyphH + 2 // 18
Pad = 6 // inner padding for every panel
TopBarH = LineH + 2 // 20 — one row plus a hairline of breathing room
DividerX = 394 // world | tape channel (61.6% of the width)
// The HUD strip is as short as its own contents allow, and is measured
// from the bottom of the screen — every pixel it takes is a pixel of the
// painting it covers. It has to hold the status row and, under it, the two
// rows of inventory slots (the tallest thing in the strip), padded above
// and below.
HUDH = Pad + LineH + 4 + invSlot*2 + invGap + Pad // 118
HUDTop = ScreenH - HUDH // 262 — the scene | HUD divider
// Derived HUD rows.
statusY = HUDTop + Pad // 268
slotsY = HUDTop + Pad + LineH // 286
slotsH = LineH*2 + Pad*2 // 48 — two rows plus padding
invY = slotsY + 4 // 290
invSlot = 40
invGap = 4
)
// Register assembles the whole HUD in the conventional Z-order: the debug
// overlay at the back, the cursor in front.
func Register(w *world.World) {
g := w.G
// The guard goes FIRST so it ticks LAST among the widgets — widgets tick
// in reverse registration order, so every HUD widget gets the click before
// it does, and it only ever catches clicks that land on the scene.
g.UIManager.Register(&UseWithGuard{Name: "usewith_guard", W: w})
g.UIManager.Register(&world.ActionPump{Name: "pump", W: w})
g.UIManager.Register(&screenNav{Name: "screen_nav", W: w})
g.UIManager.Register(&windowSizer{Name: "window", Scale: 2})
g.UIManager.Register(&inkwell.HotspotDebug{Name: "hotspot_debug"})
top := &inkwell.TopBar{
Name: "topbar",
Height: TopBarH,
// The right section prints State.Var(TimeVar). We deliberately reuse
// it for the Armilla's single-line phosphor strip: in canon the device
// display is one line of text, not a phone screen.
TimeVar: names.VarArmillaStrip,
}
w.SetTopBar(top)
g.UIManager.Register(gated(w, "topbar_gate", top))
g.UIManager.Register(&Letterbox{Name: "letterbox", W: w, Bar: 36})
g.UIManager.Register(&hudFrame{Name: "hudframe", W: w})
g.UIManager.Register(gated(w, "status_gate", &inkwell.StatusLine{
Name: "status", Y: statusY, Align: inkwell.AlignLeft, ScreenWidth: DividerX,
}))
g.UIManager.Register(gated(w, "inventory_gate", &inkwell.InventoryBar{
Name: "inventory", Origin: inkwell.Point{X: Pad + 2, Y: invY},
Slots: 8, Cols: 4, SlotSize: invSlot, Gap: invGap,
}))
g.UIManager.Register(&TapeSlots{
Name: "tapes", W: w,
Bounds: inkwell.Rect(192, slotsY, 194, slotsH),
})
g.UIManager.Register(&TapeChannel{
Name: "channel", W: w,
Bounds: inkwell.Rect(DividerX+2, HUDTop+2, ScreenW-DividerX-4, ScreenH-HUDTop-4),
})
g.UIManager.Register(&inkwell.SpeechBubble{
Name: "speech", MaxWidth: 360, Padding: Pad, OffsetY: 8, FallbackY: TopBarH + Pad,
})
// The dialogue box deliberately covers only the left region, so a tape can
// comment ALONGSIDE the conversation. It consumes every click while active,
// so the tape channel stays visible but inert — which is what we want.
g.UIManager.Register(&inkwell.DialogBox{
Name: "dialog", Bounds: inkwell.Rect(0, ScreenH-LineH*9, DividerX, LineH*9),
LineHeight: LineH, Padding: Pad,
})
// A verb coin, not a verb bar: a permanent VerbBar would eat the left
// region, leaving room for neither the inventory nor the tape slots.
g.UIManager.Register(&inkwell.RadialVerbs{
Name: "verbs", Trigger: inkwell.MouseButtonRight, Radius: 58,
Labels: map[string]string{
"look": "Look", "use": "Use", "talk": "Talk", "take": "Take",
},
})
g.UIManager.Register(&inkwell.EndCard{Name: "endcard"})
g.UIManager.Register(&inkwell.Cursor{Name: "cursor"})
}
// ----- windowSizer ------------------------------------------------------
// windowSizer resizes the window once, on the first frame.
//
// inkwell.Run hardcodes a 4× window (core.dsl.go), which at 640×380 would be
// 2560×1520 — larger than most laptop screens. Run sets the size before
// entering the loop, so the only place to override it is the first tick.
type windowSizer struct {
Name string
Scale int
done bool
}
func (s *windowSizer) GetName() string { return s.Name }
func (s *windowSizer) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
func (s *windowSizer) Tick(ctx *inkwell.UICtx) {
if s.done {
return
}
s.done = true
scale := s.Scale
if scale <= 0 {
scale = 2
}
ebiten.SetWindowSize(ctx.Game.Width*scale, ctx.Game.Height*scale)
}
// ----- gate -------------------------------------------------------------
// gate switches built-in widgets off in cutscene and menu mode. inkwell's
// widgets have no Visible field and the Manager cannot unregister, so we wrap.
// BlocksClickAt is forwarded, otherwise the verb coin would open over the HUD.
type gate struct {
Name string
W *world.World
Inner inkwell.Widget
}
func gated(w *world.World, name string, inner inkwell.Widget) inkwell.Widget {
return &gate{Name: name, W: w, Inner: inner}
}
func (n *gate) GetName() string { return n.Name }
func (n *gate) Tick(ctx *inkwell.UICtx) {
if n.W.HUDVisible() {
n.Inner.Tick(ctx)
}
}
func (n *gate) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if n.W.HUDVisible() {
n.Inner.Draw(dst, ctx)
}
}
func (n *gate) BlocksClickAt(p inkwell.Point) bool {
if !n.W.HUDVisible() {
return false
}
b, ok := n.Inner.(interface{ BlocksClickAt(inkwell.Point) bool })
return ok && b.BlocksClickAt(p)
}
// ----- hudFrame ---------------------------------------------------------
// hudFrame paints the HUD strip's backdrop and the two dividers. The rule: the
// two channels always have a visible, continuous separator between them.
type hudFrame struct {
Name string
W *world.World
}
func (h *hudFrame) GetName() string { return h.Name }
func (h *hudFrame) Tick(ctx *inkwell.UICtx) {}
func (h *hudFrame) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if !h.W.HUDVisible() {
return
}
th := ctx.Game.Theme()
vector.DrawFilledRect(dst, 0, HUDTop+1, ScreenW, ScreenH-HUDTop-1, th.PanelBG, false)
// horizontal: scene | HUD
vector.StrokeLine(dst, 0, HUDTop, ScreenW, HUDTop, 1, th.CharacterPanelBorder, false)
// vertical: world | tape
vector.StrokeLine(dst, DividerX, HUDTop+1, DividerX, ScreenH, 1, th.CharacterPanelBorder, false)
}
func (h *hudFrame) BlocksClickAt(p inkwell.Point) bool {
return h.W.HUDVisible() && p.Y >= HUDTop
}