# examples/dynamics2.py¶

This is one of the example scripts included with Shady. These scripts can be run conventionally like any normal Python script, or you can choose to run them as interactive tutorials, for example with python -m Shady demo dynamics2

#!/usr/bin/env python
# $BEGIN_SHADY_LICENSE$
#
# This file is part of the Shady project, a Python framework for
# real-time manipulation of psychophysical stimuli for vision science.
#
# Copyright (c) 2017-2020 Jeremy Hill, Scott Mooney
#
# Shady is free software: you can redistribute it and/or modify it
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see http://www.gnu.org/licenses/ .
#
# $END_SHADY_LICENSE$

#: High-level tools for directing the action (object-oriented version)
"""
This demo shows a different way to create the interactive, dynamic
behavior seen in the dynamics1demo. We recommend that you go
through that demo first, because this demo assumes you're aware of
the behavior we're trying to achieve.

Rather than keeping the state objects very simple (just duration
and next attributes) and packing all of the logic into a big
procedural switch inside the Stimulus Animate() method, this
version of the same demo compartmentalizes the logic within each
state itself. Each state has onset, ongoing, and offset
methods assigned to it that are respectively called when the state
begins, as the state progresses, and when the state ends.

As before, most of this demo is about walking you through the setup
before running the final product at the end.
"""#.
if __name__ == '__main__':
"""
Let's start by creating a World, configured according to
whatever command-line arguments you supplied, and creating
our alien stimulus.
"""#:

from Shady.Dynamics import StateMachine, Integral, Transition

cmdline.Help().Finalize()
Shady.Require( 'numpy', 'Image' ) # die with an informative error if either is missing

"""
While we're here showing alternative ways of doing the things
we did before, we'll load the alien using the animated .gif
file rather than its individual image frames:
"""#:
alien = world.Stimulus(
x=-world.width / 2,
)

"""
In dynamics1, we started off by defining duration and
next for each state as keyword arguments of the AddState
method. This time, we'll fully define each state as a subclass
of StateMachine.State, attach .next, .duration, etc
as class attributes, and load the class definitions into the
state machine as our final step.

Our Stand state is fairly simple. We define the next
attribute.  All such attributes may be either constant,
or callable with the State instance as sole argument.
"""#:
class Stand( StateMachine.State ):
next = 'Run'

"""
Note that a callable class attribute that takes an instance
of the class as its first argument is better known as... a
method. So let's call it that. And define another method,
called onset. This method does exactly what we previously
accomplished by checking state.fresh in the procedural
dynamics1: its code will be run once each time we enter
the state.
"""#:
def onset( self ):
alien.xy = 0  # stops any ongoing .xy dynamic
alien.Set( x=-world.width / 2 + 100, y=0, frame=0 )
"""
Sometimes, as in the onset example here, your implementation
of a State method might ignore the instance argument self.
suppress such warnings by putting the @staticmethod decorator
above the method definition, if the warning bothers you more
than this extra clutter does. Either way, functionality is the
same.
"""#:

"""
Our Run state is also similar to the 'run' section of the
animation callback in dynamics1, but we've spiced things up a
bit by making our alien walk with variable speed as a function
of his gait cycle. We've also made the duration attribute a
little longer than last time, to accommodate the funky gait.
"""#:
class Run( StateMachine.State ):
duration = 3.5
next = 'Jump'

def onset( self ):
gait = lambda t: 1 if alien.frame in [ 0, 12 ] else 10
alien.Set(
x=Integral( gait ) * 40 + alien.x,
frame=Integral( gait ) * 3 + 1,
)

"""
Nothing has changed in our 'Jump' state. (Note, though, that
because we set frame to 0 for the jump, the alien's dynamic
gait and hence his horizontal velocity automatically become
constant.)
"""#:
class Jump( StateMachine.State ):
duration = 0.4
next = 'Fall'

def onset( self ):
alien.Set( y=Integral( Integral(-5000) + 1500 ) + alien.y, frame=0 )
"""
The 'Fall' state is a good place to demonstrate the offset
method (called when the state machine *leaves* the state in
question) and the ongoing method (which is called repeatedly
while we're in the state, every time the state machine itself
is called with a new time argument).
"""#:

import random
class Fall( StateMachine.State ):
duration = 3
next = 'Stand'

def onset( self ):
alien.frame = Integral(160)

"""
The ongoing method offers an additional trick: you can return
the name (or instance) of another state to immediately trigger
a change to that state, as if you had called sm.ChangeState(state).
You can even return the name of the same state to restart it. If
ongoing returns None (which happens if nothing is explicitly
returned), nothing changes and the state continues. Here, we
check the alien's altitude. If he falls below the bottom edge of
the screen, we return the 'Stand' state. (The three-second duration
is unlikely to be reached in this case, but we'll keep it anyway.)
"""#:
def ongoing( self ):
if alien.y < -world.height / 2: return 'Stand'

"""
Typically, the offset method is best used to 'clean up' things
that may have been affected by the departing state. This method
will be called regardless of which state is coming up next, so
it's not a good place to initialize what's coming next (use the
next state's onset for that). Here, we'll assume that the
alien falls through a wormwhole into a parallel World of
a different (randomly-chosen) color.
"""#:
def offset(self):
world.clearColor = [random.random(), random.random(), random.random()]

"""
Finally, we have our Spiral, which behaves the same as before.
Note that, whereas before we defined an ExitSpiral function
globally and passed it as the next argument to AddState, this
time it is very natural to implement the same logic directly in
next as a method:
"""#:
class Spiral( StateMachine.State ):

def next( self ):
if self.elapsed < 3:
alien.frame = Transition( 0, 24, duration=0.1 ) # wiggle
return StateMachine.CANCEL
return 'Stand'

def onset( self ):
radius = min( world.size ) / 2 - 100
alien.Set( x=-radius, y=0, frame=0 ) # stop any .x, .y and .frame dynamics
alien.xy = Integral( Integral( 0.1 ) ).Transform( Shady.Sinusoid, phase_deg=[ 270, 180 ] ) \
* ( radius - Integral( 20 ) ).Transform( max, 0 )
"""
Now that we have our states, we can define the state machine
and pass all of our states as constructor arguments. In the same
line, we've set the alien's .Animate attribute to the state
machine, which tells Shady that this alien should call the
state machine once every frame. This saves us the effort of writing:

@alien.AnimationCallback
def TediousAnimationDefinition( self, t ):
sm( t )

in order to attach the state machine to the alien.

"""#:
sm = alien.Animate = StateMachine( Stand, Run, Jump, Fall, Spiral )

"""
Sidebar: we took the explicit route to state machine construction
there, but you could equivalently, if you find it more readable,
construct an empty StateMachine first, and then call sm.AddState()
with each of the states. The neatest way to do this is with a class
decorator:

sm = StateMachine()

def Stand( StateMachine.State ):
...

StateMachine immediately after it is defined.
"""#:

"""
Finally, we'll create the same Event Handler we had in dynamics1.
The only difference is that our state names are now capitalized,
(because they're classes, and within Shady that is our convention
for class names).
"""#:
@world.EventHandler
def KeyboardControl( self, event ):
if event.type == 'text' and event.text == ' ':
if sm.state in [ 'Stand', 'Run', 'Spiral' ]:
sm.ChangeState()
elif event.type == 'key_release' and event.key in [ 'enter', 'return' ]:
sm.ChangeState( 'Spiral' )
elif event.type == 'key_release' and event.key in [ 'q', 'escape' ]:
self.Close()

"""
All in all, there have been a few changes (some subtle, some not
behavior is the same as in dynamics1 despite the very different
programming approach.
"""#>