game skeleton
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package ui
|
||||
|
||||
// Letterbox — the cutscene frame.
|
||||
//
|
||||
// The one exception to the fixed layout. In cutscene mode the HUD strip and
|
||||
// the TopBar are hidden, the scene gets a black bar top and bottom, and a line
|
||||
// at the foot of the screen says a tape would like to speak — but it does not
|
||||
// speak on its own. The floor is the player's: a tape signals, never interrupts.
|
||||
|
||||
import (
|
||||
inkwell "git.teletypegames.org/engines/inkwell"
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
|
||||
"realworld/internal/names"
|
||||
"realworld/internal/theme"
|
||||
"realworld/internal/world"
|
||||
)
|
||||
|
||||
type Letterbox struct {
|
||||
Name string
|
||||
W *world.World
|
||||
Bar float64 // height of the black bar, top and bottom
|
||||
}
|
||||
|
||||
func (l *Letterbox) GetName() string { return l.Name }
|
||||
|
||||
func (l *Letterbox) Tick(ctx *inkwell.UICtx) {
|
||||
if l.W.Mode() != world.ModeCutscene || !l.W.TapeWaiting() {
|
||||
return
|
||||
}
|
||||
// inkwell's Input only covers the mouse; read the key directly.
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
|
||||
l.W.TakeTapeOffer()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Letterbox) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
|
||||
if l.W.Mode() != world.ModeCutscene {
|
||||
return
|
||||
}
|
||||
g := ctx.Game
|
||||
th := g.Theme()
|
||||
bar := l.Bar
|
||||
if bar <= 0 {
|
||||
bar = 18
|
||||
}
|
||||
w, h := float32(g.Width), float32(g.Height)
|
||||
black := theme.RGB(0x000000)
|
||||
vector.DrawFilledRect(dst, 0, 0, w, float32(bar), black, false)
|
||||
vector.DrawFilledRect(dst, 0, h-float32(bar), w, float32(bar), black, false)
|
||||
|
||||
if l.W.TapeWaiting() {
|
||||
msg := "SPACE — " + names.TapeDisplayName(names.Dex) + " has something to say"
|
||||
drawTextC(dst, msg, g.Width-textW(msg)-4, g.Height-int(bar)+4, th.ChatLogResponse)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package ui
|
||||
|
||||
// TapeChannel — the tape channel down the right-hand side.
|
||||
//
|
||||
// Not assistant UI: this is the voice of CHARACTERS. The wiki is explicit that
|
||||
// ACPs and Personal Tapes are not props but characters with their own voice.
|
||||
// That is why we do not use the built-in ChatLog — it paints every LogResponse
|
||||
// in one Theme.ChatLogResponse colour, whereas here three or four tapes can
|
||||
// speak into the same strip (Dex, the mystery tape, the tech support tape) and
|
||||
// the colour comes per speaker from Character.SpeechColor.
|
||||
//
|
||||
// Paul and the NPCs never appear here: they speak in the scene, through the
|
||||
// SpeechBubble. The channel is what the tapes said, plus — quietly — what you
|
||||
// did to prompt it. That gives the "joke book, not a scoreboard" log for free.
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
inkwell "git.teletypegames.org/engines/inkwell"
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
|
||||
"realworld/internal/names"
|
||||
"realworld/internal/world"
|
||||
)
|
||||
|
||||
type TapeChannel struct {
|
||||
Name string
|
||||
W *world.World
|
||||
Bounds inkwell.Rectangle
|
||||
}
|
||||
|
||||
func (t *TapeChannel) GetName() string { return t.Name }
|
||||
|
||||
func (t *TapeChannel) Tick(ctx *inkwell.UICtx) {}
|
||||
|
||||
// BlocksClickAt — the channel is not interactive, but it is a solid input
|
||||
// zone: the verb coin must not open over it.
|
||||
func (t *TapeChannel) BlocksClickAt(p inkwell.Point) bool {
|
||||
return t.W.HUDVisible() && t.Bounds.Contains(p)
|
||||
}
|
||||
|
||||
func (t *TapeChannel) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
|
||||
if !t.W.HUDVisible() {
|
||||
return
|
||||
}
|
||||
g := ctx.Game
|
||||
th := g.Theme()
|
||||
b := t.Bounds
|
||||
if b.W <= 0 || b.H <= 0 {
|
||||
return
|
||||
}
|
||||
const lh, pad = LineH, Pad
|
||||
|
||||
vector.DrawFilledRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), th.ChatLogBG, false)
|
||||
|
||||
// Header: the name of whichever tape spoke last — not "console".
|
||||
speaker, speakerCol := t.lastSpeaker(g, th)
|
||||
drawTextC(dst, speaker, int(b.X)+pad, int(b.Y)+pad, speakerCol)
|
||||
hy := float32(b.Y+pad+lh) + 2
|
||||
vector.StrokeLine(dst, float32(b.X)+pad, hy,
|
||||
float32(b.X+b.W)-pad, hy, 1, th.CharacterPanelBorder, false)
|
||||
|
||||
type line struct {
|
||||
text string
|
||||
col color.Color
|
||||
}
|
||||
wrapW := int(b.W) - pad*2
|
||||
var rendered []line
|
||||
for _, m := range g.Messages() {
|
||||
var col color.Color
|
||||
var txt string
|
||||
switch m.Kind {
|
||||
case inkwell.LogAction:
|
||||
// Your own actions — quiet, but visible: that is what makes the
|
||||
// joke book readable.
|
||||
col, txt = th.ChatLogPrompt, m.Text
|
||||
case inkwell.LogResponse:
|
||||
if !names.IsTape(m.Speaker) {
|
||||
continue // Paul and the NPCs speak in the scene, not here
|
||||
}
|
||||
col, txt = speechColor(g, m.Speaker, th.ChatLogResponse), m.Text
|
||||
default:
|
||||
col, txt = th.ChatLogSystem, m.Text
|
||||
}
|
||||
for _, wl := range wrap(txt, wrapW) {
|
||||
rendered = append(rendered, line{text: wl, col: col})
|
||||
}
|
||||
}
|
||||
|
||||
top := int(b.Y) + pad + lh + 6
|
||||
maxLines := (int(b.Y+b.H) - pad - top) / lh
|
||||
if maxLines <= 0 {
|
||||
return
|
||||
}
|
||||
if start := len(rendered) - maxLines; start > 0 {
|
||||
rendered = rendered[start:]
|
||||
}
|
||||
for i, ln := range rendered {
|
||||
drawTextC(dst, ln.text, int(b.X)+pad, top+i*lh, ln.col)
|
||||
}
|
||||
}
|
||||
|
||||
// lastSpeaker returns the display name and colour of whichever tape spoke
|
||||
// last, defaulting to the occupant of slot 1.
|
||||
func (t *TapeChannel) lastSpeaker(g *inkwell.Game, th inkwell.Theme) (string, color.Color) {
|
||||
msgs := g.Messages()
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
m := msgs[i]
|
||||
if m.Kind == inkwell.LogResponse && names.IsTape(m.Speaker) {
|
||||
return names.TapeDisplayName(m.Speaker), speechColor(g, m.Speaker, th.ChatLogResponse)
|
||||
}
|
||||
}
|
||||
return names.TapeDisplayName(names.Dex), speechColor(g, names.Dex, th.ChatLogResponse)
|
||||
}
|
||||
|
||||
// speechColor is the character's voice colour, falling back to the theme's.
|
||||
func speechColor(g *inkwell.Game, name string, fallback color.Color) color.Color {
|
||||
if c, ok := g.CharacterManager.Get(name); ok && c.SpeechColor != nil {
|
||||
return c.SpeechColor
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package ui
|
||||
|
||||
// TapeSlots — the two slots of Paul's Armilla.
|
||||
//
|
||||
// In canon Paul's Armilla has TWO slots where the standard has one: Dex sits
|
||||
// permanently in the first, and tapes picked up during the game go into the
|
||||
// second. The wireframe deck does not know about this at all — this is the one
|
||||
// HUD element that comes purely from the wiki.
|
||||
//
|
||||
// Interaction follows the selected verb, like everything else:
|
||||
//
|
||||
// talk → converse with whoever occupies the slot
|
||||
// look → describe the slot
|
||||
// use + a selected tape → load it into slot 2
|
||||
//
|
||||
// Nothing here touches the world: this is the Vox side of the two channels.
|
||||
|
||||
import (
|
||||
inkwell "git.teletypegames.org/engines/inkwell"
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
|
||||
"realworld/internal/names"
|
||||
"realworld/internal/world"
|
||||
)
|
||||
|
||||
type TapeSlots struct {
|
||||
Name string
|
||||
W *world.World
|
||||
Bounds inkwell.Rectangle
|
||||
}
|
||||
|
||||
func (t *TapeSlots) GetName() string { return t.Name }
|
||||
|
||||
func (t *TapeSlots) BlocksClickAt(p inkwell.Point) bool {
|
||||
return t.W.HUDVisible() && t.Bounds.Contains(p)
|
||||
}
|
||||
|
||||
// slotRects splits the widget bounds into the two slot rectangles.
|
||||
func (t *TapeSlots) slotRects() (inkwell.Rectangle, inkwell.Rectangle) {
|
||||
b := t.Bounds
|
||||
w := (b.W - Pad) / 2
|
||||
return inkwell.Rect(b.X, b.Y, w, b.H), inkwell.Rect(b.X+w+Pad, b.Y, w, b.H)
|
||||
}
|
||||
|
||||
func (t *TapeSlots) Tick(ctx *inkwell.UICtx) {
|
||||
if !t.W.HUDVisible() {
|
||||
return
|
||||
}
|
||||
g := ctx.Game
|
||||
mp := g.Input.Point()
|
||||
r1, r2 := t.slotRects()
|
||||
|
||||
// Hover label, so the StatusLine spells out what is about to happen.
|
||||
switch {
|
||||
case r1.Contains(mp):
|
||||
g.SetHoverLabel(names.TapeDisplayName(names.Dex))
|
||||
case r2.Contains(mp):
|
||||
if s := t.W.Slot2(); s != "" {
|
||||
g.SetHoverLabel(itemLabel(g, s))
|
||||
} else {
|
||||
g.SetHoverLabel("empty tape slot")
|
||||
}
|
||||
}
|
||||
|
||||
if !g.Input.LeftClicked() {
|
||||
return
|
||||
}
|
||||
if !r1.Contains(mp) && !r2.Contains(mp) {
|
||||
return
|
||||
}
|
||||
g.Input.ConsumeLeft()
|
||||
|
||||
slot2 := r2.Contains(mp)
|
||||
sel := g.Inventory.Selected()
|
||||
|
||||
// Loading a tape into slot 2.
|
||||
if slot2 && sel != "" {
|
||||
g.Inventory.Select("")
|
||||
if names.IsTapeItem(sel) {
|
||||
t.W.SetPending(sel)
|
||||
t.W.Do(inkwell.RunScript(names.ScriptTapeInsert))
|
||||
return
|
||||
}
|
||||
t.W.Do(world.TapeSay(names.Dex, "That isn't a tape, Paul. That's an object. There is a difference."))
|
||||
return
|
||||
}
|
||||
if !slot2 && sel != "" {
|
||||
g.Inventory.Select("")
|
||||
t.W.Do(world.TapeSay(names.Dex, "The first slot is mine. I'm not moving."))
|
||||
return
|
||||
}
|
||||
|
||||
switch g.SelectedVerb() {
|
||||
case "talk":
|
||||
if !slot2 {
|
||||
t.W.Do(world.Paused(t.W, inkwell.RunDialogue(names.DlgDex)))
|
||||
return
|
||||
}
|
||||
if s := t.W.Slot2(); s != "" {
|
||||
t.W.Do(world.Paused(t.W, inkwell.RunDialogue(names.TapeDialogue(s))))
|
||||
return
|
||||
}
|
||||
t.W.Do(world.TapeSay(names.Dex, "Empty. Nobody to talk to."))
|
||||
default: // look, take, use all fall back to a description
|
||||
if !slot2 {
|
||||
t.W.Do(world.TapeSay(names.Dex, "Me. Four kilobytes of a dead man. Be grateful."))
|
||||
return
|
||||
}
|
||||
if s := t.W.Slot2(); s != "" {
|
||||
t.W.Do(world.TapeSay(names.Dex, "That is the second slot. Be careful what you let in."))
|
||||
return
|
||||
}
|
||||
t.W.Do(world.TapeSay(names.Dex, "The second slot is empty. That's the rarer state."))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TapeSlots) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
|
||||
if !t.W.HUDVisible() {
|
||||
return
|
||||
}
|
||||
g := ctx.Game
|
||||
th := g.Theme()
|
||||
r1, r2 := t.slotRects()
|
||||
|
||||
// Two text rows per slot: the slot number on top, its occupant below.
|
||||
// Both rows are LineH tall, so nothing can overlap the border.
|
||||
drawSlot := func(r inkwell.Rectangle, label, body string, filled bool) {
|
||||
bg, border, col := th.InventorySlotBG, th.ChatLogSystem, th.ChatLogSystem
|
||||
if filled {
|
||||
bg, border, col = th.PanelBG, th.CharacterPanelBorder, th.ChatLogResponse
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), bg, false)
|
||||
vector.StrokeRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), 1, border, false)
|
||||
|
||||
x, inner := int(r.X)+Pad, int(r.W)-Pad*2
|
||||
drawTextC(dst, label, x, int(r.Y)+Pad/2, th.ChatLogSystem)
|
||||
drawTextC(dst, clip(body, inner), x, int(r.Y)+Pad/2+LineH, col)
|
||||
}
|
||||
|
||||
drawSlot(r1, "SLOT 1", names.TapeDisplayName(names.Dex), true)
|
||||
if s := t.W.Slot2(); s != "" {
|
||||
drawSlot(r2, "SLOT 2", itemLabel(g, s), true)
|
||||
} else {
|
||||
drawSlot(r2, "SLOT 2", "empty", false)
|
||||
}
|
||||
}
|
||||
|
||||
// itemLabel is a short display label for an inventory item.
|
||||
func itemLabel(g *inkwell.Game, name string) string {
|
||||
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
|
||||
return it.Description
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package ui is the game's HUD: the layout, the custom widgets and coloured
|
||||
// text rendering.
|
||||
package ui
|
||||
|
||||
// Coloured text rendering.
|
||||
//
|
||||
// inkwell's drawText (asset.text.go) discards its colour argument (`_ = c`) and
|
||||
// always renders white through the ebitenutil debug font. The two-channel
|
||||
// colour rule — "if something is amber, a tape said it" — cannot work through
|
||||
// it, so the custom widgets do not call Game.DrawText: they render glyphs onto
|
||||
// a scratch image and blit it tinted with ColorScale. The built-in widgets
|
||||
// (StatusLine, DialogBox, TopBar) still render white. See README, "Engine
|
||||
// workarounds".
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||||
)
|
||||
|
||||
// glyphW and glyphH are the ebitenutil debug font's cell. The engine's own
|
||||
// textWidth uses the same 6px but counts bytes, which mismeasures any
|
||||
// non-ASCII text; we count runes.
|
||||
const (
|
||||
glyphW = 6
|
||||
glyphH = 16
|
||||
)
|
||||
|
||||
var scratch *ebiten.Image
|
||||
|
||||
// textW is the pixel width of a string, counted in runes.
|
||||
func textW(s string) int { return utf8.RuneCountInString(s) * glyphW }
|
||||
|
||||
// drawTextC draws one line in colour c, with the same origin as DebugPrintAt.
|
||||
func drawTextC(dst *ebiten.Image, s string, x, y int, c color.Color) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
w, h := textW(s)+glyphW, glyphH
|
||||
if scratch == nil || scratch.Bounds().Dx() < w || scratch.Bounds().Dy() < h {
|
||||
nw, nh := w, h
|
||||
if nw < 320 {
|
||||
nw = 320
|
||||
}
|
||||
scratch = ebiten.NewImage(nw, nh)
|
||||
}
|
||||
scratch.Clear()
|
||||
ebitenutil.DebugPrintAt(scratch, s, 0, 0)
|
||||
|
||||
if c == nil {
|
||||
c = color.White
|
||||
}
|
||||
r, g, b, a := c.RGBA()
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
op.GeoM.Translate(float64(x), float64(y))
|
||||
op.ColorScale.Scale(
|
||||
float32(r)/0xffff, float32(g)/0xffff, float32(b)/0xffff, float32(a)/0xffff,
|
||||
)
|
||||
dst.DrawImage(scratch.SubImage(image.Rect(0, 0, w, h)).(*ebiten.Image), op)
|
||||
}
|
||||
|
||||
// wrap breaks s at word boundaries into lines no wider than maxPx. The engine's
|
||||
// wrapText is unexported and byte-based, hence our own.
|
||||
func wrap(s string, maxPx int) []string {
|
||||
if maxPx <= glyphW || textW(s) <= maxPx {
|
||||
return []string{s}
|
||||
}
|
||||
var out []string
|
||||
line, word := "", ""
|
||||
flush := func() {
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
line = ""
|
||||
}
|
||||
}
|
||||
emit := func() {
|
||||
if word == "" {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case line == "":
|
||||
line = word
|
||||
case textW(line+" "+word) <= maxPx:
|
||||
line += " " + word
|
||||
default:
|
||||
flush()
|
||||
line = word
|
||||
}
|
||||
word = ""
|
||||
}
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case ' ':
|
||||
emit()
|
||||
case '\n':
|
||||
emit()
|
||||
flush()
|
||||
default:
|
||||
word += string(r)
|
||||
}
|
||||
}
|
||||
emit()
|
||||
flush()
|
||||
if len(out) == 0 {
|
||||
return []string{s}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// clip truncates a string so it fits into maxPx.
|
||||
func clip(s string, maxPx int) string {
|
||||
r := []rune(s)
|
||||
max := maxPx / glyphW
|
||||
switch {
|
||||
case max <= 0:
|
||||
return ""
|
||||
case len(r) <= max:
|
||||
return s
|
||||
case max == 1:
|
||||
return string(r[:1])
|
||||
default:
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package ui
|
||||
|
||||
import "testing"
|
||||
|
||||
// The engine's textWidth counts bytes, which mismeasures any non-ASCII string.
|
||||
// Ours counts runes — pin that down, because wrapping depends on it.
|
||||
func TestTextWidthCountsRunes(t *testing.T) {
|
||||
if got, want := textW("naïve"), 5*glyphW; got != want {
|
||||
t.Errorf("textW(naïve) = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := textW("ab"), 2*glyphW; got != want {
|
||||
t.Errorf("textW(ab) = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapRespectsWidth(t *testing.T) {
|
||||
const maxPx = 60
|
||||
lines := wrap("An Armilla without a tape is a stupid watch, remember that.", maxPx)
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("did not wrap: %v", lines)
|
||||
}
|
||||
for _, l := range lines {
|
||||
if textW(l) > maxPx && len(wrap(l, maxPx)) > 1 {
|
||||
t.Errorf("line too long (%d px): %q", textW(l), l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipFits(t *testing.T) {
|
||||
if got := clip("black-market Armilla", 8*glyphW); textW(got) > 8*glyphW {
|
||||
t.Errorf("clip too wide: %q", got)
|
||||
}
|
||||
if got := clip("dex", 8*glyphW); got != "dex" {
|
||||
t.Errorf("clip shortened a fitting string: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression guard for the bug that made the first build unreadable: the
|
||||
// layout was derived from the wireframe deck's ratios, where text is 2.8% of
|
||||
// the screen height, while the engine's debug font is a fixed 6×16 cell. At
|
||||
// 320×200 that is 8% — so rows overlapped and the top bar overflowed into the
|
||||
// scene. Every text row must be at least as tall as the glyph cell.
|
||||
func TestLayoutFitsTheFont(t *testing.T) {
|
||||
if LineH < glyphH {
|
||||
t.Errorf("LineH (%d) is shorter than the glyph cell (%d): rows will overlap", LineH, glyphH)
|
||||
}
|
||||
if TopBarH < glyphH {
|
||||
t.Errorf("TopBarH (%d) cannot hold a %dpx glyph", TopBarH, glyphH)
|
||||
}
|
||||
// The tape channel must fit a header, a rule and at least four body rows —
|
||||
// below that the dialogue is unreadable.
|
||||
const channelH = ScreenH - HUDTop - 4
|
||||
body := (channelH - Pad*2 - LineH - 6) / LineH
|
||||
if body < 4 {
|
||||
t.Errorf("tape channel only fits %d body rows, want >= 4", body)
|
||||
}
|
||||
// The two tape slots must fit their two rows inside their border.
|
||||
if slotsH < LineH*2+Pad {
|
||||
t.Errorf("slotsH (%d) cannot hold two %dpx rows", slotsH, LineH)
|
||||
}
|
||||
}
|
||||
|
||||
// The HUD must not reach outside the screen, and the scene must keep a usable
|
||||
// aspect ratio for a point & click background.
|
||||
func TestSceneProportions(t *testing.T) {
|
||||
if invY+invSlot*2+invGap > ScreenH {
|
||||
t.Errorf("inventory runs off the bottom: %d > %d", invY+invSlot*2+invGap, ScreenH)
|
||||
}
|
||||
sceneH := HUDTop - TopBarH
|
||||
if ratio := float64(ScreenW) / float64(sceneH); ratio > 3.0 {
|
||||
t.Errorf("scene is too letterboxed for a background: %.2f:1", ratio)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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"
|
||||
|
||||
"realworld/internal/names"
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package ui
|
||||
|
||||
// UseWithGuard — enforcing authored failure.
|
||||
//
|
||||
// The rule: a failed interaction fails IN CHARACTER, never with an error
|
||||
// message. But inkwell hardcodes a "Nem ehhez." flash in core.engine.go when an
|
||||
// item/hotspot pair has no OnUseWith handler, and that string cannot be
|
||||
// replaced from the domain.
|
||||
//
|
||||
// The fix, without touching the engine: widgets tick BEFORE the engine's
|
||||
// handleSceneInput and may consume the click. So this widget catches the
|
||||
// "selected item + hotspot with no authored pair" case and fails in Paul's
|
||||
// voice with a remark from Dex — costing nothing, since nothing is consumed and
|
||||
// nothing breaks.
|
||||
//
|
||||
// IMPORTANT: it must be registered FIRST so that it ticks LAST among the
|
||||
// widgets. That way every HUD widget (inventory, tape slots, dialogue) sees the
|
||||
// click first, and this only ever catches clicks that land on the scene.
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
|
||||
inkwell "git.teletypegames.org/engines/inkwell"
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
|
||||
"realworld/internal/names"
|
||||
"realworld/internal/world"
|
||||
)
|
||||
|
||||
type UseWithGuard struct {
|
||||
Name string
|
||||
W *world.World
|
||||
}
|
||||
|
||||
func (u *UseWithGuard) GetName() string { return u.Name }
|
||||
func (u *UseWithGuard) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
|
||||
|
||||
func (u *UseWithGuard) Tick(ctx *inkwell.UICtx) {
|
||||
g := ctx.Game
|
||||
if !u.W.HUDVisible() || !g.Input.LeftClicked() {
|
||||
return
|
||||
}
|
||||
sel := g.Inventory.Selected()
|
||||
if sel == "" {
|
||||
return
|
||||
}
|
||||
h := g.HotspotAt(g.Input.Point())
|
||||
if h == nil || hasAuthoredPair(g, h, sel) {
|
||||
return // leave authored pairs to the engine
|
||||
}
|
||||
|
||||
g.Input.ConsumeLeft()
|
||||
g.Inventory.Select("")
|
||||
|
||||
label := h.Label
|
||||
if label == "" {
|
||||
label = h.Name
|
||||
}
|
||||
g.LogAction("> Use " + itemLabel(g, sel) + " on: " + label)
|
||||
|
||||
// The tone escalates on repeats: dry, then teasing, then a real nudge.
|
||||
key := "fail." + sel + "." + h.Name
|
||||
g.State.NoteTalked(key)
|
||||
n := g.State.Talked(key)
|
||||
|
||||
u.W.Do(inkwell.Seq(
|
||||
inkwell.Say(names.Paul, pick(paulFails, n)),
|
||||
world.TapeSay(names.Dex, dexFail(n)),
|
||||
))
|
||||
}
|
||||
|
||||
// hasAuthoredPair mirrors the engine's lookup order in invokeHotspotVerb.
|
||||
func hasAuthoredPair(g *inkwell.Game, h *inkwell.Hotspot, item string) bool {
|
||||
if h.OnUseWith != nil {
|
||||
if a, ok := h.OnUseWith[item]; ok && a != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if it, ok := g.ItemManager.Get(item); ok && it.OnUseWith != nil {
|
||||
if a, ok := it.OnUseWith[h.Name]; ok && a != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var paulFails = []string{
|
||||
"No. That's not going to work.",
|
||||
"Tried that. It didn't improve.",
|
||||
"All right, I know that one doesn't work.",
|
||||
"Now it's personal.",
|
||||
}
|
||||
|
||||
var dexDry = []string{
|
||||
"No. Not like that.",
|
||||
"I heard it. Nothing happened.",
|
||||
}
|
||||
|
||||
var dexTease = []string{
|
||||
"Twice the same. The second one rarely goes better.",
|
||||
"Do it a few more times, maybe physics reconsiders.",
|
||||
}
|
||||
|
||||
// dexFail escalates: dry, then teasing, then an actual hint — so the joke
|
||||
// never becomes the wall.
|
||||
func dexFail(n int) string {
|
||||
switch {
|
||||
case n <= 1:
|
||||
return pick(dexDry, n)
|
||||
case n == 2:
|
||||
return pick(dexTease, n)
|
||||
default:
|
||||
return "Paul. Leave it. Look again at what you're carrying — that's where it is."
|
||||
}
|
||||
}
|
||||
|
||||
func pick(s []string, n int) string {
|
||||
switch {
|
||||
case len(s) == 0:
|
||||
return ""
|
||||
case n <= 0:
|
||||
return s[0]
|
||||
case n-1 < len(s):
|
||||
return s[n-1]
|
||||
default:
|
||||
return s[rand.Intn(len(s))]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user