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-2022 Jeremy Hill, Scott Mooney
# 
# Shady is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# 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 `dynamics1`demo. 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.
	"""#:
	
	import Shady
	from Shady.Dynamics import StateMachine, Integral, Transition
	
	cmdline = Shady.WorldConstructorCommandLine()
	cmdline.Help().Finalize()
	Shady.Require( 'numpy', 'Image' ) # die with an informative error if either is missing
	
	world = Shady.World( **cmdline.opts )
	
	"""
	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(
		Shady.PackagePath( 'examples/media/alien1.gif' ),
		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`.
	Your Python IDE may then give you a warning about this. You can
	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()
		
		@sm.AddState
		def Stand( StateMachine.State ):
			...
			
	The decorator tells Shady to add the State subclass to the
	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
	subtle) relative to the alien's previous adventure, but broadly the
	behavior is the same as in `dynamics1` despite the very different
	programming approach.
	"""#>
	Shady.AutoFinish( world )