Files
pncdsl/README.md
2026-05-25 20:37:55 +02:00

12 KiB
Raw Blame History

pncdsl

A point-and-click adventure game framework for Go, built on top of Ebitengine. You describe your game with declarative struct literals: every entity (item, scene, character, dialogue, script, asset, verb, theme, widget) is registered through its matching XxxManager.Register(...) call, and the library handles the rest — input, rendering, state, dialog trees, cutscenes, HUD.

What it is

Inspired by the classic LucasArts SCUMM era (Maniac Mansion, Monkey Island, Day of the Tentacle). The framework doesn't try to be a generic "engine" — it's a thin Go-level DSL layer on top of Ebitengine that gives you the fixed skeleton of the adventure genre:

  • scenes with hotspots and click zones,
  • a fully configurable HUD as a tree of Widgets — verb bar, inventory, speech, dialog box, end card, cursor, hotspot debug, or your own,
  • pluggable verb input modes — built-in VerbBar (SCUMM-style) and RadialVerbs (verb-coin, right-click radial menu),
  • inventory, item-on-item and item-on-hotspot interactions,
  • dialog trees with conditional choices,
  • a script/cutscene system with composite actions (Seq, Par, If, Wait),
  • swappable color themes (4 presets shipped: SCUMM, Sierra, Paper, Terminal),
  • world state (flags, vars), validation.

The approach deliberately collapses to one single pattern: every piece is registered via Manager.Register(Entity{Name: "..."}). No builders, no fluent chains, no registry sprawl.

Quick start

Prerequisites: Go 1.24+ (for generic type aliases) and a working OpenGL context.

git clone <repo>
cd pncdsl
go run .

Opens an Ebiten window at 1280×800 (internal resolution 320×200, scaled 4×). No asset files are required — the library generates deterministic colored placeholders for anything missing under assets/. Press F1 in-game to toggle the hotspot debug overlay.

One-minute example

// main.go
package main

import (
    "log"
    "pncdsl/pncdsl"
)

func main() {
    g := pncdsl.NewGame("Sample", 320, 200)

    g.AssetManager.Register(pncdsl.Asset{Name: "bg/kitchen", Path: "assets/bg/kitchen.png", Kind: pncdsl.AssetImage})

    g.ItemManager.Register(pncdsl.Item{
        Name: "key", Sprite: "spr/key", Description: "a rusty key",
    })

    g.SceneManager.Register(pncdsl.Scene{
        Name:       "kitchen",
        Background: "bg/kitchen",
        Hotspots: []pncdsl.Hotspot{
            {
                Name:   "drawer",
                Area:   pncdsl.Rect(40, 80, 60, 40),
                Label:  "drawer",
                OnLook: pncdsl.Say("player", "A drawer. Wonder what's inside?"),
                OnUse:  pncdsl.Seq(pncdsl.Give("key"), pncdsl.Say("player", "A key!")),
            },
        },
    })

    g.CharacterManager.Register(pncdsl.Character{Name: "player", W: 28, H: 62})

    pncdsl.RegisterDefaultUI(g)        // SCUMM-style HUD widgets
    g.UseTheme("classic-scumm")        // or sierra-coin / paper-notebook / terminal-green

    g.StartAt("kitchen")
    if err := pncdsl.Run(g); err != nil {
        log.Fatal(err)
    }
}

The central pattern — Manager

Every name-addressable entity goes into the game through one shared type:

type Named interface { GetName() string }

type Manager[T Named] struct { /* ... */ }
func (m *Manager[T]) Register(v T)
func (m *Manager[T]) Get(name string) (T, bool)
func (m *Manager[T]) MustGet(name string) T
func (m *Manager[T]) Has(name string) bool
func (m *Manager[T]) Names() []string       // insertion order
func (m *Manager[T]) SortedNames() []string // alphabetical
func (m *Manager[T]) Remove(name string)

Each entity kind gets a generic alias (ItemManager = Manager[Item], SceneManager = Manager[Scene], UIManager = Manager[Widget], …) so usage is uniform:

g.ItemManager.Register(pncdsl.Item{Name: "key", ...})
g.SceneManager.Register(pncdsl.Scene{Name: "kitchen", ...})
g.UIManager.Register(&pncdsl.RadialVerbs{Name: "verbs"})
g.ThemeManager.Register(pncdsl.Theme{Name: "my-theme", ...})

A duplicate or empty Name panics — that's a construction-time bug, not a runtime error.

Full design rationale: PLAN.md.

HUD as a widget tree

The whole HUD is built from Widget instances registered into g.UIManager. The interface is minimal — three methods:

type Widget interface {
    GetName() string
    Tick(ctx *UICtx)              // input + state per frame
    Draw(dst *ebiten.Image, ctx *UICtx)
}

Tick runs in reverse registration order so the top widget claims input first; Draw runs in registration order so later widgets paint on top. Built-in widgets the library ships:

Widget Purpose
Panel Generic background panel (group/frame other widgets)
StatusLine Hover hint + 2s flash messages
VerbBar SCUMM-style permanent verb grid
RadialVerbs Verb-coin: secondary-click radial menu
InventoryBar Item slots, click to select/inspect
SpeechBubble Renders the line set by the Say action
DialogBox Active conversation with conditional choices
EndCard Fullscreen overlay for ShowEnd
Cursor Mouse cursor + selected-item sprite
HotspotDebug F1-toggleable hotspot outlines

Convenience helpers:

