diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py index fd1ac63..8099423 100644 --- a/korman/exporter/animation.py +++ b/korman/exporter/animation.py @@ -51,7 +51,10 @@ class AnimationConverter: # form of separation, but Blender's NLA editor is way confusing and appears to not work with # things that aren't the typical position, rotation, scale animations. applicators = [] - applicators.append(self._convert_transform_animation(bo.name, obj_fcurves, bo.matrix_basis)) + if isinstance(bo.data, bpy.types.Camera): + applicators.append(self._convert_camera_animation(bo, so, obj_fcurves, data_fcurves)) + else: + applicators.append(self._convert_transform_animation(bo.name, obj_fcurves, bo.matrix_basis)) if bo.plasma_modifiers.soundemit.enabled: applicators.extend(self._convert_sound_volume_animation(bo.name, obj_fcurves, bo.plasma_modifiers.soundemit)) if isinstance(bo.data, bpy.types.Lamp): @@ -102,6 +105,84 @@ class AnimationConverter: atcanim.easeOutMax = 1.0 atcanim.easeOutLength = 1.0 + def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves): + if data_fcurves: + # The hard part about this crap is that FOV animations are not stored in ATC Animations + # instead, FOV animation keyframes are held inside of the camera modifier. Cyan's solution + # in PlasmaMAX appears to be for any xform keyframe, add two messages to the camera modifier + # representing the FOV at that point. Makes more sense to me to use each FOV keyframe instead + fov_fcurve = next((i for i in data_fcurves if i.data_path == "plasma_camera.settings.fov"), None) + if fov_fcurve: + # NOTE: this is another critically important key ordering in the SceneObject modifier + # list. CameraModifier calls into AGMasterMod code that assumes the AGModifier + # is already available. Should probably consider adding some code to libHSPlasma + # to order the SceneObject modifier key vector at some point. + anim_key = self.get_animation_key(bo) + camera = self._mgr.find_create_object(plCameraModifier, so=so) + cam_key = camera.key + aspect, fps = (3.0 / 4.0), self._bl_fps + degrees = math.degrees + fov_fcurve.update() + + # Seeing as how we're transforming the data entirely, we'll just use the fcurve itself + # instead of our other animation helpers. But ugh does this mess look like sloppy C. + keyframes = fov_fcurve.keyframe_points + num_keyframes = len(keyframes) + has_fov_anim = bool(num_keyframes) + i = 0 + while i < num_keyframes: + this_keyframe = keyframes[i] + next_keyframe = keyframes[0] if i+1 == num_keyframes else keyframes[i+1] + + # So remember, these are messages. When we hit a keyframe, we're dispatching a message + # representing the NEXT desired FOV. + this_frame_time = this_keyframe.co[0] / fps + next_frame_num, next_frame_value = next_keyframe.co + next_frame_time = next_frame_num / fps + + # This message is held on the camera modifier and sent to the animation... It calls + # back when the animation reaches the keyframe time, causing the FOV message to be sent. + cb_msg = plEventCallbackMsg() + cb_msg.event = kTime + cb_msg.eventTime = this_frame_time + cb_msg.index = i + cb_msg.repeats = -1 + cb_msg.addReceiver(cam_key) + anim_msg = plAnimCmdMsg() + anim_msg.animName = "(Entire Animation)" + anim_msg.time = this_frame_time + anim_msg.sender = anim_key + anim_msg.addReceiver(anim_key) + anim_msg.addCallback(cb_msg) + anim_msg.setCmd(plAnimCmdMsg.kAddCallbacks, True) + camera.addMessage(anim_msg, anim_key) + + # This is the message actually changes the FOV. Interestingly, it is sent at + # export-time and while playing the game, the camera modifier just steals its + # parameters and passes them to the brain. Can't make this stuff up. + cam_msg = plCameraMsg() + cam_msg.addReceiver(cam_key) + cam_msg.setCmd(plCameraMsg.kAddFOVKeyFrame, True) + cam_config = cam_msg.config + cam_config.accel = next_frame_time # Yassss... + cam_config.fovW = degrees(next_frame_value) + cam_config.fovH = degrees(next_frame_value * aspect) + camera.addFOVInstruction(cam_msg) + + i += 1 + else: + has_fov_anim = False + else: + has_fov_anim = False + + # If we exported any FOV animation at all, then we need to ensure there is an applicator + # returned from here... At bare minimum, we'll need the applicator with an empty + # CompoundController. This should be sufficient to keep CWE from crashing... + applicator = self._convert_transform_animation(bo.name, obj_fcurves, bo.matrix_basis, allow_empty=has_fov_anim) + camera = locals().get("camera", self._mgr.find_create_object(plCameraModifier, so=so)) + camera.animated = applicator is not None + return applicator + def _convert_lamp_color_animation(self, name, fcurves, lamp): if not fcurves: return None @@ -271,41 +352,37 @@ class AnimationConverter: applicator.channel = channel yield applicator - def _convert_transform_animation(self, name, fcurves, xform): - if not fcurves: + def _convert_transform_animation(self, name, fcurves, xform, allow_empty=False): + tm = self.convert_transform_controller(fcurves, xform, allow_empty) + if tm is None and not allow_empty: + return None + + applicator = plMatrixChannelApplicator() + applicator.enabled = True + applicator.channelName = name + channel = plMatrixControllerChannel() + channel.controller = tm + applicator.channel = channel + channel.affine = utils.affine_parts(xform) + + return applicator + + def convert_transform_controller(self, fcurves, xform, allow_empty=False): + if not fcurves and not allow_empty: return None pos = self.make_pos_controller(fcurves, xform) rot = self.make_rot_controller(fcurves, xform) scale = self.make_scale_controller(fcurves, xform) if pos is None and rot is None and scale is None: - return None + if not allow_empty: + return None tm = plCompoundController() tm.X = pos tm.Y = rot tm.Z = scale - - applicator = plMatrixChannelApplicator() - applicator.enabled = True - applicator.channelName = name - channel = plMatrixControllerChannel() - channel.controller = tm - applicator.channel = channel - - # Decompose the matrix into the 90s-era 3ds max affine parts sillyness - # All that's missing now is something like "(c) 1998 HeadSpin" oh wait... - affine = hsAffineParts() - affine.T = hsVector3(*xform.to_translation()) - affine.K = hsVector3(*xform.to_scale()) - affine.F = -1.0 if xform.determinant() < 0.0 else 1.0 - rot = xform.to_quaternion() - affine.Q = utils.quaternion(rot) - rot.normalize() - affine.U = utils.quaternion(rot) - channel.affine = affine - - return applicator + return tm def get_anigraph_keys(self, bo=None, so=None): mod = self._mgr.find_create_key(plAGModifier, so=so, bl=bo) @@ -317,6 +394,16 @@ class AnimationConverter: master = self._mgr.find_create_object(plAGMasterMod, so=so, bl=bo) return mod, master + def get_animation_key(self, bo, so=None): + # we might be controlling more than one animation. isn't that cute? + # https://www.youtube.com/watch?v=hspNaoxzNbs + # (but obviously this is not wrong...) + group_mod = bo.plasma_modifiers.animation_group + if group_mod.enabled: + return self._mgr.find_create_key(plMsgForwarder, bl=bo, so=so, name=group_mod.key_name) + else: + return self.get_anigraph_keys(bo, so)[1] + def make_matrix44_controller(self, fcurves, pos_path, scale_path, pos_default, scale_default): def convert_matrix_keyframe(**kwargs): pos = kwargs.get(pos_path) diff --git a/korman/exporter/camera.py b/korman/exporter/camera.py new file mode 100644 index 0000000..fd47497 --- /dev/null +++ b/korman/exporter/camera.py @@ -0,0 +1,225 @@ +# This file is part of Korman. +# +# Korman 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. +# +# Korman 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 Korman. If not, see . + +import bpy +import math +from PyHSPlasma import * +import weakref + +from .explosions import * +from .. import helpers +from . import utils + +class CameraConverter: + def __init__(self, exporter): + self._exporter = weakref.ref(exporter) + + def _convert_brain(self, so, bo, camera_props, brain): + trans_props = camera_props.transition + + brain.poaOffset = hsVector3(*camera_props.poa_offset) + if camera_props.poa_type == "object": + brain.subject = self._mgr.find_create_key(plSceneObject, bl=camera_props.poa_object) + + brain.xPanLimit = camera_props.x_pan_angle / 2.0 + brain.zPanLimit = camera_props.y_pan_angle / 2.0 + brain.panSpeed = camera_props.pan_rate + if camera_props.limit_zoom: + brain.setFlags(plCameraBrain1.kZoomEnabled, True) + brain.zoomMax = camera_props.zoom_max * (4.0 / 3.0) + brain.zoomMin = camera_props.zoom_min * (4.0 / 3.0) + brain.zoomRate = camera_props.zoom_rate + + brain.acceleration = trans_props.pos_acceleration + brain.deceleration = trans_props.pos_deceleration + brain.velocity = trans_props.pos_velocity + brain.poaAcceleration = trans_props.poa_acceleration + brain.poaDeceleration = trans_props.poa_deceleration + brain.poaVelocity = trans_props.poa_velocity + + if isinstance(brain, plCameraBrain1_Avatar): + brain.setFlags(plCameraBrain1.kCutPos, trans_props.pos_cut) + brain.setFlags(plCameraBrain1.kCutPOA, trans_props.poa_cut) + else: + brain.setFlags(plCameraBrain1.kCutPos, True) + brain.setFlags(plCameraBrain1.kCutPOA, True) + if camera_props.poa_type == "avatar": + brain.setFlags(plCameraBrain1.kFollowLocalAvatar, True) + if camera_props.maintain_los: + brain.setFlags(plCameraBrain1.kMaintainLOS, True) + if camera_props.poa_worldspace: + brain.setFlags(plCameraBrain1.kWorldspacePOA, True) + if camera_props.pos_worldspace: + brain.setFlags(plCameraBrain1.kWorldspacePos, True) + if camera_props.ignore_subworld: + brain.setFlags(plCameraBrain1.kIgnoreSubworldMovement, True) + if camera_props.fall_vertical: + brain.setFlags(plCameraBrain1.kVerticalWhenFalling, True) + if camera_props.fast_run: + brain.setFlags(plCameraBrain1.kSpeedUpWhenRunning, True) + + def export_camera(self, so, bo, camera_type, camera_props, camera_trans=[]): + brain = getattr(self, "_export_{}_camera".format(camera_type))(so, bo, camera_props) + mod = self._export_camera_modifier(so, bo, camera_props, camera_trans) + mod.brain = brain.key + + def _export_camera_modifier(self, so, bo, props, trans): + mod = self._mgr.find_create_object(plCameraModifier, so=so) + + # PlasmaMAX allows the user to specify the horizontal OR vertical FOV, but not both. + # We only allow setting horizontal FOV (how often do you look up?), however. + # Plasma assumes 4:3 aspect ratio.. + fov = props.fov + mod.fovW, mod.fovH = math.degrees(fov), math.degrees(fov * (3.0 / 4.0)) + + # This junk is only valid for animated cameras... + # Animation exporter should have already run by this point :) + if mod.animated: + mod.startAnimOnPush = props.start_on_push + mod.stopAnimOnPop = props.stop_on_pop + mod.resetAnimOnPop = props.reset_on_pop + + for manual_trans in trans: + if not manual_trans.enabled or manual_trans.mode == "auto": + continue + cam_trans = plCameraModifier.CamTrans() + if manual_trans.camera: + cam_trans.transTo = self._mgr.find_create_key(plCameraModifier, bl=manual_trans.camera) + cam_trans.ignore = manual_trans.mode == "ignore" + + trans_info = manual_trans.transition + cam_trans.cutPos = trans_info.pos_cut + cam_trans.cutPOA = trans_info.poa_cut + cam_trans.accel = trans_info.pos_acceleration + cam_trans.decel = trans_info.pos_deceleration + cam_trans.velocity = trans_info.pos_velocity + cam_trans.poaAccel = trans_info.poa_acceleration + cam_trans.poaDecel = trans_info.poa_deceleration + cam_trans.poaVelocity = trans_info.poa_velocity + mod.addTrans(cam_trans) + + return mod + + def _export_circle_camera(self, so, bo, props): + brain = self._mgr.find_create_object(plCameraBrain1_Circle, so=so) + self._convert_brain(so, bo, props, brain) + + # Circle Camera specific stuff ahoy! + if props.poa_type == "avatar": + brain.circleFlags |= plCameraBrain1_Circle.kCircleLocalAvatar + elif props.poa_type == "object": + brain.poaObject = self._mgr.find_create_key(plSceneObject, bl=props.poa_object) + else: + self._report.warn("Circle Camera '{}' has no Point of Attention. Is this intended?", bo.name, indent=3) + if props.circle_pos == "farthest": + brain.circleFlags |= plCameraBrain1_Circle.kFarthest + + # If no center object is specified, we will use the camera object's location. + # We will use a simple vector for this object to make life simpler, however, + # specifying an actual center allows you to do interesting things like animate the center... + # Fascinating! Therefore, we will expose the Plasma Object... + if props.circle_center is None: + brain.center = hsVector3(*bo.location) + else: + brain.centerObject = self._mgr.find_create_key(plSceneObject, bl=props.circle_center) + # This flag has no effect in CWE, but I'm using it for correctness' sake + brain.circleFlags |= plCameraBrain1_Circle.kHasCenterObject + + # PlasmaMAX forces these values so the circle camera is rather slow, which is somewhat + # sensible to me. Fast circular motion seems like a poor idea to me... If we want these + # values to be customizable, we probably want add some kind of range limitation or at least + # a flashing red light in the UI... + brain.acceleration = 10.0 + brain.deceleration = 10.0 + brain.velocity = 15.0 + + # Related to above, circle cameras use a slightly different velocity method. + # This is stored in Plasma as a fraction of the circle's circumference. It makes + # more sense to me to present it in terms of degrees per second, however. + # NOTE that Blender returns radians!!! + brain.cirPerSec = props.circle_velocity / (2 * math.pi) + + # I consider this clever... If we have a center object, we use the magnitude of the displacement + # of the camera's object to the center object (at frame 0) to determine the radius. If no center + # object is specified, we allow the user to specify the radius. + # Well, it's clever until you realize it's the same thing Cyan did in PlasmaMAX... But it's harder + # here because Blendsucks. + brain.radius = props.get_circle_radius(bo) + + # Done already? + return brain + + def _export_fixed_camera(self, so, bo, props): + if props.anim_enabled: + self._exporter().animation.convert_object_animations(bo, so) + brain = self._mgr.find_create_object(plCameraBrain1_Fixed, so=so) + self._convert_brain(so, bo, props, brain) + return brain + + def _export_follow_camera(self, so, bo, props): + brain = self._mgr.find_create_object(plCameraBrain1_Avatar, so=so) + self._convert_brain(so, bo, props, brain) + + # Follow camera specific stuff ahoy! + brain.offset = hsVector3(*props.pos_offset) + return brain + + def _export_rail_camera(self, so, bo, props): + brain = self._mgr.find_create_object(plCameraBrain1_Fixed, so=so) + self._convert_brain(so, bo, props, brain) + + rail = self._mgr.find_create_object(plRailCameraMod, so=so) + rail.followFlags |= plLineFollowMod.kForceToLine + if props.poa_type == "object": + rail.followMode = plLineFollowMod.kFollowObject + rail.refObj = self._mgr.find_create_key(plSceneObject, bl=props.poa_object) + else: + rail.followMode = plLineFollowMod.kFollowLocalAvatar + if bo.parent: + rail.pathParent = self._mgr.find_create_key(plSceneObject, bl=bo.parent) + + # The rail is defined by a position controller in Plasma. Cyan uses a separate + # path object, but it makes more sense to me to just animate the camera with + # the details of the path... + pos_fcurves = tuple(i for i in helpers.fetch_fcurves(bo, False) if i.data_path == "location") + pos_ctrl = self._exporter().animation.convert_transform_controller(pos_fcurves, bo.matrix_basis) + if pos_ctrl is None: + raise ExportError("'{}': Rail Camera lacks appropriate rail keyframes".format(bo.name)) + path = plAnimPath() + path.controller = pos_ctrl + path.affineParts = utils.affine_parts(bo.matrix_local) + begin, end = bo.animation_data.action.frame_range + for fcurve in pos_fcurves: + f1, f2 = fcurve.evaluate(begin), fcurve.evaluate(end) + if abs(f1 - f2) > 0.001: + break + else: + # The animation is a loop + path.flags |= plAnimPath.kWrap + if props.rail_pos == "farthest": + path.flags |= plAnimPath.kFarthest + path.length = end / bpy.context.scene.render.fps + rail.path = path + brain.rail = rail.key + + return brain + + @property + def _mgr(self): + return self._exporter().mgr + + @property + def _report(self): + return self._exporter().report diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 4387751..558d9f6 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -20,6 +20,7 @@ from PyHSPlasma import * import time from . import animation +from . import camera from . import explosions from . import etlight from . import logger @@ -52,6 +53,7 @@ class Exporter: self.light = rtlight.LightConverter(self) self.animation = animation.AnimationConverter(self) self.sumfile = sumfile.SumFile() + self.camera = camera.CameraConverter(self) # Step 0.8: Init the progress mgr self.mesh.add_progress_presteps(self.report) @@ -230,7 +232,6 @@ class Exporter: sceneobject = self.mgr.find_create_object(plSceneObject, bl=bl_obj) self._export_actor(sceneobject, bl_obj) export_fn(sceneobject, bl_obj) - self.animation.convert_object_animations(bl_obj, sceneobject) # And now we puke out the modifiers... for mod in bl_obj.plasma_modifiers.modifiers: @@ -238,16 +239,21 @@ class Exporter: mod.export(self, bl_obj, sceneobject) inc_progress() + def _export_camera_blobj(self, so, bo): + # Hey, guess what? Blender's camera data is utter crap! + # NOTE: Animation export is dependent on camera type, so we'll do that later. + camera = bo.data.plasma_camera + self.camera.export_camera(so, bo, camera.camera_type, camera.settings, camera.transitions) + def _export_empty_blobj(self, so, bo): - # We don't need to do anything here. This function just makes sure we don't error out - # or add a silly special case :( - pass + self.animation.convert_object_animations(bo, so) def _export_lamp_blobj(self, so, bo): - # We'll just redirect this to the RT Light converter... + self.animation.convert_object_animations(bo, so) self.light.export_rtlight(so, bo) def _export_mesh_blobj(self, so, bo): + self.animation.convert_object_animations(bo, so) if bo.data.materials: self.mesh.export_object(bo) else: diff --git a/korman/exporter/utils.py b/korman/exporter/utils.py index 3471a33..569201d 100644 --- a/korman/exporter/utils.py +++ b/korman/exporter/utils.py @@ -15,6 +15,19 @@ from PyHSPlasma import * +def affine_parts(xform): + # Decompose the matrix into the 90s-era 3ds max affine parts sillyness + # All that's missing now is something like "(c) 1998 HeadSpin" oh wait... + affine = hsAffineParts() + affine.T = hsVector3(*xform.to_translation()) + affine.K = hsVector3(*xform.to_scale()) + affine.F = -1.0 if xform.determinant() < 0.0 else 1.0 + rot = xform.to_quaternion() + affine.Q = quaternion(rot) + rot.normalize() + affine.U = quaternion(rot) + return affine + def color(blcolor, alpha=1.0): """Converts a Blender Color into an hsColorRGBA""" return hsColorRGBA(blcolor.r, blcolor.g, blcolor.b, alpha) diff --git a/korman/helpers.py b/korman/helpers.py index 6ae2bf2..fcd60c3 100644 --- a/korman/helpers.py +++ b/korman/helpers.py @@ -51,6 +51,19 @@ class TemporaryObject: def ensure_power_of_two(value): return pow(2, math.floor(math.log(value, 2))) +def fetch_fcurves(id_data, data_fcurves=True): + """Given a Blender ID, yields its FCurves""" + def _fetch(source): + if source is not None and source.action is not None: + for i in source.action.fcurves: + yield i + + # This seems rather unpythonic IMO + for i in _fetch(id_data.animation_data): + yield i + if data_fcurves: + for i in _fetch(id_data.data.animation_data): + yield i def find_modifier(bo, modid): """Given a Blender Object, finds a given modifier and returns it or None""" diff --git a/korman/idprops.py b/korman/idprops.py index 1423943..66328d2 100644 --- a/korman/idprops.py +++ b/korman/idprops.py @@ -124,6 +124,9 @@ def poll_animated_objects(self, value): return True return False +def poll_camera_objects(self, value): + return value.type == "CAMERA" + def poll_empty_objects(self, value): return value.type == "EMPTY" diff --git a/korman/nodes/node_messages.py b/korman/nodes/node_messages.py index 1dbfe24..6c90411 100644 --- a/korman/nodes/node_messages.py +++ b/korman/nodes/node_messages.py @@ -243,14 +243,7 @@ class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode, if self.anim_type == "OBJECT": if not obj.plasma_object.has_animation_data: self.raise_error("invalid animation") - group = obj.plasma_modifiers.animation_group - if group.enabled: - # we might be controlling more than one animation. isn't that cute? - # https://www.youtube.com/watch?v=hspNaoxzNbs - # (but obviously this is not wrong...) - target = exporter.mgr.find_create_key(plMsgForwarder, bl=obj, name=group.key_name) - else: - _agmod_trash, target = exporter.animation.get_anigraph_keys(obj) + target = exporter.animation.get_animation_key(obj) else: material = self.target_material if material is None: @@ -306,6 +299,55 @@ class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode, "texture_name": bpy.data.textures} +class PlasmaCameraMsgNode(PlasmaMessageNode, bpy.types.Node): + bl_category = "MSG" + bl_idname = "PlasmaCameraMsgNode" + bl_label = "Camera" + bl_width_default = 200 + + cmd = EnumProperty(name="Command", + description="Command to send to the camera system", + items=[("push", "Push Camera", "Pushes a new camera onto the camera stack and transitions to it"), + ("pop", "Pop Camera", "Pops the camera off the camera stack"), + ("disablefp", "Disable First Person", "Forces the camera into third person if it is currently in first person and disables first person mode"), + ("enablefp", "Enable First Person", "Reenables the first person camera and switches back to it if the player was in first person previously")], + options=set()) + camera = PointerProperty(name="Camera", + type=bpy.types.Object, + poll=idprops.poll_camera_objects, + options=set()) + cut = BoolProperty(name="Cut Transition", + description="Immediately swap over to the new camera without a transition animation", + options=set()) + + def convert_message(self, exporter, so): + msg = plCameraMsg() + msg.BCastFlags |= plMessage.kLocalPropagate | plMessage.kBCastByType + if self.cmd in {"push", "pop"}: + if self.camera is not None: + msg.newCam = exporter.mgr.find_create_key(plSceneObject, bl=self.camera) + # It appears that kRegionPopCamera is unused. pushing is controlled by observing + # the presence of the kResponderTrigger command. + msg.setCmd(plCameraMsg.kResponderTrigger, self.cmd == "push") + msg.setCmd(plCameraMsg.kRegionPushCamera, True) + msg.setCmd(plCameraMsg.kSetAsPrimary, self.camera is None + or self.camera.data.plasma_camera.settings.primary_camera) + msg.setCmd(plCameraMsg.kCut, self.cut) + elif self.cmd == "disablefp": + msg.setCmd(plCameraMsg.kResponderSetThirdPerson) + elif self.cmd == "enablefp": + msg.setCmd(plCameraMsg.kResponderUndoThirdPerson) + else: + raise RuntimeError() + return msg + + def draw_buttons(self, context, layout): + layout.prop(self, "cmd") + if self.cmd in {"push", "pop"}: + layout.prop(self, "camera") + layout.prop(self, "cut") + + class PlasmaEnableMsgNode(PlasmaMessageNode, bpy.types.Node): bl_category = "MSG" bl_idname = "PlasmaEnableMsgNode" diff --git a/korman/properties/__init__.py b/korman/properties/__init__.py index dc98a70..aed2561 100644 --- a/korman/properties/__init__.py +++ b/korman/properties/__init__.py @@ -15,6 +15,7 @@ import bpy +from .prop_camera import * from .prop_lamp import * from . import modifiers from .prop_object import * @@ -23,6 +24,7 @@ from .prop_world import * def register(): + bpy.types.Camera.plasma_camera = bpy.props.PointerProperty(type=PlasmaCamera) bpy.types.Lamp.plasma_lamp = bpy.props.PointerProperty(type=PlasmaLamp) bpy.types.Object.plasma_net = bpy.props.PointerProperty(type=PlasmaNet) bpy.types.Object.plasma_object = bpy.props.PointerProperty(type=PlasmaObject) diff --git a/korman/properties/modifiers/region.py b/korman/properties/modifiers/region.py index 3377f32..5db0098 100644 --- a/korman/properties/modifiers/region.py +++ b/korman/properties/modifiers/region.py @@ -22,6 +22,7 @@ from ...helpers import TemporaryObject from ... import idprops from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz +from ..prop_camera import PlasmaCameraProperties from .physics import bounds_types footstep_surface_ids = { @@ -58,6 +59,70 @@ footstep_surfaces = [("dirt", "Dirt", "Dirt"), ("woodfloor", "Wood Floor", "Wood Floor"), ("woodladder", "Wood Ladder", "Wood Ladder")] +class PlasmaCameraRegion(PlasmaModifierProperties): + pl_id = "camera_rgn" + + bl_category = "Region" + bl_label = "Camera Region" + bl_description = "Camera Region" + bl_icon = "CAMERA_DATA" + + camera_type = EnumProperty(name="Camera Type", + description="What kind of camera should be used?", + items=[("auto_follow", "Auto Follow Camera", "Automatically generated follow camera"), + ("manual", "Manual Camera", "User specified camera object")], + default="manual", + options=set()) + camera_object = PointerProperty(name="Camera", + description="Switches to this camera", + type=bpy.types.Object, + poll=idprops.poll_camera_objects, + options=set()) + auto_camera = PointerProperty(type=PlasmaCameraProperties, options=set()) + + def export(self, exporter, bo, so): + if self.camera_type == "manual": + if self.camera_object is None: + raise ExportError("Camera Modifier '{}' does not specify a valid camera object".format(self.id_data.name)) + camera_so_key = exporter.mgr.find_create_key(plSceneObject, bl=self.camera_object) + camera_props = self.camera_object.data.plasma_camera.settings + else: + assert self.camera_type[:4] == "auto" + + # Wheedoggy! We get to export the doggone camera now. + camera_props = self.auto_camera + camera_type = self.camera_type[5:] + exporter.camera.export_camera(so, bo, camera_type, camera_props) + camera_so_key = so.key + + # Setup physical stuff + phys_mod = bo.plasma_modifiers.collision + simIface, physical = exporter.physics.generate_physical(bo, so, phys_mod.bounds, self.key_name) + physical.memberGroup = plSimDefs.kGroupDetector + physical.reportGroup = 1 << plSimDefs.kGroupAvatar + simIface.setProperty(plSimulationInterface.kPinned, True) + physical.setProperty(plSimulationInterface.kPinned, True) + + # I don't feel evil enough to make this generate a logic tree... + msg = plCameraMsg() + msg.BCastFlags |= plMessage.kLocalPropagate | plMessage.kBCastByType + msg.setCmd(plCameraMsg.kRegionPushCamera) + msg.setCmd(plCameraMsg.kSetAsPrimary, camera_props.primary_camera) + msg.newCam = camera_so_key + + region = exporter.mgr.find_create_object(plCameraRegionDetector, so=so) + region.addMessage(msg) + + def harvest_actors(self): + if self.camera_type == "manual": + if self.camera_object is None: + raise ExportError("Camera Modifier '{}' does not specify a valid camera object".format(self.id_data.name)) + camera = self.camera_object.data.plasma_camera.settings + else: + camera = self.auto_camera + return camera.harvest_actors() + + class PlasmaFootstepRegion(PlasmaModifierProperties, PlasmaModifierLogicWiz): pl_id = "footstep" diff --git a/korman/properties/prop_camera.py b/korman/properties/prop_camera.py new file mode 100644 index 0000000..cb80608 --- /dev/null +++ b/korman/properties/prop_camera.py @@ -0,0 +1,246 @@ +# This file is part of Korman. +# +# Korman 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. +# +# Korman 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 Korman. If not, see . + +import bpy +from bpy.props import * +import math + +from .. import idprops + +camera_types = [("circle", "Circle Camera", "The camera circles a fixed point"), + ("follow", "Follow Camera", "The camera follows an object"), + ("fixed", "Fixed Camera", "The camera is fixed in one location"), + ("rail", "Rail Camera", "The camera follows an object by moving along a line")] + +class PlasmaTransition(bpy.types.PropertyGroup): + poa_acceleration = FloatProperty(name="PoA Acceleration", + description="Rate the camera's Point of Attention tracking velocity increases in feet per second squared", + min=-100.0, max=100.0, precision=0, default=60.0, + unit="ACCELERATION", options=set()) + poa_deceleration = FloatProperty(name="PoA Deceleration", + description="Rate the camera's Point of Attention tracking velocity decreases in feet per second squared", + min=-100.0, max=100.0, precision=0, default=60.0, + unit="ACCELERATION", options=set()) + poa_velocity = FloatProperty(name="PoA Velocity", + description="Maximum velocity of the camera's Point of Attention tracking", + min=-100.0, max=100.0, precision=0, default=60.0, + unit="VELOCITY", options=set()) + poa_cut = BoolProperty(name="Cut", + description="The camera immediately begins tracking the Point of Attention", + options=set()) + + pos_acceleration = FloatProperty(name="Position Acceleration", + description="Rate the camera's positional velocity increases in feet per second squared", + min=-100.0, max=100.0, precision=0, default=60.0, + unit="ACCELERATION", options=set()) + pos_deceleration = FloatProperty(name="Position Deceleration", + description="Rate the camera's positional velocity decreases in feet per second squared", + min=-100.0, max=100.0, precision=0, default=60.0, + unit="ACCELERATION", options=set()) + pos_velocity = FloatProperty(name="Position Max Velocity", + description="Maximum positional velocity of the camera", + min=-100.0, max=100.0, precision=0, default=60.0, + unit="VELOCITY", options=set()) + pos_cut = BoolProperty(name="Cut", + description="The camera immediately moves to its new position", + options=set()) + + +class PlasmaManualTransition(bpy.types.PropertyGroup): + camera = PointerProperty(name="Camera", + description="The camera from which this transition is intended", + type=bpy.types.Object, + poll=idprops.poll_camera_objects, + options=set()) + transition = PointerProperty(type=PlasmaTransition, options=set()) + mode = EnumProperty(name="Transition Mode", + description="Type of transition that should occur between the two cameras", + items=[("ignore", "Ignore Camera", "Ignore this camera and do not transition"), + ("auto", "Auto", "Auto transition as defined by the two cameras' properies"), + ("manual", "Manual", "Manually defined transition")], + default="auto", + options=set()) + enabled = BoolProperty(name="Enabled", + description="Export this transition", + default=True, + options=set()) + + +class PlasmaCameraProperties(bpy.types.PropertyGroup): + # Point of Attention + poa_type = EnumProperty(name="Point of Attention", + description="The point of attention that this camera tracks", + items=[("avatar", "Track Local Player", "Camera tracks the player's avatar"), + ("object", "Track Object", "Camera tracks an object in the scene"), + ("none", "Don't Track", "Camera does not track anything")], + options=set()) + poa_object = PointerProperty(name="PoA Object", + description="Object the camera should track as its Point of Attention", + type=bpy.types.Object, + options=set()) + poa_offset = FloatVectorProperty(name="PoA Offset", + description="Offset from the point of attention's origin to track", + soft_min=-50.0, soft_max=50.0, + size=3, default=(0.0, 0.0, 3.0), + options=set()) + poa_worldspace = BoolProperty(name="Worldspace Offset", + description="Point of Attention Offset is in worldspace coordinates", + options=set()) + + # Position Offset + pos_offset = FloatVectorProperty(name="Position Offset", + description="Offset the camera's position", + soft_min=-50.0, soft_max=50.0, + size=3, default=(0.0, 10.0, 3.0), + options=set()) + pos_worldspace = BoolProperty(name="Worldspace Offset", + description="Position offset is in worldspace coordinates", + options=set()) + + # Default Transition + transition = PointerProperty(type=PlasmaTransition, options=set()) + + # Limit Panning + x_pan_angle = FloatProperty(name="X Degrees", + description="Maximum camera pan angle in the X direction", + min=0.0, max=math.radians(180.0), precision=0, default=math.radians(90.0), + subtype="ANGLE", options=set()) + y_pan_angle = FloatProperty(name="Y Degrees", + description="Maximum camera pan angle in the Y direction", + min=0.0, max=math.radians(180.0), precision=0, default=math.radians(90.0), + subtype="ANGLE", options=set()) + pan_rate = FloatProperty(name="Pan Velocity", + description="", + min=0.0, precision=1, default=50.0, + unit="VELOCITY", options=set()) + + # Zooming + fov = FloatProperty(name="Default FOV", + description="Horizontal Field of View angle", + min=0.0, max=math.radians(180.0), precision=0, default=math.radians(70.0), + subtype="ANGLE") + limit_zoom = BoolProperty(name="Limit Zoom", + description="The camera allows zooming per artist limitations", + options=set()) + zoom_max = FloatProperty(name="Max FOV", + description="Maximum camera FOV when zooming", + min=0.0, max=math.radians(180.0), precision=0, default=math.radians(120.0), + subtype="ANGLE", options=set()) + zoom_min = FloatProperty(name="Min FOV", + description="Minimum camera FOV when zooming", + min=0.0, max=math.radians(180.0), precision=0, default=math.radians(35.0), + subtype="ANGLE", options=set()) + zoom_rate = FloatProperty(name="Zoom Velocity", + description="Velocity of the camera's zoom in degrees per second", + min=0.0, max=180.0, precision=0, default=90.0, + unit="VELOCITY", options=set()) + + # Miscellaneous Movement Props + maintain_los = BoolProperty(name="Maintain LOS", + description="The camera should maintain line-of-sight with the object it's tracking", + options=set()) + fall_vertical = BoolProperty(name="Fall Camera", + description="The camera will orient itself vertically when the local player begins falling", + options=set()) + fast_run = BoolProperty(name="Faster When Falling", + description="The camera's velocity will have a floor when the local player is falling", + options=set()) + ignore_subworld = BoolProperty(name="Ignore Subworld Movement", + description="The camera will not be parented to any subworlds", + options=set()) + + # Core Type Properties + primary_camera = BoolProperty(name="Primary Camera", + description="The camera should be considered the Age's primary camera.", + options=set()) + + # Cricle Camera + def _get_circle_radius(self): + # This is coming from the UI, so we need to get the active object from + # Blender's context and pass that on to the actual getter. + return self.get_circle_radius(bpy.context.object) + def _set_circle_radius(self, value): + # Don't really care about error checking... + self.circle_radius_value = value + + circle_center = PointerProperty(name="Center", + description="Center of the circle camera's orbit", + type=bpy.types.Object, + options=set()) + circle_pos = EnumProperty(name="Position on Circle", + description="The point on the circle the camera moves to", + items=[("closest", "Closest Point", "The camera moves to the point on the circle closest to the Point of Attention"), + ("farthest", "Farthest Point", "The camera moves to the point on the circle farthest from the Point of Attention")], + options=set()) + circle_velocity = FloatProperty(name="Velocity", + description="Velocity of the circle camera in degrees per second", + min=0.0, max=math.radians(360.0), precision=0, default=math.radians(36.0), + subtype="ANGLE", options=set()) + circle_radius_ui = FloatProperty(name="Radius", + description="Radius at which the circle camera should orbit the Point of Attention", + min=0.0, get=_get_circle_radius, set=_set_circle_radius, options=set()) + circle_radius_value = FloatProperty(name="INTERNAL: Radius", + description="Radius at which the circle camera should orbit the Point of Attention", + min=0.0, default=8.5, options={"HIDDEN"}) + + # Animation + anim_enabled = BoolProperty(name="Animation Enabled", + description="Export the camera's animation", + default=True, + options=set()) + start_on_push = BoolProperty(name="Start on Push", + description="Start playing the camera's animation when the camera is activated", + default=True, + options=set()) + stop_on_pop = BoolProperty(name="Pause on Pop", + description="Pauses the camera's animation when the camera is no longer activated", + default=True, + options=set()) + reset_on_pop = BoolProperty(name="Reset on Pop", + description="Reset the camera's animation to the beginning when the camera is no longer activated", + options=set()) + + # Rail + rail_pos = EnumProperty(name="Position on Rail", + description="The point on the rail the camera moves to", + items=[("closest", "Closest Point", "The camera moves to the point on the rail closest to the Point of Attention"), + ("farthest", "Farthest Point", "The camera moves to the point on the rail farthest from the Point of Attention")], + options=set()) + + def get_circle_radius(self, bo): + """Gets the circle camera radius for this camera when it is attached to the given Object""" + assert bo is not None + if self.circle_center is not None: + vec = bo.location - self.circle_center.location + return vec.magnitude + return self.circle_radius_value + + def harvest_actors(self): + if self.poa_type == "object": + return set((self.poa_object.name),) + return set() + + +class PlasmaCamera(bpy.types.PropertyGroup): + camera_type = EnumProperty(name="Camera Type", + description="", + items=camera_types, + options=set()) + settings = PointerProperty(type=PlasmaCameraProperties, options=set()) + transitions = CollectionProperty(type=PlasmaManualTransition, + name="Transitions", + description="", + options=set()) + active_transition_index = IntProperty(options={"HIDDEN"}) diff --git a/korman/ui/__init__.py b/korman/ui/__init__.py index c1f3e3f..dde0d43 100644 --- a/korman/ui/__init__.py +++ b/korman/ui/__init__.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU General Public License # along with Korman. If not, see . +from .ui_camera import * from .ui_lamp import * from .ui_list import * from .ui_menus import * diff --git a/korman/ui/modifiers/region.py b/korman/ui/modifiers/region.py index 58b6b23..52eb6e7 100644 --- a/korman/ui/modifiers/region.py +++ b/korman/ui/modifiers/region.py @@ -14,6 +14,25 @@ # along with Korman. If not, see . import bpy +from .. import ui_camera + +def camera_rgn(modifier, layout, context): + layout.prop(modifier, "camera_type") + if modifier.camera_type == "manual": + layout.prop(modifier, "camera_object", icon="CAMERA_DATA") + else: + cam_type = modifier.camera_type[5:] + cam_props = modifier.auto_camera + + def _draw_props(layout, cb): + for i in cb: + layout.separator() + i(layout, cam_type, cam_props) + + _draw_props(layout, (ui_camera.draw_camera_mode_props, + ui_camera.draw_camera_poa_props, + ui_camera.draw_camera_pos_props, + ui_camera.draw_camera_manipulation_props)) def footstep(modifier, layout, context): layout.prop(modifier, "bounds") diff --git a/korman/ui/ui_camera.py b/korman/ui/ui_camera.py new file mode 100644 index 0000000..d0533fc --- /dev/null +++ b/korman/ui/ui_camera.py @@ -0,0 +1,272 @@ +# This file is part of Korman. +# +# Korman 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. +# +# Korman 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 Korman. If not, see . + +import bpy + +from .. import helpers +from . import ui_list + +def _draw_alert_prop(layout, props, the_prop, cam_type, alert_cam="", min=None, max=None, **kwargs): + can_alert = not alert_cam or alert_cam == cam_type + if can_alert: + value = getattr(props, the_prop) + if min is not None and value < min: + layout.alert = True + if max is not None and value > max: + layout.alert = True + layout.prop(props, the_prop, **kwargs) + layout.alert = False + else: + layout.prop(props, the_prop, **kwargs) + +def _draw_gated_prop(layout, props, gate_prop, actual_prop): + row = layout.row(align=True) + row.prop(props, gate_prop, text="") + row = row.row(align=True) + row.active = getattr(props, gate_prop) + row.prop(props, actual_prop) + +def draw_camera_manipulation_props(layout, cam_type, props): + # Camera Panning + split = layout.split() + col = split.column() + col.label("Limit Panning:") + col.prop(props, "x_pan_angle") + col.prop(props, "y_pan_angle") + + # Camera Zoom + col = split.column() + col.label("Field of View:") + col.prop(props, "fov") + _draw_gated_prop(col, props, "limit_zoom", "zoom_min") + _draw_gated_prop(col, props, "limit_zoom", "zoom_max") + _draw_gated_prop(col, props, "limit_zoom", "zoom_rate") + +def draw_camera_mode_props(layout, cam_type, props): + # Point of Attention + split = layout.split() + col = split.column() + col.label("Camera Mode:") + col = col.column() + col.alert = cam_type != "fixed" and props.poa_type == "none" + col.prop(props, "poa_type", text="") + col.alert = False + row = col.row() + row.active = props.poa_type == "object" + row.prop(props, "poa_object", text="") + col.separator() + col.prop(props, "primary_camera") + + # Miscellaneous + col = split.column() + col.label("Tracking Settings:") + col.prop(props, "maintain_los") + col.prop(props, "fall_vertical") + col.prop(props, "fast_run") + col.prop(props, "ignore_subworld") + +def draw_camera_poa_props(layout, cam_type, props): + trans = props.transition + + # PoA Tracking + split = layout.split() + col = split.column() + col.label("Default Tracking Transition:") + col.prop(trans, "poa_acceleration", text="Acceleration") + col.prop(trans, "poa_deceleration", text="Deceleration") + col.prop(trans, "poa_velocity", text="Maximum Velocity") + col = col.column() + col.active = cam_type == "follow" + col.prop(trans, "poa_cut", text="Cut Animation") + + # PoA Offset + col = split.column() + col.label("Point of Attention Offset:") + col.prop(props, "poa_offset", text="") + col.prop(props, "poa_worldspace") + +def draw_camera_pos_props(layout, cam_type, props): + trans = props.transition + + # Position Tracking (only for follow cams) + split = layout.split() + col = split.column() + + # Position Transitions + col.active = cam_type != "circle" + col.label("Default Position Transition:") + _draw_alert_prop(col, trans, "pos_acceleration", cam_type, + alert_cam="rail", max=10.0, text="Acceleration") + _draw_alert_prop(col, trans, "pos_deceleration", cam_type, + alert_cam="rail", max=10.0, text="Deceleration") + _draw_alert_prop(col, trans, "pos_velocity", cam_type, + alert_cam="rail", max=10.0, text="Maximum Velocity") + col = col.column() + col.active = cam_type == "follow" + col.prop(trans, "pos_cut", text="Cut Animation") + + # Position Offsets + col = split.column() + col.active = cam_type == "follow" + col.label("Position Offset:") + col.prop(props, "pos_offset", text="") + col.prop(props, "pos_worldspace") + +def draw_circle_camera_props(layout, props): + # Circle Camera Stuff + layout.prop(props, "circle_center") + layout.prop(props, "circle_pos") + layout.prop(props, "circle_velocity") + row = layout.row(align=True) + row.active = props.circle_center is None + row.prop(props, "circle_radius_ui") + +class CameraButtonsPanel: + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "data" + + @classmethod + def poll(cls, context): + return (context.camera and context.scene.render.engine == "PLASMA_GAME") + + +class PlasmaCameraTypePanel(CameraButtonsPanel, bpy.types.Panel): + bl_label = "" + bl_options = {"HIDE_HEADER"} + + def draw(self, context): + camera = context.camera.plasma_camera + self.layout.prop(camera, "camera_type") + + +class PlasmaCameraModePanel(CameraButtonsPanel, bpy.types.Panel): + bl_label = "Camera Tracking" + + def draw(self, context): + camera = context.camera.plasma_camera + draw_camera_mode_props(self.layout, camera.camera_type, camera.settings) + + +class PlasmaCameraAttentionPanel(CameraButtonsPanel, bpy.types.Panel): + bl_label = "Point of Attention Tracking" + + def draw(self, context): + camera = context.camera.plasma_camera + draw_camera_poa_props(self.layout, camera.camera_type, camera.settings) + + +class PlasmaCameraPositionPanel(CameraButtonsPanel, bpy.types.Panel): + bl_label = "Position Tracking" + + def draw(self, context): + camera = context.camera.plasma_camera + draw_camera_pos_props(self.layout, camera.camera_type, camera.settings) + + +class PlasmaCameraCirclePanel(CameraButtonsPanel, bpy.types.Panel): + bl_label = "Circle Camera" + + def draw(self, context): + camera = context.camera.plasma_camera + draw_circle_camera_props(self.layout, camera.settings) + + @classmethod + def poll(cls, context): + return super().poll(context) and context.camera.plasma_camera.camera_type == "circle" + + +class PlasmaCameraAnimationPanel(CameraButtonsPanel, bpy.types.Panel): + bl_label = "Camera Animation" + bl_options = {"DEFAULT_CLOSED"} + + def draw(self, context): + layout = self.layout + camera = context.camera.plasma_camera + props = camera.settings + + split = layout.split() + col = split.column() + col.label("Animation:") + col.active = props.anim_enabled and any(helpers.fetch_fcurves(context.object)) + col.prop(props, "start_on_push") + col.prop(props, "stop_on_pop") + col.prop(props, "reset_on_pop") + + col = split.column() + col.active = camera.camera_type == "rail" + col.label("Rail:") + col.prop(props, "rail_pos", text="") + + def draw_header(self, context): + self.layout.active = any(helpers.fetch_fcurves(context.object)) + self.layout.prop(context.camera.plasma_camera.settings, "anim_enabled", text="") + + +class PlasmaCameraViewPanel(CameraButtonsPanel, bpy.types.Panel): + bl_label = "Camera Lens" + + def draw(self, context): + camera = context.camera.plasma_camera + draw_camera_manipulation_props(self.layout, camera.camera_type, camera.settings) + + +class TransitionListUI(bpy.types.UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0): + if item.camera is None: + layout.label("[Default Transition]") + else: + layout.label(item.camera.name, icon="CAMERA_DATA") + layout.prop(item, "enabled", text="") + + +class PlasmaCameraTransitionPanel(CameraButtonsPanel, bpy.types.Panel): + bl_label = "Transitions" + + def draw(self, context): + layout = self.layout + camera = context.camera.plasma_camera + + ui_list.draw_list(layout, "TransitionListUI", "camera", camera, "transitions", + "active_transition_index", rows=3, maxrows=4) + + try: + item = camera.transitions[camera.active_transition_index] + trans = item.transition + except: + pass + else: + layout.separator() + box = layout.box() + box.prop(item, "camera", text="Transition From") + box.prop(item, "mode") + + box.separator() + split = box.split() + split.active = item.mode == "manual" + + col = split.column() + col.label("Tracking Transition:") + col.prop(trans, "poa_acceleration", text="Acceleration") + col.prop(trans, "poa_deceleration", text="Deceleration") + col.prop(trans, "poa_velocity", text="Maximum Velocity") + col.prop(trans, "poa_cut", text="Cut Transition") + + col = split.column() + col.label("Position Transition:") + col.prop(trans, "pos_acceleration", text="Acceleration") + col.prop(trans, "pos_deceleration", text="Deceleration") + col.prop(trans, "pos_velocity", text="Maximum Velocity") + col.prop(trans, "pos_cut", text="Cut Transition")