220 lines
8.8 KiB
Go
220 lines
8.8 KiB
Go
package ui
|
||
|
||
// HUD layout.
|
||
//
|
||
// 0 394│396 639
|
||
// ┌────────────────────────────────────────────────────────────────────┐
|
||
// 0 │ ALLEY — paused SRP: 1 tape │ TopBar (20)
|
||
// 20 ├────────────────────────────────────────────────────────────────────┤
|
||
// │ │
|
||
// │ scene — hotspots, characters, SpeechBubble │ Scene (244)
|
||
// │ │
|
||
// 264├─────────────────────────────────────────┬──────────────────────────┤
|
||
// │ Use hook on: floor grate │ DEX │
|
||
// │ ┌───┬───┬───┬───┐ ┌───────┐┌────────┐ │ "The hook is a hand │ HUD (135)
|
||
// │ ├───┼───┼───┼───┤ │ 1 DEX ││ 2 — │ │ shorter than the gap." │
|
||
// │ └───┴───┴───┴───┘ └───────┘└────────┘ │ │
|
||
// 399└─────────────────────────────────────────┴──────────────────────────┘
|
||
// InventoryBar TapeSlots TapeChannel
|
||
//
|
||
// On the resolution: the wiki's art brief asks for a "320×200 VGA look", and
|
||
// inkwell's widget defaults are authored for exactly that. We render at 640×400
|
||
// — the same 16:10, exactly 2× the brief, so art drawn at 320×200 upscales
|
||
// cleanly — because the engine's text is a fixed 6×16 debug font. At 320×200 a
|
||
// 16px glyph is 8% of the screen height and nothing fits; at 640×400 it lands
|
||
// at 4%, which is the proportion the design was drawn for.
|
||
//
|
||
// Everything vertical is derived from LineH below rather than from the
|
||
// wireframe deck's ratios, so the layout cannot drift out of step with the font
|
||
// 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 (
|
||
ScreenW = 640
|
||
ScreenH = 400
|
||
|
||
// 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
|
||
HUDTop = 264 // the scene | HUD divider
|
||
DividerX = 394 // world | tape channel (61.6% of the width)
|
||
|
||
// Derived HUD rows.
|
||
statusY = HUDTop + Pad // 270
|
||
slotsY = HUDTop + Pad + LineH // 288
|
||
slotsH = LineH*2 + Pad*2 // 48 — two rows plus padding
|
||
invY = slotsY + 4 // 292
|
||
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(&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×400 would be
|
||
// 2560×1600 — 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
|
||
}
|