Files
sanspie f22242e5aa
ci / release (push) Successful in 5m5s
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / checks (push) Successful in 4m41s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 7m14s
Stage 11g2–11g3: registry texts, CLI and the site in Russian (D348)
The remaining editor strings and undo step names; component, field, enum, setting, diagnostic and Script API texts under schema.*, enum.*, setting.*, diag.* and api.*; the launcher, the in-game Settings View with a Language setting, the CLI and aego schema show --lang. Every documentation page has a .ru.md translation; the site has a full Russian tree with hreflang, a language switch and ru/llms.txt; aego docs check --lang ru. New doc.tutorial and doc.recipe icons. Fix: an animator restored by a rewind prepares its controller before the replayed step.
2026-10-25 15:31:22 +03:00

540 lines
14 KiB
Go

package animator
import (
"math"
"aego/core/mathx"
"aego/editor/core/document"
"aego/engine/i18n"
"aego/platform/input"
"aego/ui/core"
"aego/ui/widgets"
)
type dragKind uint8
const (
dragNone dragKind = iota
dragNode
dragLink
dragPan
)
type dragState struct {
kind dragKind
node int
start mathx.Vec2
off mathx.Vec2
moved bool
}
func (p *Panel) toScreen(r mathx.Rect, g [2]float32) mathx.Vec2 {
return mathx.Vec2{X: r.X + (g[0]-p.cam.X)*p.zoom, Y: r.Y + (g[1]-p.cam.Y)*p.zoom}
}
func (p *Panel) toGraph(r mathx.Rect, s mathx.Vec2) [2]float32 {
return [2]float32{(s.X-r.X)/p.zoom + p.cam.X, (s.Y-r.Y)/p.zoom + p.cam.Y}
}
// box is the screen rect of a state, of Entry (-1) or of Any (-2), with the drag offset applied.
func (p *Panel) box(r mathx.Rect, i int) mathx.Rect {
var at [2]float32
w, h := nodeW, nodeH
switch i {
case -1:
at, w, h = p.g.EntryPos, pillW, pillH
case -2:
at, w, h = p.g.AnyPos, pillW, pillH
default:
at = p.g.States[i].Pos
}
s := p.toScreen(r, at)
if p.drag.kind == dragNode && p.drag.node == i {
s = s.Add(p.drag.off)
}
return mathx.Rect{X: s.X, Y: s.Y, W: w * p.zoom, H: h * p.zoom}
}
func center(b mathx.Rect) mathx.Vec2 { return mathx.Vec2{X: b.X + b.W/2, Y: b.Y + b.H/2} }
// rim is where a ray from the centre of b towards to leaves b.
func rim(b mathx.Rect, to mathx.Vec2) mathx.Vec2 {
c := center(b)
d := to.Sub(c)
if d.X == 0 && d.Y == 0 {
return c
}
t := float32(math.Inf(1))
if d.X != 0 {
t = min(t, mathx.Abs(b.W/2/d.X))
}
if d.Y != 0 {
t = min(t, mathx.Abs(b.H/2/d.Y))
}
return c.Add(d.Scale(t))
}
func (p *Panel) frameAll() { p.zoom = 0 }
func (p *Panel) fit(r mathx.Rect) {
lo := mathx.Vec2{X: p.g.EntryPos[0], Y: p.g.EntryPos[1]}
hi := lo.Add(mathx.Vec2{X: pillW, Y: pillH})
grow := func(at [2]float32, w, h float32) {
lo = mathx.Vec2{X: min(lo.X, at[0]), Y: min(lo.Y, at[1])}
hi = mathx.Vec2{X: max(hi.X, at[0]+w), Y: max(hi.Y, at[1]+h)}
}
grow(p.g.AnyPos, pillW, pillH)
for _, s := range p.g.States {
grow(s.Pos, nodeW, nodeH)
}
size := hi.Sub(lo)
p.zoom = mathx.Clamp(min((r.W-60)/max(size.X, 1), (r.H-60)/max(size.Y, 1)), 0.5, 1.2)
p.cam = mathx.Vec2{X: lo.X - (r.W/p.zoom-size.X)/2, Y: lo.Y - (r.H/p.zoom-size.Y)/2}
}
func (p *Panel) canvas(d *document.SceneDocument, r mathx.Rect) {
ui := p.f.UI
th := &ui.Theme
p.area = r
if p.zoom == 0 {
p.fit(r)
}
ui.Rect(r, th.Background)
ui.PushClip(r)
p.grid(r)
live, state, progress := p.liveState()
p.drawEdges(r)
if p.drag.kind == dragLink {
a := center(p.box(r, p.drag.node))
ui.Line(a, ui.Mouse(), 2, th.Accent)
}
p.pill(r, -1, i18n.Tr("editor.animator.entry", "Entry"), p.f.UI.Theme.StateEntry)
p.pill(r, -2, i18n.Tr("editor.animator.any_state", "Any State"), p.f.UI.Theme.StateAny)
for i := range p.g.States {
p.stateNode(r, i, live && i == state, progress)
}
p.edgeLabels(r)
ui.PopClip()
p.input(d, r)
p.menus(r)
}
func (p *Panel) grid(r mathx.Rect) {
ui := p.f.UI
col := ui.Theme.Border
col.A *= 0.35
step := 40 * p.zoom
if step < 12 {
return
}
ox := float32(math.Mod(float64(-p.cam.X*p.zoom), float64(step)))
oy := float32(math.Mod(float64(-p.cam.Y*p.zoom), float64(step)))
for x := r.X + ox; x < r.X+r.W; x += step {
ui.Rect(mathx.Rect{X: x, Y: r.Y, W: 1, H: r.H}, col)
}
for y := r.Y + oy; y < r.Y+r.H; y += step {
ui.Rect(mathx.Rect{X: r.X, Y: y, W: r.W, H: 1}, col)
}
}
func (p *Panel) liveState() (bool, int, float32) {
if p.d.Live == nil {
return false, -1, 0
}
l := p.d.Live()
if l == nil {
return false, -1, 0
}
name, progress, ok := l.State(p.node)
if !ok {
return false, -1, 0
}
return true, int(p.g.StateIndex(name)), progress
}
func (p *Panel) pill(r mathx.Rect, i int, label string, col mathx.Color) {
ui := p.f.UI
th := &ui.Theme
b := p.box(r, i)
sel := (i == -1 && p.sel.kind == selEntry) || (i == -2 && p.sel.kind == selAny)
border := col
if sel {
border = th.Accent
}
ui.Box(b, b.H/2, col.Lerp(th.Background, 0.65), border)
ui.TextIn(b, label, th.Text, core.AlignCenter)
}
func (p *Panel) stateNode(r mathx.Rect, i int, active bool, progress float32) {
ui := p.f.UI
th := &ui.Theme
b := p.box(r, i)
s := &p.g.States[i]
bg, border := th.CardBg, th.Border
if int32(i) == p.g.Entry {
border = p.f.UI.Theme.StateEntry
}
if p.sel.kind == selState && p.sel.index == i {
border = th.Accent
}
if active {
bg = th.Selection
}
ui.Box(b, 6, bg, border)
if active {
ui.List().RectOutline(mathx.Rect{X: b.X - 2, Y: b.Y - 2, W: b.W + 4, H: b.H + 4}, th.Accent, 2)
ring(ui, mathx.Vec2{X: b.X + 12*p.zoom, Y: b.Y + b.H/2}, 6*p.zoom, progress, th.Accent)
}
ui.PushClip(b)
ui.TextIn(b, s.Name, th.Text, core.AlignCenter)
ui.PopClip()
h := p.handle(b)
ui.RoundRect(h, h.W/2, th.TextDim)
}
// ring draws the share f of a circle, clockwise from the top.
func ring(ui *core.Context, c mathx.Vec2, rad, f float32, col mathx.Color) {
const n = 24
faint := col
faint.A *= 0.3
prev := c.Add(mathx.Vec2{Y: -rad})
for k := 1; k <= n; k++ {
a := float64(k) / n * 2 * math.Pi
pt := c.Add(mathx.Vec2{X: rad * float32(math.Sin(a)), Y: -rad * float32(math.Cos(a))})
cc := faint
if float32(k)/n <= f {
cc = col
}
ui.Line(prev, pt, 2, cc)
prev = pt
}
}
func (p *Panel) handle(b mathx.Rect) mathx.Rect {
s := max(8*p.zoom, 6)
return mathx.Rect{X: b.X + b.W - s/2, Y: b.Y + b.H/2 - s/2, W: s, H: s}
}
func (p *Panel) drawEdges(r mathx.Rect) {
ui := p.f.UI
th := &ui.Theme
g := p.g
p.edges = p.edges[:0]
if g.Entry >= 0 && int(g.Entry) < len(g.States) {
a, b := p.box(r, -1), p.box(r, int(g.Entry))
arrow(ui, rim(a, center(b)), rim(b, center(a)), p.f.UI.Theme.StateEntry)
}
for k := range g.Transitions {
t := &g.Transitions[k]
from := int(t.From)
if from < 0 {
from = -2
}
if t.To < 0 || int(t.To) >= len(g.States) || from >= len(g.States) {
p.edges = append(p.edges, edge{})
continue
}
fa, fb := p.box(r, from), p.box(r, int(t.To))
ca, cb := center(fa), center(fb)
var bend float32
if p.reverse(k) {
bend = 34 * p.zoom
}
d := cb.Sub(ca)
var n mathx.Vec2
if l := d.Len(); l > 0 {
n = mathx.Vec2{X: d.Y / l, Y: -d.X / l}
}
mid := ca.Add(cb).Scale(0.5).Add(n.Scale(bend))
a, b := rim(fa, mid), rim(fb, mid)
ctl := a.Add(b).Scale(0.5).Add(n.Scale(bend * 1.4))
col := th.TextDim
if t.From < 0 {
col = p.f.UI.Theme.StateAny
}
if p.sel.kind == selTransition && p.sel.index == k {
col = th.Accent
}
curveArrow(ui, a, ctl, b, col)
p.edges = append(p.edges, edge{a: a, c: ctl, b: b, label: p.conds[k]})
}
}
// edgeLabels draws the conditions on chips; a chip that would cover a node or an earlier chip is left out, except the selected one.
func (p *Panel) edgeLabels(r mathx.Rect) {
ui := p.f.UI
th := &ui.Theme
if p.zoom < 0.6 {
return
}
p.chips = p.chips[:0]
for i := range p.g.States {
p.chips = append(p.chips, p.box(r, i))
}
p.chips = append(p.chips, p.box(r, -1), p.box(r, -2))
order := func(k int) {
e := &p.edges[k]
if e.label == "" || e.a == e.b {
return
}
sel := p.sel.kind == selTransition && p.sel.index == k
top := bezier(e.a, e.c, e.b, 0.5)
sz := ui.TextSize(e.label)
chip := mathx.Rect{X: top.X - sz.X/2 - 4, Y: top.Y - sz.Y/2 - 1, W: sz.X + 8, H: sz.Y + 2}
if !sel {
for _, o := range p.chips {
if overlaps(o, chip) {
return
}
}
}
p.chips = append(p.chips, chip)
col := th.TextDim
if sel {
col = th.Text
}
ui.Box(chip, 3, th.Background, th.Border)
ui.Text(e.label, mathx.Vec2{X: chip.X + 4, Y: chip.Y + 1}, col)
}
sel := -1
if p.sel.kind == selTransition && p.sel.index < len(p.edges) {
sel = p.sel.index
order(sel)
}
for k := range p.edges {
if k != sel {
order(k)
}
}
}
func bezier(a, c, b mathx.Vec2, t float32) mathx.Vec2 {
u := 1 - t
return a.Scale(u * u).Add(c.Scale(2 * u * t)).Add(b.Scale(t * t))
}
// curveArrow draws a quadratic curve from a to b bending towards c, with a head at b.
func curveArrow(ui *core.Context, a, c, b mathx.Vec2, col mathx.Color) {
const n = 12
prev := a
for k := 1; k <= n; k++ {
pt := bezier(a, c, b, float32(k)/n)
ui.Line(prev, pt, 2, col)
prev = pt
}
head(ui, bezier(a, c, b, 0.9), b, col)
}
func (p *Panel) reverse(k int) bool {
t := &p.g.Transitions[k]
for j := range p.g.Transitions {
if o := &p.g.Transitions[j]; j != k && o.From == t.To && o.To == t.From {
return true
}
}
return false
}
func arrow(ui *core.Context, a, b mathx.Vec2, col mathx.Color) {
ui.Line(a, b, 2, col)
head(ui, a, b, col)
}
func head(ui *core.Context, a, b mathx.Vec2, col mathx.Color) {
d := b.Sub(a)
l := d.Len()
if l < 1 {
return
}
u := d.Scale(1 / l)
n := mathx.Vec2{X: -u.Y, Y: u.X}
back := b.Sub(u.Scale(10))
ui.Line(b, back.Add(n.Scale(5)), 2, col)
ui.Line(b, back.Sub(n.Scale(5)), 2, col)
}
func segDist(pt, a, b mathx.Vec2) float32 {
d := b.Sub(a)
l := d.Dot(d)
if l == 0 {
return pt.Sub(a).Len()
}
t := mathx.Clamp(pt.Sub(a).Dot(d)/l, 0, 1)
return pt.Sub(a.Add(d.Scale(t))).Len()
}
// hit finds what is under a screen point: a node (state index, -1 Entry, -2 Any), its link handle, or a transition.
func (p *Panel) hit(r mathx.Rect, m mathx.Vec2) (sel selection, node int, handle bool) {
for i := len(p.g.States) - 1; i >= 0; i-- {
b := p.box(r, i)
if p.handle(b).Contains(m) {
return selection{kind: selState, index: i}, i, true
}
if b.Contains(m) {
return selection{kind: selState, index: i}, i, false
}
}
if p.box(r, -1).Contains(m) {
return selection{kind: selEntry}, -1, false
}
if b := p.box(r, -2); b.Contains(m) {
return selection{kind: selAny}, -2, b.X+b.W-12 < m.X
}
for k, e := range p.edges {
if e.a == e.b {
continue
}
prev := e.a
for s := 1; s <= 8; s++ {
pt := bezier(e.a, e.c, e.b, float32(s)/8)
if segDist(m, prev, pt) < 5 {
return selection{kind: selTransition, index: k}, 0, false
}
prev = pt
}
}
return selection{}, 0, false
}
func (p *Panel) input(d *document.SceneDocument, r mathx.Rect) {
ui := p.f.UI
id := ui.ID("canvas")
p.menuID = ui.ID("graphmenu")
over := ui.MouseOver(id, r)
m := ui.Mouse()
if over {
if z := ui.ZoomY(); z != 0 {
at := p.toGraph(r, m)
p.zoom = mathx.Clamp(p.zoom*float32(math.Pow(1.15, float64(z))), 0.3, 2.5)
p.cam = mathx.Vec2{X: at[0] - (m.X-r.X)/p.zoom, Y: at[1] - (m.Y-r.Y)/p.zoom}
}
if ui.MousePressed(input.MouseMiddle) {
p.drag = dragState{kind: dragPan, start: m}
ui.CaptureMouse(id)
}
}
if ui.Pressed(id, r) {
sel, node, handle := p.hit(r, m)
p.sel = sel
switch {
case handle:
p.drag = dragState{kind: dragLink, node: node, start: m}
case sel.kind == selState || sel.kind == selEntry || sel.kind == selAny:
p.drag = dragState{kind: dragNode, node: node, start: m}
case sel.kind == selNone:
p.drag = dragState{kind: dragPan, start: m}
}
}
if ui.RightClicked(id, r) {
sel, _, _ := p.hit(r, m)
p.sel, p.menu, p.menuAt = sel, sel, m
widgets.OpenContextMenu(ui, p.menuID)
}
p.dragging(r)
if ui.Focus() == 0 && p.f.Focused && ui.KeyPressed(input.KeyDelete) {
p.deleteSelection()
}
}
func (p *Panel) dragging(r mathx.Rect) {
ui := p.f.UI
if p.drag.kind == dragNone {
return
}
m := ui.Mouse()
held := ui.MouseDown(input.MouseLeft) || (p.drag.kind == dragPan && ui.MouseDown(input.MouseMiddle))
if held {
delta := m.Sub(p.drag.start)
if delta.Len() > 3 {
p.drag.moved = true
}
switch p.drag.kind {
case dragNode:
p.drag.off = delta
case dragPan:
p.cam = p.cam.Sub(ui.MouseDelta().Scale(1 / p.zoom))
}
return
}
d := p.drag
p.drag = dragState{}
ui.ReleaseMouse()
switch {
case d.kind == dragNode && d.moved:
var at [2]float32
switch d.node {
case -1:
at = p.g.EntryPos
case -2:
at = p.g.AnyPos
default:
at = p.g.States[d.node].Pos
}
at = [2]float32{at[0] + d.off.X/p.zoom, at[1] + d.off.Y/p.zoom}
p.notify(p.MoveState(d.node, at))
case d.kind == dragLink:
if _, to, _ := p.hit(r, m); to >= 0 {
from := d.node
if from == -2 {
from = -1
}
p.notify(p.AddTransition(from, to))
}
}
}
func (p *Panel) deleteSelection() {
switch p.sel.kind {
case selState:
p.notify(p.RemoveState(p.sel.index))
case selTransition:
i := p.sel.index
p.sel = selection{}
p.notify(p.RemoveTransition(i))
}
}
func (p *Panel) menus(r mathx.Rect) {
ui := p.f.UI
if !widgets.ContextMenu(ui, p.menuID) {
return
}
g := p.g
switch p.menu.kind {
case selState:
i := p.menu.index
if widgets.MenuItem(ui, i18n.Tr("editor.animator.set_as_entry", "Set as Entry"), "", int32(i) != g.Entry, false) {
p.notify(p.SetEntry(i))
}
if widgets.MenuItem(ui, i18n.Tr("editor.animator.delete_state", "Delete State"), i18n.Tr("editor.animator.del", "Del"), true, false) {
p.notify(p.RemoveState(i))
}
case selTransition:
i := p.menu.index
if widgets.MenuItemIcon(ui, i18n.Tr("editor.animator.move_up", "Move Up"), "", "ui.move_up", i > 0, false) {
p.notify(p.MoveTransition(i, -1))
}
if widgets.MenuItemIcon(ui, i18n.Tr("editor.animator.move_down", "Move Down"), "", "ui.move_down", i+1 < len(g.Transitions), false) {
p.notify(p.MoveTransition(i, 1))
}
if widgets.MenuItem(ui, i18n.Tr("editor.animator.delete_transition", "Delete Transition"), i18n.Tr("editor.animator.del", "Del"), true, false) {
p.notify(p.RemoveTransition(i))
}
default:
if widgets.MenuItemIcon(ui, i18n.Tr("editor.animator.add_state", "Add State"), "", "ui.plus", true, false) {
p.notify(p.AddState(p.toGraph(r, p.menuAt)))
}
if widgets.MenuItemIcon(ui, i18n.Tr("editor.animator.auto_layout", "Auto Layout"), "", "anim.graph", true, false) {
p.notify(p.AutoLayout())
}
if widgets.MenuItem(ui, i18n.Tr("editor.animator.frame_all", "Frame All"), "", true, false) {
p.frameAll()
}
}
widgets.EndContextMenu(ui)
}
func overlaps(a, b mathx.Rect) bool {
return a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H
}