Files
aego/app/agent/anim.go
T
sanspie 7b83e82c6e Stage 11b: AI agent in the editor (D341–D343)
MCP over loopback HTTP and an editor session over open documents; proposals with apply, reject, auto-apply and card undo; the Agent panel with Markdown answers, @ mentions, Ask Agent from Hierarchy and Problems, session logs.
2026-10-25 15:31:22 +03:00

550 lines
18 KiB
Go

package agent
import (
"errors"
"strconv"
"strings"
"aego/anim"
"aego/format/aetext"
"aego/scene/mutate"
)
type textDoc interface {
Apply(mutate.Tx) (*mutate.Result, error)
DryRun(mutate.Tx) (*mutate.Result, error)
Bytes() []byte
}
func loadAnim(src []byte, rel string) (textDoc, error) {
d, err := mutate.LoadAnim(src, rel)
if err != nil {
return nil, err
}
return d, nil
}
func loadCtrl(src []byte, rel string) (textDoc, error) {
d, err := mutate.LoadCtrl(src, rel)
if err != nil {
return nil, err
}
return d, nil
}
func (s *Session) textTx(a Args, label string, load func([]byte, string) (textDoc, error), ctrl bool, ops ...mutate.Op) (*Output, error) {
rel, err := s.rel(a.Str("file"))
if err != nil {
return nil, err
}
src, err := s.read(rel)
if err != nil {
return nil, err
}
d, err := load(src, rel)
if err != nil {
return nil, err
}
tx := mutate.Tx{Label: label, Ops: ops}
if s.host != nil && !a.Bool("dry-run") {
res, err := s.host.ApplyFile(rel, ctrl, label, tx)
if err != nil {
return nil, err
}
out := &Output{Notes: []string{"applied to " + rel + " in the editor as one Undo step"}}
if res != nil {
out.Changes, out.Problems = res.Changes, res.Problems
}
return out, nil
}
var res *mutate.Result
if a.Bool("dry-run") {
res, err = d.DryRun(tx)
} else {
res, err = d.Apply(tx)
}
if err != nil {
return nil, err
}
out := &Output{DryRun: res.DryRun, Changes: res.Changes, Problems: res.Problems}
if res.DryRun {
return out, nil
}
if out.Errors() {
return out, errors.New("the result has errors, nothing was written")
}
out.Files, err = s.save(rel, src, d.Bytes())
return out, err
}
func (s *Session) animDoc(a Args) (*mutate.AnimDocument, error) {
rel, err := s.rel(a.Str("file"))
if err != nil {
return nil, err
}
src, err := s.read(rel)
if err != nil {
return nil, err
}
return mutate.LoadAnim(src, rel)
}
func (s *Session) ctrlDoc(a Args) (*mutate.CtrlDocument, error) {
rel, err := s.rel(a.Str("file"))
if err != nil {
return nil, err
}
src, err := s.read(rel)
if err != nil {
return nil, err
}
return mutate.LoadCtrl(src, rel)
}
func animTarget(a Args) string {
if t := a.Str("target"); t != "." {
return t
}
return ""
}
func attrMap(n *aetext.Node) map[string]any {
out := map[string]any{}
for _, f := range n.Attrs {
out[f.Name] = mutate.FormatValue(f.Value)
}
return out
}
func number(a Args, name string) (int, error) {
n, err := a.Int(name, 0)
if err != nil {
return 0, err
}
if n < 1 {
return 0, errors.New(name + " counts from 1")
}
return n - 1, nil
}
func optValue(a Args, name string) (aetext.Value, bool, error) {
if !a.Has(name) {
return aetext.Value{}, false, nil
}
v, err := mutate.ParseValue(a.Str(name))
return v, err == nil, err
}
func animCommands() []*Command {
file := Arg{Name: "file", Type: "string", Doc: ".aeanim file", Required: true, Pos: true}
tgt := Arg{Name: "target", Type: "string", Doc: "node path from the player's node, . for the node itself", Required: true, Pos: true}
prop := Arg{Name: "prop", Type: "string", Doc: "property, like SpriteRenderer.tint or transform.position", Required: true, Pos: true}
at := Arg{Name: "time", Type: "string", Doc: "seconds", Required: true, Pos: true}
val := Arg{Name: "value", Type: "string", Doc: "value as in the clip file: 0.5, [1, 2], #ff0000ff, asset(\"…\")", Required: true, Pos: true}
index := Arg{Name: "index", Type: "string", Doc: "event number, from 1, in the order anim get lists them", Required: true, Pos: true}
tx := func(build func(Args) ([]mutate.Op, error)) func(*Session, Args) (*Output, error) {
return func(s *Session, a Args) (*Output, error) {
ops, err := build(a)
if err != nil {
return nil, err
}
return s.textTx(a, "Animation", loadAnim, false, ops...)
}
}
return []*Command{
{Name: "anim.get", Doc: "length, loop, tracks with their keys, frames blocks and events of a clip", Args: []Arg{file}, Run: (*Session).animGet},
{Name: "anim.set-key", Doc: "set a key; a new track needs --kind", Write: true,
Args: []Arg{file, tgt, prop, at, val,
{Name: "kind", Type: "string", Doc: "kind of a new track: float32, int32, bool, enum, vec2, vec3, vec4, color, rect, quat, assetRef"},
{Name: "interp", Type: "string", Doc: "bezier, linear or step"},
{Name: "tangent", Type: "string", Doc: "auto, linear or free"}},
Run: tx(func(a Args) ([]mutate.Op, error) {
v, err := mutate.ParseValue(a.Str("value"))
if err != nil {
return nil, err
}
op := mutate.AnimKeySet(animTarget(a), a.Str("prop"), a.Str("time"), v)
op.Comp = a.Str("kind")
extra := &aetext.Node{Head: "values"}
for _, n := range []string{"interp", "tangent"} {
if a.Has(n) {
extra.Attrs = append(extra.Attrs, aetext.Field{Name: n, Value: aetext.Ident(a.Str(n))})
}
}
if len(extra.Attrs) > 0 {
op.Values = extra
}
return []mutate.Op{op}, nil
})},
{Name: "anim.remove-key", Doc: "remove a key; the last key removes its track", Write: true, Args: []Arg{file, tgt, prop, at},
Run: tx(func(a Args) ([]mutate.Op, error) {
return []mutate.Op{mutate.AnimKeyRemove(animTarget(a), a.Str("prop"), a.Str("time"))}, nil
})},
{Name: "anim.move-key", Doc: "move a key to another time", Write: true,
Args: []Arg{file, tgt, prop, at, {Name: "to", Type: "string", Doc: "new time in seconds", Required: true, Pos: true}},
Run: tx(func(a Args) ([]mutate.Op, error) {
return []mutate.Op{mutate.AnimKeyMove(animTarget(a), a.Str("prop"), a.Str("time"), a.Str("to"))}, nil
})},
{Name: "anim.remove-track", Doc: "remove a track with all its keys", Write: true, Args: []Arg{file, tgt, prop},
Run: tx(func(a Args) ([]mutate.Op, error) {
return []mutate.Op{mutate.AnimTrackRemove(animTarget(a), a.Str("prop"))}, nil
})},
{Name: "anim.add-event", Doc: "add an event; the name must be declared in Project Settings → Animation", Write: true,
Args: []Arg{file, at, {Name: "name", Type: "string", Doc: "event name", Required: true, Pos: true},
{Name: "payload", Type: "string", Doc: "number or true/false"},
{Name: "mode", Type: "string", Doc: "gameplay, presentation or preview"}},
Run: tx(func(a Args) ([]mutate.Op, error) {
op := mutate.AnimEventAdd(a.Str("time"), a.Str("name"))
op.Text, op.Level = a.Str("payload"), a.Str("mode")
return []mutate.Op{op}, nil
})},
{Name: "anim.remove-event", Doc: "remove an event", Write: true, Args: []Arg{file, index},
Run: tx(func(a Args) ([]mutate.Op, error) {
i, err := number(a, "index")
if err != nil {
return nil, err
}
return []mutate.Op{mutate.AnimEventRemove(i)}, nil
})},
{Name: "anim.move-event", Doc: "move an event to another time", Write: true, Args: []Arg{file, index, at},
Run: tx(func(a Args) ([]mutate.Op, error) {
i, err := number(a, "index")
if err != nil {
return nil, err
}
return []mutate.Op{mutate.AnimEventMove(i, a.Str("time"))}, nil
})},
{Name: "anim.set", Doc: "set length, loop or fps of a clip; no value clears loop or fps", Write: true,
Args: []Arg{file, {Name: "attr", Type: "string", Doc: "length, loop or fps", Required: true, Pos: true},
{Name: "value", Type: "string", Doc: "number, or once, loop, pingPong", Pos: true}},
Run: tx(func(a Args) ([]mutate.Op, error) {
v, ok, err := optValue(a, "value")
if err != nil {
return nil, err
}
if !ok {
return []mutate.Op{mutate.AnimClipClear(a.Str("attr"))}, nil
}
return []mutate.Op{mutate.AnimClipSet(a.Str("attr"), v)}, nil
})},
{Name: "anim.expand-frames", Doc: "turn a frames block into sprite tracks with step keys, so its frames can be edited one by one", Write: true, Args: []Arg{file, tgt},
Run: tx(func(a Args) ([]mutate.Op, error) {
return []mutate.Op{mutate.AnimFramesExpand(animTarget(a))}, nil
})},
}
}
func (s *Session) animGet(a Args) (*Output, error) {
d, err := s.animDoc(a)
if err != nil {
return nil, err
}
root := d.Root()
v := attrMap(root)
delete(v, "version")
var tracks, frames, events []map[string]any
for _, b := range root.Blocks {
switch b.Head {
case "track":
t := attrMap(b)
t["target"] = b.Label
var keys []map[string]any
for _, k := range b.Blocks {
keys = append(keys, attrMap(k))
}
t["keys"] = keys
tracks = append(tracks, t)
case "frames":
f := attrMap(b)
f["target"] = b.Label
if sv, ok := b.Field("sprites"); ok {
f["sprites"] = len(sv.List)
}
frames = append(frames, f)
case "event":
e := attrMap(b)
e["index"] = len(events) + 1
events = append(events, e)
}
}
v["tracks"], v["frames"], v["events"] = tracks, frames, events
return &Output{Value: v}, nil
}
var condOps = []struct{ sym, op string }{{">=", "ge"}, {"<=", "le"}, {"!=", "ne"}, {"==", "eq"}, {">", "gt"}, {"<", "lt"}}
func condBlock(text string) (*aetext.Node, error) {
text = strings.TrimSpace(text)
for _, c := range condOps {
name, rest, ok := strings.Cut(text, c.sym)
if !ok {
continue
}
v, err := mutate.ParseValue(strings.TrimSpace(rest))
if err != nil {
return nil, err
}
return &aetext.Node{Head: "cond", Label: strings.TrimSpace(name), Attrs: []aetext.Field{
{Name: "op", Value: aetext.Ident(c.op)}, {Name: "value", Value: v}}}, nil
}
if text == "" {
return nil, errors.New("an empty condition")
}
return &aetext.Node{Head: "cond", Label: text}, nil
}
func posValue(text string) (aetext.Value, error) {
x, y, ok := strings.Cut(text, ",")
fx, err1 := strconv.ParseFloat(strings.TrimSpace(x), 32)
fy, err2 := strconv.ParseFloat(strings.TrimSpace(y), 32)
if !ok || err1 != nil || err2 != nil {
return aetext.Value{}, errors.New("a position is x,y")
}
return aetext.List(aetext.Float32(float32(fx)), aetext.Float32(float32(fy))), nil
}
func (s *Session) stateValue(attr, text string) (aetext.Value, error) {
switch attr {
case "clip":
id, err := s.asset(text)
if err != nil {
return aetext.Value{}, err
}
return aetext.AssetRef(id.String()), nil
case "pos":
return posValue(text)
case "loop", "events":
return aetext.Ident(text), nil
}
return mutate.ParseValue(text)
}
func ctrlCommands() []*Command {
file := Arg{Name: "file", Type: "string", Doc: ".aectrl file", Required: true, Pos: true}
name := Arg{Name: "name", Type: "string", Doc: "state name", Required: true, Pos: true}
param := Arg{Name: "name", Type: "string", Doc: "parameter name", Required: true, Pos: true}
index := Arg{Name: "index", Type: "string", Doc: "transition number, from 1, as ctrl get lists them", Required: true, Pos: true}
attr := Arg{Name: "attr", Type: "string", Doc: "attribute", Required: true, Pos: true}
value := Arg{Name: "value", Type: "string", Doc: "new value; empty removes the attribute", Pos: true}
tx := func(build func(*Session, Args) ([]mutate.Op, error)) func(*Session, Args) (*Output, error) {
return func(s *Session, a Args) (*Output, error) {
ops, err := build(s, a)
if err != nil {
return nil, err
}
return s.textTx(a, "Controller", loadCtrl, true, ops...)
}
}
return []*Command{
{Name: "ctrl.get", Doc: "parameters, states and transitions of a controller", Args: []Arg{file}, Run: (*Session).ctrlGet},
{Name: "ctrl.add-state", Doc: "add a state", Write: true,
Args: []Arg{file, name,
{Name: "clip", Type: "string", Doc: "clip id or path"},
{Name: "speed", Type: "string", Doc: "speed"},
{Name: "loop", Type: "string", Doc: "once, loop or pingPong"},
{Name: "pos", Type: "string", Doc: "position in the graph, x,y"}},
Run: tx(func(s *Session, a Args) ([]mutate.Op, error) {
body := &aetext.Node{}
for _, k := range []string{"clip", "speed", "loop", "pos"} {
if !a.Has(k) {
continue
}
v, err := s.stateValue(k, a.Str(k))
if err != nil {
return nil, err
}
body.Attrs = append(body.Attrs, aetext.Field{Name: k, Value: v})
}
return []mutate.Op{mutate.CtrlPut("state", a.Str("name"), body)}, nil
})},
{Name: "ctrl.set-state", Doc: "set clip, speed, loop, events or pos of a state", Write: true, Args: []Arg{file, name, attr, value},
Run: tx(func(s *Session, a Args) ([]mutate.Op, error) {
if !a.Has("value") {
return []mutate.Op{mutate.CtrlAttrClear("state", a.Str("name"), a.Str("attr"))}, nil
}
v, err := s.stateValue(a.Str("attr"), a.Str("value"))
if err != nil {
return nil, err
}
return []mutate.Op{mutate.CtrlAttr("state", a.Str("name"), a.Str("attr"), v)}, nil
})},
{Name: "ctrl.remove-state", Doc: "remove a state and the transitions into and out of it", Write: true, Args: []Arg{file, name},
Run: tx(func(s *Session, a Args) ([]mutate.Op, error) {
d, err := s.ctrlDoc(a)
if err != nil {
return nil, err
}
g, err := d.Graph()
if err != nil {
return nil, err
}
st := g.StateIndex(a.Str("name"))
switch {
case st < 0:
return nil, errors.New("no state " + a.Str("name"))
case st == g.Entry:
return nil, errors.New("this is the entry state; set another entry first")
}
var ops []mutate.Op
for i := len(g.Transitions) - 1; i >= 0; i-- {
if t := g.Transitions[i]; t.From == st || t.To == st {
ops = append(ops, mutate.CtrlRemove("transition", "").At(i))
}
}
return append(ops, mutate.CtrlRemove("state", a.Str("name"))), nil
})},
{Name: "ctrl.add-param", Doc: "add a parameter", Write: true,
Args: []Arg{file, param, {Name: "kind", Type: "string", Doc: "bool, int, float or trigger", Required: true, Pos: true},
{Name: "default", Type: "string", Doc: "default value, not for triggers"}},
Run: tx(func(s *Session, a Args) ([]mutate.Op, error) {
body := &aetext.Node{Attrs: []aetext.Field{{Name: "kind", Value: aetext.Ident(a.Str("kind"))}}}
v, ok, err := optValue(a, "default")
if err != nil {
return nil, err
}
if ok {
body.Attrs = append(body.Attrs, aetext.Field{Name: "default", Value: v})
}
return []mutate.Op{mutate.CtrlPut("param", a.Str("name"), body)}, nil
})},
{Name: "ctrl.remove-param", Doc: "remove a parameter; conditions on it become problems", Write: true, Args: []Arg{file, param},
Run: tx(func(s *Session, a Args) ([]mutate.Op, error) {
return []mutate.Op{mutate.CtrlRemove("param", a.Str("name"))}, nil
})},
{Name: "ctrl.add-transition", Doc: "add a transition; from may be any", Write: true,
Args: []Arg{file,
{Name: "from", Type: "string", Doc: "source state or any", Required: true, Pos: true},
{Name: "to", Type: "string", Doc: "target state", Required: true, Pos: true},
{Name: "duration", Type: "string", Doc: "seconds of the blend"},
{Name: "exit", Type: "string", Doc: "exit time in passes of the clip"},
{Name: "interrupt", Type: "boolean", Doc: "may cut into a running blend"},
{Name: "cond", Type: "string", Doc: "conditions separated by ;, like Speed>0.1;Grounded"}},
Run: tx(func(s *Session, a Args) ([]mutate.Op, error) {
body := &aetext.Node{Attrs: []aetext.Field{{Name: "to", Value: aetext.String(a.Str("to"))}}}
for _, x := range [][2]string{{"duration", "duration"}, {"exit", "exitTime"}} {
v, ok, err := optValue(a, x[0])
if err != nil {
return nil, err
}
if ok {
body.Attrs = append(body.Attrs, aetext.Field{Name: x[1], Value: v})
}
}
if a.Bool("interrupt") {
body.Attrs = append(body.Attrs, aetext.Field{Name: "interrupt", Value: aetext.Bool(true)})
}
if a.Has("cond") {
for _, c := range strings.Split(a.Str("cond"), ";") {
cb, err := condBlock(c)
if err != nil {
return nil, err
}
body.Blocks = append(body.Blocks, cb)
}
}
return []mutate.Op{mutate.CtrlPut("transition", a.Str("from"), body)}, nil
})},
{Name: "ctrl.set-transition", Doc: "set to, duration, exitTime or interrupt of a transition", Write: true, Args: []Arg{file, index, attr, value},
Run: tx(func(s *Session, a Args) ([]mutate.Op, error) {
i, err := number(a, "index")
if err != nil {
return nil, err
}
if !a.Has("value") {
return []mutate.Op{mutate.CtrlAttrClear("transition", "", a.Str("attr")).At(i)}, nil
}
v, err := mutate.ParseValue(a.Str("value"))
if a.Str("attr") == "to" {
v, err = aetext.String(a.Str("value")), nil
}
if err != nil {
return nil, err
}
return []mutate.Op{mutate.CtrlAttr("transition", "", a.Str("attr"), v).At(i)}, nil
})},
{Name: "ctrl.remove-transition", Doc: "remove a transition", Write: true, Args: []Arg{file, index},
Run: tx(func(s *Session, a Args) ([]mutate.Op, error) {
i, err := number(a, "index")
if err != nil {
return nil, err
}
return []mutate.Op{mutate.CtrlRemove("transition", "").At(i)}, nil
})},
{Name: "ctrl.entry", Doc: "set the state the animator starts in", Write: true, Args: []Arg{file, name},
Run: tx(func(s *Session, a Args) ([]mutate.Op, error) {
return []mutate.Op{mutate.CtrlRoot("entry", aetext.String(a.Str("name")))}, nil
})},
}
}
func stateName(g *anim.Graph, i int32) string {
if i == anim.AnyState {
return anim.AnyName
}
if i >= 0 && int(i) < len(g.States) {
return g.States[i].Name
}
return ""
}
func condText(g *anim.Graph, c anim.Cond) string {
name := ""
if c.Param >= 0 && int(c.Param) < len(g.Params) {
name = g.Params[c.Param].Name
}
if c.Op == anim.OpSet {
return name
}
return name + " " + c.Op.String() + " " + strconv.FormatFloat(c.Value, 'g', -1, 64)
}
func (s *Session) ctrlGet(a Args) (*Output, error) {
d, err := s.ctrlDoc(a)
if err != nil {
return nil, err
}
g, err := d.Graph()
if err != nil {
return nil, err
}
var params, states, trans []map[string]any
for _, p := range g.Params {
m := map[string]any{"name": p.Name, "kind": p.Kind.String()}
if p.Kind != anim.ParamTrigger {
m["default"] = p.Default
}
params = append(params, m)
}
for i, st := range g.States {
m := map[string]any{"name": st.Name, "speed": st.Speed, "entry": int32(i) == g.Entry}
if !st.Clip.IsZero() {
m["clip"] = s.label(st.Clip)
}
if st.HasLoop {
m["loop"] = st.Loop.String()
}
if st.Events != anim.EventsDominant {
m["events"] = st.Events.String()
}
if st.HasPos {
m["pos"] = st.Pos
}
states = append(states, m)
}
for i, t := range g.Transitions {
m := map[string]any{"index": i + 1, "from": stateName(g, t.From), "to": stateName(g, t.To), "duration": t.Duration, "interrupt": t.Interrupt}
if t.HasExit {
m["exitTime"] = t.ExitTime
}
var conds []string
for _, c := range t.Conds {
conds = append(conds, condText(g, c))
}
m["conds"] = conds
trans = append(trans, m)
}
out := &Output{Value: map[string]any{"params": params, "states": states, "transitions": trans}}
out.Problems = d.Problems()
return out, nil
}