Files
aego/modules/gameplay/fsm.go
T
sanspie 1030dc9a43
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / checks (push) Successful in 4m58s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 27m47s
ci / release (push) Successful in 11m57s
Stage 10d2: audio fast path, shutdown order, profiler and monitor sections
2026-10-22 14:41:24 +03:00

242 lines
6.7 KiB
Go

package gameplay
import (
"aego/contracts/scriptcall"
"aego/engine"
"aego/engine/module"
"aego/engine/problems"
"aego/scene"
"aego/schema"
"aego/script/bind"
"aego/script/vm"
)
const (
NameStateMachine = "gameplay.StateMachine"
CodeBadStates = "GMP005"
maxTransitions = 8
)
type fsmHooks struct {
enter, exit, fixed, update scriptcall.Ref
hasEnter, hasExit, hasFixed, hasUpdate bool
}
type fsmState struct {
started bool
gen uint64
hooks []fsmHooks
queued int32
}
type StateMachine struct {
States []string `aego:"states"`
Initial string `aego:"initial"`
Current string `aego:"current,transient,readonly,category=Runtime"`
Previous string `aego:"previous,transient,readonly,category=Runtime"`
Time float32 `aego:"time,transient,readonly,category=Runtime"`
Index int32 `aego:"index,transient,readonly,category=Runtime"`
st fsmState
}
func DefaultStateMachine() StateMachine {
return StateMachine{States: []string{"Idle"}, Initial: "Idle"}
}
func (m *StateMachine) find(name string) int32 {
for i, s := range m.States {
if s == name {
return int32(i)
}
}
return -1
}
// Go queues a change; the system applies it after the running hook returns.
func (m *StateMachine) Go(state string) bool {
i := m.find(state)
if i < 0 {
return false
}
m.st.queued = i + 1
return true
}
func (m *StateMachine) Is(state string) bool {
return m.st.started && m.Current == state
}
func validateFSM(_ module.ValidateContext, m *StateMachine) []problems.Diagnostic {
bad := func(msg string) []problems.Diagnostic {
return []problems.Diagnostic{{Code: CodeBadStates, Severity: problems.Warning, Message: "State Machine: " + msg}}
}
if len(m.States) == 0 {
return bad("has no states")
}
for i, s := range m.States {
if s == "" {
return bad("a state has no name")
}
for _, o := range m.States[:i] {
if o == s {
return bad("state " + s + " is listed twice")
}
}
}
if m.Initial != "" && m.find(m.Initial) < 0 {
return bad("Initial " + m.Initial + " is not one of the states")
}
return nil
}
func (s *Service) caller() scriptcall.Caller {
if s.call == nil && s.world != nil {
s.call, _ = engine.ServiceOf[scriptcall.Caller](s.world, scriptcall.ServiceID)
}
return s.call
}
// bindHooks looks the hooks up by name again after a hot reload changed the program generation.
func (s *Service) bindHooks(h scene.NodeHandle, m *StateMachine) {
c := s.caller()
if c == nil {
return
}
st := &m.st
gen := c.Generation()
if st.gen == gen && len(st.hooks) == len(m.States) {
return
}
st.gen = gen
st.hooks = st.hooks[:0]
for _, name := range m.States {
var k fsmHooks
k.enter, k.hasEnter = c.Method(h, "Enter"+name, 0)
k.exit, k.hasExit = c.Method(h, "Exit"+name, 0)
k.fixed, k.hasFixed = c.Method(h, "Fixed"+name, 1)
k.update, k.hasUpdate = c.Method(h, "Update"+name, 1)
st.hooks = append(st.hooks, k)
}
}
func (s *Service) hook(m *StateMachine, i int32) *fsmHooks {
if i >= 0 && int(i) < len(m.st.hooks) {
return &m.st.hooks[i]
}
return nil
}
func (s *Service) run(ref *scriptcall.Ref, ok bool) {
if ok && s.call != nil {
s.call.Call(ref, nil)
}
}
func (s *Service) runDt(ref *scriptcall.Ref, ok bool, dt float32) {
if ok && s.call != nil {
s.args[0] = vm.Float(dt)
s.call.Call(ref, s.args[:1])
}
}
func (s *Service) startFSM(h scene.NodeHandle, m *StateMachine) {
m.st.started = true
if len(m.States) == 0 {
return
}
i := m.find(m.Initial)
if i < 0 {
i = 0
}
m.Index, m.Current, m.Previous, m.Time = i, m.States[i], "", 0
if k := s.hook(m, i); k != nil {
s.run(&k.enter, k.hasEnter)
}
s.applyQueued(h, m)
}
func (s *Service) applyQueued(h scene.NodeHandle, m *StateMachine) {
st := &m.st
for n := 0; n < maxTransitions && st.queued > 0; n++ {
to := st.queued - 1
st.queued = 0
if to == m.Index || int(to) >= len(m.States) {
continue
}
from := m.Index
if k := s.hook(m, from); k != nil {
s.run(&k.exit, k.hasExit)
}
m.Previous, m.Current, m.Index, m.Time = m.Current, m.States[to], to, 0
if k := s.hook(m, to); k != nil {
s.run(&k.enter, k.hasEnter)
}
s.record(h, "state", m.States[to])
if s.world != nil && s.sig != nil {
s.sig.changed.Emit(s.world, h, int64(from), int64(to))
}
}
}
func (s *Service) fsmFixed(h scene.NodeHandle, m *StateMachine) {
if !m.st.started || !s.scene.EffectiveEnabled(h) {
return
}
s.bindHooks(h, m)
s.applyQueued(h, m)
m.Time += s.dt
if k := s.hook(m, m.Index); k != nil {
s.runDt(&k.fixed, k.hasFixed, s.dt)
}
s.applyQueued(h, m)
}
func (s *Service) fsmUpdate(h scene.NodeHandle, m *StateMachine) {
if !s.scene.EffectiveEnabled(h) {
return
}
s.bindHooks(h, m)
if !m.st.started {
s.startFSM(h, m)
return
}
s.applyQueued(h, m)
if k := s.hook(m, m.Index); k != nil {
s.runDt(&k.update, k.hasUpdate, s.fdt)
}
s.applyQueued(h, m)
}
func (s *Service) FSMFixed(dt float32) {
s.dt = dt
scene.Query1[StateMachine](s.scene).Each(s.fsmFixFn)
}
func (s *Service) FSMUpdate(dt float32) {
s.fdt = dt
scene.Query1[StateMachine](s.scene).Each(s.fsmUpdFn)
}
func registerFSM(r *module.Registry, sig *signals) error {
b := module.Component[StateMachine](r, schema.Name(NameStateMachine),
schema.Doc("Named states with Enter, Exit, Fixed and Update hooks found by name on the node's scripts: EnterRun(), ExitRun(), FixedRun(dt), UpdateRun(dt). Scripts change the state with Go."),
schema.FieldDoc("states", "State names, also the suffix of the hook methods."),
schema.FieldDoc("initial", "State entered when the node starts, after the scripts' Ready. Empty uses the first state."),
schema.FieldDoc("current", "State now."),
schema.FieldDoc("previous", "State before the last change."),
schema.FieldDoc("time", "Seconds of fixed steps spent in the current state."),
schema.FieldDoc("index", "Index of the current state in States."),
schema.Runtime("current", "previous", "time", "index"),
).Category(Category).Display("State Machine").Icon("gameplay.fsm").Default(DefaultStateMachine).Validate(validateFSM)
b.Method1("Go", func(_ *bind.Ctx, m *StateMachine, state string) bool { return m.Go(state) },
bind.Doc("Go switches to a state after the current hook returns; Exit of the old state and Enter of the new one run in between. Returns false for an unknown state."),
bind.Params("state"))
b.Method1("Is", func(_ *bind.Ctx, m *StateMachine, state string) bool { return m.Is(state) },
bind.Doc("Is reports whether the machine is in a state."), bind.Params("state"))
b.Method0("TimeIn", func(_ *bind.Ctx, m *StateMachine) float32 { return m.Time },
bind.Doc("TimeIn returns the seconds spent in the current state."))
sig.changed = b.Signal2[int64, int64]("Changed", "Changed fires after a state change with the indices of the old and the new state.", "from", "to")
return b.Err()
}