pncdsl.RegisterDefaultUI(g)     // SCUMM-style: verb bar + inv + dialog + speech + cursor
pncdsl.RegisterRadialVerbUI(g)  // Verb-coin instead of verb bar

Adding your own widget

Anything that implements the Widget interface plugs in — a chat panel, minimap, hotbar, scrollable journal, whatever:

type ChatPanel struct {
    Name   string
    Bounds pncdsl.Rectangle
    lines  []string
}

func (c *ChatPanel) GetName() string                       { return c.Name }
func (c *ChatPanel) Tick(ctx *pncdsl.UICtx)                {}
func (c *ChatPanel) Draw(dst *ebiten.Image, ctx *pncdsl.UICtx) {
    th := ctx.Game.Theme()
    vector.DrawFilledRect(dst, /* … */, th.PanelBG, false)
    for i, ln := range c.lines {
        ctx.Game.DrawText(dst, ln, int(c.Bounds.X)+2, int(c.Bounds.Y)+2+i*14, th.StatusText)
    }
}

g.UIManager.Register(&ChatPanel{Name: "chat", Bounds: pncdsl.Rect(220, 4, 96, 130)})

Themes

Colors live on a registered Theme entity, addressed by name. Four presets are pre-registered by NewGame; classic-scumm is selected by default. Switch at any time (including at runtime):

g.UseTheme("paper-notebook")    // light cream + ink
g.UseTheme("terminal-green")    // retro CRT

Register your own theme by copying any preset and tweaking the fields:

g.ThemeManager.Register(pncdsl.Theme{
    Name:      "midnight-noir",
    PanelBG:   color.RGBA{6, 6, 12, 255},
    // ... ~25 color fields ...
})
g.UseTheme("midnight-noir")

File layout

pncdsl/
├── main.go                  # entry point: pncdsl.Run(domain.Build())
├── pncdsl/                  # the library — theme-prefixed file names
│   ├── core.*.go            # Game, engine, manager, dsl, errors
│   ├── scene.*.go           # Scene, Hotspot, Trigger, Camera, Transition
│   ├── item.*.go            # Item, Inventory
│   ├── actor.*.go           # Character, Animation
│   ├── dialog.*.go          # Dialogue (entity + manager)
│   ├── action.*.go          # Action/Runner, built-in actions, Condition
│   ├── state.*.go           # State, Save (stub)
│   ├── ui.widget.go         # Widget interface, UICtx, Size, Align
│   ├── ui.manager.go        # UIManager alias
│   ├── ui.theme*.go         # Theme + preset themes
│   ├── ui.<widget>.go       # one file per built-in widget
│   ├── ui.defaults.go       # RegisterDefaultUI, RegisterRadialVerbUI
│   ├── asset.*.go           # Registry, lazy load, Audio (stub), Text
│   └── util.*.go            # geometry, timer, log
├── domain/                  # the concrete game — one file per entity
│   ├── game.go              # Build()
│   ├── scene.bedroom.go     # ↔ g.SceneManager.Register(...Name: "bedroom"...)
│   ├── item.key.go          # ↔ g.ItemManager.Register(...Name: "key"...)
│   └── ...
├── PLAN.md                  # detailed design document
├── UIPLAN.md                # widget-system design
├── DEMO.md                  # demo game overview  ◀────────
├── GFX.md                   # asset prompts for image-AI
└── README.md                # this file

Files under domain/ always follow theme.identifier.go (e.g. scene.kitchen.go, character.player.go) — ls domain/scene.* instantly lists every location.

The demo

The repo ships with a mini-game (domain/) titled "Morning Coffee". Two scenes, three items, one NPC with a dialog, one intro and one ending cutscene — just enough to exercise every library feature without the demo outgrowing the library.

Full walkthrough and file mapping: DEMO.md.

If you want to generate art for the game, prompts for image AIs are in GFX.md.

Current status

Area State
Manager registries Item, Scene, Character, Dialogue, Script, Asset, Verb, UI, Theme
Hotspot + verb interaction
Inventory, use-with-item (both hotspot.OnUseWith and item.OnUseWith)
Dialog tree + conditional choices (Show condition, Once flag)
Action/Runner engine Seq / Par / If / Wait / Say / Walk / Give / GoTo / …
Condition library Flag, HasItem, SelectedItem, InScene, VarEq, Not/And/Or
Widget HUD (Widget interface) 10 built-in widgets, custom widgets via interface
Verb-coin (RadialVerbs) secondary-click radial menu
Theme system + presets 4 presets, runtime switching
Scene transitions (fade) on scene change
Asset placeholders deterministic color for missing files
Stylized character placeholder humanoid / quadruped shape when no sprite art
Audio (PlayMusic/PlaySound) 🟡 log-only — Ebiten audio backend not wired up yet
Animation (sprite sheet) 🟡 AnimationClip field exists, rendering not yet
Walkbox + A* 🟡 straight-line movement only
Save / Load (JSON) 🟡 API in place, implementation stubbed
Trigger processing 🟡 struct exists, engine doesn't run them yet
Custom fonts 🟡 ebitenutil debug font for now

Testing

go test ./...

domain/build_test.go is a headless smoke test: it calls Build() and then Validate() to cross-check every name reference between managers. No Ebiten window is opened.

Further reading

  • DEMO.md — walkthrough of the "Morning Coffee" demo

License

MIT — see LICENSE.md. Copyright © 2026 Teletype Games.