initial commit
This commit is contained in:
876
PLAN.md
Normal file
876
PLAN.md
Normal file
@@ -0,0 +1,876 @@
|
||||
# PLAN.md — `pncdsl` point-and-click adventure library
|
||||
|
||||
## Cél
|
||||
|
||||
Egy Go nyelvű, [Ebitengine](https://ebitengine.org/) tetejére épülő könyvtár, amellyel
|
||||
klasszikus point-and-click kalandjátékok (Lucasarts/Sierra stílus) gyorsan, **deklaratív
|
||||
struct literal**-ekkel összerakhatók. A library a `pncdsl` modulban él, a konkrét játék
|
||||
pedig a `domain` package-ben a regisztráló hívásokon keresztül definiálódik. A `main.go`
|
||||
mindössze behúzza a domaint, példányosítja a `pncdsl.Game`-et és elindítja.
|
||||
|
||||
**Vezérlő minta — Manager-regisztráció.** Minden névvel hivatkozható entitás (item,
|
||||
scene, character, dialogue, script, asset, verb) egy-egy globális (Game-en lógó)
|
||||
**`XxxManager`**-be kerül a **`Register(...)`** metódussal, és a `Get(name)` adja
|
||||
vissza. Például `g.ItemManager.Register(pncdsl.Item{Name: "key", ...})` és onnantól
|
||||
`g.ItemManager.Get("key")`. Mindegyik manager pontosan ugyanúgy viselkedik —
|
||||
egységes konvenció, nincs entitásonkénti egyedi API.
|
||||
|
||||
## Felhasználói élmény (DSL ízelítő)
|
||||
|
||||
A `domain/` minden építőkövét **külön fájlba** tesszük, a fájlnév az adott entitás
|
||||
típusát és azonosítóját tükrözi (`téma.azonosító.go`). A `domain/game.go` csak
|
||||
behúzza az építő függvényeket egy `Build()`-be:
|
||||
|
||||
```go
|
||||
// domain/game.go
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
func Build() *p.Game {
|
||||
g := p.NewGame("Reggeli Kávé", 320, 200)
|
||||
|
||||
// assetek (a domain.asset.go-ban felsorolva)
|
||||
registerAssets(g)
|
||||
|
||||
// karakterek
|
||||
definePlayer(g)
|
||||
defineCat(g)
|
||||
|
||||
// itemek
|
||||
defineKey(g)
|
||||
defineBeans(g)
|
||||
defineMug(g)
|
||||
|
||||
// dialógusok
|
||||
defineCatMorning(g)
|
||||
|
||||
// scenek
|
||||
defineBedroom(g)
|
||||
defineKitchen(g)
|
||||
|
||||
// scriptek / cutscene-ek
|
||||
defineIntroScript(g)
|
||||
defineVictoryScript(g)
|
||||
|
||||
g.StartAt("bedroom").OnStart(p.RunScript("intro"))
|
||||
return g
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// domain/scene.kitchen.go
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
func defineKitchen(g *p.Game) {
|
||||
g.SceneManager.Register(p.Scene{
|
||||
Name: "kitchen",
|
||||
Background: "bg/kitchen",
|
||||
Music: "mus/calm",
|
||||
Hotspots: []p.Hotspot{
|
||||
{
|
||||
Name: "cupboard",
|
||||
Area: p.Rect(40, 60, 80, 120),
|
||||
Label: "szekrény",
|
||||
OnLook: p.Say("player", "Egy konyhaszekrény. Zárva."),
|
||||
OnUse: p.If(p.Flag("cupboard_open"),
|
||||
p.Say("player", "Már nyitva van."),
|
||||
p.Seq(
|
||||
p.RequireItem("key"),
|
||||
p.Say("player", "Klikk."),
|
||||
p.SetFlag("cupboard_open"),
|
||||
p.Give("beans"),
|
||||
p.TakeAway("key"),
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "cat",
|
||||
Area: p.Rect(200, 130, 30, 30),
|
||||
Label: "Cirmos",
|
||||
OnTalk: p.RunDialogue("cat_morning"),
|
||||
},
|
||||
{
|
||||
Name: "door",
|
||||
Area: p.Rect(280, 60, 30, 120),
|
||||
OnUse: p.GoTo("bedroom"),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// domain/item.key.go
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
func defineKey(g *p.Game) {
|
||||
g.ItemManager.Register(p.Item{
|
||||
Name: "key",
|
||||
Sprite: "spr/key",
|
||||
Description: "rozsdás kulcs",
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Az építőkövek tehát **plain struct literal**-ek, amiket a megfelelő `*Manager`-be
|
||||
regisztrálunk. Nincs fluent builder, nincs `.Done()` — a struct mezői önmagukban
|
||||
deklaratívak, az IDE autocomplete pedig kvázi-DSL-szerű élményt ad.
|
||||
|
||||
```go
|
||||
// main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"pncdsl/domain"
|
||||
"pncdsl/pncdsl"
|
||||
)
|
||||
|
||||
func main() {
|
||||
pncdsl.Run(domain.Build())
|
||||
}
|
||||
```
|
||||
|
||||
## Mappa- és fájlstruktúra
|
||||
|
||||
**Konvenció**: minden Go-fájl neve `téma.azonosító.go`. A pont szeparátor szándékos —
|
||||
így `ls`-ben a fájlok témánként szépen csoportosulnak. A library oldalon a téma a
|
||||
komponens funkcióját jelöli (`core`, `scene`, `actor`, `dialog`, `action`, `state`,
|
||||
`ui`, `input`, `asset`, `util`); a domain oldalon a téma az entitás típusa (`scene`,
|
||||
`item`, `character`, `dialog`, `script`), az azonosító pedig pontosan az a string,
|
||||
amivel a DSL-ben hivatkozunk rá (`scene.kitchen.go` ↔ `g.DefineScene("kitchen")`).
|
||||
|
||||
```
|
||||
pncdsl/
|
||||
├── go.mod
|
||||
├── PLAN.md
|
||||
├── main.go # belépési pont
|
||||
├── assets/ # képek, hangok, fontok (futásidőben olvasva)
|
||||
│ ├── bg/ spr/ snd/ mus/ fnt/
|
||||
│
|
||||
├── pncdsl/ # maga a library
|
||||
│ ├── core.doc.go # package docs, design rationale
|
||||
│ ├── core.game.go # Game (root aggregátum), Run()
|
||||
│ ├── core.engine.go # Ebiten adapter: Update/Draw/Layout
|
||||
│ ├── core.manager.go # Manager[T Named] — KÖZPONTI minta
|
||||
│ ├── core.dsl.go # toplevel helperek (Rect, Seq, Par, Say...)
|
||||
│ ├── core.errors.go # tipizált hibák, sentinel értékek
|
||||
│ │
|
||||
│ ├── scene.def.go # Scene struct (Name + mezők)
|
||||
│ ├── scene.manager.go # SceneManager (alias: Manager[Scene])
|
||||
│ ├── scene.hotspot.go # Hotspot struct (Scene gyermeke)
|
||||
│ ├── scene.trigger.go # Trigger (Scene gyermeke)
|
||||
│ ├── scene.transition.go # Fade, Cut, Wipe a scenek között
|
||||
│ ├── scene.camera.go # Camera (pan/follow/shake)
|
||||
│ │
|
||||
│ ├── item.def.go # Item struct (Name + mezők)
|
||||
│ ├── item.manager.go # ItemManager (alias: Manager[Item])
|
||||
│ ├── item.inventory.go # Inventory, slotok, drag&drop
|
||||
│ │
|
||||
│ ├── actor.def.go # Character struct (Name + mezők)
|
||||
│ ├── actor.manager.go # CharacterManager (alias: Manager[Character])
|
||||
│ ├── actor.animation.go # SpriteSheet, AnimationClip, Animator
|
||||
│ │
|
||||
│ ├── dialog.def.go # Dialogue, DialogueNode, Choice
|
||||
│ ├── dialog.manager.go # DialogueManager (alias: Manager[Dialogue])
|
||||
│ ├── dialog.box.go # DialogBox UI a Dialogue megjelenítéshez
|
||||
│ │
|
||||
│ ├── action.def.go # Action interface + beépített konstruktorok
|
||||
│ ├── action.condition.go # Condition interface + beépítettek
|
||||
│ ├── action.script.go # Script struct (Name + Actions)
|
||||
│ ├── action.manager.go # ScriptManager (alias: Manager[Script])
|
||||
│ │
|
||||
│ ├── state.def.go # World state, Flags, Vars, History
|
||||
│ ├── state.save.go # SaveSlot, serialize/restore
|
||||
│ │
|
||||
│ ├── ui.def.go # UI root, layout
|
||||
│ ├── ui.verb.go # Verb struct + VerbBar
|
||||
│ ├── ui.verb_manager.go # VerbManager (alias: Manager[Verb])
|
||||
│ ├── ui.cursor.go # Cursor sprite-ek és állapotok
|
||||
│ ├── ui.speech.go # SpeechBubble renderelés
|
||||
│ │
|
||||
│ ├── input.def.go # Egér, billentyű, hover/click resolve
|
||||
│ │
|
||||
│ ├── asset.def.go # Asset struct (Name, Path, Kind)
|
||||
│ ├── asset.manager.go # AssetManager (alias: Manager[Asset]) + lazy load
|
||||
│ ├── asset.audio.go # AudioPlayer, Music, Sfx mixing
|
||||
│ ├── asset.text.go # FontLoader, TextRenderer, Wrapper
|
||||
│ │
|
||||
│ ├── util.geometry.go # Point, Rect, Polygon, Path
|
||||
│ ├── util.timer.go # Tickek, Tween, Delay
|
||||
│ └── util.log.go # opcionális debug overlay
|
||||
│
|
||||
└── domain/ # a konkrét játék tartalma — lásd Demo szakasz
|
||||
├── game.go # Build() — sorban hívja a define*() függvényeket
|
||||
├── domain.asset.go # registerAssets(g) — minden asset egy helyen
|
||||
├── domain.flag.go # flag/var konstansok (string típusos enum)
|
||||
│
|
||||
├── scene.bedroom.go # defineBedroom(g)
|
||||
├── scene.kitchen.go # defineKitchen(g)
|
||||
│
|
||||
├── item.key.go # defineKey(g)
|
||||
├── item.beans.go # defineBeans(g)
|
||||
├── item.mug.go # defineMug(g)
|
||||
│
|
||||
├── character.player.go # definePlayer(g)
|
||||
├── character.cat.go # defineCat(g)
|
||||
│
|
||||
├── dialog.cat_morning.go # defineCatMorning(g)
|
||||
│
|
||||
├── script.intro.go # defineIntroScript(g)
|
||||
└── script.victory.go # defineVictoryScript(g)
|
||||
```
|
||||
|
||||
A domain-konvenció előnye: amikor új scene-t/itemet teszek hozzá, **csak egy új fájlt
|
||||
hozok létre + egy sort a `Build()`-ben**. Nincs egyetlen ezer soros `items.go`. Ha a
|
||||
jövőben többszáz entitás lesz, a fájllista önmagában dokumentáció: `ls domain/scene.*`
|
||||
megmutatja az összes helyszínt.
|
||||
|
||||
## Központi struktúrák
|
||||
|
||||
### `Manager[T]` — a regisztráló minta (`pncdsl/core.manager.go`)
|
||||
|
||||
Minden névvel hivatkozható entitást egyetlen generikus `Manager` kezel. A library
|
||||
egyetlen helyen definiálja, és típus-aliasokkal kap nevet entitásonként — így minden
|
||||
manager egységesen viselkedik (`Register`, `Get`, `MustGet`, `Has`, `Names`, `Each`).
|
||||
|
||||
```go
|
||||
// pncdsl/core.manager.go
|
||||
package pncdsl
|
||||
|
||||
type Named interface {
|
||||
GetName() string
|
||||
}
|
||||
|
||||
type Manager[T Named] struct {
|
||||
items map[string]T
|
||||
}
|
||||
|
||||
func NewManager[T Named]() *Manager[T] {
|
||||
return &Manager[T]{items: make(map[string]T)}
|
||||
}
|
||||
|
||||
func (m *Manager[T]) Register(v T) {
|
||||
name := v.GetName()
|
||||
if name == "" {
|
||||
panic("pncdsl: Register: empty Name")
|
||||
}
|
||||
if _, dup := m.items[name]; dup {
|
||||
panic("pncdsl: Register: duplicate 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]) Names() []string { /* sorted keys */ }
|
||||
func (m *Manager[T]) Each(fn func(T)) { /* iterate */ }
|
||||
```
|
||||
|
||||
Minden entitás implementálja a `Named` interfészt egyetlen kis metódussal:
|
||||
|
||||
```go
|
||||
type Item struct {
|
||||
Name string
|
||||
Sprite string
|
||||
Description string
|
||||
// ... handlerek
|
||||
}
|
||||
func (i Item) GetName() string { return i.Name }
|
||||
```
|
||||
|
||||
Entitásonkénti **nevesített manager-típus** (generic alias, Go 1.24+):
|
||||
|
||||
```go
|
||||
// pncdsl/item.manager.go
|
||||
type ItemManager = Manager[Item]
|
||||
|
||||
// pncdsl/scene.manager.go
|
||||
type SceneManager = Manager[Scene]
|
||||
|
||||
// pncdsl/actor.manager.go
|
||||
type CharacterManager = Manager[Character]
|
||||
|
||||
// pncdsl/dialog.manager.go
|
||||
type DialogueManager = Manager[Dialogue]
|
||||
|
||||
// pncdsl/action.manager.go
|
||||
type ScriptManager = Manager[Script]
|
||||
|
||||
// pncdsl/asset.manager.go
|
||||
type AssetManager = Manager[Asset]
|
||||
|
||||
// pncdsl/ui.verb_manager.go
|
||||
type VerbManager = Manager[Verb]
|
||||
```
|
||||
|
||||
A használat ezért minden entitáson **pontosan ugyanígy néz ki**:
|
||||
|
||||
```go
|
||||
g.ItemManager.Register(p.Item{Name: "key", Sprite: "spr/key"})
|
||||
g.ItemManager.Get("key") // (Item, bool)
|
||||
g.ItemManager.MustGet("key") // Item, panic ha nincs
|
||||
g.SceneManager.Register(p.Scene{Name: "kitchen", ...})
|
||||
g.AssetManager.Register(p.Asset{Name: "bg/kitchen", Path: "assets/bg/kitchen.png"})
|
||||
```
|
||||
|
||||
**Manager-listája:** `ItemManager`, `SceneManager`, `CharacterManager`,
|
||||
`DialogueManager`, `ScriptManager`, `AssetManager`, `VerbManager`. (A `Hotspot`,
|
||||
`Trigger`, `Walkbox`, `DialogueNode` nem kap saját managert — ezek mindig egy
|
||||
parent struct mezőiként élnek, scoped a Scene-hez ill. a Dialogue-hoz.)
|
||||
|
||||
### `Game` — a gyökér aggregátum (`pncdsl/core.game.go`)
|
||||
|
||||
A `Game` az összes managert mezőként hordozza, plusz a futásidejű állapotot:
|
||||
|
||||
```go
|
||||
type Game struct {
|
||||
Title string
|
||||
Width, Height int
|
||||
|
||||
// regisztrálható entitások — minden mező egyforma alakú
|
||||
ItemManager *ItemManager
|
||||
SceneManager *SceneManager
|
||||
CharacterManager *CharacterManager
|
||||
DialogueManager *DialogueManager
|
||||
ScriptManager *ScriptManager
|
||||
AssetManager *AssetManager
|
||||
VerbManager *VerbManager
|
||||
|
||||
// futásidejű állapot
|
||||
State *State
|
||||
Inventory *Inventory
|
||||
UI *UI
|
||||
Audio *AudioPlayer
|
||||
Camera *Camera
|
||||
Cursor *Cursor
|
||||
|
||||
current *Scene
|
||||
startID string
|
||||
onStart Action
|
||||
}
|
||||
|
||||
func NewGame(title string, w, h int) *Game {
|
||||
return &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(),
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A `Game` többi metódusa minimális — nem builder, csak konfig:
|
||||
|
||||
- `func NewGame(title string, w, h int) *Game`
|
||||
- `func (g *Game) StartAt(sceneName string) *Game`
|
||||
- `func (g *Game) OnStart(a Action) *Game`
|
||||
- `func (g *Game) Validate() error`
|
||||
- `func (g *Game) Run() error`
|
||||
- `func (g *Game) Save(slot int) error` / `func (g *Game) Load(slot int) error`
|
||||
|
||||
A toplevel `pncdsl.Run(g *Game) error` annyi, hogy `g.Validate()` után belép az
|
||||
Ebiten ciklusba.
|
||||
|
||||
### `Asset` (`pncdsl/asset.def.go`)
|
||||
|
||||
```go
|
||||
type Asset struct {
|
||||
Name string // pl. "bg/kitchen"
|
||||
Path string // pl. "assets/bg/kitchen.png"
|
||||
Kind AssetKind // Image | Audio | Font
|
||||
}
|
||||
func (a Asset) GetName() string { return a.Name }
|
||||
```
|
||||
|
||||
Az `AssetManager` lazy: `Register` csak metaadatot tárol, az első tényleges
|
||||
hozzáférésnél tölti be és cache-eli (a betöltött resource az `AssetManager`
|
||||
belső térképében, nem az `Asset` structban — utóbbi marad immutable konfig).
|
||||
|
||||
### `Scene` (`pncdsl/scene.def.go`)
|
||||
|
||||
```go
|
||||
type Scene struct {
|
||||
Name string
|
||||
Background string // Asset.Name
|
||||
Music string // Asset.Name
|
||||
Hotspots []Hotspot
|
||||
Walkboxes []Polygon
|
||||
Triggers []Trigger
|
||||
Actors []SceneActor // { CharacterName string; At Point }
|
||||
OnEnter Action
|
||||
OnLeave Action
|
||||
}
|
||||
func (s Scene) GetName() string { return s.Name }
|
||||
```
|
||||
|
||||
Hotspot / Trigger / Walkbox értékek inline, ugyanabban a struct literalban —
|
||||
nem kell külön regisztrálni őket; egy Hotspot eleve scene-scoped.
|
||||
|
||||
### `Hotspot` (`pncdsl/scene.hotspot.go`)
|
||||
|
||||
```go
|
||||
type Hotspot struct {
|
||||
Name string
|
||||
Area Shape // Rect | Polygon
|
||||
Label string // a kurzor melletti felirat hover-kor
|
||||
Cursor CursorKind
|
||||
|
||||
// verbenkénti handler — egyszerű mezők, nem map
|
||||
OnLook Action
|
||||
OnUse Action
|
||||
OnTalk Action
|
||||
OnTake Action
|
||||
OnGive Action
|
||||
|
||||
// egyéni verbek (`g.VerbManager.Register(Verb{Name: "push"})`-hoz)
|
||||
OnVerb map[string]Action
|
||||
}
|
||||
```
|
||||
|
||||
### `Item` (`pncdsl/item.def.go`)
|
||||
|
||||
```go
|
||||
type Item struct {
|
||||
Name string
|
||||
Sprite string // Asset.Name
|
||||
Description string
|
||||
|
||||
OnUseSelf Action // pl. „nem tudom mit kezdjek vele"
|
||||
OnUseWith map[string]Action // targetName -> action (hotspot vagy másik item)
|
||||
}
|
||||
func (i Item) GetName() string { return i.Name }
|
||||
```
|
||||
|
||||
### `Inventory` (`pncdsl/item.inventory.go`)
|
||||
|
||||
```go
|
||||
type Inventory struct {
|
||||
items []string
|
||||
capacity int
|
||||
selected string
|
||||
}
|
||||
|
||||
func (i *Inventory) Add(name string)
|
||||
func (i *Inventory) Remove(name string)
|
||||
func (i *Inventory) Has(name string) bool
|
||||
func (i *Inventory) Select(name string)
|
||||
func (i *Inventory) Selected() string
|
||||
```
|
||||
|
||||
(Az `Inventory` nem manager — futásidejű állapot, nem regisztráció.)
|
||||
|
||||
### `Character` (`pncdsl/actor.def.go`)
|
||||
|
||||
```go
|
||||
type Character struct {
|
||||
Name string
|
||||
Sprite string // Asset.Name
|
||||
Animations map[string]AnimationClip // idle, walk_left, walk_right, talk
|
||||
Speed float64
|
||||
SpeechColor color.Color
|
||||
Start Point // alappozíció (scene-en belül felülírhatja Actor)
|
||||
}
|
||||
func (c Character) GetName() string { return c.Name }
|
||||
```
|
||||
|
||||
Futásidejű mozgás-API a `Game`-en keresztül érhető el (nem a Character struct
|
||||
módosításán át, hiszen az regisztrált konfig):
|
||||
`g.WalkCharacter("player", Point{...})`, `g.FaceCharacter("player", dir)`.
|
||||
|
||||
### `Dialogue` (`pncdsl/dialog.def.go`)
|
||||
|
||||
```go
|
||||
type Dialogue struct {
|
||||
Name string
|
||||
Start string // kezdő node Name
|
||||
Nodes []DialogueNode // mezőként, nem manager — Dialogue-scoped
|
||||
}
|
||||
func (d Dialogue) GetName() string { return d.Name }
|
||||
|
||||
type DialogueNode struct {
|
||||
Name string
|
||||
Lines []DialogueLine // ki mit mond a node belépésén
|
||||
Choices []DialogueChoice
|
||||
}
|
||||
|
||||
type DialogueLine struct {
|
||||
Speaker string // Character.Name
|
||||
Text string
|
||||
}
|
||||
|
||||
type DialogueChoice struct {
|
||||
Text string
|
||||
Show Condition // nil = mindig látszik
|
||||
Once bool
|
||||
Actions []Action
|
||||
}
|
||||
```
|
||||
|
||||
### `Script` (`pncdsl/action.script.go`)
|
||||
|
||||
```go
|
||||
type Script struct {
|
||||
Name string
|
||||
Actions Action // tipikusan Seq(...)
|
||||
}
|
||||
func (s Script) GetName() string { return s.Name }
|
||||
```
|
||||
|
||||
### `Verb` (`pncdsl/ui.verb.go`)
|
||||
|
||||
```go
|
||||
type Verb struct {
|
||||
Name string // "look", "use", "talk", "take", ...
|
||||
Label string // megjelenő szöveg a VerbBar-on
|
||||
Default Action // ha a hotspot nem definiál erre handlert
|
||||
}
|
||||
func (v Verb) GetName() string { return v.Name }
|
||||
```
|
||||
|
||||
### `Action` interface (`pncdsl/action.def.go`)
|
||||
|
||||
```go
|
||||
type Action interface {
|
||||
Run(ctx *Ctx) Status // Running | Done | Failed
|
||||
}
|
||||
|
||||
type Ctx struct {
|
||||
Game *Game
|
||||
Scene *Scene
|
||||
Hotspot *Hotspot
|
||||
Item *Item
|
||||
dt float64
|
||||
}
|
||||
```
|
||||
|
||||
Beépített actionök (sima konstruktorfüggvények, hogy DSL-ből szépen olvasódjon):
|
||||
|
||||
- `Say(speakerID, text string)`
|
||||
- `Wait(seconds float64)`
|
||||
- `GoTo(sceneID string)`
|
||||
- `Walk(charID string, to Point)`
|
||||
- `Give(itemID string)` / `TakeAway(itemID string)`
|
||||
- `SetFlag(name string)` / `ClearFlag(name string)` / `SetVar(name string, v any)`
|
||||
- `PlaySound(assetID string)` / `PlayMusic(assetID string)` / `StopMusic()`
|
||||
- `RunDialogue(dialogueID string)` / `EndDialogue()` / `GotoNode(nodeID string)`
|
||||
- `RunScript(scriptID string)`
|
||||
- `Seq(a ...Action)` — sorban (default builder-szemantika)
|
||||
- `Par(a ...Action)` — párhuzamosan
|
||||
- `If(c Condition, then Action, else_ ...Action)`
|
||||
- `RequireItem(itemID string)` — short-circuit ha nincs nála (visszadob egy "nem tudom használni"-t)
|
||||
- `Custom(fn func(*Ctx) Status)` — escape hatch a domainnek
|
||||
|
||||
### `Condition` (`pncdsl/action.condition.go`)
|
||||
|
||||
```go
|
||||
type Condition interface{ Eval(*Ctx) bool }
|
||||
```
|
||||
|
||||
Beépítettek: `HasItem(name)`, `Flag(name)`, `Not(c)`, `And(c...)`, `Or(c...)`,
|
||||
`VarEq(name, v)`, `InScene(name)`, `SelectedItem(name)`.
|
||||
|
||||
### `State` (`pncdsl/state.def.go`)
|
||||
|
||||
```go
|
||||
type State struct {
|
||||
flags map[string]bool
|
||||
vars map[string]any
|
||||
visited map[string]int // scene name -> hányszor lépett be
|
||||
talked map[string]int // dialogue node -> hányszor futott le (a `Once` choice-okhoz)
|
||||
}
|
||||
```
|
||||
|
||||
Metódusok: `Flag/SetFlag/ClearFlag`, `Var/SetVar`, `Visited(name)`, `TalkedTo(name)`,
|
||||
`Snapshot()`, `Restore(snap)`. (Ez sem manager — runtime állapot.)
|
||||
|
||||
### `UI` (`pncdsl/ui.def.go`, `pncdsl/dialog.box.go`)
|
||||
|
||||
```go
|
||||
type UI struct {
|
||||
verbBar *VerbBar
|
||||
invBar *InventoryBar
|
||||
speech *SpeechBubble
|
||||
dialogBox *DialogBox
|
||||
hoverLabel string
|
||||
}
|
||||
```
|
||||
|
||||
Konfigurálható: layout, font, színek. Default a SCUMM-szerű alsó verbsáv + jobboldali
|
||||
inventory; ki is kapcsolható ha valaki modern verb-coin variánst akar.
|
||||
|
||||
### `Engine` — Ebiten adapter (`pncdsl/core.engine.go`)
|
||||
|
||||
```go
|
||||
type engine struct {
|
||||
g *Game
|
||||
}
|
||||
|
||||
func (e *engine) Update() error
|
||||
func (e *engine) Draw(screen *ebiten.Image)
|
||||
func (e *engine) Layout(outW, outH int) (int, int)
|
||||
```
|
||||
|
||||
Az `Update` belerajzol:
|
||||
1. input → resolve (melyik hotspoton van a kurzor, milyen verb aktív)
|
||||
2. aktív cutscene/dialogue lépése
|
||||
3. karakterek mozgása, animation tick
|
||||
4. trigger feltételek
|
||||
5. UI állapot frissítése
|
||||
|
||||
A `Draw` rétegei alulról felfele: háttér → walkbox debug (csak ha bekapcsolva) →
|
||||
karakterek (y-sorted) → speech bubble-k → UI → cursor → tranzíció overlay.
|
||||
|
||||
## Asset és audio kezelés
|
||||
|
||||
Az `AssetManager` egy hagyományos `Manager[Asset]` — a `Register` csak az
|
||||
útvonalat és típust tárolja, az első `Load(name)` hívásra olvas be a diszkről
|
||||
és cache-el (a betöltött `*ebiten.Image` / audio buffer egy belső térképben él,
|
||||
az `Asset` struct maga immutable konfig marad).
|
||||
|
||||
```go
|
||||
g.AssetManager.Register(p.Asset{Name: "bg/kitchen", Path: "assets/bg/kitchen.png", Kind: p.AssetImage})
|
||||
g.AssetManager.Register(p.Asset{Name: "mus/calm", Path: "assets/mus/calm.ogg", Kind: p.AssetAudio})
|
||||
```
|
||||
|
||||
`AudioPlayer` egy zenecsatornát + N sfx csatornát kezel, fade-in/out támogatással
|
||||
a `PlayMusic` action-höz.
|
||||
|
||||
## Save / Load
|
||||
|
||||
`SaveSlot` JSON-be sorosít:
|
||||
- aktuális scene `Name`-je
|
||||
- karakterek pozíciói
|
||||
- `State` (flags, vars, visited, talked)
|
||||
- inventory tartalom (item `Name`-ek listája)
|
||||
- futó script PC (ha cutscene közben menteni szabad — opcionálisan tilthatóan)
|
||||
|
||||
A managerek **tartalmát nem mentjük** — azok a `Build()`-ből minden indításkor
|
||||
újra előállnak. Csak a runtime-állapotot persistáljuk.
|
||||
|
||||
API: `Game.Save(slot int) error`, `Game.Load(slot int) error`.
|
||||
|
||||
## Hibakezelés és validáció
|
||||
|
||||
`Game.Validate() error` a `Run()` előtt fut. Ellenőrzi a managerek közötti
|
||||
név-hivatkozások konzisztenciáját:
|
||||
|
||||
- minden `Scene.Background`/`Music` és `Character.Sprite` szerepel-e az `AssetManager`-ben,
|
||||
- minden `GoTo(name)` célja él-e a `SceneManager`-ben,
|
||||
- `RunDialogue(name)` és `Dialogue` `Choice.Actions`-ben szereplő `GotoNode` ill.
|
||||
`RunScript(name)` célok léteznek-e,
|
||||
- minden `Hotspot.OnUseWith` map kulcs (item/hotspot név) feloldható,
|
||||
- `StartAt(name)` scene létezik,
|
||||
- nincs két azonos nevű regisztráció (ezt amúgy a `Register` panic-cal megfogja).
|
||||
|
||||
Hibák tipizáltak: `ErrUnknownAsset`, `ErrUnknownScene`, `ErrUnknownDialogue`,
|
||||
`ErrUnknownDialogueNode`, `ErrUnknownScript`, `ErrUnknownItem`, `ErrDuplicateName`.
|
||||
|
||||
## Tesztelési stratégia
|
||||
|
||||
- A `pncdsl/*.go` minden nem-render részére táblavezérelt unit teszt
|
||||
(`action_test.go`, `dialogue_test.go`, `state_test.go`).
|
||||
- A `domain/`-hoz egy headless smoke test, ami `Build()` után `Validate()`-et hív —
|
||||
így a játék statikus integritását CI-ben is le lehet csekkolni Ebiten ablak nélkül.
|
||||
- A renderhez `ebiten/ebitenutil` segítségével offscreen image diff — opcionális,
|
||||
csak ha sok regressziónk lenne.
|
||||
|
||||
## Demo játék: „Reggeli Kávé"
|
||||
|
||||
A library kipróbálására egy szándékosan apró, ~5 perces játék, ami a fontos
|
||||
mechanikákat **egyenként** gyakorolja anélkül, hogy a domain túlnőne a libraryn.
|
||||
|
||||
### Sztori egy mondatban
|
||||
|
||||
Frissen ébredsz, és muszáj kávét főznöd, mielőtt a macskád szétszedné a konyhát.
|
||||
|
||||
### Helyszínek
|
||||
|
||||
| ID | Háttér | Zene | Tartalom |
|
||||
|------------|---------------------|-------------|------------------------------------------------------------|
|
||||
| `bedroom` | `bg/bedroom.png` | `mus/wakeup`| ágy, éjjeliszekrény, ajtó |
|
||||
| `kitchen` | `bg/kitchen.png` | `mus/calm` | szekrény, polc, kávéfőző, macska, ajtó |
|
||||
|
||||
### Itemek
|
||||
|
||||
| ID | Sprite | Honnan szerezhető | Mire jó |
|
||||
|---------|-----------------|------------------------------------------|-------------------------------------------|
|
||||
| `key` | `spr/key.png` | `bedroom.nightstand` → Take | `kitchen.cupboard` kinyitása |
|
||||
| `beans` | `spr/beans.png` | `kitchen.cupboard` (kinyitás után) | `kitchen.coffee_machine`-be tölteni |
|
||||
| `mug` | `spr/mug.png` | `kitchen.shelf` → Take | `kitchen.coffee_machine`-re tenni |
|
||||
|
||||
### Karakterek
|
||||
|
||||
- **`player`** — az általunk irányított PC. `WalkTo` célokra mozog, beszél (`Say`).
|
||||
- **`cat`** — az NPC, a konyhában ül. Csak `Talk` interakció, dialog-fa.
|
||||
|
||||
### Dialógus (`dialog.cat_morning.go`)
|
||||
|
||||
```
|
||||
[start]
|
||||
cat: Mióóóóóóóóu. Késő van.
|
||||
> „Mindjárt megy a kávé." → SetFlag("promised_coffee") → end
|
||||
> „Te etted meg a babot?" → goto cat_beans
|
||||
> „Hagyj békén." → end
|
||||
|
||||
[cat_beans] (csak ha NEM flag `cupboard_open`)
|
||||
cat: A szekrényben van. Mindig ott szokott lenni.
|
||||
> „Köszi." → end
|
||||
```
|
||||
|
||||
### Hotspot-tábla
|
||||
|
||||
**`scene.bedroom.go`**
|
||||
|
||||
| Hotspot | Look | Use |
|
||||
|--------------|-----------------------------------|------------------------------------------------------------------|
|
||||
| `bed` | „A párnám még meleg." | `Say("player", "Most keltem fel.")` |
|
||||
| `nightstand` | „Az éjjeliszekrényemen egy kulcs." | egyszer: `Give("key")` + `SetFlag("key_taken")`; utána: „Üres." |
|
||||
| `door` | „Az ajtó a konyhába vezet." | `GoTo("kitchen")` |
|
||||
|
||||
**`scene.kitchen.go`**
|
||||
|
||||
| Hotspot | Look | Use |
|
||||
|------------------|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `cupboard` | „Konyhaszekrény. Zárva." | ha `cupboard_open`: „Már nyitva."; különben `RequireItem("key")` → `SetFlag("cupboard_open")` + `Give("beans")` + `TakeAway("key")` |
|
||||
| `shelf` | „A polc tele bögrékkel." | egyszer: `Give("mug")`; utána: „Tiszta már mind." |
|
||||
| `coffee_machine` | „A jó öreg kávéfőző." | progresszív: 0/2 → kell `beans` és `mug`; 1/2 → kell a másik; 2/2 → `RunScript("victory")` |
|
||||
| `cat` (NPC) | „Cirmos, a házikedvencem." | (Talk) `RunDialogue("cat_morning")` |
|
||||
| `door` | „Vissza a hálóba." | `GoTo("bedroom")` |
|
||||
|
||||
A „progresszív" használat egy kis state-trükkel: a kávéfőző két flaget figyel
|
||||
(`coffee_has_beans`, `coffee_has_mug`); a `Use` ág `If` action-ökkel szétágazik
|
||||
aszerint, hogy melyik item van kiválasztva az inventory-ban (`SelectedItem`
|
||||
condition), és csak a megfelelőt fogadja el. Konkrétan így néz ki a hotspot
|
||||
regisztrációja:
|
||||
|
||||
```go
|
||||
// domain/scene.kitchen.go (részlet)
|
||||
{
|
||||
Name: "coffee_machine",
|
||||
Area: p.Rect(140, 70, 50, 60),
|
||||
Label: "kávéfőző",
|
||||
OnLook: p.Say("player", "A jó öreg kávéfőző."),
|
||||
OnUse: p.Seq(
|
||||
p.If(p.And(p.SelectedItem("beans"), p.Not(p.Flag("coffee_has_beans"))),
|
||||
p.Seq(p.SetFlag("coffee_has_beans"), p.TakeAway("beans"),
|
||||
p.Say("player", "Bab betöltve."))),
|
||||
p.If(p.And(p.SelectedItem("mug"), p.Not(p.Flag("coffee_has_mug"))),
|
||||
p.Seq(p.SetFlag("coffee_has_mug"), p.TakeAway("mug"),
|
||||
p.Say("player", "Bögre a helyén."))),
|
||||
p.If(p.And(p.Flag("coffee_has_beans"), p.Flag("coffee_has_mug")),
|
||||
p.RunScript("victory")),
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
És így néz ki egy item regisztrációja, ami egy másik item-re vagy hotspotra reagál:
|
||||
|
||||
```go
|
||||
// domain/item.key.go
|
||||
func defineKey(g *p.Game) {
|
||||
g.ItemManager.Register(p.Item{
|
||||
Name: "key",
|
||||
Sprite: "spr/key",
|
||||
Description: "rozsdás kulcs",
|
||||
OnUseWith: map[string]p.Action{
|
||||
"cupboard": p.Seq(p.SetFlag("cupboard_open"), p.Give("beans"),
|
||||
p.TakeAway("key"), p.Say("player", "Klikk.")),
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Scriptek
|
||||
|
||||
**`script.intro.go`** — az `OnStart`-ra kötve, `bedroom` scene-ben:
|
||||
|
||||
```
|
||||
Wait(0.5) → fade in →
|
||||
Say("player", "Brr, hideg van.") →
|
||||
Say("player", "Egy kávé kéne...") →
|
||||
Walk("player", Point{160, 140})
|
||||
```
|
||||
|
||||
**`script.victory.go`** — amikor a `coffee_machine` mindkét flaget látja:
|
||||
|
||||
```
|
||||
Say("player", "Készen is van!") →
|
||||
PlaySound("snd/coffee_done") →
|
||||
Wait(0.8) →
|
||||
Say("cat", "Mióóóóu. *boldog macska*") →
|
||||
Say("player", "Reggeli kávé: megmentve.") →
|
||||
fade out → ShowEnd("Vége — kösz hogy játszottál!")
|
||||
```
|
||||
|
||||
### Flag/var táblázat (`domain.flag.go`)
|
||||
|
||||
```go
|
||||
const (
|
||||
FlagKeyTaken = "key_taken"
|
||||
FlagCupboardOpen = "cupboard_open"
|
||||
FlagCoffeeHasBeans = "coffee_has_beans"
|
||||
FlagCoffeeHasMug = "coffee_has_mug"
|
||||
FlagPromisedCoffee = "promised_coffee"
|
||||
)
|
||||
```
|
||||
|
||||
### Mit gyakorol a demo
|
||||
|
||||
| Library képesség | Hol jelenik meg a demoban |
|
||||
|--------------------------|------------------------------------------------------------|
|
||||
| `Scene` + `GoTo` | bedroom ↔ kitchen ajtó |
|
||||
| `Hotspot` + verbek | minden helyszínen több hotspot, Look/Use/Talk |
|
||||
| `Inventory` + `Give` | key, beans, mug felvétele |
|
||||
| `RequireItem` | szekrény nyitása csak kulccsal |
|
||||
| Use-with-item | kávéfőző két különböző itemet kér, progresszíven |
|
||||
| `Flag` + `If` | szekrény „már nyitva", éjjeliszekrény „már üres" |
|
||||
| `Dialogue` + `Choice` | macska dialógusfája feltételes ággal |
|
||||
| `Script` / cutscene | intro (mozgás+beszéd) és victory (több actor) |
|
||||
| `Walk` + walkbox | player A* a kattintott pontig |
|
||||
| `PlayMusic` / `PlaySound`| szobánkénti zene, victory hangeffekt |
|
||||
| `Save` / `Load` | mid-game mentés, pl. a szekrény kinyitása után |
|
||||
| `Validate()` | CI smoke test a `Build()` outputra |
|
||||
|
||||
Ami **nincs benne** szándékosan: több aktor egyszerre mozogva, kamera-pan,
|
||||
inventory-tárgyak kombinálása (item+item). Ezeket egy esetleges 2. demo (pl.
|
||||
"A pajta") gyakorolná, de a libraryban már első körben benne van a kapacitás.
|
||||
|
||||
## Implementációs ütemterv
|
||||
|
||||
1. **Csontváz** — `Game`, `Scene`, `Hotspot`, `AssetRegistry`, Ebiten `engine`, egér +
|
||||
háttér + egy darab Look kattintás. Egy szoba, három hotspot, működő `Say`.
|
||||
2. **Verbek és inventory** — `VerbBar`, `Inventory`, `Item`, `UseOn`. SCUMM-szerű flow.
|
||||
3. **Karakterek és walkboxok** — `Character`, `Walkbox`, A* az útkereséshez.
|
||||
4. **Dialógus rendszer** — `Dialogue`, `DialogBox`, `DialogueChoice`, `Once`.
|
||||
5. **Cutscene / Script** — `Action` interface, `Seq`/`Par`/`If`, `RunScript`.
|
||||
6. **State, Flags, Save/Load** — perzisztencia.
|
||||
7. **Polish** — tranzíciók, audio fade, font wrapping, debug overlay, validáció.
|
||||
|
||||
Minden lépés végén egy minimális `domain/` példajáték validálja, hogy a DSL valóban
|
||||
kényelmes-e arra, amire ki van találva.
|
||||
29
domain/build_test.go
Normal file
29
domain/build_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestBuildValidates is the headless smoke test described in PLAN.md:
|
||||
// it composes the full game and asks the library to cross-check every
|
||||
// name reference between managers. No window opens.
|
||||
func TestBuildValidates(t *testing.T) {
|
||||
g := Build()
|
||||
if err := g.Validate(); err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
for _, name := range []string{"bedroom", "kitchen"} {
|
||||
if !g.SceneManager.Has(name) {
|
||||
t.Errorf("missing scene %q", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"key", "beans", "mug"} {
|
||||
if !g.ItemManager.Has(name) {
|
||||
t.Errorf("missing item %q", name)
|
||||
}
|
||||
}
|
||||
if !g.DialogueManager.Has("cat_morning") {
|
||||
t.Errorf("missing dialogue cat_morning")
|
||||
}
|
||||
if !g.ScriptManager.Has("intro") || !g.ScriptManager.Has("victory") {
|
||||
t.Errorf("missing intro/victory script")
|
||||
}
|
||||
}
|
||||
18
domain/character.cat.go
Normal file
18
domain/character.cat.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
p "pncdsl/pncdsl"
|
||||
)
|
||||
|
||||
func defineCat(g *p.Game) {
|
||||
g.CharacterManager.Register(p.Character{
|
||||
Name: "cat",
|
||||
Sprite: "spr/cat",
|
||||
Speed: 40,
|
||||
SpeechColor: color.RGBA{200, 200, 255, 255},
|
||||
W: 18,
|
||||
H: 14,
|
||||
})
|
||||
}
|
||||
18
domain/character.player.go
Normal file
18
domain/character.player.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
p "pncdsl/pncdsl"
|
||||
)
|
||||
|
||||
func definePlayer(g *p.Game) {
|
||||
g.CharacterManager.Register(p.Character{
|
||||
Name: "player",
|
||||
Sprite: "spr/player",
|
||||
Speed: 80,
|
||||
SpeechColor: color.RGBA{255, 230, 160, 255},
|
||||
W: 14,
|
||||
H: 28,
|
||||
})
|
||||
}
|
||||
45
domain/dialog.cat_morning.go
Normal file
45
domain/dialog.cat_morning.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
func defineCatMorning(g *p.Game) {
|
||||
g.DialogueManager.Register(p.Dialogue{
|
||||
Name: "cat_morning",
|
||||
Start: "start",
|
||||
Nodes: []p.DialogueNode{
|
||||
{
|
||||
Name: "start",
|
||||
Lines: []p.DialogueLine{
|
||||
{Speaker: "cat", Text: "Mióóóóóóóóu. Késő van."},
|
||||
},
|
||||
Choices: []p.DialogueChoice{
|
||||
{
|
||||
Text: "Mindjárt megy a kávé.",
|
||||
Actions: []p.Action{p.SetFlag(FlagPromisedCoffee), p.EndDialogue()},
|
||||
},
|
||||
{
|
||||
Text: "Te etted meg a babot?",
|
||||
Show: p.Not(p.Flag(FlagCupboardOpen)),
|
||||
Actions: []p.Action{p.GotoNode("beans")},
|
||||
},
|
||||
{
|
||||
Text: "Hagyj békén.",
|
||||
Actions: []p.Action{p.EndDialogue()},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "beans",
|
||||
Lines: []p.DialogueLine{
|
||||
{Speaker: "cat", Text: "A szekrényben van. Mindig ott szokott lenni."},
|
||||
},
|
||||
Choices: []p.DialogueChoice{
|
||||
{
|
||||
Text: "Köszi.",
|
||||
Actions: []p.Action{p.EndDialogue()},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
31
domain/domain.asset.go
Normal file
31
domain/domain.asset.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
// registerAssets declares every asset the game references. Files don't
|
||||
// need to exist on disk — the library will substitute deterministic
|
||||
// colored placeholders for anything missing.
|
||||
func registerAssets(g *p.Game) {
|
||||
imgs := []string{
|
||||
"bg/bedroom", "bg/kitchen",
|
||||
"spr/key", "spr/beans", "spr/mug",
|
||||
"spr/player", "spr/cat",
|
||||
}
|
||||
for _, name := range imgs {
|
||||
g.AssetManager.Register(p.Asset{
|
||||
Name: name,
|
||||
Path: "assets/" + name + ".png",
|
||||
Kind: p.AssetImage,
|
||||
})
|
||||
}
|
||||
audios := []string{
|
||||
"mus/wakeup", "mus/calm", "snd/coffee_done",
|
||||
}
|
||||
for _, name := range audios {
|
||||
g.AssetManager.Register(p.Asset{
|
||||
Name: name,
|
||||
Path: "assets/" + name + ".ogg",
|
||||
Kind: p.AssetAudio,
|
||||
})
|
||||
}
|
||||
}
|
||||
10
domain/domain.flag.go
Normal file
10
domain/domain.flag.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package domain
|
||||
|
||||
const (
|
||||
FlagKeyTaken = "key_taken"
|
||||
FlagCupboardOpen = "cupboard_open"
|
||||
FlagMugTaken = "mug_taken"
|
||||
FlagCoffeeHasBeans = "coffee_has_beans"
|
||||
FlagCoffeeHasMug = "coffee_has_mug"
|
||||
FlagPromisedCoffee = "promised_coffee"
|
||||
)
|
||||
30
domain/game.go
Normal file
30
domain/game.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
// Build assembles the full Reggeli Kávé game by registering every entity
|
||||
// into its matching manager on a fresh *Game. The order matters only for
|
||||
// readability — Validate() runs after Build to cross-check references.
|
||||
func Build() *p.Game {
|
||||
g := p.NewGame("Reggeli Kávé", 320, 200)
|
||||
|
||||
registerAssets(g)
|
||||
|
||||
definePlayer(g)
|
||||
defineCat(g)
|
||||
|
||||
defineKey(g)
|
||||
defineBeans(g)
|
||||
defineMug(g)
|
||||
|
||||
defineCatMorning(g)
|
||||
|
||||
defineIntroScript(g)
|
||||
defineVictoryScript(g)
|
||||
|
||||
defineBedroom(g)
|
||||
defineKitchen(g)
|
||||
|
||||
g.StartAt("bedroom").OnStart(p.RunScript("intro"))
|
||||
return g
|
||||
}
|
||||
11
domain/item.beans.go
Normal file
11
domain/item.beans.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
func defineBeans(g *p.Game) {
|
||||
g.ItemManager.Register(p.Item{
|
||||
Name: "beans",
|
||||
Sprite: "spr/beans",
|
||||
Description: "őrölt kávébab",
|
||||
})
|
||||
}
|
||||
20
domain/item.key.go
Normal file
20
domain/item.key.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
func defineKey(g *p.Game) {
|
||||
g.ItemManager.Register(p.Item{
|
||||
Name: "key",
|
||||
Sprite: "spr/key",
|
||||
Description: "rozsdás kulcs",
|
||||
OnUseWith: map[string]p.Action{
|
||||
"cupboard": p.Seq(
|
||||
p.Say("player", "Klikk."),
|
||||
p.SetFlag(FlagCupboardOpen),
|
||||
p.Give("beans"),
|
||||
p.TakeAway("key"),
|
||||
p.Say("player", "Bab van benne. Pont, ami kell."),
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
11
domain/item.mug.go
Normal file
11
domain/item.mug.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
func defineMug(g *p.Game) {
|
||||
g.ItemManager.Register(p.Item{
|
||||
Name: "mug",
|
||||
Sprite: "spr/mug",
|
||||
Description: "kedvenc bögréje",
|
||||
})
|
||||
}
|
||||
60
domain/scene.bedroom.go
Normal file
60
domain/scene.bedroom.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
func defineBedroom(g *p.Game) {
|
||||
g.SceneManager.Register(p.Scene{
|
||||
Name: "bedroom",
|
||||
Background: "bg/bedroom",
|
||||
Music: "mus/wakeup",
|
||||
Actors: []p.SceneActor{
|
||||
{CharacterName: "player", At: p.Point{X: 160, Y: 120}},
|
||||
},
|
||||
Hotspots: []p.Hotspot{
|
||||
{
|
||||
Name: "bed",
|
||||
Area: p.Rect(20, 70, 90, 60),
|
||||
Label: "ágy",
|
||||
OnLook: p.Say("player", "A párnám még meleg."),
|
||||
OnUse: p.Say("player", "Most keltem fel. Nem alszom vissza."),
|
||||
},
|
||||
{
|
||||
Name: "nightstand",
|
||||
Area: p.Rect(115, 80, 35, 50),
|
||||
Label: "éjjeliszekrény",
|
||||
OnLook: p.If(p.Flag(FlagKeyTaken),
|
||||
p.Say("player", "Üres a tetején."),
|
||||
p.Say("player", "Egy rozsdás kulcs az éjjeliszekrényemen."),
|
||||
),
|
||||
OnTake: p.If(p.Flag(FlagKeyTaken),
|
||||
p.Say("player", "Már elvittem."),
|
||||
p.Seq(
|
||||
p.Walk("player", p.Point{X: 130, Y: 120}),
|
||||
p.Give("key"),
|
||||
p.SetFlag(FlagKeyTaken),
|
||||
p.Say("player", "Pont jól jöhet."),
|
||||
),
|
||||
),
|
||||
OnUse: p.If(p.Flag(FlagKeyTaken),
|
||||
p.Say("player", "Már elvittem a kulcsot."),
|
||||
p.Seq(
|
||||
p.Walk("player", p.Point{X: 130, Y: 120}),
|
||||
p.Give("key"),
|
||||
p.SetFlag(FlagKeyTaken),
|
||||
p.Say("player", "Megvan a kulcs."),
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "door",
|
||||
Area: p.Rect(260, 50, 40, 90),
|
||||
Label: "ajtó",
|
||||
OnLook: p.Say("player", "Az ajtó a konyhába vezet."),
|
||||
OnUse: p.Seq(
|
||||
p.Walk("player", p.Point{X: 260, Y: 130}),
|
||||
p.GoTo("kitchen"),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
114
domain/scene.kitchen.go
Normal file
114
domain/scene.kitchen.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
func defineKitchen(g *p.Game) {
|
||||
g.SceneManager.Register(p.Scene{
|
||||
Name: "kitchen",
|
||||
Background: "bg/kitchen",
|
||||
Music: "mus/calm",
|
||||
Actors: []p.SceneActor{
|
||||
{CharacterName: "player", At: p.Point{X: 60, Y: 130}},
|
||||
{CharacterName: "cat", At: p.Point{X: 210, Y: 132}},
|
||||
},
|
||||
Hotspots: []p.Hotspot{
|
||||
{
|
||||
Name: "cupboard",
|
||||
Area: p.Rect(30, 40, 80, 80),
|
||||
Label: "szekrény",
|
||||
OnLook: p.Say("player", "Konyhaszekrény. Zárva."),
|
||||
OnUse: p.If(p.Flag(FlagCupboardOpen),
|
||||
p.Say("player", "Már nyitva van."),
|
||||
p.If(p.HasItem("key"),
|
||||
p.Seq(
|
||||
p.Walk("player", p.Point{X: 70, Y: 130}),
|
||||
p.Say("player", "Klikk."),
|
||||
p.SetFlag(FlagCupboardOpen),
|
||||
p.Give("beans"),
|
||||
p.TakeAway("key"),
|
||||
),
|
||||
p.Say("player", "Zárva. Kéne egy kulcs."),
|
||||
),
|
||||
),
|
||||
OnUseWith: map[string]p.Action{
|
||||
"key": p.Seq(
|
||||
p.Walk("player", p.Point{X: 70, Y: 130}),
|
||||
p.Say("player", "Klikk."),
|
||||
p.SetFlag(FlagCupboardOpen),
|
||||
p.Give("beans"),
|
||||
p.TakeAway("key"),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "shelf",
|
||||
Area: p.Rect(115, 45, 35, 50),
|
||||
Label: "polc",
|
||||
OnLook: p.Say("player", "A polc tele bögrékkel."),
|
||||
OnTake: p.If(p.Flag(FlagMugTaken),
|
||||
p.Say("player", "Tiszta már mind elfogyott."),
|
||||
p.Seq(
|
||||
p.Walk("player", p.Point{X: 125, Y: 130}),
|
||||
p.Give("mug"),
|
||||
p.SetFlag(FlagMugTaken),
|
||||
p.Say("player", "Egy bögre, kérem."),
|
||||
),
|
||||
),
|
||||
OnUse: p.If(p.Flag(FlagMugTaken),
|
||||
p.Say("player", "Már elvettem egyet."),
|
||||
p.Seq(
|
||||
p.Walk("player", p.Point{X: 125, Y: 130}),
|
||||
p.Give("mug"),
|
||||
p.SetFlag(FlagMugTaken),
|
||||
p.Say("player", "Egy bögre, kérem."),
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "coffee_machine",
|
||||
Area: p.Rect(160, 75, 50, 55),
|
||||
Label: "kávéfőző",
|
||||
OnLook: p.Say("player", "A jó öreg kávéfőző."),
|
||||
OnUse: p.If(p.And(p.Flag(FlagCoffeeHasBeans), p.Flag(FlagCoffeeHasMug)),
|
||||
p.RunScript("victory"),
|
||||
p.Say("player", "Még hiányzik valami. Bab? Bögre?"),
|
||||
),
|
||||
OnUseWith: map[string]p.Action{
|
||||
"beans": p.Seq(
|
||||
p.Walk("player", p.Point{X: 180, Y: 130}),
|
||||
p.SetFlag(FlagCoffeeHasBeans),
|
||||
p.TakeAway("beans"),
|
||||
p.Say("player", "Bab a helyén."),
|
||||
p.If(p.And(p.Flag(FlagCoffeeHasBeans), p.Flag(FlagCoffeeHasMug)),
|
||||
p.RunScript("victory")),
|
||||
),
|
||||
"mug": p.Seq(
|
||||
p.Walk("player", p.Point{X: 180, Y: 130}),
|
||||
p.SetFlag(FlagCoffeeHasMug),
|
||||
p.TakeAway("mug"),
|
||||
p.Say("player", "Bögre a helyén."),
|
||||
p.If(p.And(p.Flag(FlagCoffeeHasBeans), p.Flag(FlagCoffeeHasMug)),
|
||||
p.RunScript("victory")),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cat",
|
||||
Area: p.Rect(195, 118, 35, 22),
|
||||
Label: "Cirmos",
|
||||
OnLook: p.Say("player", "Cirmos, a házikedvencem."),
|
||||
OnTalk: p.RunDialogue("cat_morning"),
|
||||
},
|
||||
{
|
||||
Name: "door",
|
||||
Area: p.Rect(0, 40, 22, 90),
|
||||
Label: "ajtó",
|
||||
OnLook: p.Say("player", "Vissza a hálóba."),
|
||||
OnUse: p.Seq(
|
||||
p.Walk("player", p.Point{X: 20, Y: 130}),
|
||||
p.GoTo("bedroom"),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
15
domain/script.intro.go
Normal file
15
domain/script.intro.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
func defineIntroScript(g *p.Game) {
|
||||
g.ScriptManager.Register(p.Script{
|
||||
Name: "intro",
|
||||
Actions: p.Seq(
|
||||
p.Wait(0.4),
|
||||
p.Say("player", "Brr, hideg van."),
|
||||
p.Say("player", "Egy kávé kéne, mielőtt szétszakad a fejem."),
|
||||
p.Walk("player", p.Point{X: 160, Y: 130}),
|
||||
),
|
||||
})
|
||||
}
|
||||
17
domain/script.victory.go
Normal file
17
domain/script.victory.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package domain
|
||||
|
||||
import p "pncdsl/pncdsl"
|
||||
|
||||
func defineVictoryScript(g *p.Game) {
|
||||
g.ScriptManager.Register(p.Script{
|
||||
Name: "victory",
|
||||
Actions: p.Seq(
|
||||
p.Say("player", "Készen is van!"),
|
||||
p.PlaySound("snd/coffee_done"),
|
||||
p.Wait(0.6),
|
||||
p.Say("cat", "Mióóóóu. *boldog macska*"),
|
||||
p.Say("player", "Reggeli kávé: megmentve."),
|
||||
p.ShowEnd("Vége — kösz, hogy játszottál!"),
|
||||
),
|
||||
})
|
||||
}
|
||||
13
go.mod
Normal file
13
go.mod
Normal file
@@ -0,0 +1,13 @@
|
||||
module pncdsl
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect
|
||||
github.com/ebitengine/hideconsole v1.0.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.0 // indirect
|
||||
github.com/hajimehoshi/ebiten/v2 v2.9.9 // indirect
|
||||
github.com/jezek/xgb v1.1.1 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
)
|
||||
14
go.sum
Normal file
14
go.sum
Normal file
@@ -0,0 +1,14 @@
|
||||
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0=
|
||||
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI=
|
||||
github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE=
|
||||
github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A=
|
||||
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/hajimehoshi/ebiten/v2 v2.9.9 h1:JdDag6Ndj12iD4lxQGG8kbsrh7ssj4Sbzth6r929H/M=
|
||||
github.com/hajimehoshi/ebiten/v2 v2.9.9/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM=
|
||||
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
|
||||
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
14
main.go
Normal file
14
main.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"pncdsl/domain"
|
||||
"pncdsl/pncdsl"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := pncdsl.Run(domain.Build()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
66
pncdsl/action.condition.go
Normal file
66
pncdsl/action.condition.go
Normal file
@@ -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 }
|
||||
450
pncdsl/action.def.go
Normal file
450
pncdsl/action.def.go
Normal file
@@ -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
|
||||
}
|
||||
3
pncdsl/action.manager.go
Normal file
3
pncdsl/action.manager.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type ScriptManager = Manager[Script]
|
||||
9
pncdsl/action.script.go
Normal file
9
pncdsl/action.script.go
Normal file
@@ -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" }
|
||||
10
pncdsl/actor.animation.go
Normal file
10
pncdsl/actor.animation.go
Normal file
@@ -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
|
||||
}
|
||||
17
pncdsl/actor.def.go
Normal file
17
pncdsl/actor.def.go
Normal file
@@ -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" }
|
||||
3
pncdsl/actor.manager.go
Normal file
3
pncdsl/actor.manager.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type CharacterManager = Manager[Character]
|
||||
30
pncdsl/asset.audio.go
Normal file
30
pncdsl/asset.audio.go
Normal file
@@ -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)
|
||||
}
|
||||
18
pncdsl/asset.def.go
Normal file
18
pncdsl/asset.def.go
Normal file
@@ -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" }
|
||||
78
pncdsl/asset.manager.go
Normal file
78
pncdsl/asset.manager.go
Normal file
@@ -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
|
||||
}
|
||||
72
pncdsl/asset.text.go
Normal file
72
pncdsl/asset.text.go
Normal file
@@ -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
|
||||
}
|
||||
6
pncdsl/core.doc.go
Normal file
6
pncdsl/core.doc.go
Normal file
@@ -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
|
||||
42
pncdsl/core.dsl.go
Normal file
42
pncdsl/core.dsl.go
Normal file
@@ -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) }
|
||||
292
pncdsl/core.engine.go
Normal file
292
pncdsl/core.engine.go
Normal file
@@ -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)
|
||||
}
|
||||
17
pncdsl/core.errors.go
Normal file
17
pncdsl/core.errors.go
Normal file
@@ -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")
|
||||
)
|
||||
259
pncdsl/core.game.go
Normal file
259
pncdsl/core.game.go
Normal file
@@ -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 }
|
||||
74
pncdsl/core.manager.go
Normal file
74
pncdsl/core.manager.go
Normal file
@@ -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"
|
||||
}
|
||||
97
pncdsl/dialog.box.go
Normal file
97
pncdsl/dialog.box.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
37
pncdsl/dialog.def.go
Normal file
37
pncdsl/dialog.def.go
Normal file
@@ -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
|
||||
}
|
||||
3
pncdsl/dialog.manager.go
Normal file
3
pncdsl/dialog.manager.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type DialogueManager = Manager[Dialogue]
|
||||
44
pncdsl/input.def.go
Normal file
44
pncdsl/input.def.go
Normal file
@@ -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
|
||||
}
|
||||
14
pncdsl/item.def.go
Normal file
14
pncdsl/item.def.go
Normal file
@@ -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" }
|
||||
46
pncdsl/item.inventory.go
Normal file
46
pncdsl/item.inventory.go
Normal file
@@ -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...) }
|
||||
3
pncdsl/item.manager.go
Normal file
3
pncdsl/item.manager.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type ItemManager = Manager[Item]
|
||||
10
pncdsl/scene.camera.go
Normal file
10
pncdsl/scene.camera.go
Normal file
@@ -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} }
|
||||
22
pncdsl/scene.def.go
Normal file
22
pncdsl/scene.def.go
Normal file
@@ -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
|
||||
}
|
||||
52
pncdsl/scene.hotspot.go
Normal file
52
pncdsl/scene.hotspot.go
Normal file
@@ -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
|
||||
}
|
||||
3
pncdsl/scene.manager.go
Normal file
3
pncdsl/scene.manager.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type SceneManager = Manager[Scene]
|
||||
61
pncdsl/scene.transition.go
Normal file
61
pncdsl/scene.transition.go
Normal file
@@ -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)
|
||||
}
|
||||
10
pncdsl/scene.trigger.go
Normal file
10
pncdsl/scene.trigger.go
Normal file
@@ -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
|
||||
}
|
||||
27
pncdsl/state.def.go
Normal file
27
pncdsl/state.def.go
Normal file
@@ -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]++ }
|
||||
6
pncdsl/state.save.go
Normal file
6
pncdsl/state.save.go
Normal file
@@ -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 }
|
||||
30
pncdsl/ui.cursor.go
Normal file
30
pncdsl/ui.cursor.go
Normal file
@@ -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)
|
||||
}
|
||||
187
pncdsl/ui.def.go
Normal file
187
pncdsl/ui.def.go
Normal file
@@ -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 ""
|
||||
}
|
||||
74
pncdsl/ui.speech.go
Normal file
74
pncdsl/ui.speech.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
21
pncdsl/ui.verb.go
Normal file
21
pncdsl/ui.verb.go
Normal file
@@ -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"},
|
||||
}
|
||||
}
|
||||
3
pncdsl/ui.verb_manager.go
Normal file
3
pncdsl/ui.verb_manager.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package pncdsl
|
||||
|
||||
type VerbManager = Manager[Verb]
|
||||
79
pncdsl/util.geometry.go
Normal file
79
pncdsl/util.geometry.go
Normal file
@@ -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}
|
||||
}
|
||||
15
pncdsl/util.log.go
Normal file
15
pncdsl/util.log.go
Normal file
@@ -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...)
|
||||
}
|
||||
10
pncdsl/util.timer.go
Normal file
10
pncdsl/util.timer.go
Normal file
@@ -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