Files
sanspie 70be14646a
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 9m1s
ci / release (push) Successful in 11m0s
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / checks (push) Successful in 6m39s
Stage 11b5b: follow mode and agent presence (D343 amended)
Follow mode leads the editor after the agent: each tool call opens the scene, selects the node in a second selection that Delete and Copy never read, frames it smoothly and shows it in the Inspector at the component; scripts open at the line without taking the keyboard, with the lines of a waiting card as ghost lines and changed lines marked in the gutter; runs and checks bring the Console or Problems forward with new entries lit. Changed fields light for two seconds, new nodes and components light whole, removed nodes fade where they were.

The agent's avatar marks where it works in the Hierarchy, the Scene View and the Inspector; the Agent panel says where it is, with a timeline of its steps to go back to them or undo to one. Follow is the eye in the header, Window → Follow the Agent or Ctrl+Alt+F, kept in prefs.aetext; the user's own input pauses it until Resume or 30 idle seconds. A user edit of a field a waiting card changes is made again after the card. During Play the agent edits the running game's scene, so its changes show in Play changes.

editor/core/presence holds who works where; ui/widgets/presence draws it. backend.Replay plays a recorded stream-json session against the editor's MCP server for the tests.
2026-10-26 00:52:59 +03:00

261 lines
6.0 KiB
Go

