Files
aego/app/agent/scene.go
T

394 lines
14 KiB
Go

package agent
import (
"errors"
"os"
"path"
"slices"
"sort"
"strconv"
"strings"
"aego/project/refs"
"aego/scene"
"aego/scene/mutate"
"aego/scene/sceneio"
"aego/scene/scriptcomp"
"aego/schema"
)
var (
argFile = Arg{Name: "file", Type: "string", Doc: "scene or prefab file, relative to the project", Required: true, Pos: true}
argNode = Arg{Name: "node", Type: "string", Doc: "node path from the root (Player/Sprite), Root, or #id", Required: true, Pos: true}
argPath = Arg{Name: "path", Type: "string", Doc: "Comp.field, Comp.field.x, Comp.list[2], transform.position, enabled", Required: true, Pos: true}
argValue = Arg{Name: "value", Type: "string", Doc: `value in scene syntax: 2.5, [1, 2], #ff8800ff, Kinematic, "text", asset("<id>") or a project path`, Required: true, Pos: true}
argIndex = Arg{Name: "index", Type: "integer", Doc: "position among the siblings"}
)
type NodeView struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Enabled bool `json:"enabled"`
Prefab string `json:"prefab,omitempty"`
Transform map[string]string `json:"transform,omitempty"`
Components []CompView `json:"components,omitempty"`
Scripts []string `json:"scripts,omitempty"`
Children []NodeView `json:"children,omitempty"`
}
type CompView struct {
Type string `json:"type"`
Fields map[string]string `json:"fields,omitempty"`
}
func sceneCommands() []*Command {
optPath := argPath
optPath.Required = false
optParent := Arg{Name: "parent", Type: "string", Doc: "parent node, the root by default"}
return []*Command{
{Name: "scene.tree", Doc: "node tree with IDs, components, scripts and prefab links", Args: []Arg{argFile}, Run: (*Session).sceneTree},
{Name: "scene.get", Doc: "a node with its transform and every saved field, or one field", Args: []Arg{argFile, argNode, optPath}, Run: (*Session).sceneGet},
{Name: "scene.set", Doc: "set a field", Write: true, Args: []Arg{argFile, argNode, argPath, argValue}, Run: func(s *Session, a Args) (*Output, error) {
return s.sceneTx(a, "Set", mutate.Set(a.Str("node"), a.Str("path"), a.Str("value")))
}},
{Name: "scene.reset", Doc: "reset a field to its default, a transform part to identity", Write: true, Args: []Arg{argFile, argNode, argPath}, Run: func(s *Session, a Args) (*Output, error) {
return s.sceneTx(a, "Reset", mutate.Reset(a.Str("node"), a.Str("path")))
}},
{Name: "scene.add-node", Doc: "add an empty node", Write: true, Args: []Arg{argFile, {Name: "name", Type: "string", Doc: "node name", Required: true, Pos: true}, optParent,
{Name: "space", Type: "string", Doc: "2d or 3d, the parent's space by default"}, argIndex}, Run: (*Session).sceneAddNode},
{Name: "scene.remove-node", Doc: "remove a node with its children", Write: true, Args: []Arg{argFile, argNode}, Run: func(s *Session, a Args) (*Output, error) {
return s.sceneTx(a, "Remove Node", mutate.RemoveNode(a.Str("node")))
}},
{Name: "scene.move", Doc: "move a node under another parent", Write: true, Args: []Arg{argFile, argNode, {Name: "parent", Type: "string", Doc: "new parent node", Required: true, Pos: true}, argIndex,
{Name: "keep-world", Type: "boolean", Doc: "keep the world transform instead of the local one"}}, Run: (*Session).sceneMove},
{Name: "scene.rename", Doc: "rename a node", Write: true, Args: []Arg{argFile, argNode, {Name: "name", Type: "string", Doc: "new name", Required: true, Pos: true}}, Run: func(s *Session, a Args) (*Output, error) {
return s.sceneTx(a, "Rename", mutate.Rename(a.Str("node"), a.Str("name")))
}},
{Name: "scene.add-comp", Doc: "add a component with its defaults", Write: true, Args: []Arg{argFile, argNode, {Name: "comp", Type: "string", Doc: "component name, full or a unique suffix (Body2D)", Required: true, Pos: true}}, Run: func(s *Session, a Args) (*Output, error) {
return s.sceneTx(a, "Add Component", mutate.AddComp(a.Str("node"), a.Str("comp")))
}},
{Name: "scene.remove-comp", Doc: "remove a component", Write: true, Args: []Arg{argFile, argNode, {Name: "comp", Type: "string", Doc: "component name", Required: true, Pos: true}}, Run: func(s *Session, a Args) (*Output, error) {
return s.sceneTx(a, "Remove Component", mutate.RemoveComp(a.Str("node"), a.Str("comp")))
}},
{Name: "scene.apply", Doc: "apply a transaction (tx version=1 { op … }) in one step", Write: true, Args: []Arg{argFile,
{Name: "tx", Type: "string", Doc: "transaction text"}, {Name: "tx-file", Type: "string", Doc: "transaction file"}}, Run: (*Session).sceneApply},
{Name: "scene.validate", Doc: "load problems and references to nodes the scene does not have", Args: []Arg{argFile}, Run: (*Session).sceneValidate},
{Name: "scene.new", Doc: "create an empty scene", Write: true, Args: []Arg{{Name: "file", Type: "string", Doc: "new scene file, scenes/<name>.aescene", Required: true, Pos: true}}, Run: (*Session).sceneNew},
{Name: "scene.run", Doc: "run a scene headless and report the log and the state hash", Args: []Arg{{Name: "file", Type: "string", Doc: "scene file, the main scene by default", Pos: true},
{Name: "frames", Type: "integer", Doc: "frames to run, 120 by default"}, {Name: "seed", Type: "integer", Doc: "random seed, 1 by default"}}, Run: (*Session).sceneRun},
}
}
func (s *Session) scene(a Args) (string, []byte, *mutate.Document, error) {
rel, err := s.rel(a.Str("file"))
if err != nil {
return "", nil, nil, err
}
src, err := s.read(rel)
if err != nil {
return "", nil, nil, err
}
doc, err := mutate.Load(src, rel, s.env())
return rel, src, doc, err
}
func (s *Session) sceneTx(a Args, label string, ops ...mutate.Op) (*Output, error) {
rel, src, doc, err := s.scene(a)
if err != nil {
return nil, err
}
return s.applyDoc(a, rel, src, doc, mutate.Tx{Label: label, Ops: ops})
}
func (s *Session) applyDoc(a Args, rel string, src []byte, doc *mutate.Document, tx mutate.Tx) (*Output, error) {
var res *mutate.Result
var err error
if a.Bool("dry-run") {
res, err = doc.DryRun(tx)
} else {
res, err = doc.Apply(tx)
}
if err != nil {
return nil, err
}
out := &Output{DryRun: res.DryRun, Changes: res.Changes, Problems: append(slices.Clone(doc.Problems), res.Problems...), Created: res.Created}
if res.DryRun {
return out, nil
}
out.Files, err = s.save(rel, src, doc.Bytes())
return out, err
}
func (s *Session) sceneAddNode(a Args) (*Output, error) {
op := mutate.AddNode(a.Str("parent"), a.Str("name")).In(a.Str("space"))
if a.Has("index") {
i, err := a.Int("index", 0)
if err != nil {
return nil, err
}
op = op.At(i)
}
return s.sceneTx(a, "Add Node", op)
}
func (s *Session) sceneMove(a Args) (*Output, error) {
op := mutate.Move(a.Str("node"), a.Str("parent"))
if a.Has("index") {
i, err := a.Int("index", 0)
if err != nil {
return nil, err
}
op = op.At(i)
}
if a.Bool("keep-world") {
op = op.KeepingWorld()
}
return s.sceneTx(a, "Move", op)
}
func (s *Session) sceneApply(a Args) (*Output, error) {
text, name := []byte(a.Str("tx")), "tx"
if a.Has("tx-file") {
b, err := os.ReadFile(a.Str("tx-file"))
if err != nil {
return nil, err
}
text, name = b, a.Str("tx-file")
}
if len(text) == 0 {
return nil, errors.New("scene.apply needs tx or tx-file")
}
tx, err := mutate.ParseTx(text, name)
if err != nil {
return nil, err
}
rel, src, doc, err := s.scene(a)
if err != nil {
return nil, err
}
return s.applyDoc(a, rel, src, doc, tx)
}
func (s *Session) sceneValidate(a Args) (*Output, error) {
rel, _, doc, err := s.scene(a)
if err != nil {
return nil, err
}
out := &Output{Problems: slices.Clone(doc.Problems)}
for _, m := range refs.FileMissingNodes(s.Pipeline, rel) {
out.Problems = append(out.Problems, mutate.Problem{Error: true, Node: m.Path, Message: m.Owner + "." + m.Field + " references node #" + m.Target.String() + ", which is not in the scene"})
}
if len(out.Problems) == 0 {
out.Notes = []string{rel + ": ok"}
}
return out, nil
}
func (s *Session) sceneNew(a Args) (*Output, error) {
rel, err := s.rel(a.Str("file"))
if err != nil {
return nil, err
}
if path.Ext(rel) != SceneExt {
rel += SceneExt
}
if a.Bool("dry-run") {
if err := s.writable(); err != nil {
return nil, err
}
if s.exists(rel) {
return nil, errors.New(rel + " already exists")
}
return &Output{DryRun: true, Notes: []string{"would create " + rel}}, nil
}
sc := scene.New(s.Types, sources{s.Pipeline}, scene.Options{})
id, err := s.create(rel, sceneio.Save(sc))
if err != nil {
return nil, err
}
return &Output{Created: []string{id.String()}, Files: []string{rel}}, nil
}
func (s *Session) sceneRun(a Args) (*Output, error) {
if s.opt.Run == nil {
return nil, errors.New("headless runs are not available in this build")
}
frames, err := a.Int("frames", 120)
if err != nil {
return nil, err
}
seed, err := a.Int("seed", 1)
if err != nil {
return nil, err
}
req := RunRequest{Frames: frames, Seed: uint64(seed)}
if a.Has("file") {
rel, err := s.rel(a.Str("file"))
if err != nil {
return nil, err
}
req.Scene = s.Pipeline.Abs(rel)
}
var rep RunReport
err = s.release(func() error {
var rerr error
rep, rerr = s.opt.Run(s.Project, req)
return rerr
})
if err != nil {
return nil, err
}
out := &Output{Value: rep}
if rep.Error != "" {
out.Problems = append(out.Problems, mutate.Problem{Error: true, Message: rep.Error})
}
var b strings.Builder
b.WriteString("frames: " + strconv.Itoa(rep.Frames) + "\nhash: " + rep.Hash + "\n")
for _, l := range rep.Log {
b.WriteString(l + "\n")
}
out.Text = b.String()
return out, nil
}
func (s *Session) sceneTree(a Args) (*Output, error) {
_, _, doc, err := s.scene(a)
if err != nil {
return nil, err
}
v := s.view(doc.Scene, doc.Scene.Root(), false, true)
var b strings.Builder
treeText(&b, v, 0)
return &Output{Value: v, Text: b.String(), Problems: doc.Problems}, nil
}
func (s *Session) sceneGet(a Args) (*Output, error) {
_, _, doc, err := s.scene(a)
if err != nil {
return nil, err
}
h, err := doc.Node(a.Str("node"))
if err != nil {
return nil, err
}
v := s.view(doc.Scene, h, true, false)
if !a.Has("path") {
return &Output{Value: v}, nil
}
text, ok := pick(v, a.Str("path"))
if !ok {
return nil, errors.New(v.Path + " has no field " + a.Str("path"))
}
return &Output{Value: text, Text: text}, nil
}
func pick(v NodeView, p string) (string, bool) {
comp, field, ok := strings.Cut(p, ".")
if !ok {
return "", false
}
if strings.EqualFold(comp, "transform") {
t, ok := v.Transform[strings.ToLower(field)]
return t, ok
}
for _, c := range v.Components {
if c.Type == comp || strings.HasSuffix(c.Type, "."+comp) {
t, ok := c.Fields[field]
return t, ok
}
}
return "", false
}
func (s *Session) view(sc *scene.Scene, h scene.NodeHandle, fields, deep bool) NodeView {
reg := sc.Registry()
v := NodeView{Name: sc.Name(h), Path: mutate.RootName + sc.Path(h), Enabled: sc.Enabled(h)}
if h == sc.Root() {
v.ID, v.Path = mutate.RootName, mutate.RootName
} else {
v.ID = "#" + sc.ID(h).String()
if fields {
v.Transform = pose(reg, sc, h)
}
}
sc.ComponentsOf(h, func(t schema.TypeID) {
if t == scriptcomp.ScriptTypeID {
return
}
td, ok := reg.ByID(t)
if !ok || (td.Hidden && td.FullName != "engine.Unknown") {
return
}
c := CompView{Type: td.FullName}
if fields {
c.Fields = fieldValues(sc, h, td)
}
v.Components = append(v.Components, c)
})
sort.Slice(v.Components, func(i, j int) bool { return v.Components[i].Type < v.Components[j].Type })
if scs, ok := scriptcomp.ScriptsOf(sc, h); ok {
for _, at := range scs.Attachments {
v.Scripts = append(v.Scripts, at.TypeName)
}
}
if pi, ok := sceneio.InstanceOf(sc, h); ok && pi.Owner {
v.Prefab = s.label(pi.Prefab)
}
if deep {
sc.Children(h, func(c scene.NodeHandle) bool {
if sc.IsAlive(c) {
v.Children = append(v.Children, s.view(sc, c, fields, true))
}
return true
})
}
return v
}
func fieldValues(sc *scene.Scene, h scene.NodeHandle, td *schema.TypeDescriptor) map[string]string {
out := map[string]string{}
reg := sc.Registry()
td.Serializable(func(fd *schema.FieldDescriptor) {
if v, err := sc.GetProperty(h, td.ID, fd.ID); err == nil {
out[fd.Name] = mutate.FormatValue(sceneio.WriteValue(reg, fd, v))
}
})
return out
}
func pose(reg *schema.Registry, sc *scene.Scene, h scene.NodeHandle) map[string]string {
f := func(k schema.Kind, v schema.Value) string {
return mutate.FormatValue(sceneio.WriteValue(reg, &schema.FieldDescriptor{Kind: k, Flags: schema.FlagSerialize}, v))
}
p := sc.Pose(h)
if p.Space == scene.Space3D {
return map[string]string{"space": "3d", "position": f(schema.KindVec3, schema.Vec3(p.T3.Position)),
"rotation": f(schema.KindQuat, schema.Quat(p.T3.Rotation)), "scale": f(schema.KindVec3, schema.Vec3(p.T3.Scale))}
}
return map[string]string{"space": "2d", "position": f(schema.KindVec2, schema.Vec2(p.T2.Position)),
"rotation": f(schema.KindFloat32, schema.Float32(p.T2.Rotation)), "scale": f(schema.KindVec2, schema.Vec2(p.T2.Scale))}
}
func treeText(b *strings.Builder, v NodeView, depth int) {
b.WriteString(strings.Repeat(" ", depth) + v.Name + " " + v.ID)
var tags []string
for _, c := range v.Components {
tags = append(tags, c.Type[strings.LastIndexByte(c.Type, '.')+1:])
}
tags = append(tags, v.Scripts...)
if len(tags) > 0 {
b.WriteString(" [" + strings.Join(tags, ", ") + "]")
}
if v.Prefab != "" {
b.WriteString(" ← " + v.Prefab)
}
if !v.Enabled {
b.WriteString(" (disabled)")
}
b.WriteString("\n")
for _, c := range v.Children {
treeText(b, c, depth+1)
}
}