initial commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package pncdsl
|
||||
|
||||
type Condition interface {
|
||||
Eval(ctx *Ctx) bool
|
||||
}
|
||||
|
||||
// ----- combinators ------------------------------------------------------
|
||||
|
||||
type notCond struct{ c Condition }
|
||||
|
||||
func Not(c Condition) Condition { return ¬Cond{c: c} }
|
||||
func (n *notCond) Eval(ctx *Ctx) bool { return !n.c.Eval(ctx) }
|
||||
|
||||
type andCond struct{ cs []Condition }
|
||||
|
||||
func And(cs ...Condition) Condition { return &andCond{cs: cs} }
|
||||
func (a *andCond) Eval(ctx *Ctx) bool {
|
||||
for _, c := range a.cs {
|
||||
if !c.Eval(ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type orCond struct{ cs []Condition }
|
||||
|
||||
func Or(cs ...Condition) Condition { return &orCond{cs: cs} }
|
||||
func (o *orCond) Eval(ctx *Ctx) bool {
|
||||
for _, c := range o.cs {
|
||||
if c.Eval(ctx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ----- state predicates -------------------------------------------------
|
||||
|
||||
type flagCond struct{ name string }
|
||||
|
||||
func Flag(name string) Condition { return &flagCond{name: name} }
|
||||
func (f *flagCond) Eval(ctx *Ctx) bool { return ctx.Game.State.Flag(f.name) }
|
||||
|
||||
type hasItemCond struct{ name string }
|
||||
|
||||
func HasItem(name string) Condition { return &hasItemCond{name: name} }
|
||||
func (h *hasItemCond) Eval(ctx *Ctx) bool { return ctx.Game.Inventory.Has(h.name) }
|
||||
|
||||
type selectedItemCond struct{ name string }
|
||||
|
||||
func SelectedItem(name string) Condition { return &selectedItemCond{name: name} }
|
||||
func (s *selectedItemCond) Eval(ctx *Ctx) bool { return ctx.Game.Inventory.Selected() == s.name }
|
||||
|
||||
type inSceneCond struct{ name string }
|
||||
|
||||
func InScene(name string) Condition { return &inSceneCond{name: name} }
|
||||
func (i *inSceneCond) Eval(ctx *Ctx) bool { return ctx.Scene != nil && ctx.Scene.Name == i.name }
|
||||
|
||||
type varEqCond struct {
|
||||
name string
|
||||
v any
|
||||
}
|
||||
|
||||
func VarEq(name string, v any) Condition { return &varEqCond{name: name, v: v} }
|
||||
func (e *varEqCond) Eval(ctx *Ctx) bool { return ctx.Game.State.Var(e.name) == e.v }
|
||||
@@ -0,0 +1,450 @@
|
||||
package pncdsl
|
||||
|
||||
// Status is the outcome of one tick of a Runner.
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusRunning Status = iota
|
||||
StatusDone
|
||||
StatusFailed
|
||||
)
|
||||
|
||||
// Ctx is the per-tick context passed to running actions.
|
||||
type Ctx struct {
|
||||
Game *Game
|
||||
DT float64
|
||||
Scene *Scene
|
||||
Hotspot *Hotspot
|
||||
Item *Item
|
||||
}
|
||||
|
||||
// Action is an immutable spec of work; Start makes a fresh Runner with
|
||||
// state. Storing an Action in a struct field (e.g. Hotspot.OnUse) is safe
|
||||
// because the engine always calls Start before ticking, so two invocations
|
||||
// can never share mutable state.
|
||||
type Action interface {
|
||||
Start() Runner
|
||||
}
|
||||
|
||||
// Runner is one in-flight execution of an Action. Tick advances it one
|
||||
// frame and returns whether it's still running, done, or failed.
|
||||
type Runner interface {
|
||||
Tick(ctx *Ctx) Status
|
||||
}
|
||||
|
||||
// ----- immediate action helper -----------------------------------------
|
||||
|
||||
type immediateAction struct {
|
||||
fn func(*Ctx) Status
|
||||
}
|
||||
|
||||
func (a *immediateAction) Start() Runner { return a }
|
||||
func (a *immediateAction) Tick(ctx *Ctx) Status {
|
||||
if a.fn == nil {
|
||||
return StatusDone
|
||||
}
|
||||
return a.fn(ctx)
|
||||
}
|
||||
|
||||
// Custom wraps a user function as an Action. Returns Done on first tick
|
||||
// unless the function itself returns StatusRunning.
|
||||
func Custom(fn func(*Ctx) Status) Action { return &immediateAction{fn: fn} }
|
||||
|
||||
// ----- Seq --------------------------------------------------------------
|
||||
|
||||
type seqAction struct{ children []Action }
|
||||
type seqRunner struct {
|
||||
spec *seqAction
|
||||
idx int
|
||||
current Runner
|
||||
}
|
||||
|
||||
func Seq(actions ...Action) Action {
|
||||
return &seqAction{children: flattenSeq(actions)}
|
||||
}
|
||||
|
||||
func flattenSeq(in []Action) []Action {
|
||||
out := make([]Action, 0, len(in))
|
||||
for _, a := range in {
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := a.(*seqAction); ok {
|
||||
out = append(out, s.children...)
|
||||
} else {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *seqAction) Start() Runner { return &seqRunner{spec: a} }
|
||||
|
||||
func (r *seqRunner) Tick(ctx *Ctx) Status {
|
||||
for {
|
||||
if r.idx >= len(r.spec.children) {
|
||||
return StatusDone
|
||||
}
|
||||
if r.current == nil {
|
||||
r.current = r.spec.children[r.idx].Start()
|
||||
}
|
||||
s := r.current.Tick(ctx)
|
||||
switch s {
|
||||
case StatusDone:
|
||||
r.idx++
|
||||
r.current = nil
|
||||
// keep going only if the just-finished action consumed no time
|
||||
// (zero-dt would loop forever otherwise — that's fine here since
|
||||
// immediate actions complete in one Tick call).
|
||||
continue
|
||||
case StatusFailed:
|
||||
return StatusFailed
|
||||
default:
|
||||
return StatusRunning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Par --------------------------------------------------------------
|
||||
|
||||
type parAction struct{ children []Action }
|
||||
type parRunner struct {
|
||||
runners []Runner
|
||||
done []bool
|
||||
}
|
||||
|
||||
func Par(actions ...Action) Action { return &parAction{children: actions} }
|
||||
|
||||
func (a *parAction) Start() Runner {
|
||||
rs := make([]Runner, len(a.children))
|
||||
for i, c := range a.children {
|
||||
rs[i] = c.Start()
|
||||
}
|
||||
return &parRunner{runners: rs, done: make([]bool, len(rs))}
|
||||
}
|
||||
|
||||
func (r *parRunner) Tick(ctx *Ctx) Status {
|
||||
allDone := true
|
||||
for i, rn := range r.runners {
|
||||
if r.done[i] {
|
||||
continue
|
||||
}
|
||||
s := rn.Tick(ctx)
|
||||
if s == StatusFailed {
|
||||
return StatusFailed
|
||||
}
|
||||
if s == StatusDone {
|
||||
r.done[i] = true
|
||||
continue
|
||||
}
|
||||
allDone = false
|
||||
}
|
||||
if allDone {
|
||||
return StatusDone
|
||||
}
|
||||
return StatusRunning
|
||||
}
|
||||
|
||||
// ----- If ---------------------------------------------------------------
|
||||
|
||||
type ifAction struct {
|
||||
cond Condition
|
||||
then Action
|
||||
els Action
|
||||
}
|
||||
|
||||
func If(cond Condition, then Action, els ...Action) Action {
|
||||
var e Action
|
||||
if len(els) > 0 {
|
||||
e = Seq(els...)
|
||||
}
|
||||
return &ifAction{cond: cond, then: then, els: e}
|
||||
}
|
||||
|
||||
func (a *ifAction) Start() Runner { return &ifRunner{spec: a} }
|
||||
|
||||
type ifRunner struct {
|
||||
spec *ifAction
|
||||
started bool
|
||||
inner Runner
|
||||
}
|
||||
|
||||
func (r *ifRunner) Tick(ctx *Ctx) Status {
|
||||
if !r.started {
|
||||
r.started = true
|
||||
take := r.spec.then
|
||||
if r.spec.cond == nil || !r.spec.cond.Eval(ctx) {
|
||||
take = r.spec.els
|
||||
}
|
||||
if take == nil {
|
||||
return StatusDone
|
||||
}
|
||||
r.inner = take.Start()
|
||||
}
|
||||
if r.inner == nil {
|
||||
return StatusDone
|
||||
}
|
||||
return r.inner.Tick(ctx)
|
||||
}
|
||||
|
||||
// ----- Wait -------------------------------------------------------------
|
||||
|
||||
type waitAction struct{ seconds float64 }
|
||||
type waitRunner struct {
|
||||
spec *waitAction
|
||||
elapsed float64
|
||||
}
|
||||
|
||||
func Wait(seconds float64) Action { return &waitAction{seconds: seconds} }
|
||||
func (a *waitAction) Start() Runner { return &waitRunner{spec: a} }
|
||||
func (r *waitRunner) Tick(ctx *Ctx) Status {
|
||||
r.elapsed += ctx.DT
|
||||
if r.elapsed >= r.spec.seconds {
|
||||
return StatusDone
|
||||
}
|
||||
return StatusRunning
|
||||
}
|
||||
|
||||
// ----- Say --------------------------------------------------------------
|
||||
|
||||
type sayAction struct{ speaker, text string }
|
||||
type sayRunner struct {
|
||||
spec *sayAction
|
||||
elapsed float64
|
||||
duration float64
|
||||
started bool
|
||||
}
|
||||
|
||||
func Say(speaker, text string) Action { return &sayAction{speaker: speaker, text: text} }
|
||||
|
||||
func (a *sayAction) Start() Runner { return &sayRunner{spec: a} }
|
||||
|
||||
func (r *sayRunner) Tick(ctx *Ctx) Status {
|
||||
if !r.started {
|
||||
r.started = true
|
||||
// duration scales with text length, with a 1.2s floor
|
||||
r.duration = 1.2 + float64(len(r.spec.text))*0.05
|
||||
ctx.Game.UI.SetSpeech(r.spec.speaker, r.spec.text)
|
||||
}
|
||||
r.elapsed += ctx.DT
|
||||
// skip on click
|
||||
if ctx.Game.input.consumedClick() {
|
||||
r.elapsed = r.duration
|
||||
}
|
||||
if r.elapsed >= r.duration {
|
||||
ctx.Game.UI.ClearSpeech()
|
||||
return StatusDone
|
||||
}
|
||||
return StatusRunning
|
||||
}
|
||||
|
||||
// ----- GoTo -------------------------------------------------------------
|
||||
|
||||
type gotoAction struct{ scene string }
|
||||
|
||||
func GoTo(scene string) Action { return &gotoAction{scene: scene} }
|
||||
func (a *gotoAction) Start() Runner { return a }
|
||||
func (a *gotoAction) Tick(ctx *Ctx) Status {
|
||||
ctx.Game.changeScene(a.scene)
|
||||
return StatusDone
|
||||
}
|
||||
|
||||
// ----- inventory --------------------------------------------------------
|
||||
|
||||
type giveAction struct{ item string }
|
||||
|
||||
func Give(item string) Action { return &giveAction{item: item} }
|
||||
func (a *giveAction) Start() Runner { return a }
|
||||
func (a *giveAction) Tick(ctx *Ctx) Status {
|
||||
ctx.Game.Inventory.Add(a.item)
|
||||
return StatusDone
|
||||
}
|
||||
|
||||
type takeAwayAction struct{ item string }
|
||||
|
||||
func TakeAway(item string) Action { return &takeAwayAction{item: item} }
|
||||
func (a *takeAwayAction) Start() Runner { return a }
|
||||
func (a *takeAwayAction) Tick(ctx *Ctx) Status {
|
||||
ctx.Game.Inventory.Remove(a.item)
|
||||
return StatusDone
|
||||
}
|
||||
|
||||
// RequireItem fails silently with a generic line if the player doesn't have
|
||||
// the named item selected or in inventory. Used at the top of Use handlers.
|
||||
type requireItemAction struct{ item string }
|
||||
|
||||
func RequireItem(item string) Action { return &requireItemAction{item: item} }
|
||||
func (a *requireItemAction) Start() Runner { return a }
|
||||
func (a *requireItemAction) Tick(ctx *Ctx) Status {
|
||||
if ctx.Game.Inventory.Has(a.item) {
|
||||
return StatusDone
|
||||
}
|
||||
ctx.Game.UI.FlashLine("Ehhez kell egy " + a.item + ".")
|
||||
return StatusFailed
|
||||
}
|
||||
|
||||
// ----- flags / vars -----------------------------------------------------
|
||||
|
||||
type setFlagAction struct{ name string }
|
||||
|
||||
func SetFlag(name string) Action { return &setFlagAction{name: name} }
|
||||
func (a *setFlagAction) Start() Runner { return a }
|
||||
func (a *setFlagAction) Tick(ctx *Ctx) Status { ctx.Game.State.SetFlag(a.name); return StatusDone }
|
||||
|
||||
type clearFlagAction struct{ name string }
|
||||
|
||||
func ClearFlag(name string) Action { return &clearFlagAction{name: name} }
|
||||
func (a *clearFlagAction) Start() Runner { return a }
|
||||
func (a *clearFlagAction) Tick(ctx *Ctx) Status {
|
||||
ctx.Game.State.ClearFlag(a.name)
|
||||
return StatusDone
|
||||
}
|
||||
|
||||
type setVarAction struct {
|
||||
name string
|
||||
v any
|
||||
}
|
||||
|
||||
func SetVar(name string, v any) Action { return &setVarAction{name: name, v: v} }
|
||||
func (a *setVarAction) Start() Runner { return a }
|
||||
func (a *setVarAction) Tick(ctx *Ctx) Status {
|
||||
ctx.Game.State.SetVar(a.name, a.v)
|
||||
return StatusDone
|
||||
}
|
||||
|
||||
// ----- audio ------------------------------------------------------------
|
||||
|
||||
type playMusicAction struct{ name string }
|
||||
|
||||
func PlayMusic(name string) Action { return &playMusicAction{name: name} }
|
||||
func (a *playMusicAction) Start() Runner { return a }
|
||||
func (a *playMusicAction) Tick(ctx *Ctx) Status {
|
||||
ctx.Game.Audio.PlayMusic(a.name)
|
||||
return StatusDone
|
||||
}
|
||||
|
||||
type stopMusicAction struct{}
|
||||
|
||||
func StopMusic() Action { return &stopMusicAction{} }
|
||||
func (a *stopMusicAction) Start() Runner { return a }
|
||||
func (a *stopMusicAction) Tick(ctx *Ctx) Status { ctx.Game.Audio.StopMusic(); return StatusDone }
|
||||
|
||||
type playSoundAction struct{ name string }
|
||||
|
||||
func PlaySound(name string) Action { return &playSoundAction{name: name} }
|
||||
func (a *playSoundAction) Start() Runner { return a }
|
||||
func (a *playSoundAction) Tick(ctx *Ctx) Status {
|
||||
ctx.Game.Audio.PlaySound(a.name)
|
||||
return StatusDone
|
||||
}
|
||||
|
||||
// ----- dialogue ---------------------------------------------------------
|
||||
|
||||
type runDialogueAction struct{ name string }
|
||||
|
||||
func RunDialogue(name string) Action { return &runDialogueAction{name: name} }
|
||||
func (a *runDialogueAction) Start() Runner { return &runDialogueRunner{spec: a} }
|
||||
|
||||
type runDialogueRunner struct {
|
||||
spec *runDialogueAction
|
||||
started bool
|
||||
}
|
||||
|
||||
func (r *runDialogueRunner) Tick(ctx *Ctx) Status {
|
||||
if !r.started {
|
||||
r.started = true
|
||||
ctx.Game.startDialogue(r.spec.name)
|
||||
}
|
||||
if ctx.Game.dialogueActive() {
|
||||
return StatusRunning
|
||||
}
|
||||
return StatusDone
|
||||
}
|
||||
|
||||
type endDialogueAction struct{}
|
||||
|
||||
func EndDialogue() Action { return &endDialogueAction{} }
|
||||
func (a *endDialogueAction) Start() Runner { return a }
|
||||
func (a *endDialogueAction) Tick(ctx *Ctx) Status {
|
||||
ctx.Game.endDialogue()
|
||||
return StatusDone
|
||||
}
|
||||
|
||||
type gotoNodeAction struct{ node string }
|
||||
|
||||
func GotoNode(node string) Action { return &gotoNodeAction{node: node} }
|
||||
func (a *gotoNodeAction) Start() Runner { return a }
|
||||
func (a *gotoNodeAction) Tick(ctx *Ctx) Status {
|
||||
ctx.Game.gotoDialogueNode(a.node)
|
||||
return StatusDone
|
||||
}
|
||||
|
||||
// ----- scripts ----------------------------------------------------------
|
||||
|
||||
type runScriptAction struct{ name string }
|
||||
|
||||
func RunScript(name string) Action { return &runScriptAction{name: name} }
|
||||
func (a *runScriptAction) Start() Runner { return &runScriptRunner{spec: a} }
|
||||
|
||||
type runScriptRunner struct {
|
||||
spec *runScriptAction
|
||||
inner Runner
|
||||
}
|
||||
|
||||
func (r *runScriptRunner) Tick(ctx *Ctx) Status {
|
||||
if r.inner == nil {
|
||||
s, ok := ctx.Game.ScriptManager.Get(r.spec.name)
|
||||
if !ok || s.Actions == nil {
|
||||
return StatusFailed
|
||||
}
|
||||
r.inner = s.Actions.Start()
|
||||
}
|
||||
return r.inner.Tick(ctx)
|
||||
}
|
||||
|
||||
// ----- character movement ----------------------------------------------
|
||||
|
||||
type walkAction struct {
|
||||
character string
|
||||
to Point
|
||||
}
|
||||
|
||||
func Walk(character string, to Point) Action { return &walkAction{character: character, to: to} }
|
||||
|
||||
func (a *walkAction) Start() Runner { return &walkRunner{spec: a} }
|
||||
|
||||
type walkRunner struct {
|
||||
spec *walkAction
|
||||
started bool
|
||||
}
|
||||
|
||||
func (r *walkRunner) Tick(ctx *Ctx) Status {
|
||||
if !r.started {
|
||||
r.started = true
|
||||
ctx.Game.walkCharacter(r.spec.character, r.spec.to)
|
||||
}
|
||||
if ctx.Game.characterMoving(r.spec.character) {
|
||||
return StatusRunning
|
||||
}
|
||||
return StatusDone
|
||||
}
|
||||
|
||||
// ----- misc -------------------------------------------------------------
|
||||
|
||||
type showEndAction struct{ text string }
|
||||
|
||||
func ShowEnd(text string) Action { return &showEndAction{text: text} }
|
||||
func (a *showEndAction) Start() Runner { return &showEndRunner{spec: a} }
|
||||
|
||||
type showEndRunner struct {
|
||||
spec *showEndAction
|
||||
started bool
|
||||
}
|
||||
|
||||
func (r *showEndRunner) Tick(ctx *Ctx) Status {
|
||||
if !r.started {
|
||||
r.started = true
|
||||
ctx.Game.showEndCard(r.spec.text)
|
||||
}
|
||||
return StatusRunning // never finishes; player closes the window
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type ScriptManager = Manager[Script]
|
||||
@@ -0,0 +1,9 @@
|
||||
package pncdsl
|
||||
|
||||
type Script struct {
|
||||
Name string
|
||||
Actions Action
|
||||
}
|
||||
|
||||
func (s Script) GetName() string { return s.Name }
|
||||
func (s Script) TypeLabel() string { return "script" }
|
||||
@@ -0,0 +1,10 @@
|
||||
package pncdsl
|
||||
|
||||
// AnimationClip is a placeholder for sprite-sheet animation data. Not used
|
||||
// for rendering yet — characters draw as flat colored rectangles in this
|
||||
// milestone — but the field exists so domain code can reference it.
|
||||
type AnimationClip struct {
|
||||
Frames []Rectangle // source rects on the sprite sheet
|
||||
FrameTime float64
|
||||
Loop bool
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package pncdsl
|
||||
|
||||
import "image/color"
|
||||
|
||||
type Character struct {
|
||||
Name string
|
||||
Sprite string // Asset.Name (placeholder if missing)
|
||||
Animations map[string]AnimationClip
|
||||
Speed float64
|
||||
SpeechColor color.Color
|
||||
Start Point
|
||||
// Size hints used when the sprite is a placeholder rectangle.
|
||||
W, H float64
|
||||
}
|
||||
|
||||
func (c Character) GetName() string { return c.Name }
|
||||
func (c Character) TypeLabel() string { return "character" }
|
||||
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type CharacterManager = Manager[Character]
|
||||
@@ -0,0 +1,30 @@
|
||||
package pncdsl
|
||||
|
||||
// AudioPlayer is a stub for music/sfx playback. The action constructors
|
||||
// (PlayMusic/PlaySound/StopMusic) call through here; on this milestone we
|
||||
// just log the request so the game runs without an audio device.
|
||||
type AudioPlayer struct {
|
||||
currentMusic string
|
||||
}
|
||||
|
||||
func NewAudioPlayer() *AudioPlayer { return &AudioPlayer{} }
|
||||
|
||||
func (a *AudioPlayer) PlayMusic(name string) {
|
||||
if a.currentMusic == name {
|
||||
return
|
||||
}
|
||||
a.currentMusic = name
|
||||
logf("audio.PlayMusic %q", name)
|
||||
}
|
||||
|
||||
func (a *AudioPlayer) StopMusic() {
|
||||
if a.currentMusic == "" {
|
||||
return
|
||||
}
|
||||
logf("audio.StopMusic (was %q)", a.currentMusic)
|
||||
a.currentMusic = ""
|
||||
}
|
||||
|
||||
func (a *AudioPlayer) PlaySound(name string) {
|
||||
logf("audio.PlaySound %q", name)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package pncdsl
|
||||
|
||||
type AssetKind int
|
||||
|
||||
const (
|
||||
AssetImage AssetKind = iota
|
||||
AssetAudio
|
||||
AssetFont
|
||||
)
|
||||
|
||||
type Asset struct {
|
||||
Name string
|
||||
Path string
|
||||
Kind AssetKind
|
||||
}
|
||||
|
||||
func (a Asset) GetName() string { return a.Name }
|
||||
func (a Asset) TypeLabel() string { return "asset" }
|
||||
@@ -0,0 +1,78 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"os"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
)
|
||||
|
||||
type AssetManager = Manager[Asset]
|
||||
|
||||
// loadedAssets is the runtime cache for decoded image/audio resources.
|
||||
// The Asset structs in the manager remain immutable spec; this is where
|
||||
// the actual *ebiten.Image bytes live, lazily decoded on first access.
|
||||
type loadedAssets struct {
|
||||
images map[string]*ebiten.Image
|
||||
defaultW, defaultH int
|
||||
}
|
||||
|
||||
func newLoadedAssets(w, h int) *loadedAssets {
|
||||
return &loadedAssets{
|
||||
images: make(map[string]*ebiten.Image),
|
||||
defaultW: w,
|
||||
defaultH: h,
|
||||
}
|
||||
}
|
||||
|
||||
func (la *loadedAssets) image(am *AssetManager, name string) *ebiten.Image {
|
||||
if img, ok := la.images[name]; ok {
|
||||
return img
|
||||
}
|
||||
a, ok := am.Get(name)
|
||||
if !ok {
|
||||
img := placeholderImage(name, la.defaultW, la.defaultH)
|
||||
la.images[name] = img
|
||||
return img
|
||||
}
|
||||
img := loadImageFile(a.Path)
|
||||
if img == nil {
|
||||
img = placeholderImage(name, la.defaultW, la.defaultH)
|
||||
}
|
||||
la.images[name] = img
|
||||
return img
|
||||
}
|
||||
|
||||
func loadImageFile(path string) *ebiten.Image {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
src, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return ebiten.NewImageFromImage(src)
|
||||
}
|
||||
|
||||
// placeholderImage creates a deterministic colored rectangle for missing
|
||||
// assets so the game still runs without art on disk.
|
||||
func placeholderImage(seed string, w, h int) *ebiten.Image {
|
||||
img := ebiten.NewImage(w, h)
|
||||
h32 := fnv.New32a()
|
||||
_, _ = h32.Write([]byte(seed))
|
||||
sum := h32.Sum32()
|
||||
c := color.RGBA{
|
||||
R: 60 + uint8(sum&0x7F),
|
||||
G: 60 + uint8((sum>>8)&0x7F),
|
||||
B: 60 + uint8((sum>>16)&0x7F),
|
||||
A: 255,
|
||||
}
|
||||
img.Fill(c)
|
||||
return img
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||||
)
|
||||
|
||||
// drawText is a minimal text draw using ebiten's built-in debug font.
|
||||
// The glyphs are 6×16-ish; good enough for a SCUMM-style 320×200 demo.
|
||||
func drawText(dst *ebiten.Image, s string, x, y int, c color.Color) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
// ebitenutil.DebugPrintAt only draws white; for color we render to a
|
||||
// tiny offscreen, then ColorScale-tint when blitting. Simpler: just
|
||||
// use the white draw and skip color. (TODO: text/v2 once needed.)
|
||||
_ = c
|
||||
ebitenutil.DebugPrintAt(dst, s, x, y)
|
||||
}
|
||||
|
||||
// textWidth is a coarse pixel-width estimate, used for centering.
|
||||
func textWidth(s string) int { return len(s) * 6 }
|
||||
|
||||
// wrapText splits s at word boundaries into lines no wider than maxPx.
|
||||
func wrapText(s string, maxPx int) []string {
|
||||
if maxPx <= 0 || textWidth(s) <= maxPx {
|
||||
return []string{s}
|
||||
}
|
||||
var (
|
||||
out []string
|
||||
line string
|
||||
)
|
||||
flush := func() {
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
line = ""
|
||||
}
|
||||
}
|
||||
word := ""
|
||||
for _, r := range s {
|
||||
if r == ' ' || r == '\n' {
|
||||
if line == "" {
|
||||
line = word
|
||||
} else if textWidth(line+" "+word) <= maxPx {
|
||||
line += " " + word
|
||||
} else {
|
||||
flush()
|
||||
line = word
|
||||
}
|
||||
word = ""
|
||||
if r == '\n' {
|
||||
flush()
|
||||
}
|
||||
continue
|
||||
}
|
||||
word += string(r)
|
||||
}
|
||||
if word != "" {
|
||||
if line == "" {
|
||||
line = word
|
||||
} else if textWidth(line+" "+word) <= maxPx {
|
||||
line += " " + word
|
||||
} else {
|
||||
flush()
|
||||
line = word
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Package pncdsl is a point-and-click adventure game library built on top of
|
||||
// Ebitengine. Games are composed declaratively by registering plain struct
|
||||
// literals into per-entity *Manager registries hanging off the *Game root.
|
||||
//
|
||||
// See PLAN.md in the repo root for the full design notes.
|
||||
package pncdsl
|
||||
@@ -0,0 +1,42 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
)
|
||||
|
||||
// Run validates the game, then enters the ebiten main loop. The window is
|
||||
// sized to 4× the internal resolution.
|
||||
func Run(g *Game) error {
|
||||
if err := g.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
g.UI.rebuildVerbButtons()
|
||||
|
||||
// Initial scene goes in directly (no transition) so OnEnter / OnStart
|
||||
// run cleanly as one composed sequence on the first script tick.
|
||||
s := g.SceneManager.MustGet(g.startID)
|
||||
g.currentScene = g.startID
|
||||
g.State.NoteVisit(g.startID)
|
||||
g.placeActors(g.startID)
|
||||
if s.Music != "" {
|
||||
g.Audio.PlayMusic(s.Music)
|
||||
}
|
||||
var seq []Action
|
||||
if s.OnEnter != nil {
|
||||
seq = append(seq, s.OnEnter)
|
||||
}
|
||||
if g.onStart != nil {
|
||||
seq = append(seq, g.onStart)
|
||||
}
|
||||
if len(seq) > 0 {
|
||||
g.queueAction(Seq(seq...), "init")
|
||||
}
|
||||
|
||||
ebiten.SetWindowSize(g.Width*4, g.Height*4)
|
||||
ebiten.SetWindowTitle(g.Title)
|
||||
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
|
||||
return ebiten.RunGame(&engine{g: g})
|
||||
}
|
||||
|
||||
// (Game.Run is provided here for convenience; some callers prefer it.)
|
||||
func (g *Game) Run() error { return Run(g) }
|
||||
@@ -0,0 +1,292 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// engine is the ebiten.Game adapter. It owns the per-frame Update/Draw flow
|
||||
// and delegates everything stateful to *Game.
|
||||
type engine struct {
|
||||
g *Game
|
||||
}
|
||||
|
||||
func (e *engine) Layout(outsideWidth, outsideHeight int) (int, int) {
|
||||
return e.g.Width, e.g.Height
|
||||
}
|
||||
|
||||
func (e *engine) Update() error {
|
||||
g := e.g
|
||||
dt := 1.0 / 60.0
|
||||
g.input.poll()
|
||||
g.UI.tick(dt)
|
||||
g.transition.update(dt)
|
||||
|
||||
if g.transition.active && g.transition.out {
|
||||
// during fade-out, freeze input
|
||||
return nil
|
||||
}
|
||||
|
||||
if g.UI.endCard != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if g.scriptRunner != nil {
|
||||
ctx := g.scriptCtx
|
||||
ctx.DT = dt
|
||||
// keep Scene reference fresh in case the script changed it
|
||||
if g.currentScene != "" {
|
||||
s := g.SceneManager.MustGet(g.currentScene)
|
||||
ctx.Scene = &s
|
||||
}
|
||||
s := g.scriptRunner.Tick(ctx)
|
||||
if s != StatusRunning {
|
||||
g.scriptRunner = nil
|
||||
g.scriptCtx = nil
|
||||
}
|
||||
g.tickCharacters(dt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dialog click handling
|
||||
if g.dialog != nil {
|
||||
if g.input.LeftClicked() {
|
||||
g.input.ConsumeLeft()
|
||||
ctx := g.makeCtx()
|
||||
picked := g.dialog.handleClick(ctx, g.input.Point())
|
||||
if picked != nil {
|
||||
// record once-tracking
|
||||
if picked.Once {
|
||||
g.State.NoteTalked(g.dialog.node.Name + ":" + picked.Text)
|
||||
}
|
||||
// run the choice's actions as a synthetic script
|
||||
if len(picked.Actions) > 0 {
|
||||
g.queueAction(Seq(picked.Actions...), "choice")
|
||||
}
|
||||
}
|
||||
}
|
||||
g.tickCharacters(dt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// free interaction
|
||||
e.handleFreeInput()
|
||||
g.tickCharacters(dt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *engine) handleFreeInput() {
|
||||
g := e.g
|
||||
g.UI.rebuildVerbButtons()
|
||||
mp := g.input.Point()
|
||||
|
||||
// hover label resolution
|
||||
g.UI.hoverLabel = ""
|
||||
if mp.Y < uiPanelY-4 {
|
||||
if h := e.hotspotAt(mp); h != nil {
|
||||
if h.Label != "" {
|
||||
g.UI.hoverLabel = h.Label
|
||||
} else {
|
||||
g.UI.hoverLabel = h.Name
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// hovering UI: show item description when over an inv slot
|
||||
if hit := g.UI.hitTest(mp); len(hit) > 4 && hit[:4] == "inv:" {
|
||||
name := hit[4:]
|
||||
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
|
||||
g.UI.hoverLabel = it.Description
|
||||
} else {
|
||||
g.UI.hoverLabel = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if g.input.RightClicked() {
|
||||
g.input.ConsumeRight()
|
||||
// right click: deselect item / reset verb
|
||||
if g.Inventory.Selected() != "" {
|
||||
g.Inventory.Select("")
|
||||
} else {
|
||||
g.selectedVerb = "look"
|
||||
}
|
||||
}
|
||||
|
||||
if !g.input.LeftClicked() {
|
||||
return
|
||||
}
|
||||
|
||||
// UI hits first
|
||||
if hit := g.UI.hitTest(mp); hit != "" {
|
||||
g.input.ConsumeLeft()
|
||||
switch hit[:4] {
|
||||
case "verb":
|
||||
g.selectedVerb = hit[5:]
|
||||
case "inv:":
|
||||
name := hit[4:]
|
||||
if g.selectedVerb == "look" {
|
||||
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
|
||||
g.queueAction(Say("player", it.Description), "inv-look")
|
||||
return
|
||||
}
|
||||
}
|
||||
if g.Inventory.Selected() == name {
|
||||
g.Inventory.Select("")
|
||||
} else {
|
||||
g.Inventory.Select(name)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// game-world click
|
||||
g.input.ConsumeLeft()
|
||||
h := e.hotspotAt(mp)
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
sel := g.Inventory.Selected()
|
||||
if sel != "" {
|
||||
// use-with: first check hotspot's OnUseWith, then item's
|
||||
if h.OnUseWith != nil {
|
||||
if a, ok := h.OnUseWith[sel]; ok && a != nil {
|
||||
g.queueAction(a, "useWith hotspot")
|
||||
g.Inventory.Select("")
|
||||
return
|
||||
}
|
||||
}
|
||||
if it, ok := g.ItemManager.Get(sel); ok && it.OnUseWith != nil {
|
||||
if a, ok := it.OnUseWith[h.Name]; ok && a != nil {
|
||||
g.queueAction(a, "useWith item")
|
||||
g.Inventory.Select("")
|
||||
return
|
||||
}
|
||||
}
|
||||
g.UI.FlashLine("Nem ehhez.")
|
||||
g.Inventory.Select("")
|
||||
return
|
||||
}
|
||||
a := h.handler(g.selectedVerb)
|
||||
if a == nil {
|
||||
if v, ok := g.VerbManager.Get(g.selectedVerb); ok && v.Default != nil {
|
||||
a = v.Default
|
||||
}
|
||||
}
|
||||
if a == nil {
|
||||
g.UI.FlashLine("Semmi említésre méltó.")
|
||||
return
|
||||
}
|
||||
g.queueAction(a, "hotspot "+g.selectedVerb+" "+h.Name)
|
||||
}
|
||||
|
||||
func (e *engine) hotspotAt(p Point) *Hotspot {
|
||||
g := e.g
|
||||
if g.currentScene == "" {
|
||||
return nil
|
||||
}
|
||||
s := g.SceneManager.MustGet(g.currentScene)
|
||||
for i := range s.Hotspots {
|
||||
h := &s.Hotspots[i]
|
||||
if h.Area != nil && h.Area.Contains(p) {
|
||||
return h
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *engine) Draw(screen *ebiten.Image) {
|
||||
g := e.g
|
||||
// scene background
|
||||
if g.currentScene != "" {
|
||||
s := g.SceneManager.MustGet(g.currentScene)
|
||||
img := g.loaded.image(g.AssetManager, s.Background)
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
bw, bh := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
if bw > 0 && bh > 0 {
|
||||
op.GeoM.Scale(float64(g.Width)/float64(bw), float64(g.Height)/float64(bh))
|
||||
}
|
||||
screen.DrawImage(img, op)
|
||||
|
||||
// hotspot debug outlines
|
||||
if DebugLog {
|
||||
for _, h := range s.Hotspots {
|
||||
if r, ok := h.Area.(Rectangle); ok {
|
||||
vector.StrokeRect(screen, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), 1, color.RGBA{255, 255, 0, 200}, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
screen.Fill(color.RGBA{0, 0, 0, 255})
|
||||
}
|
||||
|
||||
// characters (y-sorted)
|
||||
for _, c := range sortedChars(g) {
|
||||
drawCharacter(screen, g, c)
|
||||
}
|
||||
|
||||
// speech bubble
|
||||
g.UI.speech.draw(screen, g)
|
||||
|
||||
// dialog box (if active)
|
||||
if g.dialog != nil {
|
||||
g.dialog.draw(screen, g)
|
||||
} else {
|
||||
g.UI.drawHUD(screen)
|
||||
}
|
||||
|
||||
// transition overlay
|
||||
g.transition.draw(screen, g.Width, g.Height)
|
||||
|
||||
// end card
|
||||
if g.UI.endCard != "" {
|
||||
vector.DrawFilledRect(screen, 0, 0, float32(g.Width), float32(g.Height), color.RGBA{0, 0, 0, 230}, false)
|
||||
w := textWidth(g.UI.endCard)
|
||||
drawText(screen, g.UI.endCard, (g.Width-w)/2, g.Height/2-8, color.White)
|
||||
}
|
||||
|
||||
// cursor on top
|
||||
drawCursor(screen, g)
|
||||
}
|
||||
|
||||
func sortedChars(g *Game) []*runtimeChar {
|
||||
out := make([]*runtimeChar, 0, len(g.chars))
|
||||
for _, c := range g.chars {
|
||||
out = append(out, c)
|
||||
}
|
||||
// insertion sort by Y (small N)
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j].pos.Y < out[j-1].pos.Y; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func drawCharacter(dst *ebiten.Image, g *Game, c *runtimeChar) {
|
||||
w := c.def.W
|
||||
h := c.def.H
|
||||
if w == 0 {
|
||||
w = 14
|
||||
}
|
||||
if h == 0 {
|
||||
h = 26
|
||||
}
|
||||
if c.def.Sprite != "" {
|
||||
img := g.loaded.image(g.AssetManager, c.def.Sprite)
|
||||
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
if sw > 0 && sh > 0 {
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
op.GeoM.Scale(w/float64(sw), h/float64(sh))
|
||||
op.GeoM.Translate(c.pos.X-w/2, c.pos.Y-h)
|
||||
dst.DrawImage(img, op)
|
||||
return
|
||||
}
|
||||
}
|
||||
col := color.RGBA{200, 200, 200, 255}
|
||||
if rgba, ok := c.def.SpeechColor.(color.RGBA); ok {
|
||||
col = rgba
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(c.pos.X-w/2), float32(c.pos.Y-h), float32(w), float32(h), col, false)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package pncdsl
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUnknownAsset = errors.New("pncdsl: unknown asset")
|
||||
ErrUnknownScene = errors.New("pncdsl: unknown scene")
|
||||
ErrUnknownItem = errors.New("pncdsl: unknown item")
|
||||
ErrUnknownCharacter = errors.New("pncdsl: unknown character")
|
||||
ErrUnknownDialogue = errors.New("pncdsl: unknown dialogue")
|
||||
ErrUnknownDialogueNode = errors.New("pncdsl: unknown dialogue node")
|
||||
ErrUnknownScript = errors.New("pncdsl: unknown script")
|
||||
ErrUnknownVerb = errors.New("pncdsl: unknown verb")
|
||||
ErrDuplicateName = errors.New("pncdsl: duplicate name")
|
||||
ErrNoStartScene = errors.New("pncdsl: StartAt not set or unknown scene")
|
||||
ErrSceneMissingBackground = errors.New("pncdsl: scene has no background")
|
||||
)
|
||||
@@ -0,0 +1,259 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Game is the root aggregate. It carries every Manager plus the runtime
|
||||
// state. A domain package builds one via NewGame, registers entities, then
|
||||
// calls Run (or hands it to pncdsl.Run).
|
||||
type Game struct {
|
||||
Title string
|
||||
Width, Height int
|
||||
|
||||
ItemManager *ItemManager
|
||||
SceneManager *SceneManager
|
||||
CharacterManager *CharacterManager
|
||||
DialogueManager *DialogueManager
|
||||
ScriptManager *ScriptManager
|
||||
AssetManager *AssetManager
|
||||
VerbManager *VerbManager
|
||||
|
||||
State *State
|
||||
Inventory *Inventory
|
||||
UI *UI
|
||||
Audio *AudioPlayer
|
||||
Camera *Camera
|
||||
|
||||
startID string
|
||||
onStart Action
|
||||
|
||||
// runtime
|
||||
loaded *loadedAssets
|
||||
input *Input
|
||||
currentScene string
|
||||
chars map[string]*runtimeChar
|
||||
scriptRunner Runner
|
||||
scriptCtx *Ctx // for click consumption
|
||||
transition *transition
|
||||
dialog *dialogBox
|
||||
activeDialog string
|
||||
selectedVerb string
|
||||
}
|
||||
|
||||
// NewGame initializes a game with empty managers and the SCUMM-style verb set.
|
||||
func NewGame(title string, w, h int) *Game {
|
||||
g := &Game{
|
||||
Title: title,
|
||||
Width: w,
|
||||
Height: h,
|
||||
ItemManager: NewManager[Item](),
|
||||
SceneManager: NewManager[Scene](),
|
||||
CharacterManager: NewManager[Character](),
|
||||
DialogueManager: NewManager[Dialogue](),
|
||||
ScriptManager: NewManager[Script](),
|
||||
AssetManager: NewManager[Asset](),
|
||||
VerbManager: NewManager[Verb](),
|
||||
State: NewState(),
|
||||
Inventory: NewInventory(),
|
||||
Audio: NewAudioPlayer(),
|
||||
Camera: NewCamera(),
|
||||
loaded: newLoadedAssets(w, h),
|
||||
input: newInput(),
|
||||
chars: make(map[string]*runtimeChar),
|
||||
transition: &transition{},
|
||||
selectedVerb: "look",
|
||||
}
|
||||
g.UI = newUI(g)
|
||||
for _, v := range defaultVerbs() {
|
||||
g.VerbManager.Register(v)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *Game) StartAt(name string) *Game { g.startID = name; return g }
|
||||
func (g *Game) OnStart(a Action) *Game { g.onStart = a; return g }
|
||||
|
||||
// Validate cross-checks name references between managers.
|
||||
func (g *Game) Validate() error {
|
||||
if !g.SceneManager.Has(g.startID) {
|
||||
return fmt.Errorf("%w: %q", ErrNoStartScene, g.startID)
|
||||
}
|
||||
for _, name := range g.SceneManager.Names() {
|
||||
s := g.SceneManager.MustGet(name)
|
||||
if s.Background == "" {
|
||||
return fmt.Errorf("%w: scene %q", ErrSceneMissingBackground, name)
|
||||
}
|
||||
if s.Background != "" && !g.AssetManager.Has(s.Background) {
|
||||
return fmt.Errorf("%w: scene %q background %q", ErrUnknownAsset, name, s.Background)
|
||||
}
|
||||
if s.Music != "" && !g.AssetManager.Has(s.Music) {
|
||||
return fmt.Errorf("%w: scene %q music %q", ErrUnknownAsset, name, s.Music)
|
||||
}
|
||||
for _, a := range s.Actors {
|
||||
if !g.CharacterManager.Has(a.CharacterName) {
|
||||
return fmt.Errorf("%w: scene %q actor %q", ErrUnknownCharacter, name, a.CharacterName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----- runtime helpers ---------------------------------------------------
|
||||
|
||||
type runtimeChar struct {
|
||||
def Character
|
||||
pos Point
|
||||
target Point
|
||||
moving bool
|
||||
}
|
||||
|
||||
func (g *Game) makeCtx() *Ctx {
|
||||
c := &Ctx{Game: g, DT: 1.0 / 60.0}
|
||||
if g.currentScene != "" {
|
||||
s := g.SceneManager.MustGet(g.currentScene)
|
||||
c.Scene = &s
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (g *Game) changeScene(name string) {
|
||||
if !g.SceneManager.Has(name) {
|
||||
logf("changeScene: unknown %q", name)
|
||||
return
|
||||
}
|
||||
prev := g.currentScene
|
||||
g.transition.start(func() {
|
||||
if prev != "" {
|
||||
old := g.SceneManager.MustGet(prev)
|
||||
if old.OnLeave != nil {
|
||||
g.queueAction(old.OnLeave, "OnLeave")
|
||||
}
|
||||
}
|
||||
g.currentScene = name
|
||||
g.State.NoteVisit(name)
|
||||
g.placeActors(name)
|
||||
s := g.SceneManager.MustGet(name)
|
||||
if s.Music != "" {
|
||||
g.Audio.PlayMusic(s.Music)
|
||||
}
|
||||
if s.OnEnter != nil {
|
||||
g.queueAction(s.OnEnter, "OnEnter")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Game) placeActors(sceneName string) {
|
||||
s := g.SceneManager.MustGet(sceneName)
|
||||
for _, a := range s.Actors {
|
||||
def := g.CharacterManager.MustGet(a.CharacterName)
|
||||
rc, ok := g.chars[def.Name]
|
||||
if !ok {
|
||||
rc = &runtimeChar{def: def, pos: a.At}
|
||||
g.chars[def.Name] = rc
|
||||
} else {
|
||||
rc.def = def
|
||||
rc.pos = a.At
|
||||
rc.moving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) runtimeChar(name string) (*runtimeChar, bool) {
|
||||
c, ok := g.chars[name]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
func (g *Game) walkCharacter(name string, to Point) {
|
||||
c, ok := g.chars[name]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.target = to
|
||||
c.moving = true
|
||||
}
|
||||
|
||||
func (g *Game) characterMoving(name string) bool {
|
||||
c, ok := g.chars[name]
|
||||
return ok && c.moving
|
||||
}
|
||||
|
||||
func (g *Game) tickCharacters(dt float64) {
|
||||
for _, c := range g.chars {
|
||||
if !c.moving {
|
||||
continue
|
||||
}
|
||||
dx := c.target.X - c.pos.X
|
||||
dy := c.target.Y - c.pos.Y
|
||||
d := c.pos.Dist(c.target)
|
||||
speed := c.def.Speed
|
||||
if speed <= 0 {
|
||||
speed = 60
|
||||
}
|
||||
step := speed * dt
|
||||
if d <= step {
|
||||
c.pos = c.target
|
||||
c.moving = false
|
||||
continue
|
||||
}
|
||||
c.pos.X += dx / d * step
|
||||
c.pos.Y += dy / d * step
|
||||
}
|
||||
}
|
||||
|
||||
// ----- script & dialog plumbing -----------------------------------------
|
||||
|
||||
func (g *Game) queueAction(a Action, label string) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
if g.scriptRunner != nil {
|
||||
logf("queueAction: %s ignored, runner busy", label)
|
||||
return
|
||||
}
|
||||
g.scriptRunner = a.Start()
|
||||
g.scriptCtx = g.makeCtx()
|
||||
logf("queueAction %s", label)
|
||||
}
|
||||
|
||||
func (g *Game) startDialogue(name string) {
|
||||
d, ok := g.DialogueManager.Get(name)
|
||||
if !ok {
|
||||
logf("startDialogue: unknown %q", name)
|
||||
return
|
||||
}
|
||||
startNode := d.Start
|
||||
if startNode == "" && len(d.Nodes) > 0 {
|
||||
startNode = d.Nodes[0].Name
|
||||
}
|
||||
node, ok := d.Node(startNode)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
g.activeDialog = name
|
||||
g.dialog = newDialogBox(&d, &node)
|
||||
}
|
||||
|
||||
func (g *Game) endDialogue() {
|
||||
g.activeDialog = ""
|
||||
g.dialog = nil
|
||||
}
|
||||
|
||||
func (g *Game) gotoDialogueNode(name string) {
|
||||
if g.dialog == nil || g.activeDialog == "" {
|
||||
return
|
||||
}
|
||||
d := g.DialogueManager.MustGet(g.activeDialog)
|
||||
node, ok := d.Node(name)
|
||||
if !ok {
|
||||
logf("gotoNode: unknown %q in %q", name, g.activeDialog)
|
||||
return
|
||||
}
|
||||
g.dialog.node = &node
|
||||
g.dialog.lineIdx = 0
|
||||
g.dialog.choiceHits = nil
|
||||
}
|
||||
|
||||
func (g *Game) dialogueActive() bool { return g.dialog != nil }
|
||||
|
||||
func (g *Game) showEndCard(text string) { g.UI.endCard = text }
|
||||
@@ -0,0 +1,74 @@
|
||||
package pncdsl
|
||||
|
||||
import "sort"
|
||||
|
||||
// Named is implemented by every entity that can be registered into a Manager.
|
||||
type Named interface {
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// Manager is the single registry shape used by every entity type. Each entity
|
||||
// kind gets a named alias (ItemManager = Manager[Item], etc.).
|
||||
type Manager[T Named] struct {
|
||||
items map[string]T
|
||||
}
|
||||
|
||||
func NewManager[T Named]() *Manager[T] {
|
||||
return &Manager[T]{items: make(map[string]T)}
|
||||
}
|
||||
|
||||
// Register adds v to the registry. Panics on empty or duplicate name —
|
||||
// these are construction-time bugs, not runtime conditions.
|
||||
func (m *Manager[T]) Register(v T) {
|
||||
name := v.GetName()
|
||||
if name == "" {
|
||||
panic("pncdsl: Register: empty Name on " + typeName(v))
|
||||
}
|
||||
if _, dup := m.items[name]; dup {
|
||||
panic("pncdsl: Register: duplicate " + typeName(v) + " name: " + name)
|
||||
}
|
||||
m.items[name] = v
|
||||
}
|
||||
|
||||
func (m *Manager[T]) Get(name string) (T, bool) {
|
||||
v, ok := m.items[name]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (m *Manager[T]) MustGet(name string) T {
|
||||
v, ok := m.items[name]
|
||||
if !ok {
|
||||
panic("pncdsl: MustGet: unknown name: " + name)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *Manager[T]) Has(name string) bool {
|
||||
_, ok := m.items[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *Manager[T]) Len() int { return len(m.items) }
|
||||
|
||||
func (m *Manager[T]) Names() []string {
|
||||
names := make([]string, 0, len(m.items))
|
||||
for n := range m.items {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func (m *Manager[T]) Each(fn func(T)) {
|
||||
for _, n := range m.Names() {
|
||||
fn(m.items[n])
|
||||
}
|
||||
}
|
||||
|
||||
func typeName(v any) string {
|
||||
type namer interface{ TypeLabel() string }
|
||||
if n, ok := v.(namer); ok {
|
||||
return n.TypeLabel()
|
||||
}
|
||||
return "entity"
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// dialogBox is the active-conversation UI: it shows the current node's
|
||||
// lines (one at a time, advanced on click) followed by the visible choices.
|
||||
type dialogBox struct {
|
||||
dialogue *Dialogue
|
||||
node *DialogueNode
|
||||
lineIdx int // -1 = lines done, showing choices
|
||||
choiceHits []Rectangle
|
||||
}
|
||||
|
||||
func newDialogBox(d *Dialogue, n *DialogueNode) *dialogBox {
|
||||
return &dialogBox{dialogue: d, node: n, lineIdx: 0}
|
||||
}
|
||||
|
||||
// resolveChoices returns the list of choices currently visible to the player.
|
||||
func (db *dialogBox) resolveChoices(ctx *Ctx) []DialogueChoice {
|
||||
out := make([]DialogueChoice, 0, len(db.node.Choices))
|
||||
for _, c := range db.node.Choices {
|
||||
if c.Show != nil && !c.Show.Eval(ctx) {
|
||||
continue
|
||||
}
|
||||
if c.Once && ctx.Game.State.Talked(db.node.Name+":"+c.Text) > 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleClick advances the dialog state. Returns the picked choice (if any).
|
||||
func (db *dialogBox) handleClick(ctx *Ctx, p Point) (picked *DialogueChoice) {
|
||||
if db.lineIdx >= 0 && db.lineIdx < len(db.node.Lines) {
|
||||
db.lineIdx++
|
||||
if db.lineIdx >= len(db.node.Lines) {
|
||||
db.lineIdx = -1
|
||||
db.choiceHits = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for i, r := range db.choiceHits {
|
||||
if r.Contains(p) {
|
||||
ch := db.resolveChoices(ctx)[i]
|
||||
return &ch
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *dialogBox) currentLine() (DialogueLine, bool) {
|
||||
if db.lineIdx >= 0 && db.lineIdx < len(db.node.Lines) {
|
||||
return db.node.Lines[db.lineIdx], true
|
||||
}
|
||||
return DialogueLine{}, false
|
||||
}
|
||||
|
||||
func (db *dialogBox) draw(dst *ebiten.Image, g *Game) {
|
||||
// box across the bottom 60 px
|
||||
vector.DrawFilledRect(dst, 0, 140, 320, 60, color.RGBA{15, 15, 25, 240}, false)
|
||||
vector.StrokeRect(dst, 0, 140, 320, 60, 1, color.RGBA{80, 80, 110, 255}, false)
|
||||
|
||||
if ln, ok := db.currentLine(); ok {
|
||||
speakerCol := color.RGBA{220, 220, 120, 255}
|
||||
drawText(dst, ln.Speaker+":", 6, 142, speakerCol)
|
||||
lines := wrapText(ln.Text, 300)
|
||||
for i, l := range lines {
|
||||
drawText(dst, l, 6, 158+i*14, color.White)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// choices
|
||||
ctx := g.makeCtx()
|
||||
choices := db.resolveChoices(ctx)
|
||||
db.choiceHits = db.choiceHits[:0]
|
||||
for i, c := range choices {
|
||||
y := 144 + i*14
|
||||
r := Rect(6, float64(y), 308, 13)
|
||||
db.choiceHits = append(db.choiceHits, r)
|
||||
mx, my := g.input.Pos()
|
||||
hover := r.Contains(Point{X: float64(mx), Y: float64(my)})
|
||||
bg := color.RGBA{30, 30, 45, 255}
|
||||
if hover {
|
||||
bg = color.RGBA{70, 70, 100, 255}
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), bg, false)
|
||||
drawText(dst, c.Text, int(r.X)+2, int(r.Y)-1, color.White)
|
||||
_ = i
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package pncdsl
|
||||
|
||||
type Dialogue struct {
|
||||
Name string
|
||||
Start string
|
||||
Nodes []DialogueNode
|
||||
}
|
||||
|
||||
func (d Dialogue) GetName() string { return d.Name }
|
||||
func (d Dialogue) TypeLabel() string { return "dialogue" }
|
||||
|
||||
func (d Dialogue) Node(name string) (DialogueNode, bool) {
|
||||
for _, n := range d.Nodes {
|
||||
if n.Name == name {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return DialogueNode{}, false
|
||||
}
|
||||
|
||||
type DialogueNode struct {
|
||||
Name string
|
||||
Lines []DialogueLine
|
||||
Choices []DialogueChoice
|
||||
}
|
||||
|
||||
type DialogueLine struct {
|
||||
Speaker string
|
||||
Text string
|
||||
}
|
||||
|
||||
type DialogueChoice struct {
|
||||
Text string
|
||||
Show Condition // nil = always
|
||||
Once bool
|
||||
Actions []Action
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type DialogueManager = Manager[Dialogue]
|
||||
@@ -0,0 +1,44 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||
)
|
||||
|
||||
// Input is read once per Update() and exposes a tiny "consume on use" API
|
||||
// so that one click can be authoritative — e.g. clicking on a hotspot
|
||||
// while a Say is active should only skip the Say, not also fire the
|
||||
// hotspot underneath.
|
||||
type Input struct {
|
||||
mouseX, mouseY int
|
||||
leftPressed bool
|
||||
leftConsumed bool
|
||||
rightPressed bool
|
||||
rightConsumed bool
|
||||
}
|
||||
|
||||
func newInput() *Input { return &Input{} }
|
||||
|
||||
func (i *Input) poll() {
|
||||
i.mouseX, i.mouseY = ebiten.CursorPosition()
|
||||
i.leftPressed = inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft)
|
||||
i.leftConsumed = false
|
||||
i.rightPressed = inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight)
|
||||
i.rightConsumed = false
|
||||
}
|
||||
|
||||
func (i *Input) Pos() (int, int) { return i.mouseX, i.mouseY }
|
||||
func (i *Input) Point() Point { return Point{X: float64(i.mouseX), Y: float64(i.mouseY)} }
|
||||
func (i *Input) LeftClicked() bool { return i.leftPressed && !i.leftConsumed }
|
||||
func (i *Input) RightClicked() bool { return i.rightPressed && !i.rightConsumed }
|
||||
func (i *Input) ConsumeLeft() { i.leftConsumed = true }
|
||||
func (i *Input) ConsumeRight() { i.rightConsumed = true }
|
||||
|
||||
// consumedClick is the Say-style "did the user click to skip?" probe.
|
||||
func (i *Input) consumedClick() bool {
|
||||
if i.LeftClicked() {
|
||||
i.ConsumeLeft()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package pncdsl
|
||||
|
||||
type Item struct {
|
||||
Name string
|
||||
Sprite string // Asset.Name
|
||||
Description string
|
||||
|
||||
OnUseSelf Action
|
||||
// OnUseWith: targetName -> action. Target may be a hotspot Name or another item Name.
|
||||
OnUseWith map[string]Action
|
||||
}
|
||||
|
||||
func (i Item) GetName() string { return i.Name }
|
||||
func (i Item) TypeLabel() string { return "item" }
|
||||
@@ -0,0 +1,46 @@
|
||||
package pncdsl
|
||||
|
||||
type Inventory struct {
|
||||
items []string
|
||||
selected string
|
||||
}
|
||||
|
||||
func NewInventory() *Inventory { return &Inventory{} }
|
||||
|
||||
func (i *Inventory) Add(name string) {
|
||||
if i.Has(name) {
|
||||
return
|
||||
}
|
||||
i.items = append(i.items, name)
|
||||
}
|
||||
|
||||
func (i *Inventory) Remove(name string) {
|
||||
for k, n := range i.items {
|
||||
if n == name {
|
||||
i.items = append(i.items[:k], i.items[k+1:]...)
|
||||
if i.selected == name {
|
||||
i.selected = ""
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Inventory) Has(name string) bool {
|
||||
for _, n := range i.items {
|
||||
if n == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (i *Inventory) Select(name string) {
|
||||
if name != "" && !i.Has(name) {
|
||||
return
|
||||
}
|
||||
i.selected = name
|
||||
}
|
||||
|
||||
func (i *Inventory) Selected() string { return i.selected }
|
||||
func (i *Inventory) Items() []string { return append([]string(nil), i.items...) }
|
||||
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type ItemManager = Manager[Item]
|
||||
@@ -0,0 +1,10 @@
|
||||
package pncdsl
|
||||
|
||||
// Camera is a stub identity transform — no scrolling in this milestone.
|
||||
type Camera struct {
|
||||
Offset Point
|
||||
}
|
||||
|
||||
func NewCamera() *Camera { return &Camera{} }
|
||||
|
||||
func (c *Camera) Apply(p Point) Point { return Point{p.X - c.Offset.X, p.Y - c.Offset.Y} }
|
||||
@@ -0,0 +1,22 @@
|
||||
package pncdsl
|
||||
|
||||
type Scene struct {
|
||||
Name string
|
||||
Background string // Asset.Name
|
||||
Music string // Asset.Name (optional)
|
||||
Hotspots []Hotspot
|
||||
Walkboxes []Polygon
|
||||
Triggers []Trigger
|
||||
Actors []SceneActor
|
||||
OnEnter Action
|
||||
OnLeave Action
|
||||
}
|
||||
|
||||
func (s Scene) GetName() string { return s.Name }
|
||||
func (s Scene) TypeLabel() string { return "scene" }
|
||||
|
||||
// SceneActor places a registered Character at a starting position inside a scene.
|
||||
type SceneActor struct {
|
||||
CharacterName string
|
||||
At Point
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package pncdsl
|
||||
|
||||
type CursorKind int
|
||||
|
||||
const (
|
||||
CursorDefault CursorKind = iota
|
||||
CursorLook
|
||||
CursorUse
|
||||
CursorTalk
|
||||
CursorTake
|
||||
CursorExit
|
||||
)
|
||||
|
||||
type Hotspot struct {
|
||||
Name string
|
||||
Area Shape
|
||||
Label string
|
||||
Cursor CursorKind
|
||||
|
||||
OnLook Action
|
||||
OnUse Action
|
||||
OnTalk Action
|
||||
OnTake Action
|
||||
OnGive Action
|
||||
|
||||
// OnUseWith: when the player has an item selected and clicks this hotspot,
|
||||
// the engine looks up the item's Name here first.
|
||||
OnUseWith map[string]Action
|
||||
|
||||
// OnVerb: custom verbs registered via g.VerbManager.
|
||||
OnVerb map[string]Action
|
||||
}
|
||||
|
||||
// handler returns the action bound to verb v for this hotspot, or nil.
|
||||
func (h Hotspot) handler(v string) Action {
|
||||
switch v {
|
||||
case "look":
|
||||
return h.OnLook
|
||||
case "use":
|
||||
return h.OnUse
|
||||
case "talk":
|
||||
return h.OnTalk
|
||||
case "take":
|
||||
return h.OnTake
|
||||
case "give":
|
||||
return h.OnGive
|
||||
}
|
||||
if h.OnVerb != nil {
|
||||
return h.OnVerb[v]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type SceneManager = Manager[Scene]
|
||||
@@ -0,0 +1,61 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// transition is a fade-to-black overlay used between scene swaps.
|
||||
type transition struct {
|
||||
active bool
|
||||
t float64
|
||||
duration float64
|
||||
out bool // true: fading to black; false: fading from black
|
||||
onMid func()
|
||||
}
|
||||
|
||||
func (t *transition) start(onMid func()) {
|
||||
t.active = true
|
||||
t.out = true
|
||||
t.t = 0
|
||||
t.duration = 0.25
|
||||
t.onMid = onMid
|
||||
}
|
||||
|
||||
func (t *transition) update(dt float64) {
|
||||
if !t.active {
|
||||
return
|
||||
}
|
||||
t.t += dt
|
||||
if t.t >= t.duration {
|
||||
if t.out {
|
||||
if t.onMid != nil {
|
||||
t.onMid()
|
||||
t.onMid = nil
|
||||
}
|
||||
t.out = false
|
||||
t.t = 0
|
||||
return
|
||||
}
|
||||
t.active = false
|
||||
}
|
||||
}
|
||||
|
||||
func (t *transition) draw(dst *ebiten.Image, w, h int) {
|
||||
if !t.active {
|
||||
return
|
||||
}
|
||||
alpha := t.t / t.duration
|
||||
if !t.out {
|
||||
alpha = 1 - alpha
|
||||
}
|
||||
if alpha < 0 {
|
||||
alpha = 0
|
||||
} else if alpha > 1 {
|
||||
alpha = 1
|
||||
}
|
||||
c := color.RGBA{0, 0, 0, uint8(alpha * 255)}
|
||||
vector.DrawFilledRect(dst, 0, 0, float32(w), float32(h), c, false)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pncdsl
|
||||
|
||||
// Trigger fires Do when its When condition first becomes true after the
|
||||
// trigger arms (on scene enter). One-shot per scene visit by default.
|
||||
type Trigger struct {
|
||||
Name string
|
||||
When Condition
|
||||
Do Action
|
||||
Once bool
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package pncdsl
|
||||
|
||||
type State struct {
|
||||
flags map[string]bool
|
||||
vars map[string]any
|
||||
visited map[string]int
|
||||
talked map[string]int
|
||||
}
|
||||
|
||||
func NewState() *State {
|
||||
return &State{
|
||||
flags: make(map[string]bool),
|
||||
vars: make(map[string]any),
|
||||
visited: make(map[string]int),
|
||||
talked: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) Flag(name string) bool { return s.flags[name] }
|
||||
func (s *State) SetFlag(name string) { s.flags[name] = true }
|
||||
func (s *State) ClearFlag(name string) { delete(s.flags, name) }
|
||||
func (s *State) Var(name string) any { return s.vars[name] }
|
||||
func (s *State) SetVar(name string, v any) { s.vars[name] = v }
|
||||
func (s *State) Visited(name string) int { return s.visited[name] }
|
||||
func (s *State) NoteVisit(name string) { s.visited[name]++ }
|
||||
func (s *State) Talked(node string) int { return s.talked[node] }
|
||||
func (s *State) NoteTalked(node string) { s.talked[node]++ }
|
||||
@@ -0,0 +1,6 @@
|
||||
package pncdsl
|
||||
|
||||
// Save/Load are stubbed out in this milestone — the runtime state machine
|
||||
// is in place, but JSON serialization will land alongside the polish pass.
|
||||
func (g *Game) Save(slot int) error { _ = slot; return nil }
|
||||
func (g *Game) Load(slot int) error { _ = slot; return nil }
|
||||
@@ -0,0 +1,30 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// drawCursor renders a simple crosshair at the mouse position. When an
|
||||
// inventory item is selected, the item sprite is drawn instead.
|
||||
func drawCursor(dst *ebiten.Image, g *Game) {
|
||||
x, y := g.input.Pos()
|
||||
if sel := g.Inventory.Selected(); sel != "" {
|
||||
if it, ok := g.ItemManager.Get(sel); ok {
|
||||
img := g.loaded.image(g.AssetManager, it.Sprite)
|
||||
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
if sw > 0 && sh > 0 {
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
op.GeoM.Scale(16/float64(sw), 16/float64(sh))
|
||||
op.GeoM.Translate(float64(x-8), float64(y-8))
|
||||
dst.DrawImage(img, op)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
c := color.RGBA{255, 255, 255, 255}
|
||||
vector.StrokeLine(dst, float32(x-3), float32(y), float32(x+4), float32(y), 1, c, false)
|
||||
vector.StrokeLine(dst, float32(x), float32(y-3), float32(x), float32(y+4), 1, c, false)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// Layout constants in the internal 320×200 coordinate space.
|
||||
const (
|
||||
uiSceneTop = 0
|
||||
uiSceneBottom = 140
|
||||
uiStatusY = 142
|
||||
uiPanelY = 152
|
||||
uiPanelH = 48
|
||||
uiVerbsX = 4
|
||||
uiVerbsW = 120
|
||||
uiInvX = 132
|
||||
uiInvW = 184
|
||||
uiInvSlotW = 22
|
||||
)
|
||||
|
||||
// UI is the per-game UI state. It's a simple bag of fields the engine
|
||||
// writes into and the renderer reads. No event/signal layer — the engine
|
||||
// drives everything top-down each tick.
|
||||
type UI struct {
|
||||
g *Game
|
||||
|
||||
hoverLabel string
|
||||
flash string
|
||||
flashTimer float64
|
||||
|
||||
speech *speechBubble
|
||||
dialog *dialogBox
|
||||
endCard string
|
||||
|
||||
verbButtons []verbButton
|
||||
}
|
||||
|
||||
func newUI(g *Game) *UI {
|
||||
return &UI{
|
||||
g: g,
|
||||
speech: &speechBubble{},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UI) rebuildVerbButtons() {
|
||||
verbs := u.g.VerbManager.Names()
|
||||
u.verbButtons = u.verbButtons[:0]
|
||||
cols := 2
|
||||
bw := uiVerbsW / cols
|
||||
bh := 14
|
||||
for i, name := range verbs {
|
||||
col := i % cols
|
||||
row := i / cols
|
||||
x := uiVerbsX + col*bw
|
||||
y := uiPanelY + row*bh
|
||||
u.verbButtons = append(u.verbButtons, verbButton{
|
||||
Name: name,
|
||||
Label: u.g.VerbManager.MustGet(name).Label,
|
||||
Bounds: Rect(float64(x), float64(y), float64(bw-2), float64(bh-2)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type verbButton struct {
|
||||
Name string
|
||||
Label string
|
||||
Bounds Rectangle
|
||||
}
|
||||
|
||||
// SetSpeech / ClearSpeech are called from the Say action.
|
||||
func (u *UI) SetSpeech(speaker, text string) {
|
||||
u.speech.set(u.g, speaker, text)
|
||||
}
|
||||
|
||||
func (u *UI) ClearSpeech() { u.speech.clear() }
|
||||
|
||||
// FlashLine shows a short status string (e.g. "Ehhez kell a kulcs.") for ~2s.
|
||||
func (u *UI) FlashLine(text string) {
|
||||
u.flash = text
|
||||
u.flashTimer = 2.0
|
||||
}
|
||||
|
||||
func (u *UI) tick(dt float64) {
|
||||
if u.flashTimer > 0 {
|
||||
u.flashTimer -= dt
|
||||
if u.flashTimer <= 0 {
|
||||
u.flash = ""
|
||||
}
|
||||
}
|
||||
u.speech.tick(dt)
|
||||
}
|
||||
|
||||
// drawHUD paints the bottom UI panel (status line + verbs + inventory).
|
||||
// Dialog box, if active, is drawn separately by the engine on top.
|
||||
func (u *UI) drawHUD(dst *ebiten.Image) {
|
||||
// status / hover / flash line
|
||||
status := u.flash
|
||||
if status == "" {
|
||||
status = u.composedHoverLabel()
|
||||
}
|
||||
if status != "" {
|
||||
w := textWidth(status)
|
||||
drawText(dst, status, (320-w)/2, uiStatusY, color.White)
|
||||
}
|
||||
|
||||
// panel background
|
||||
vector.DrawFilledRect(dst, 0, float32(uiPanelY-2), 320, float32(uiPanelH+2), color.RGBA{20, 20, 30, 255}, false)
|
||||
|
||||
// verb buttons
|
||||
for _, b := range u.verbButtons {
|
||||
bg := color.RGBA{50, 50, 70, 255}
|
||||
if b.Name == u.g.selectedVerb {
|
||||
bg = color.RGBA{120, 90, 40, 255}
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(b.Bounds.X), float32(b.Bounds.Y), float32(b.Bounds.W), float32(b.Bounds.H), bg, false)
|
||||
drawText(dst, b.Label, int(b.Bounds.X)+2, int(b.Bounds.Y)-1, color.White)
|
||||
}
|
||||
|
||||
// inventory slots
|
||||
items := u.g.Inventory.Items()
|
||||
for k := 0; k < 8; k++ {
|
||||
x := uiInvX + k*uiInvSlotW
|
||||
y := uiPanelY
|
||||
bg := color.RGBA{40, 40, 50, 255}
|
||||
if k < len(items) && items[k] == u.g.Inventory.Selected() {
|
||||
bg = color.RGBA{120, 90, 40, 255}
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(x), float32(y), float32(uiInvSlotW-2), float32(uiInvSlotW-2), bg, false)
|
||||
if k < len(items) {
|
||||
it, ok := u.g.ItemManager.Get(items[k])
|
||||
if ok {
|
||||
img := u.g.loaded.image(u.g.AssetManager, it.Sprite)
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
if sw == 0 || sh == 0 {
|
||||
continue
|
||||
}
|
||||
slotPx := float64(uiInvSlotW - 4)
|
||||
sx := slotPx / float64(sw)
|
||||
sy := slotPx / float64(sh)
|
||||
op.GeoM.Scale(sx, sy)
|
||||
op.GeoM.Translate(float64(x+1), float64(y+1))
|
||||
dst.DrawImage(img, op)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UI) composedHoverLabel() string {
|
||||
verb := ""
|
||||
if v, ok := u.g.VerbManager.Get(u.g.selectedVerb); ok {
|
||||
verb = v.Label
|
||||
}
|
||||
target := u.hoverLabel
|
||||
if sel := u.g.Inventory.Selected(); sel != "" {
|
||||
if target != "" {
|
||||
return verb + " " + sel + " ezen: " + target
|
||||
}
|
||||
return verb + " " + sel
|
||||
}
|
||||
if target == "" {
|
||||
return ""
|
||||
}
|
||||
return verb + " " + target
|
||||
}
|
||||
|
||||
// hitTestUI returns the click target (verbButton, inventory slot index) or "".
|
||||
// "verb:<name>", "inv:<i>", "" for none.
|
||||
func (u *UI) hitTest(p Point) string {
|
||||
for _, b := range u.verbButtons {
|
||||
if b.Bounds.Contains(p) {
|
||||
return "verb:" + b.Name
|
||||
}
|
||||
}
|
||||
items := u.g.Inventory.Items()
|
||||
for k := 0; k < len(items); k++ {
|
||||
x := uiInvX + k*uiInvSlotW
|
||||
r := Rect(float64(x), float64(uiPanelY), float64(uiInvSlotW-2), float64(uiInvSlotW-2))
|
||||
if r.Contains(p) {
|
||||
return "inv:" + items[k]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
)
|
||||
|
||||
// speechBubble holds the currently-spoken line. The Say action sets it and
|
||||
// the engine draws it above the speaker (or centered at screen top if the
|
||||
// speaker is unknown).
|
||||
type speechBubble struct {
|
||||
speaker string
|
||||
lines []string
|
||||
pos Point
|
||||
active bool
|
||||
}
|
||||
|
||||
func (s *speechBubble) set(g *Game, speaker, text string) {
|
||||
s.speaker = speaker
|
||||
s.lines = wrapText(text, 200)
|
||||
s.active = true
|
||||
// position above the speaker, if we know where they are
|
||||
if c, ok := g.runtimeChar(speaker); ok {
|
||||
s.pos = Point{X: c.pos.X, Y: c.pos.Y - c.def.H - 4}
|
||||
} else {
|
||||
s.pos = Point{X: 160, Y: 14}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *speechBubble) clear() {
|
||||
s.active = false
|
||||
s.speaker = ""
|
||||
s.lines = nil
|
||||
}
|
||||
|
||||
func (s *speechBubble) tick(dt float64) { _ = dt }
|
||||
|
||||
func (s *speechBubble) draw(dst *ebiten.Image, g *Game) {
|
||||
if !s.active || len(s.lines) == 0 {
|
||||
return
|
||||
}
|
||||
maxW := 0
|
||||
for _, ln := range s.lines {
|
||||
if w := textWidth(ln); w > maxW {
|
||||
maxW = w
|
||||
}
|
||||
}
|
||||
pad := 3
|
||||
w := maxW + pad*2
|
||||
h := len(s.lines)*16 + pad*2
|
||||
x := int(s.pos.X) - w/2
|
||||
y := int(s.pos.Y) - h
|
||||
if x < 2 {
|
||||
x = 2
|
||||
}
|
||||
if x+w > 318 {
|
||||
x = 318 - w
|
||||
}
|
||||
if y < 2 {
|
||||
y = 2
|
||||
}
|
||||
vector.DrawFilledRect(dst, float32(x), float32(y), float32(w), float32(h), color.RGBA{0, 0, 0, 200}, false)
|
||||
speechColor := color.RGBA{255, 255, 255, 255}
|
||||
if c, ok := g.CharacterManager.Get(s.speaker); ok {
|
||||
if sc, ok := c.SpeechColor.(color.RGBA); ok {
|
||||
speechColor = sc
|
||||
}
|
||||
}
|
||||
for k, ln := range s.lines {
|
||||
drawText(dst, ln, x+pad, y+pad+k*16-2, speechColor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package pncdsl
|
||||
|
||||
type Verb struct {
|
||||
Name string
|
||||
Label string
|
||||
Default Action
|
||||
}
|
||||
|
||||
func (v Verb) GetName() string { return v.Name }
|
||||
func (v Verb) TypeLabel() string { return "verb" }
|
||||
|
||||
// defaultVerbs returns the built-in SCUMM-style verb set. A game can replace
|
||||
// or extend these by registering its own Verbs after NewGame.
|
||||
func defaultVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "look", Label: "Nézd"},
|
||||
{Name: "use", Label: "Használd"},
|
||||
{Name: "talk", Label: "Beszélj"},
|
||||
{Name: "take", Label: "Vedd fel"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type VerbManager = Manager[Verb]
|
||||
@@ -0,0 +1,79 @@
|
||||
package pncdsl
|
||||
|
||||
import "math"
|
||||
|
||||
type Point struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
func (p Point) Add(q Point) Point { return Point{p.X + q.X, p.Y + q.Y} }
|
||||
func (p Point) Sub(q Point) Point { return Point{p.X - q.X, p.Y - q.Y} }
|
||||
func (p Point) Dist(q Point) float64 {
|
||||
dx, dy := p.X-q.X, p.Y-q.Y
|
||||
return math.Sqrt(dx*dx + dy*dy)
|
||||
}
|
||||
|
||||
// Shape is a hotspot area. Rectangle and Polygon both satisfy it.
|
||||
type Shape interface {
|
||||
Contains(p Point) bool
|
||||
Bounds() Rectangle
|
||||
}
|
||||
|
||||
type Rectangle struct {
|
||||
X, Y, W, H float64
|
||||
}
|
||||
|
||||
func Rect(x, y, w, h float64) Rectangle { return Rectangle{X: x, Y: y, W: w, H: h} }
|
||||
|
||||
func (r Rectangle) Contains(p Point) bool {
|
||||
return p.X >= r.X && p.X < r.X+r.W && p.Y >= r.Y && p.Y < r.Y+r.H
|
||||
}
|
||||
|
||||
func (r Rectangle) Bounds() Rectangle { return r }
|
||||
|
||||
func (r Rectangle) Center() Point { return Point{X: r.X + r.W/2, Y: r.Y + r.H/2} }
|
||||
|
||||
type Polygon struct {
|
||||
Points []Point
|
||||
}
|
||||
|
||||
func Poly(pts ...Point) Polygon { return Polygon{Points: pts} }
|
||||
|
||||
func (g Polygon) Contains(pt Point) bool {
|
||||
n := len(g.Points)
|
||||
if n < 3 {
|
||||
return false
|
||||
}
|
||||
inside := false
|
||||
for i, j := 0, n-1; i < n; j, i = i, i+1 {
|
||||
pi, pj := g.Points[i], g.Points[j]
|
||||
if (pi.Y > pt.Y) != (pj.Y > pt.Y) &&
|
||||
pt.X < (pj.X-pi.X)*(pt.Y-pi.Y)/(pj.Y-pi.Y)+pi.X {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
func (g Polygon) Bounds() Rectangle {
|
||||
if len(g.Points) == 0 {
|
||||
return Rectangle{}
|
||||
}
|
||||
minX, maxX := g.Points[0].X, g.Points[0].X
|
||||
minY, maxY := g.Points[0].Y, g.Points[0].Y
|
||||
for _, p := range g.Points[1:] {
|
||||
if p.X < minX {
|
||||
minX = p.X
|
||||
}
|
||||
if p.X > maxX {
|
||||
maxX = p.X
|
||||
}
|
||||
if p.Y < minY {
|
||||
minY = p.Y
|
||||
}
|
||||
if p.Y > maxY {
|
||||
maxY = p.Y
|
||||
}
|
||||
}
|
||||
return Rectangle{X: minX, Y: minY, W: maxX - minX, H: maxY - minY}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package pncdsl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
var DebugLog = false
|
||||
|
||||
func logf(format string, args ...any) {
|
||||
if !DebugLog {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[pncdsl] "+format+"\n", args...)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pncdsl
|
||||
|
||||
// Timer accumulates seconds. Tick advances it; Reset zeroes it.
|
||||
type Timer struct {
|
||||
Elapsed float64
|
||||
}
|
||||
|
||||
func (t *Timer) Tick(dt float64) { t.Elapsed += dt }
|
||||
func (t *Timer) Reset() { t.Elapsed = 0 }
|
||||
func (t *Timer) Done(after float64) bool { return t.Elapsed >= after }
|
||||
Reference in New Issue
Block a user