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
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.
857 lines
22 KiB
Go
857 lines
22 KiB
Go
package animation
|
|
|
|
import (
|
|
"math"
|
|
"path"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"aego/anim"
|
|
"aego/core/curve"
|
|
"aego/core/ids"
|
|
"aego/core/mathx"
|
|
"aego/editor/core/animedit"
|
|
"aego/editor/core/context"
|
|
"aego/editor/core/history"
|
|
"aego/editor/core/services"
|
|
"aego/engine/i18n"
|
|
"aego/platform/input"
|
|
"aego/render/texture"
|
|
"aego/scene"
|
|
"aego/scene/mutate"
|
|
"aego/schema"
|
|
"aego/ui/core"
|
|
"aego/ui/widgets"
|
|
)
|
|
|
|
const (
|
|
ID = "animation"
|
|
playerName = "animation.AnimationPlayer"
|
|
animatorName = "animation.Animator"
|
|
nameW = float32(220)
|
|
rulerH = float32(22)
|
|
laneH = float32(18)
|
|
pad = float32(10)
|
|
keyR = float32(5)
|
|
markLife = 0.8
|
|
)
|
|
|
|
const (
|
|
dragNone uint8 = iota
|
|
dragKeys
|
|
dragScrub
|
|
dragEvent
|
|
)
|
|
|
|
var (
|
|
interps = [...]string{"bezier", "linear", "step"}
|
|
)
|
|
|
|
type Deps struct {
|
|
Anim *animedit.Registry
|
|
Docs services.Documents
|
|
Selection services.Selection
|
|
Notices services.Notices
|
|
Files animedit.Files
|
|
PathOf func(ids.AssetID) (string, bool)
|
|
Events func() []string
|
|
Thumbs context.ThumbSource
|
|
AssetRev func() uint64
|
|
}
|
|
|
|
type candKey struct {
|
|
node ids.NodeID
|
|
ver uint64
|
|
set bool
|
|
}
|
|
|
|
type labelKey struct {
|
|
t, length, fps float32
|
|
loop anim.LoopMode
|
|
}
|
|
|
|
type drag struct {
|
|
kind uint8
|
|
start float32
|
|
delta float32
|
|
event int
|
|
}
|
|
|
|
type shown struct {
|
|
name string
|
|
t float32
|
|
at float64
|
|
}
|
|
|
|
type frame struct {
|
|
x0, w, length, step, t0, span float32
|
|
}
|
|
|
|
func (g frame) x(t float32) float32 { return g.x0 + (t-g.t0)/g.span*g.w }
|
|
func (g frame) t(x float32) float32 { return g.t0 + (x-g.x0)/g.w*g.span }
|
|
|
|
func (g frame) snap(t float32) float32 {
|
|
return mathx.Clamp(float32(math.Round(float64(t/g.step)))*g.step, -g.length, g.length)
|
|
}
|
|
|
|
type Panel struct {
|
|
rowRects []trackRect
|
|
d Deps
|
|
f *services.Frame
|
|
svc *animedit.Service
|
|
clip *anim.Clip
|
|
view animedit.View
|
|
sel map[animedit.KeyRef]bool
|
|
drag drag
|
|
scroll float32
|
|
cands []string
|
|
names []string
|
|
candKey candKey
|
|
label string
|
|
labelOf labelKey
|
|
ticks []string
|
|
tickOf [2]float32
|
|
pick int
|
|
failed string
|
|
marks []shown
|
|
buf []animedit.Mark
|
|
menuT float32
|
|
menuEv int
|
|
pressed bool
|
|
t0 float32
|
|
span float32
|
|
curves bool
|
|
ctrack int
|
|
lane int
|
|
cst *widgets.CurveState
|
|
cbase curve.Curve
|
|
ccur curve.Curve
|
|
cof curveKey
|
|
ctx *history.Transaction
|
|
thumbs map[ids.AssetID]texture.Region
|
|
viewOf string
|
|
keyMenuID core.ID
|
|
rev uint64
|
|
}
|
|
|
|
func New(d Deps) *Panel {
|
|
return &Panel{d: d, sel: map[animedit.KeyRef]bool{}, ctrack: -1, cst: widgets.NewCurveState(), thumbs: map[ids.AssetID]texture.Region{}}
|
|
}
|
|
|
|
func (p *Panel) ID() string {
|
|
return ID
|
|
}
|
|
|
|
func (p *Panel) Title() string {
|
|
if p.svc != nil && p.svc.Mode() == animedit.Record {
|
|
return i18n.Tr("editor.animation.title_rec", "Animation (Rec)")
|
|
}
|
|
return i18n.Tr("editor.animation.title", "Animation")
|
|
}
|
|
|
|
func (p *Panel) notify(err error) {
|
|
if err != nil && p.d.Notices != nil {
|
|
p.d.Notices.Notify(context.LevelError, err.Error())
|
|
}
|
|
}
|
|
|
|
func owner(s *scene.Scene, h scene.NodeHandle) (scene.NodeHandle, *schema.TypeDescriptor) {
|
|
reg := s.Registry()
|
|
for n := h; !n.IsZero() && n != s.Root() && s.IsAlive(n); n = s.Parent(n) {
|
|
for _, name := range [...]string{playerName, animatorName} {
|
|
if td, ok := reg.ByName(name); ok {
|
|
if _, has := s.GetByType(n, td.ID); has {
|
|
return n, td
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return scene.NodeHandle{}, nil
|
|
}
|
|
|
|
func (p *Panel) Draw(f *services.Frame, r mathx.Rect) {
|
|
p.f = f
|
|
ui := f.UI
|
|
d := p.d.Docs.Doc()
|
|
switch {
|
|
case d == nil || p.d.Anim == nil:
|
|
widgets.LabelDim(ui, i18n.Tr("editor.animation.open_a_scene_to_animate", "Open a scene to animate its nodes"))
|
|
return
|
|
case d.IsPlay():
|
|
widgets.LabelDim(ui, i18n.Tr("editor.animation.clips_are_edited_outside_play", "Clips are edited outside Play"))
|
|
return
|
|
}
|
|
s := d.Scene()
|
|
svc := p.d.Anim.For(d)
|
|
if svc != p.svc {
|
|
p.svc, p.clip, p.candKey = svc, nil, candKey{}
|
|
clear(p.sel)
|
|
}
|
|
from := svc.Target()
|
|
if sel := p.d.Selection.Selected(); len(sel) > 0 {
|
|
from = sel[0]
|
|
}
|
|
node, td := owner(s, s.Handle(from))
|
|
if node.IsZero() {
|
|
widgets.LabelDim(ui, i18n.Tr("editor.animation.select_a_node_with_an", "Select a node with an Animation Player or an Animator, or Create → Animation → Clip"))
|
|
return
|
|
}
|
|
p.pickClip(s, node, td, d.History.Version())
|
|
if svc.Clip() == nil {
|
|
widgets.LabelDim(ui, i18n.Tr("editor.animation.the_node_has_no_clip", "The node has no clip yet: set the Clip of its Animation Player or the clips of its controller"))
|
|
return
|
|
}
|
|
if p.d.AssetRev != nil {
|
|
if rev := p.d.AssetRev(); rev != p.rev {
|
|
p.rev = rev
|
|
clear(p.thumbs)
|
|
}
|
|
}
|
|
if svc.ClipPath() != p.viewOf {
|
|
p.viewOf = svc.ClipPath()
|
|
p.resetView()
|
|
}
|
|
if svc.Clip() != p.clip {
|
|
p.clip = svc.Clip()
|
|
p.view = svc.View()
|
|
p.prune()
|
|
}
|
|
p.collectMarks()
|
|
p.toolbar()
|
|
if sk := svc.Skipped(); len(sk) > 0 && svc.Mode() != animedit.Normal {
|
|
widgets.LabelDim(ui, i18n.Tr("editor.animation.not_previewed_sk", "Not previewed: {sk}", i18n.Str("sk", sk[0])))
|
|
}
|
|
top := ui.Cursor().Y
|
|
body := mathx.Rect{X: r.X, Y: top, W: r.W, H: max(0, r.Y+r.H-top)}
|
|
p.timeline(body)
|
|
if svc.Mode() == animedit.Record {
|
|
ui.Outline(r, ui.Theme.Record)
|
|
}
|
|
}
|
|
|
|
func (p *Panel) candidates(s *scene.Scene, node scene.NodeHandle, td *schema.TypeDescriptor) []string {
|
|
field := "clip"
|
|
if td.FullName == animatorName {
|
|
field = "controller"
|
|
}
|
|
fd, ok := td.Field(field)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
v, err := s.GetProperty(node, td.ID, fd.ID)
|
|
if err != nil || v.Asset.IsZero() {
|
|
return nil
|
|
}
|
|
rel, ok := p.d.PathOf(v.Asset)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if field == "clip" {
|
|
return []string{rel}
|
|
}
|
|
data, err := p.d.Files.Read(rel)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
cd, err := mutate.LoadCtrl(data, rel)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
g, err := cd.Graph()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, st := range g.States {
|
|
if c, ok := p.d.PathOf(st.Clip); ok && !slices.Contains(out, c) {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PickClip shows a clip of the node's list, for screenshots and tests.
|
|
func (p *Panel) PickClip(rel string) bool {
|
|
if i := slices.Index(p.cands, rel); i >= 0 {
|
|
p.pick = i
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *Panel) pickClip(s *scene.Scene, node scene.NodeHandle, td *schema.TypeDescriptor, ver uint64) {
|
|
svc := p.svc
|
|
id := s.ID(node)
|
|
if svc.Target() != id {
|
|
svc.SetTarget(id)
|
|
}
|
|
if key := (candKey{node: id, ver: ver, set: true}); key != p.candKey {
|
|
p.candKey = key
|
|
if list := p.candidates(s, node, td); !slices.Equal(list, p.cands) {
|
|
p.cands, p.pick = list, 0
|
|
p.names = p.names[:0]
|
|
for _, c := range list {
|
|
p.names = append(p.names, strings.TrimSuffix(path.Base(c), path.Ext(c)))
|
|
}
|
|
if i := slices.Index(list, svc.ClipPath()); i >= 0 {
|
|
p.pick = i
|
|
}
|
|
}
|
|
}
|
|
if p.pick >= len(p.cands) {
|
|
return
|
|
}
|
|
want := p.cands[p.pick]
|
|
if svc.ClipPath() == want || p.failed == want {
|
|
return
|
|
}
|
|
if err := svc.SetClip(want); err != nil {
|
|
p.failed = want
|
|
p.notify(err)
|
|
return
|
|
}
|
|
p.failed = ""
|
|
}
|
|
|
|
func (p *Panel) collectMarks() {
|
|
p.buf = p.svc.TakeMarks(p.buf)
|
|
for _, m := range p.buf {
|
|
p.marks = append(p.marks, shown{name: m.Name, t: m.T, at: p.f.Now})
|
|
}
|
|
n := 0
|
|
for _, m := range p.marks {
|
|
if p.f.Now-m.at < markLife {
|
|
p.marks[n] = m
|
|
n++
|
|
}
|
|
}
|
|
p.marks = p.marks[:n]
|
|
}
|
|
|
|
func (p *Panel) prune() {
|
|
for k := range p.sel {
|
|
keep := false
|
|
if k.Track >= 0 && k.Track < len(p.clip.Tracks) {
|
|
for _, key := range p.clip.Tracks[k.Track].Keys {
|
|
if key.T == k.T {
|
|
keep = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !keep {
|
|
delete(p.sel, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func clock(t, fps float32) string {
|
|
n := int(math.Round(float64(fps)))
|
|
if n <= 0 {
|
|
n = 60
|
|
}
|
|
total := int(math.Round(float64(t) * float64(n)))
|
|
sec, fr := total/n, total%n
|
|
return strconv.Itoa(sec/60) + ":" + pad2(sec%60) + "." + pad2(fr)
|
|
}
|
|
|
|
func pad2(v int) string {
|
|
if v < 10 {
|
|
return "0" + strconv.Itoa(v)
|
|
}
|
|
return strconv.Itoa(v)
|
|
}
|
|
|
|
func (p *Panel) toolbar() {
|
|
ui := p.f.UI
|
|
th := &ui.Theme
|
|
svc := p.svc
|
|
bar := ui.Cursor().Next(0)
|
|
x := bar.X
|
|
base := ui.ID("tool")
|
|
n := 0
|
|
icon := func(name, tip string, on bool) bool {
|
|
r := mathx.Rect{X: x, Y: bar.Y, W: bar.H, H: bar.H}
|
|
x += bar.H + 2
|
|
n++
|
|
hit := widgets.IconButton(ui, core.IDSeq(base, n), r, name, tip)
|
|
if on {
|
|
ui.Rect(mathx.Rect{X: r.X + 2, Y: r.Y + r.H - 2, W: r.W - 4, H: 2}, th.Accent)
|
|
}
|
|
return hit
|
|
}
|
|
if len(p.cands) > 1 {
|
|
cr := mathx.Rect{X: x, Y: bar.Y, W: 180, H: bar.H}
|
|
x += 184
|
|
if widgets.Combo(ui, ui.ID("clip"), cr, &p.pick, p.names, false) {
|
|
p.failed = ""
|
|
}
|
|
} else {
|
|
name := strings.TrimSuffix(path.Base(svc.ClipPath()), path.Ext(svc.ClipPath()))
|
|
ui.Text(name, mathx.Vec2{X: x, Y: ui.TextY(bar)}, th.Text)
|
|
x += ui.TextSize(name).X + 10
|
|
}
|
|
labeled := func(name, label string, tint mathx.Color, on bool) bool {
|
|
w := ui.TextSize(label).X + 36
|
|
r := mathx.Rect{X: x, Y: bar.Y, W: w, H: bar.H}
|
|
x += w + 4
|
|
n++
|
|
hit := widgets.ButtonRect(ui, core.IDSeq(base, n), r, "")
|
|
widgets.IconAt(ui, mathx.Rect{X: r.X + 8, Y: r.Y + (r.H-16)/2, W: 16, H: 16}, name, tint)
|
|
ui.Text(label, mathx.Vec2{X: r.X + 28, Y: ui.TextY(r)}, th.Text)
|
|
if on {
|
|
ui.Rect(mathx.Rect{X: r.X + 2, Y: r.Y + r.H - 2, W: r.W - 4, H: 2}, th.Accent)
|
|
}
|
|
return hit
|
|
}
|
|
mode := svc.Mode()
|
|
if labeled("anim.preview", i18n.Tr("editor.animation.preview", "Preview"), th.Text, mode != animedit.Normal) {
|
|
if mode == animedit.Normal {
|
|
svc.SetMode(animedit.Preview)
|
|
} else {
|
|
svc.SetMode(animedit.Normal)
|
|
}
|
|
}
|
|
if labeled("anim.record", i18n.Tr("editor.animation.rec", "Rec"), ui.Theme.Record, mode == animedit.Record) {
|
|
if mode == animedit.Record {
|
|
svc.SetMode(animedit.Preview)
|
|
} else {
|
|
svc.SetMode(animedit.Record)
|
|
}
|
|
}
|
|
if labeled("anim.key", i18n.Tr("editor.animation.auto_key", "Auto Key"), th.Text, svc.AutoKey) {
|
|
svc.AutoKey = !svc.AutoKey
|
|
}
|
|
if labeled("ui.curve", i18n.Tr("editor.animation.curves", "Curves"), th.Text, p.curves) {
|
|
p.curves = !p.curves
|
|
}
|
|
if icon("anim.to_start", i18n.Tr("editor.animation.go_to_start", "Go to start"), false) {
|
|
svc.SetMode(max(mode, animedit.Preview))
|
|
svc.Pause()
|
|
svc.SetTime(0)
|
|
}
|
|
play, tip := "play.start", i18n.Tr("editor.animation.play_space", "Play (Space)")
|
|
if svc.Playing() {
|
|
play, tip = "play.pause", i18n.Tr("editor.animation.pause_space", "Pause (Space)")
|
|
}
|
|
if icon(play, tip, svc.Playing()) {
|
|
if svc.Playing() {
|
|
svc.Pause()
|
|
} else {
|
|
svc.Play()
|
|
}
|
|
}
|
|
if icon("anim.to_end", i18n.Tr("editor.animation.go_to_end", "Go to end"), false) {
|
|
svc.SetMode(max(mode, animedit.Preview))
|
|
svc.Pause()
|
|
svc.SetTime(p.clip.Length)
|
|
}
|
|
if k := (labelKey{t: svc.Time(), length: p.clip.Length, fps: p.clip.FPS, loop: p.clip.Loop}); k != p.labelOf || p.label == "" {
|
|
p.labelOf = k
|
|
p.label = clock(k.t, k.fps) + " / " + clock(k.length, k.fps) + " " + k.loop.String()
|
|
}
|
|
ui.Text(p.label, mathx.Vec2{X: x + 6, Y: ui.TextY(bar)}, th.TextDim)
|
|
}
|
|
|
|
func (p *Panel) step() float32 {
|
|
if fps := p.clip.FPS; fps > 0 {
|
|
return 1 / fps
|
|
}
|
|
return 1.0 / 60
|
|
}
|
|
|
|
func (p *Panel) timeline(r mathx.Rect) {
|
|
ui := p.f.UI
|
|
th := &ui.Theme
|
|
area := mathx.Rect{X: r.X + nameW, Y: r.Y, W: max(r.W-nameW, 40), H: r.H}
|
|
g := p.frame(area)
|
|
p.ruler(mathx.Rect{X: area.X, Y: r.Y, W: area.W, H: rulerH}, g)
|
|
lane := mathx.Rect{X: area.X, Y: r.Y + rulerH, W: area.W, H: laneH}
|
|
widgets.IconAt(ui, mathx.Rect{X: r.X + 6, Y: lane.Y + (lane.H-16)/2, W: 16, H: 16}, "anim.event", th.TextDim)
|
|
ui.Text(i18n.Tr("editor.animation.events", "Events"), mathx.Vec2{X: r.X + 26, Y: ui.TextY(lane)}, th.TextDim)
|
|
p.events(lane, g)
|
|
body := mathx.Rect{X: r.X, Y: lane.Y + laneH, W: r.W, H: max(0, r.Y+r.H-lane.Y-laneH)}
|
|
p.pressed = false
|
|
dope := mathx.Rect{X: area.X, Y: body.Y, W: area.W, H: body.H}
|
|
list := body
|
|
if p.curves {
|
|
list.W = nameW
|
|
}
|
|
keep := p.scroll
|
|
p.rowRects = p.rowRects[:0]
|
|
widgets.VirtualList(ui, ui.ID("rows"), list, len(p.view.Rows), th.RowHeight, &p.scroll, func(i int, row mathx.Rect) {
|
|
p.row(i, row, g)
|
|
})
|
|
if area.Contains(ui.Mouse()) {
|
|
p.scroll = keep
|
|
}
|
|
if p.curves {
|
|
p.curveView(dope, g)
|
|
ui.PushClip(area)
|
|
x := g.x(p.svc.Time())
|
|
ui.Rect(mathx.Rect{X: x - 0.75, Y: area.Y, W: 1.5, H: rulerH + laneH}, th.Accent)
|
|
ui.PopClip()
|
|
p.dragging(g)
|
|
return
|
|
}
|
|
bg := ui.ID("dope")
|
|
if !p.pressed && ui.Pressed(bg, dope) && !ui.Mods().Has(input.ModShift) {
|
|
clear(p.sel)
|
|
}
|
|
p.keyMenuID = ui.ID("keymenu")
|
|
if ui.RightClicked(bg, dope) {
|
|
widgets.OpenContextMenu(ui, p.keyMenuID)
|
|
}
|
|
p.keyMenu()
|
|
p.dragging(g)
|
|
ui.PushClip(area)
|
|
x := g.x(p.svc.Time())
|
|
ui.Rect(mathx.Rect{X: x - 0.75, Y: area.Y, W: 1.5, H: area.H}, th.Accent)
|
|
ui.PopClip()
|
|
}
|
|
|
|
func niceStep(g frame) float32 {
|
|
raw := 80 * g.span / g.w
|
|
for _, s := range [...]float32{1.0 / 60, 1.0 / 30, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 30, 60} {
|
|
if s >= raw {
|
|
return s
|
|
}
|
|
}
|
|
return 60
|
|
}
|
|
|
|
func (p *Panel) ruler(r mathx.Rect, g frame) {
|
|
ui := p.f.UI
|
|
th := &ui.Theme
|
|
ui.Rect(r, th.CardHeader)
|
|
ui.PushClip(r)
|
|
st := niceStep(g)
|
|
if of := [2]float32{st, g.length}; of != p.tickOf || p.ticks == nil {
|
|
p.tickOf, p.ticks = of, p.ticks[:0]
|
|
for i := 0; float32(i)*st <= g.length+1e-4; i++ {
|
|
p.ticks = append(p.ticks, widgets.FormatNumber(float64(float32(i)*st), widgets.NumberOpts{Prec: 2}))
|
|
}
|
|
}
|
|
first := max(0, int(g.t0/st))
|
|
for i := first; i < len(p.ticks); i++ {
|
|
label := p.ticks[i]
|
|
if float32(i)*st > g.t0+g.span {
|
|
break
|
|
}
|
|
x := g.x(float32(i) * st)
|
|
ui.Rect(mathx.Rect{X: x, Y: r.Y + r.H - 6, W: 1, H: 6}, th.TextDim)
|
|
ui.Text(label, mathx.Vec2{X: x + 2, Y: r.Y + 1}, th.TextDim)
|
|
}
|
|
ui.PopClip()
|
|
if ui.Pressed(ui.ID("ruler"), r) {
|
|
svc := p.svc
|
|
if svc.Mode() == animedit.Normal {
|
|
svc.SetMode(animedit.Preview)
|
|
}
|
|
svc.Pause()
|
|
p.drag = drag{kind: dragScrub}
|
|
ui.CaptureMouse(ui.ID("drag"))
|
|
svc.SetTime(g.snap(g.t(ui.Mouse().X)))
|
|
}
|
|
}
|
|
|
|
func (p *Panel) events(r mathx.Rect, g frame) {
|
|
ui := p.f.UI
|
|
th := &ui.Theme
|
|
faint := th.Border
|
|
faint.A *= 0.4
|
|
ui.Rect(r, faint)
|
|
ui.PushClip(r)
|
|
base := ui.ID("event")
|
|
hit := false
|
|
for i, e := range p.view.Events {
|
|
t := e.T
|
|
if p.drag.kind == dragEvent && p.drag.event == i {
|
|
t = mathx.Clamp(t+p.drag.delta, 0, g.length)
|
|
}
|
|
x := g.x(t)
|
|
box := mathx.Rect{X: x - 5, Y: r.Y, W: 10, H: r.H}
|
|
id := core.IDSeq(base, i+1)
|
|
if ui.Pressed(id, box) {
|
|
hit = true
|
|
p.drag = drag{kind: dragEvent, start: g.t(ui.Mouse().X), event: i}
|
|
ui.CaptureMouse(ui.ID("drag"))
|
|
}
|
|
if ui.RightClicked(id, box) {
|
|
hit = true
|
|
p.menuEv = i
|
|
widgets.OpenContextMenu(ui, ui.ID("eventmenu"))
|
|
}
|
|
col := th.Events[min(int(e.Mode), len(th.Events)-1)]
|
|
ui.Rect(mathx.Rect{X: x - 1, Y: r.Y + 2, W: 2, H: r.H - 4}, col)
|
|
ui.Rect(mathx.Rect{X: x - 4, Y: r.Y + 2, W: 8, H: 4}, col)
|
|
widgets.TooltipAfter(ui, id, e.Name, 0.3)
|
|
}
|
|
for _, m := range p.marks {
|
|
a := float32(1 - (p.f.Now-m.at)/markLife)
|
|
col := th.Events[anim.EventPreview]
|
|
col.A *= a
|
|
ui.Text(m.name, mathx.Vec2{X: g.x(m.t) + 6, Y: r.Y + 1}, col)
|
|
}
|
|
ui.PopClip()
|
|
lane := ui.ID("events")
|
|
if !hit && ui.Pressed(lane, r) && ui.DoubleClicked(lane) {
|
|
p.menuT = mathx.Clamp(g.snap(g.t(ui.Mouse().X)), 0, g.length)
|
|
widgets.OpenContextMenu(ui, ui.ID("addevent"))
|
|
}
|
|
if widgets.ContextMenu(ui, ui.ID("addevent")) {
|
|
var names []string
|
|
if p.d.Events != nil {
|
|
names = p.d.Events()
|
|
}
|
|
for _, n := range names {
|
|
if widgets.MenuItem(ui, n, "", true, false) {
|
|
p.notify(p.svc.EditClip(i18n.Tr("editor.history.add_event", "Add Event"), mutate.AnimEventAdd(strconv.FormatFloat(float64(p.menuT), 'g', -1, 32), n)))
|
|
}
|
|
}
|
|
if len(names) == 0 {
|
|
widgets.MenuItem(ui, i18n.Tr("editor.animation.declare_events_in_project_settings", "Declare events in Project Settings → Animation"), "", false, false)
|
|
}
|
|
widgets.EndContextMenu(ui)
|
|
}
|
|
if widgets.ContextMenu(ui, ui.ID("eventmenu")) {
|
|
if widgets.MenuItem(ui, i18n.Tr("editor.animation.remove_event", "Remove Event"), "", true, false) {
|
|
p.notify(p.svc.EditClip(i18n.Tr("editor.history.remove_event", "Remove Event"), mutate.AnimEventRemove(p.view.Events[p.menuEv].Index)))
|
|
}
|
|
widgets.EndContextMenu(ui)
|
|
}
|
|
}
|
|
|
|
func diamond(ui *core.Context, c mathx.Vec2, r float32, col mathx.Color, square bool) {
|
|
if square {
|
|
ui.Rect(mathx.Rect{X: c.X - r + 1, Y: c.Y - r + 1, W: 2*r - 2, H: 2*r - 2}, col)
|
|
return
|
|
}
|
|
for y := -r; y <= r; y++ {
|
|
w := r - float32(math.Abs(float64(y)))
|
|
ui.Rect(mathx.Rect{X: c.X - w, Y: c.Y + y, W: 2 * w, H: 1}, col)
|
|
}
|
|
}
|
|
|
|
func (p *Panel) row(i int, rr mathx.Rect, g frame) {
|
|
ui := p.f.UI
|
|
th := &ui.Theme
|
|
row := &p.view.Rows[i]
|
|
name := mathx.Rect{X: rr.X, Y: rr.Y, W: nameW, H: rr.H}
|
|
dope := mathx.Rect{X: rr.X + nameW, Y: rr.Y, W: rr.W - nameW, H: rr.H}
|
|
col := th.Text
|
|
if row.Track < 0 {
|
|
col = th.TextDim
|
|
ui.Rect(rr, th.CardHeader)
|
|
}
|
|
if p.curves && row.Track >= 0 {
|
|
nid := core.IDSeq(ui.ID("name"), row.Track+1)
|
|
if ui.Pressed(nid, name) && p.svc.Lanes(row.Track) > 0 {
|
|
p.ctrack, p.lane = row.Track, 0
|
|
}
|
|
switch {
|
|
case row.Track == p.ctrack:
|
|
ui.Rect(name, th.Selection)
|
|
case p.svc.Lanes(row.Track) == 0:
|
|
col = th.TextDisabled
|
|
}
|
|
}
|
|
ui.PushClip(name)
|
|
ui.Text(row.Label, mathx.Vec2{X: rr.X + 6 + float32(row.Depth)*14, Y: ui.TextY(rr)}, col)
|
|
ui.PopClip()
|
|
if row.Track < 0 || p.curves {
|
|
return
|
|
}
|
|
p.rowRects = append(p.rowRects, trackRect{track: row.Track, r: dope})
|
|
p.dropSprites(row.Track, dope, g)
|
|
ui.PushClip(dope)
|
|
base := core.IDSeq(ui.ID("key"), row.Track+1)
|
|
for k, t := range row.Keys {
|
|
ref := animedit.KeyRef{Track: row.Track, T: t}
|
|
sel := p.sel[ref]
|
|
x := g.x(t)
|
|
if sel && p.drag.kind == dragKeys {
|
|
x = g.x(mathx.Clamp(t+p.drag.delta, 0, g.length))
|
|
}
|
|
c := mathx.Vec2{X: x, Y: rr.Y + rr.H/2}
|
|
id := core.IDSeq(base, k+1)
|
|
if ui.Pressed(id, mathx.Rect{X: c.X - keyR - 1, Y: rr.Y, W: 2*keyR + 2, H: rr.H}) {
|
|
p.pressKey(ref, g)
|
|
}
|
|
kc := th.Text
|
|
switch {
|
|
case sel:
|
|
kc = th.Accent
|
|
case ui.Hot() == id:
|
|
kc = th.TextDim
|
|
}
|
|
if k < len(row.Assets) && p.thumb(row, k, g) {
|
|
reg := p.thumbOf(row.Assets[k])
|
|
box := mathx.Rect{X: c.X - 8, Y: c.Y - 8, W: 16, H: 16}
|
|
if !reg.IsZero() {
|
|
ui.List().Texture(reg, box, mathx.White)
|
|
if sel || ui.Hot() == id {
|
|
ui.Outline(box, kc)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
diamond(ui, c, keyR, kc, row.Step)
|
|
}
|
|
ui.PopClip()
|
|
}
|
|
|
|
func (p *Panel) pressKey(ref animedit.KeyRef, g frame) {
|
|
ui := p.f.UI
|
|
p.pressed = true
|
|
switch {
|
|
case ui.Mods().Has(input.ModShift):
|
|
if p.sel[ref] {
|
|
delete(p.sel, ref)
|
|
return
|
|
}
|
|
p.sel[ref] = true
|
|
case !p.sel[ref]:
|
|
clear(p.sel)
|
|
p.sel[ref] = true
|
|
}
|
|
p.drag = drag{kind: dragKeys, start: g.t(ui.Mouse().X)}
|
|
ui.CaptureMouse(ui.ID("drag"))
|
|
}
|
|
|
|
func (p *Panel) selected() []animedit.KeyRef {
|
|
out := make([]animedit.KeyRef, 0, len(p.sel))
|
|
for k := range p.sel {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *Panel) dragging(g frame) {
|
|
ui := p.f.UI
|
|
if p.drag.kind == dragNone {
|
|
return
|
|
}
|
|
if ui.MouseDown(input.MouseLeft) {
|
|
t := g.t(ui.Mouse().X)
|
|
switch p.drag.kind {
|
|
case dragScrub:
|
|
p.svc.SetTime(mathx.Clamp(g.snap(t), 0, g.length))
|
|
default:
|
|
p.drag.delta = g.snap(t - p.drag.start)
|
|
}
|
|
return
|
|
}
|
|
d := p.drag
|
|
p.drag = drag{}
|
|
ui.ReleaseMouse()
|
|
if d.delta == 0 {
|
|
return
|
|
}
|
|
switch d.kind {
|
|
case dragKeys:
|
|
moved, err := p.svc.MoveKeys(p.selected(), d.delta)
|
|
if err != nil {
|
|
p.notify(err)
|
|
return
|
|
}
|
|
clear(p.sel)
|
|
for _, k := range moved {
|
|
p.sel[k] = true
|
|
}
|
|
case dragEvent:
|
|
e := p.view.Events[d.event]
|
|
to := mathx.Clamp(e.T+d.delta, 0, g.length)
|
|
p.notify(p.svc.EditClip(i18n.Tr("editor.history.move_event", "Move Event"), mutate.AnimEventMove(e.Index, strconv.FormatFloat(float64(to), 'g', -1, 32))))
|
|
}
|
|
}
|
|
|
|
func (p *Panel) keyMenu() {
|
|
ui := p.f.UI
|
|
if !widgets.ContextMenu(ui, ui.ID("keymenu")) {
|
|
return
|
|
}
|
|
has := len(p.sel) > 0
|
|
if widgets.MenuItem(ui, i18n.Tr("editor.animation.copy_keys", "Copy Keys"), "Ctrl+C", has, false) {
|
|
p.CopyKeys()
|
|
}
|
|
if widgets.MenuItem(ui, i18n.Tr("editor.animation.paste_keys", "Paste Keys"), "Ctrl+V", true, false) {
|
|
p.notify(p.PasteKeys())
|
|
}
|
|
if widgets.MenuItem(ui, i18n.Tr("editor.animation.set_from_scene", "Set from Scene"), "", has, false) {
|
|
p.svc.SetMode(max(p.svc.Mode(), animedit.Preview))
|
|
p.notify(p.svc.SetFromScene(p.selected()))
|
|
}
|
|
if widgets.MenuItem(ui, i18n.Tr("editor.animation.delete_keys", "Delete Keys"), i18n.Tr("editor.animation.del", "Del"), has, false) {
|
|
p.notify(p.DeleteKeys())
|
|
}
|
|
for _, in := range interps {
|
|
if widgets.MenuItem(ui, i18n.Tr("editor.animation.interpolation_in", "Interpolation: {in}", i18n.Str("in", in)), "", has, false) {
|
|
p.notify(p.svc.SetInterp(p.selected(), in))
|
|
}
|
|
}
|
|
widgets.EndContextMenu(ui)
|
|
}
|
|
|
|
// Select replaces the key selection, for tests and other tools.
|
|
func (p *Panel) Select(keys ...animedit.KeyRef) {
|
|
clear(p.sel)
|
|
for _, k := range keys {
|
|
p.sel[k] = true
|
|
}
|
|
}
|
|
|
|
// KeyMenuOpen reports whether the key menu is open, for tests.
|
|
func (p *Panel) KeyMenuOpen() bool {
|
|
return p.f != nil && p.keyMenuID != 0 && p.f.UI.PopupOpen(p.keyMenuID)
|
|
}
|
|
|
|
// dropSprites keys a sprite track with the sprites dragged onto it, the first where they land.
|
|
func (p *Panel) dropSprites(track int, r mathx.Rect, g frame) {
|
|
c := p.svc.Clip()
|
|
if c == nil || track >= len(c.Tracks) || c.Tracks[track].Kind != schema.KindAssetRef || p.f.DnD == nil || !p.f.DnD.Active() {
|
|
return
|
|
}
|
|
ui := p.f.UI
|
|
var frames []ids.AssetID
|
|
pl, ok := p.f.DnD.Payload()
|
|
if !ok {
|
|
return
|
|
}
|
|
switch v := pl.Data.(type) {
|
|
case context.AssetPayload:
|
|
if pl.Kind == context.KindAsset && context.AssetMatches("texture", v.Kind) {
|
|
frames = append(frames, v.ID)
|
|
}
|
|
case context.AssetsPayload:
|
|
for _, it := range v.Items {
|
|
if context.AssetMatches("texture", it.Kind) {
|
|
frames = append(frames, it.ID)
|
|
}
|
|
}
|
|
}
|
|
if len(frames) == 0 {
|
|
return
|
|
}
|
|
if _, ok := p.f.DnD.Target(ui, r, pl.Kind); ok {
|
|
at := g.snap(mathx.Clamp(g.t(ui.Mouse().X), 0, g.length))
|
|
p.notify(p.svc.KeySprites(track, at, frames))
|
|
}
|
|
}
|
|
|
|
type trackRect struct {
|
|
track int
|
|
r mathx.Rect
|
|
}
|
|
|
|
// TrackRect is where the keys of track i were drawn on the last frame.
|
|
func (p *Panel) TrackRect(i int) (mathx.Rect, bool) {
|
|
for _, t := range p.rowRects {
|
|
if t.track == i {
|
|
return t.r, true
|
|
}
|
|
}
|
|
return mathx.Rect{}, false
|
|
}
|