# -*- test-case-name: mv3d.test.test_ -*- # Copyright (C) 2012 Mortal Coil Games # See LICENSE for details. """ Functions for handling higher level animation operations including an animation state machine which can be used to control characters. @author: mike """ from time import time from functools import partial from inspect import isclass try: from xml.etree.cElementTree import SubElement, Element, tostring, fromstring, \ parse except ImportError: from xml.etree.ElementTree import SubElement, Element, tostring, fromstring, \ parse from twisted.internet.task import coiterate from twisted.internet import reactor from mv3d.util.enum import Enum from mv3d.util.guide import ChangeNotifier, NotifierProperty, FloatConverter, \ VectorConverter, DockableFrame from mv3d.util.math3d import Vector, Quaternion from mv3d.phys.representation import parseVector, parseQuaternion, makeVector, \ makeQuaternion from mv3d.util.statemachine import State, StateMachine class BlendType(Enum): """ Defines a blend direction """ blendInLinear = 0 blendOutLinear = 1 class AnimationBlendIterator(object): """ An iterator that will blend an animation in or out """ timeFunc = time def __init__(self, animatedObject, animationName, blendDuration, blendType, timeFunc=None): self.animatedObject = animatedObject self.animationName = animationName self.blendDuration = blendDuration self.blendType = blendType self.timeFunc = timeFunc or self.timeFunc self.startTime = self.timeFunc() self.lastTime = self.startTime self.next = getattr(self, BlendType[blendType]) def blendInLinear(self): """ Blends in linearly """ currentTime = self.timeFunc() blendAmount = (currentTime - self.startTime) / float(self.blendDuration) blendAmount = min(1, blendAmount) self.animatedObject.modifyActiveAnimation(self.animationName, weight=blendAmount * 100) if blendAmount == 1: raise StopIteration def blendOutLinear(self): """ Blends in linearly """ currentTime = self.timeFunc() blendAmount = (currentTime - self.startTime) / float(self.blendDuration) blendAmount = 1.0 - min(1, blendAmount) self.animatedObject.modifyActiveAnimation(self.animationName, weight=blendAmount * 100) if blendAmount == 0: raise StopIteration class ActiveAnimationCallback(object): """ An animation callback that's currently active. """ callLater = reactor.callLater #@UndefinedVariable delayedCall = None def __init__(self, animatedObject, animationName, absoluteTime, callback, callLater=None): self.animatedObject = animatedObject self.animationName = animationName self.absoluteTime = absoluteTime self.callback = callback self.callLater = callLater or self.callLater self.update() def __call__(self): """ Call the callback """ self.delayedCall = None self.callback(self.animationName) def cancel(self): """ Cancel the callback """ if self.delayedCall is not None: self.delayedCall.cancel() self.delayedCall = None def update(self): """ Call when the animation time or speed has changed, and the callback will be appropriately updated. """ self.cancel() position = self.animatedObject.getAnimationPosition(self.animationName) speed = self.animatedObject.getAnimationSpeed(self.animationName) duration = self.animatedObject.getAnimationLength(self.animationName) if self.absoluteTime > duration: raise ValueError("Callback time <= duration (%.2f > %.2f)" % ( self.absoluteTime, duration)) if speed == 0.0: return delay = (self.absoluteTime - position) / float(speed) self.delayedCall = self.callLater(abs(delay), self) class AnimationCallback(ChangeNotifier): """ Defines a point in an animation where an event should be fired """ animationName = NotifierProperty("animationName", "") name = NotifierProperty("name", "") notifyTime = NotifierProperty("notifyTime", converter=FloatConverter()) def __str__(self): return self.name @classmethod def load(cls, node, animationName): """ Load this callback from XML. """ callback = cls() callback.animationName = animationName callback.name = node.attrib["name"] callback.notifyTime = float(node.attrib["time"]) return callback def save(self, root): """ Save this callback to XML """ node = SubElement(root, "Callback") node.attrib["name"] = self.name node.attrib["time"] = str(self.notifyTime) def registerCallback(self, animatedObject, callback): """ Create a callback and return it """ return ActiveAnimationCallback(animatedObject, self.animationName, self.notifyTime, callback) class Animation(ChangeNotifier): """ Defines a single animation """ name = NotifierProperty("name", "") callbacks = NotifierProperty("callbacks", None) def __init__(self): self.callbacks = [] def __str__(self): return self.name @classmethod def load(cls, node): """ Load this animation from XML """ anim = cls() anim.name = node.attrib["name"] anim.callbacks = [] for cbNode in node.findall("Callback") or []: anim.callbacks.append(AnimationCallback.load(cbNode, anim.name)) return anim def save(self, root): """ Save this animation to XML """ node = SubElement(root, "Animation") node.attrib["name"] = self.name for callback in self.callbacks: callback.save(node) def registerCallbacks(self, animatedObject, callback): """ Register all the callbacks for the object """ activeCallbacks = [] for callback in self.callbacks: activeCallbacks.append(callback.registerCallbacks(animatedObject, callback)) return activeCallbacks class Socket(ChangeNotifier): """ A socket is a point that is attached to a bone and has an offset position and orientation. """ name = NotifierProperty("name", "") bone = NotifierProperty("bone", "") position = NotifierProperty("position", None) orientation = NotifierProperty("orientation", None) def __init__(self): self.position = Vector() self.orientation = Quaternion() def __str__(self): return self.name @classmethod def load(cls, node): """ Load this callback from XML. """ socket = cls() socket.name = node.attrib["name"] socket.bone = node.attrib["bone"] socket.position = parseVector(node.find("Position")) socket.orientation = parseQuaternion(node.find("Orientation")) return socket def save(self, root): """ Save this callback to XML """ node = SubElement(root, "Socket") node.attrib["name"] = self.name node.attrib["bone"] = self.bone makeVector(node, "Position", self.position) makeQuaternion(node, "Orientation", self.orientation) class Parameter(ChangeNotifier): """ Defines a parameter that the state machine listens on. """ name = NotifierProperty("name", "") parameterType = NotifierProperty("parameterType", "") default = NotifierProperty("default", "") def __str__(self): return self.name @classmethod def load(cls, node): """ Load this parameter from XML """ param = cls() param.name = node.attrib["name"] param.parameterType = node.attrib["type"] param.default = node.attrib["default"] return param def save(self, root): """ Save this parameter to XML """ node = SubElement(root, "Parameter") node.attrib["name"] = self.name node.attrib["type"] = self.parameterType node.attrib["default"] = self.default class Event(ChangeNotifier): """ An event in a sequence """ name = NotifierProperty("name", "") action = NotifierProperty("action", "") arguments = NotifierProperty("arguments") def __init__(self): self.arguments = [] def __str__(self): return self.name class Sequence(State, ChangeNotifier): """ A state in an splicer state machine. """ name = NotifierProperty("name", "") overlay = NotifierProperty("overlay", False) events = NotifierProperty("events") animations = NotifierProperty("animations") sockets = NotifierProperty("sockets") parameters = NotifierProperty("parameters") code = NotifierProperty("code") def __init__(self, controller=None, previousState=None): if controller is not None: State.__init__(self, controller, previousState) self.events = [] def __str__(self): return self.name @classmethod def load(cls, node): """ Loads a sequence for an xml node. """ seq = cls() seq.name = node.attrib["name"] seq.overlay = node.attrib.get("overlay", "false").lower() == "true" events = node.findall("Event") or [] descNode = node.find("Description") if descNode is not None: seq.__doc__ = descNode.text else: seq.__doc__ = "" for event in events: eventInstance = Event() eventInstance.name = event.attrib["name"] descNode = event.find("Description") if descNode is not None: eventInstance.__doc__ = descNode.text else: eventInstance.__doc__ = "" eventInstance.action = event.find("Action").text or "" eventInstance.arguments = [arg.text.strip() for arg in event.findall("Argument") or []] seq.events.append(eventInstance) return seq.build() @classmethod def save(cls, root): """ Saves this sequence to a node. """ node = SubElement(root, "Sequence") node.attrib["name"] = cls.name node.attrib["overlay"] = str(cls.overlay) for event in cls.events: evt = SubElement(node, "Event") evt.attrib["name"] = event.name for argument in event.arguments: arg = SubElement(evt, "Argument") arg.text = argument action = SubElement(evt, "Action") action.text = event.action.strip() @classmethod def fromClass(cls, seqClass): seq = Sequence() seq.__doc__ = seqClass.__doc__ seq.name = seqClass.name seq.events = seqClass.events seq.overlay = seqClass.overlay return seq def build(self): """ Build the class and return it. """ code = "class %s(Sequence):\n" % self.name.replace(" ", "_") code += " " * 4 + '"""\n' if self.__doc__ is not None: for line in self.__doc__.strip(" \n\r").split("\n"): code += " " * 4 + line.strip(" \n\r") + "\n" code += " " * 4 + '"""\n' code += " overlay = %s\n" % self.overlay code += "\n" eventInstances = [] initDone = False for event in self.events: if event.name == "enter": code += " def __init__(self, controller, previousState," initDone = True elif event.name == "exit": code += " def event_exit(self, controller, newState):\n" else: code += " def event_%s(self, controller, " % ( event.name.replace(" ", "_")) if event.name != "exit": code += ",".join(event.arguments) + "):\n" code += " " * 8 + '"""\n' for line in event.__doc__.strip(" \n\r").split("\n"): code += " " * 8 + line.strip(" \n\r") + "\n" code += " " * 8 + '"""\n' if event.name == "enter": code += " " * 8 + "Sequence.__init__(self, controller, " code += "previousState)\n" for line in event.action.strip(" \n\r").split("\n"): code += " " * 8 code += line.strip("\n\r") code += "\n" code += "\n\n" eventInstance = Event() eventInstance.name = event.name eventInstance.action = event.action eventInstance.arguments = event.arguments eventInstances.append(eventInstance) # print code self.code = code exec code sequence = locals()[self.name.replace(" ", "_")] sequence.name = self.name sequence.overlay = self.overlay sequence.events = eventInstances return sequence def onNewEvent(self, widget, _prop): """ Create a new event in this sequence """ self.events.append(Event()) self.events[-1].name = "new" self.propertyChanged("events") splicer = widget.findParent(DockableFrame).dataContext splicer.onUpdateTree() splicer.tree.selection = self.events[-1] class CompositeMaster(StateMachine, ChangeNotifier): """ The statemachine which controls active sequences """ animatedObject = NotifierProperty("animatedObject") sequences = NotifierProperty("sequences") animations = NotifierProperty("animations") sockets = NotifierProperty("sockets") parameters = NotifierProperty("parameters") initialState = NotifierProperty("initialState") currentState = NotifierProperty("currentState") def __init__(self, animatedObject): StateMachine.__init__(self) self.animatedObject = animatedObject self.sequences = {} self.animationCallbacks = {} self.animations = {} self.sockets = [] self.parameters = [] def _getActiveAnimations(self): """ Returns information about active animations. """ anims = [] for anim in self.animatedObject.getActiveAnimations(): anims.append((anim, False, None, 100, 1.0)) return anims activeAnimations = property(_getActiveAnimations) def _getAvailableAnimations(self): """ Returns the list of available animations """ return self.animatedObject.getAnimations() availableAnimations = property(_getAvailableAnimations) def _buildParameters(self): """ Build the code for the parameters class. """ params = self.parameters callback = self._sendParameterEvent class _Parameters(ChangeNotifier): _params = params def __init__(self): ChangeNotifier.__init__(self) for parameter in params: self.addPropertyListener(parameter.name, callback) def __getitem__(self, index): if isinstance(index, int): return self._params[index] return getattr(self, index) def __setitem__(self, index, value): if isinstance(index, int): self._params[index] = value return setattr(self, index, value) def __len__(self): return len(self._params) def append(self, item): self._params.append(item) def remove(self, item): self._params.remove(item) for parameter in params: if parameter.parameterType == "number": converter = FloatConverter() elif parameter.parameterType == "position": converter = VectorConverter() else: converter = None setattr(_Parameters, parameter.name, NotifierProperty( parameter.name, parameter.default, converter)) self.parameters = _Parameters() return self.parameters def _createSockets(self): """ Create all the sockets """ if self.animatedObject is None: return for socket in self.sockets: self.animatedObject.createSocket(socket.name, socket.bone, socket.position, socket.orientation) def _sendParameterEvent(self, _obj, prop): """ Send an event based on a property change. """ value = getattr(self.parameters, prop) self.handleEvent(prop, value) def _animationFinishedCallback(self, name): """ An animation finished. """ self.handleEvent("%s_Finished" % name) self.propertyChanged("activeAnimations") def _animationCallback(self, callbackName, animation): """ An animation finished. """ self.handleEvent("%s_%s" % (animation, callbackName)) @classmethod def load(cls, node, animatedObject): """ Load all animation data from node. """ comp = cls(animatedObject) animations = node.findall("Animation") or [] sockets = node.findall("Socket") or [] parameters = node.findall("Parameter") or [] for socket in sockets: comp.sockets.append(Socket.load(socket)) for animNode in animations: anim = Animation.load(animNode) comp.animations[anim.name] = anim for seqNode in node.findall("Sequence") or []: seq = Sequence.load(seqNode) comp.sequences[seq.name] = seq for parameter in parameters: comp.parameters.append(Parameter.load(parameter)) comp._buildParameters() comp._createSockets() initNode = node.find("InitialState") if initNode is not None: comp.initialState = initNode.text.strip() comp.activate(comp.initialState) return comp @classmethod def readFromXML(cls, animatedObject, filename=None, xmlString=None): """ Create a new instance and read its data from either an XML file or XML in a string. """ if not filename and not xmlString: raise TypeError("One of filename or fromString is required!") if filename: root = parse(filename).getroot() else: root = fromstring(xmlString) return CompositeMaster.load(root, animatedObject) def save(self, node): """ Save the animation data to the node. """ if self.initialState is not None: SubElement(node, "InitialState").text = self.initialState for socket in self.sockets: socket.save(node) for animation in self.animations.values(): animation.save(node) for sequence in self.sequences.values(): sequence.save(node) for parameter in self.parameters: parameter.save(node) def writeToXML(self, filename=None): """ Creates an XML splicer file and saves it to filename if specified. Returns the raw XML string """ root = Element("AnimationData", { "xmlns:xsi":"http://www.w3.org/2001/XMLSchema-instance", "xmlns:tns":"http://www.mv3d.com/splicer", "xmlns:cns":"http://www.mv3d.com/common", "xsi:schemaLocation":"http://www.mv3d.com/splicer " "http://www.mv3d.com/trac/export/head/trunk/schema/splicer.xsd", "xsi:schemaLocation":"http://www.mv3d.com/common " "http://www.mv3d.com/trac/export/head/trunk/schema/common.xsd", }) self.save(root) def pretty(element, indent=0, isEnd=False): element.tail = "\n" + " " * (indent - isEnd) if element.text: element.text = "\n" + " " * (indent + 1) + element.text element.text += "\n" + " " * indent elif len(element): element.text = "\n" + " " * (indent + 1) for idx, subelement in enumerate(element): pretty(subelement, indent + 1, idx == len(element) - 1) pretty(root) data = tostring(root) if filename is not None: fil = open(filename, "w") fil.write(data) fil.close() return data def reset(self): """ Reloads all the states and resets the animated object """ if self.animatedObject is not None: for anim in self.animatedObject.getActiveAnimations(): self.animatedObject.stopAnimation(anim) sequences = self.sequences.values() self.sequences = {} for sequence in sequences: if isclass(sequence): scls = Sequence.fromClass(sequence).build() else: scls = sequence.build() self.sequences[scls.name] = scls self._buildParameters() self._createSockets() if self.initialState is not None: self.activate(self.initialState) def startAnimation(self, name, loop=True, weight=100, speed=1.0, blendTime=None): """ Start an animation. """ self.animatedObject.startAnimation(name, loop, weight, speed) animLen = self.animatedObject.getAnimationLength(name) animCallback = ActiveAnimationCallback(self.animatedObject, name, animLen, self._animationFinishedCallback) try: self.animationCallbacks[name].append(animCallback) except KeyError: self.animationCallbacks[name] = [animCallback] if self.animations.has_key(name): for callback in self.animations[name].callbacks: callback.registerCallback(self.animatedObject, partial(self._animationCallback, callback.name)) if blendTime is not None: coiterate(AnimationBlendIterator(self.animatedObject, name, blendTime, BlendType.blendInLinear)) self.propertyChanged("activeAnimations") def stopAnimation(self, name, blendTime=None): """ Stop an animation """ def doStop(_=None): self.animatedObject.stopAnimation(name) for callback in self.animationCallbacks.get(name, []): callback.cancel() self.animationCallbacks[name] = [] if blendTime is None: doStop() else: coiterate(AnimationBlendIterator(self.animatedObject, name, blendTime, BlendType.blendOutLinear)).addCallback(doStop) self.propertyChanged("activeAnimations") def activate(self, sequenceName): """ Activate a sequence by name """ self.changeState(self.sequences[sequenceName])