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