Files
pncdsl/README.md
2026-05-25 20:04:33 +02:00

7.2 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) is registered through its matching XxxManager.Register(...) call, and the library handles the rest — input, rendering, state, dialog trees, cutscenes.

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 verb-bar UI: Look / Use / Talk / Take,
  • 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),
  • 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/.

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})

    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

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

g.ItemManager.Register(pncdsl.Item{Name: "key", ...})
g.SceneManager.Register(pncdsl.Scene{Name: "kitchen", ...})
g.AssetManager.Register(pncdsl.Asset{Name: "bg/kitchen", Path: "..."})

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

Full design rationale: PLAN.md.

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, DialogBox
│   ├── action.*.go          # Action/Runner, built-in actions, Condition
│   ├── state.*.go           # State, Save (stub)
│   ├── ui.*.go              # VerbBar, Cursor, SpeechBubble
│   ├── 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
├── 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 complete (Item, Scene, Character, Dialogue, Script, Asset, Verb)
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
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

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.