// Package presence is where others work in the project: the agent now, collaborators later (11b5b, D343).
package presence
import (
"aego/core/ids"
"aego/core/mathx"
)
const (
// Linger is how long a marker stays after the last call that moved it.
Linger = 5.0
// FlashTime is how long a changed field, node or line is lit.
FlashTime = 2.0
// LineTime is how long changed lines stay marked in the gutter.
LineTime = 10.0
)
// Spot is one place: a document, and in it a node, a component and a field, or a line of a script.
type Spot struct {
Doc string
Node ids.NodeID
Type string
Field string
Line int
Panel string
}
// Who is one presence: its name and where it was last, at what time.
type Who struct {
Name string
Spot Spot
At float64
}
type FlashKind uint8
const (
Changed FlashKind = iota
Added
Removed
)
// Flash lights a changed field, an added node or component, or a removed node's ghost.
type Flash struct {
Spot Spot
Kind FlashKind
At float64
// Ghost is the world box of a removed node, drawn fading where it was.
Ghost mathx.Rect
Name string
}
// Lines marks lines of a script the agent changed, or will change when a card is applied.
type Lines struct {
Doc string
From, To int
At float64
// Pending holds the text a waiting card would put there, drawn as ghost lines.
Pending []string
}
// Edit is a field a waiting card will change, so the Inspector warns before the user edits it too.
type Edit struct {
Spot Spot
Card int
}
type Model struct {
// Following is true while the editor follows the agent: the Inspector shows the agent's node.
Following bool
who []Who
flashes []Flash
lines []Lines
edits []Edit
rev uint64
}
func New() *Model {
return &Model{}
}
// Rev changes whenever something is added, so panels know when to look again.
func (m *Model) Rev() uint64 {
return m.rev
}
// Move puts name at s.
func (m *Model) Move(name string, s Spot, now float64) {
m.rev++
for i := range m.who {
if m.who[i].Name == name {
m.who[i].Spot, m.who[i].At = s, now
return
}
}
m.who = append(m.who, Who{Name: name, Spot: s, At: now})
}
// Where is where name is, while its marker lingers.
func (m *Model) Where(name string, now float64) (Spot, float64, bool) {
for i := range m.who {
w := &m.who[i]
if w.Name == name && now-w.At < Linger {
return w.Spot, w.At, true
}
}
return Spot{}, 0, false
}
// Present lists everyone whose marker still shows.
func (m *Model) Present(now float64, dst []Who) []Who {
for _, w := range m.who {
if now-w.At < Linger {
dst = append(dst, w)
}
}
return dst
}
// At reports whether someone is on the node of doc, and how long ago they got there.
func (m *Model) At(doc string, node ids.NodeID, now float64) (float64, bool) {
for i := range m.who {
w := &m.who[i]
if w.Spot.Doc == doc && w.Spot.Node == node && !node.IsZero() && now-w.At < Linger {
return now - w.At, true
}
}
return 0, false
}
func (m *Model) Light(f Flash) {
m.rev++
m.flashes = append(m.flashes, f)
}
// FieldLight is how lit a field is, 1 when it just changed and 0 after FlashTime.
func (m *Model) FieldLight(doc string, node ids.NodeID, typ, field string, now float64) float32 {
var best float32
for i := range m.flashes {
f := &m.flashes[i]
s := &f.Spot
if s.Doc != doc || s.Node != node || s.Type != typ || s.Field != field {
continue
}
best = max(best, fade(now-f.At, FlashTime))
}
return best
}
// NodeLight is how lit a node or one of its components is: added ones light whole.
func (m *Model) NodeLight(doc string, node ids.NodeID, typ string, now float64) float32 {
var best float32
for i := range m.flashes {
f := &m.flashes[i]
s := &f.Spot
if f.Kind != Added || s.Doc != doc || s.Node != node || s.Type != typ || s.Field != "" {
continue
}
best = max(best, fade(now-f.At, FlashTime))
}
return best
}
// Ghosts calls fn for the removed nodes of doc that still fade.
func (m *Model) Ghosts(doc string, now float64, fn func(f *Flash, a float32)) {
for i := range m.flashes {
f := &m.flashes[i]
if f.Kind == Removed && f.Spot.Doc == doc {
if a := fade(now-f.At, FlashTime); a > 0 {
fn(f, a)
}
}
}
}
func fade(age, span float64) float32 {
if age < 0 || age >= span {
return 0
}
return float32(1 - age/span)
}
func (m *Model) Mark(l Lines) {
m.rev++
for i := range m.lines {
if m.lines[i].Doc == l.Doc && m.lines[i].From == l.From {
m.lines[i] = l
return
}
}
m.lines = append(m.lines, l)
}
// Unmark drops the ghost lines of doc that start at from, when their card is decided.
func (m *Model) Unmark(doc string, from int) {
for i := range m.lines {
if m.lines[i].Doc == doc && m.lines[i].From == from && m.lines[i].Pending != nil {
m.rev++
m.lines = append(m.lines[:i], m.lines[i+1:]...)
return
}
}
}
// Lines calls fn for the marked lines of doc.
func (m *Model) LinesOf(doc string, now float64, fn func(l *Lines, a float32)) {
for i := range m.lines {
l := &m.lines[i]
if l.Doc != doc {
continue
}
if l.Pending != nil {
fn(l, 1)
} else if a := fade(now-l.At, LineTime); a > 0 {
fn(l, a)
}
}
}
// SetEdits are the fields the waiting cards will change.
func (m *Model) SetEdits(list []Edit) {
m.edits = append(m.edits[:0], list...)
}
// Editing reports whether a waiting card will change this field.
func (m *Model) Editing(doc string, node ids.NodeID, typ, field string) bool {
for i := range m.edits {
s := &m.edits[i].Spot
if s.Doc == doc && s.Node == node && s.Type == typ && (s.Field == field || s.Field == "") {
return true
}
}
return false
}
// Clear forgets every marker and light.
func (m *Model) Clear() {
m.rev++
m.who, m.flashes, m.lines, m.edits = m.who[:0], m.flashes[:0], m.lines[:0], m.edits[:0]
}
// Prune drops lights that have faded, so the lists stay short.
func (m *Model) Prune(now float64) {
n := 0
for _, f := range m.flashes {
if now-f.At < FlashTime {
m.flashes[n] = f
n++
}
}
m.flashes = m.flashes[:n]
n = 0
for _, l := range m.lines {
if l.Pending != nil || now-l.At < LineTime {
m.lines[n] = l
n++
}
}
m.lines = m.lines[:n]
}