Files
aego/modules/particles/editor/inspector.go
sanspie 5dd9cc91bb editor: refine agent, scene controls and curves
Improve mentions and the default Design workspace, clarify scene lighting controls, and polish editor workflows for 0.12.5-dev.
2026-11-05 20:14:53 +03:00

334 lines
9.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package editor
import (
"fmt"
"math"
"slices"
"aego/core/ids"
"aego/core/mathx"
"aego/editor/core/context"
"aego/editor/core/document"
"aego/editor/ext"
"aego/engine/i18n"
"aego/modules/particles"
"aego/platform/input"
"aego/scene"
"aego/schema"
"aego/ui/core"
"aego/ui/widgets"
)
type fieldGroup struct {
title i18n.Text
fields []string
}
var groups = []fieldGroup{
{i18n.T("editor.particles.playback", "Playback"), []string{"playOnStart", "duration", "loop", "prewarm", "timeDomain", "seed", "maxParticles"}},
{i18n.T("editor.particles.emission", "Emission"), []string{"rate", "bursts"}},
{i18n.T("editor.particles.shape", "Shape"), []string{"shape", "offset", "radius", "rectSize", "edgeA", "edgeB", "mask", "maskThreshold"}},
{i18n.T("editor.particles.initial", "Initial"), []string{"lifetime", "speed", "size", "rotation", "direction", "spread", "color", "space"}},
{i18n.T("editor.particles.life", "Over Life"), []string{"sizeOverLife", "velocityOverLife", "alphaOverLife", "colorOverLife", "rotationSpeed"}},
{i18n.T("editor.particles.forces", "Forces"), []string{"gravity", "drag", "noiseStrength", "noiseFrequency", "killYEnabled", "killY", "planeEnabled", "planeNormal", "planeDistance"}},
{i18n.T("editor.particles.render", "Render"), []string{"sprite", "frames", "frameFPS", "frameLoop", "material", "layer", "order", "sortMode"}},
{i18n.T("editor.particles.access", "Accessibility"), []string{"reducedMotion", "reducedRate"}},
}
type previewControls interface {
controls(*context.Editor, ids.NodeID)
}
type particleInspector struct {
parent previewControls
section int
closed [8]bool
grad GradientEditor
doc document.ID
node ids.NodeID
td *schema.TypeDescriptor
invalid map[string]bool
}
// Emission and shape are the starting controls for an authored effect.
func defaultParticleFolds() [8]bool {
return [8]bool{true, false, false, true, true, true, true, true}
}
func registerInspector(r *ext.Registry, p previewControls) error {
td, ok := r.Schema().ByName(particles.NameSystem)
if !ok {
return fmt.Errorf("%s is not registered", particles.NameSystem)
}
if err := r.Inspector(particles.NameSystem, &particleInspector{parent: p, td: td, section: -1, closed: defaultParticleFolds()}); err != nil {
return err
}
return r.Gizmo(particles.NameSystem, &shapeGizmo{td: td})
}
func (s *particleInspector) FieldVisible(ctx *context.Editor, nodes []ids.NodeID, f *schema.FieldDescriptor) bool {
if s.section < 0 {
return true
}
if !slices.Contains(groups[s.section].fields, f.Name) {
return false
}
if s.invalid[f.Name] && f.Name != "colorOverLife" {
return true
}
if ctx == nil || ctx.Doc() == nil || len(nodes) != 1 {
return true
}
d := ctx.Doc()
c, ok := scene.Get[particles.ParticleSystem2D](d.Scene(), d.Scene().Handle(nodes[0]))
if !ok {
return false
}
switch f.Name {
case "colorOverLife":
return false
case "radius":
return c.Shape == particles.Circle
case "rectSize":
return c.Shape == particles.Rect
case "edgeA", "edgeB":
return c.Shape == particles.Edge
case "mask", "maskThreshold":
return c.Shape == particles.SpriteMask
case "killY":
return c.KillYEnabled
case "planeNormal", "planeDistance":
return c.PlaneEnabled
case "reducedRate":
return c.ReducedMotion == particles.Reduce
}
return true
}
func (s *particleInspector) FieldError(_ *context.Editor, _ []ids.NodeID, field *schema.FieldDescriptor) string {
if !s.invalid[field.Name] {
return ""
}
switch field.Name {
case "velocityOverLife", "sizeOverLife", "alphaOverLife", "colorOverLife":
return i18n.Tr("editor.particles.invalid_life_curve", "Keys must be finite, ordered and within the 0–1 lifetime.")
default:
return i18n.Tr("editor.particles.invalid_field", "Invalid value; check the allowed range and related fields.")
}
}
func (*particleInspector) NormalizedCurve(_ *context.Editor, _ []ids.NodeID, field *schema.FieldDescriptor) bool {
return field.Name == "velocityOverLife" || field.Name == "sizeOverLife" || field.Name == "alphaOverLife"
}
func (s *particleInspector) Draw(ctx *context.Editor, nodes []ids.NodeID, fields func()) {
if ctx == nil || ctx.UI == nil || ctx.Doc() == nil || len(nodes) == 0 {
fields()
return
}
ui, d := ctx.UI, ctx.Doc()
clear(s.invalid)
for _, node := range nodes {
if component, ok := scene.Get[particles.ParticleSystem2D](d.Scene(), d.Scene().Handle(node)); ok {
for name := range component.InvalidFields() {
if s.invalid == nil {
s.invalid = make(map[string]bool)
}
s.invalid[name] = true
}
}
}
var c *particles.ParticleSystem2D
if len(nodes) == 1 {
c, _ = scene.Get[particles.ParticleSystem2D](d.Scene(), d.Scene().Handle(nodes[0]))
if d.ID != s.doc || s.node != nodes[0] {
s.grad = GradientEditor{}
s.doc, s.node = d.ID, nodes[0]
}
if c != nil {
s.parent.controls(ctx, nodes[0])
}
}
defer func() { s.section = -1 }()
for i, g := range groups {
s.section = i
open := !s.closed[i]
ui.PushIDInt(i)
header := mathx.Rect{X: ui.Cursor().X, Y: ui.Cursor().Y, W: ui.Cursor().W, H: ui.Cursor().RowH}
if widgets.TreeNode(ui, ui.ID("section"), g.title.String(), &open, false) {
open = !open
}
s.closed[i] = !open
for _, name := range g.fields {
if s.invalid[name] {
ui.Rect(mathx.Rect{X: header.X, Y: header.Y + 2, W: 2, H: max(header.H-4, 1)}, ui.Theme.Error)
break
}
}
if open {
fields()
if i == 4 && c != nil {
start := *ui.Cursor()
s.grad.Draw(ui, c.ColorOverLife, func(keys []particles.ColorKey) {
if err := setGradient(d, s.td, nodes, keys); err != nil {
ctx.Notify(context.LevelError, err.Error())
}
})
if s.invalid["colorOverLife"] {
ui.List().RectOutline(mathx.Rect{X: start.X, Y: start.Y, W: start.W, H: ui.Cursor().Y - start.Y}, ui.Theme.Error, 1)
}
}
}
ui.PopID()
}
}
func setGradient(d *document.SceneDocument, td *schema.TypeDescriptor, nodes []ids.NodeID, keys []particles.ColorKey) error {
f, ok := td.Field("colorOverLife")
if !ok {
return fmt.Errorf("colorOverLife is unavailable")
}
values := make([]schema.Value, len(keys))
for i, k := range keys {
values[i] = schema.Value{Kind: schema.KindStruct, Obj: &schema.Object{Type: f.Struct, Fields: []schema.Value{schema.Float32(k.Time), schema.Color(k.Color)}}}
}
return d.Props.Set(nil, nodes, td.ID, f.ID, schema.Value{Kind: schema.KindList, List: values})
}
type GradientEditor struct {
sel int
editing bool
draft []particles.ColorKey
base []particles.ColorKey
}
func (w *GradientEditor) begin(keys []particles.ColorKey) {
if !w.editing {
w.base = slices.Clone(keys)
w.draft = slices.Clone(keys)
w.editing = true
}
}
func (w *GradientEditor) commit(keys []particles.ColorKey, apply func([]particles.ColorKey)) {
if w.editing && slices.Equal(keys, w.base) && !slices.Equal(w.draft, w.base) {
apply(slices.Clone(w.draft))
}
w.editing = false
}
func addColor(keys []particles.ColorKey) ([]particles.ColorKey, int) {
if len(keys) >= 256 {
return keys, 0
}
t := float32(0)
if len(keys) > 0 && keys[0].Time == 0 {
if keys[len(keys)-1].Time < 1 {
t = 1
} else {
var gap float32
for i := 1; i < len(keys); i++ {
a, b := keys[i-1].Time, keys[i].Time
if b-a > gap {
gap = b - a
t = (a + b) / 2
}
}
}
}
i, _ := slices.BinarySearchFunc(keys, t, func(k particles.ColorKey, t float32) int {
if k.Time < t {
return -1
}
if k.Time > t {
return 1
}
return 0
})
return slices.Insert(slices.Clone(keys), i, particles.ColorKey{Time: t, Color: particles.GradientAt(keys, t)}), i
}
func (w *GradientEditor) move(t float32) {
lo, hi := float32(0), float32(1)
if w.sel > 0 {
lo = math.Nextafter32(w.draft[w.sel-1].Time, 1)
}
if w.sel+1 < len(w.draft) {
hi = math.Nextafter32(w.draft[w.sel+1].Time, 0)
}
if lo <= hi {
w.draft[w.sel].Time = mathx.Clamp(t, lo, hi)
}
}
func (w *GradientEditor) Draw(ui *core.Context, keys []particles.ColorKey, apply func([]particles.ColorKey)) {
if w.editing && !slices.Equal(w.base, keys) {
w.editing = false
}
v := keys
if w.editing {
v = w.draft
}
w.sel = min(max(0, len(v)-1), max(0, w.sel))
widgets.Label(ui, i18n.Tr("editor.particles.gradient", "Color over life"))
r := ui.Cursor().Next(28)
const n = 48
for j := range n {
x := float32(j) / n
tile := mathx.Rect{X: r.X + x*r.W, Y: r.Y, W: r.W/n + .5, H: r.H * .65}
widgets.Swatch(ui, tile, particles.GradientAt(v, (float32(j)+.5)/n), false)
}
id := ui.ID("gradient-strip")
for i, k := range v {
x := r.X + k.Time*r.W
col := ui.Theme.TextDim
if i == w.sel {
col = ui.Theme.Text
}
ui.Rect(mathx.Rect{X: x - 2, Y: r.Y + r.H*.58, W: 4, H: r.H * .42}, col)
}
if ui.Pressed(id, r) && len(v) > 0 {
t := mathx.Clamp((ui.Mouse().X-r.X)/max(1, r.W), 0, 1)
best := float32(2)
for i, k := range v {
if d := mathx.Abs(k.Time - t); d < best {
best = d
w.sel = i
}
}
w.begin(keys)
ui.CaptureMouse(id)
}
if ui.Captured() == id && w.editing {
if ui.MouseDown(input.MouseLeft) {
w.move((ui.Mouse().X - r.X) / max(1, r.W))
}
if ui.MouseReleased(input.MouseLeft) {
w.commit(keys, apply)
ui.ReleaseMouse()
return
}
}
if len(v) > 0 {
col := v[w.sel].Color
edit := widgets.ColorField(ui, ui.ID("gradient-key"), ui.Cursor().Next(0), &col, false)
if edit.Begin || edit.Changed {
w.begin(keys)
w.draft[w.sel].Color = col
}
if edit.End {
w.commit(keys, apply)
}
}
row := ui.Cursor().Next(0)
half := row.W / 2
left, right := row, row
left.W = half - 2
right.X += half
right.W = half
if widgets.ButtonRect(ui, ui.ID("add-key"), left, i18n.Tr("editor.particles.add_key", "Add key")) && len(keys) < 256 {
next, sel := addColor(keys)
w.sel = sel
w.editing = false
apply(next)
}
if widgets.ButtonRect(ui, ui.ID("del-key"), right, i18n.Tr("editor.particles.delete_key", "Delete key")) && len(keys) > 0 {
w.editing = false
apply(slices.Delete(slices.Clone(keys), w.sel, w.sel+1))
}
}