import bpy, mathutils import typing, enum, math from . import UTIL_functions, PROP_virtools_camera #region Enum Defines class ResolutionKind(enum.IntEnum): Normal = enum.auto() WideScreen = enum.auto() _g_ResolutionKindDesc: dict[ResolutionKind, tuple[str, str]] = { ResolutionKind.Normal: ("Normal", "Vanilla Ballance Resolution"), ResolutionKind.WideScreen: ("Wide Screen", "Ballance Resolution with Wide Screen Fix"), } _g_EnumHelper_ResolutionKind = UTIL_functions.EnumPropHelper( ResolutionKind, lambda x: str(x.value), lambda x: ResolutionKind(int(x)), lambda x: _g_ResolutionKindDesc[x][0], lambda x: _g_ResolutionKindDesc[x][1], lambda _: "" ) class TargetKind(enum.IntEnum): Cursor = enum.auto() ActiveObject = enum.auto() _g_TargetKindDesc: dict[TargetKind, tuple[str, str, str]] = { TargetKind.Cursor: ("3D Cursor", "3D cursor is player ball.", "CURSOR"), TargetKind.ActiveObject: ("Active Object", "The origin point of active object is player ball.", "OBJECT_DATA"), } _g_EnumHelper_TargetKind = UTIL_functions.EnumPropHelper( TargetKind, lambda x: str(x.value), lambda x: TargetKind(int(x)), lambda x: _g_TargetKindDesc[x][0], lambda x: _g_TargetKindDesc[x][1], lambda x: _g_TargetKindDesc[x][2], ) class RotationKind(enum.IntEnum): Preset = enum.auto() Custom = enum.auto() _g_RotationKindDesc: dict[RotationKind, tuple[str, str]] = { RotationKind.Preset: ("Preset", "8 preset rotation angles usually used in game."), RotationKind.Custom: ("Custom", "User manually input rotation angle.") } _g_EnumHelper_RotationKind = UTIL_functions.EnumPropHelper( RotationKind, lambda x: str(x.value), lambda x: RotationKind(int(x)), lambda x: _g_RotationKindDesc[x][0], lambda x: _g_RotationKindDesc[x][1], lambda _: "" ) class RotationAngle(enum.IntEnum): Deg0 = enum.auto() Deg45 = enum.auto() Deg90 = enum.auto() Deg135 = enum.auto() Deg180 = enum.auto() Deg225 = enum.auto() Deg270 = enum.auto() Deg315 = enum.auto() def to_degree(self) -> float: match self: case RotationAngle.Deg0: return 0 case RotationAngle.Deg45: return 45 case RotationAngle.Deg90: return 90 case RotationAngle.Deg135: return 135 case RotationAngle.Deg180: return 180 case RotationAngle.Deg225: return 225 case RotationAngle.Deg270: return 270 case RotationAngle.Deg315: return 315 def to_radians(self) -> float: return math.radians(self.to_degree()) _g_RotationAngleDesc: dict[RotationAngle, tuple[str, str]] = { # TODO: Add axis direction in description after we add Camera support when importing # (because we only can confirm game camera behavior after that). RotationAngle.Deg0: ("0 Degree", "0 degree"), RotationAngle.Deg45: ("45 Degree", "45 degree"), RotationAngle.Deg90: ("90 Degree", "90 degree"), RotationAngle.Deg135: ("135 Degree", "135 degree"), RotationAngle.Deg180: ("180 Degree", "180 degree"), RotationAngle.Deg225: ("225 Degree", "225 degree"), RotationAngle.Deg270: ("270 Degree", "270 degree"), RotationAngle.Deg315: ("315 Degree", "315 degree"), } _g_EnumHelper_RotationAngle = UTIL_functions.EnumPropHelper( RotationAngle, lambda x: str(x.value), lambda x: RotationAngle(int(x)), lambda x: _g_RotationAngleDesc[x][0], lambda x: _g_RotationAngleDesc[x][1], lambda _: "" ) class PerspectiveKind(enum.IntEnum): Ordinary = enum.auto() Lift = enum.auto() EasterEgg = enum.auto() _g_PerspectiveKindDesc: dict[PerspectiveKind, tuple[str, str]] = { PerspectiveKind.Ordinary: ("Ordinary", "The default perspective for game camera."), PerspectiveKind.Lift: ("Lift", "Lifted camera in game for downcast level."), PerspectiveKind.EasterEgg: ("Easter Egg", "A very close view to player ball in game."), } _g_EnumHelper_PerspectiveKind = UTIL_functions.EnumPropHelper( PerspectiveKind, lambda x: str(x.value), lambda x: PerspectiveKind(int(x)), lambda x: _g_PerspectiveKindDesc[x][0], lambda x: _g_PerspectiveKindDesc[x][1], lambda _: "" ) #endregion class BBP_OT_game_camera(bpy.types.Operator): """Order active camera look at target like Ballance does""" bl_idname = "bbp.game_camera" bl_label = "Game Camera" bl_options = {'REGISTER', 'UNDO'} bl_translation_context = 'BBP_OT_game_camera' target_kind: bpy.props.EnumProperty( name = "Target Kind", description = "", items = _g_EnumHelper_TargetKind.generate_items(), default = _g_EnumHelper_TargetKind.to_selection(TargetKind.Cursor), translation_context = 'BBP_OT_game_camera/property' ) # type: ignore rotation_kind: bpy.props.EnumProperty( # YYC MAKR: # This property is not shown on UI layout, # but it should be translated because it is not PURE assistant property. name = "Rotation Angle Kind", description = "", items = _g_EnumHelper_RotationKind.generate_items(), default = _g_EnumHelper_RotationKind.to_selection(RotationKind.Preset), translation_context = 'BBP_OT_game_camera/property' ) # type: ignore preset_rotation_angle: bpy.props.EnumProperty( name = "Preset Rotation Angle", description = "", translation_context = 'BBP_OT_game_camera/property', options = {'HIDDEN'}, items = _g_EnumHelper_RotationAngle.generate_items(), default = _g_EnumHelper_RotationAngle.to_selection(RotationAngle.Deg0), ) # type: ignore def preset_rotation_angle_deg_getter(self, probe) -> bool: return _g_EnumHelper_RotationAngle.get_selection(self.preset_rotation_angle) == probe def preset_rotation_angle_deg_setter(self, val) -> None: self.preset_rotation_angle = _g_EnumHelper_RotationAngle.to_selection(val) return None preset_rotation_angle_deg0: bpy.props.BoolProperty( name = "0 Degree", translation_context = 'BBP_OT_game_camera/property', get = lambda self: BBP_OT_game_camera.preset_rotation_angle_deg_getter(self, RotationAngle.Deg0), set = lambda self, _: BBP_OT_game_camera.preset_rotation_angle_deg_setter(self, RotationAngle.Deg0) ) # type: ignore preset_rotation_angle_deg45: bpy.props.BoolProperty( name = "45 Degree", translation_context = 'BBP_OT_game_camera/property', get = lambda self: BBP_OT_game_camera.preset_rotation_angle_deg_getter(self, RotationAngle.Deg45), set = lambda self, _: BBP_OT_game_camera.preset_rotation_angle_deg_setter(self, RotationAngle.Deg45) ) # type: ignore preset_rotation_angle_deg90: bpy.props.BoolProperty( name = "90 Degree", translation_context = 'BBP_OT_game_camera/property', get = lambda self: BBP_OT_game_camera.preset_rotation_angle_deg_getter(self, RotationAngle.Deg90), set = lambda self, _: BBP_OT_game_camera.preset_rotation_angle_deg_setter(self, RotationAngle.Deg90) ) # type: ignore preset_rotation_angle_deg135: bpy.props.BoolProperty( name = "135 Degree", translation_context = 'BBP_OT_game_camera/property', get = lambda self: BBP_OT_game_camera.preset_rotation_angle_deg_getter(self, RotationAngle.Deg135), set = lambda self, _: BBP_OT_game_camera.preset_rotation_angle_deg_setter(self, RotationAngle.Deg135) ) # type: ignore preset_rotation_angle_deg180: bpy.props.BoolProperty( name = "180 Degree", translation_context = 'BBP_OT_game_camera/property', get = lambda self: BBP_OT_game_camera.preset_rotation_angle_deg_getter(self, RotationAngle.Deg180), set = lambda self, _: BBP_OT_game_camera.preset_rotation_angle_deg_setter(self, RotationAngle.Deg180) ) # type: ignore preset_rotation_angle_deg225: bpy.props.BoolProperty( name = "225 Degree", translation_context = 'BBP_OT_game_camera/property', get = lambda self: BBP_OT_game_camera.preset_rotation_angle_deg_getter(self, RotationAngle.Deg225), set = lambda self, _: BBP_OT_game_camera.preset_rotation_angle_deg_setter(self, RotationAngle.Deg225) ) # type: ignore preset_rotation_angle_deg270: bpy.props.BoolProperty( name = "270 Degree", translation_context = 'BBP_OT_game_camera/property', get = lambda self: BBP_OT_game_camera.preset_rotation_angle_deg_getter(self, RotationAngle.Deg270), set = lambda self, _: BBP_OT_game_camera.preset_rotation_angle_deg_setter(self, RotationAngle.Deg270) ) # type: ignore preset_rotation_angle_deg315: bpy.props.BoolProperty( name = "315 Degree", translation_context = 'BBP_OT_game_camera/property', get = lambda self: BBP_OT_game_camera.preset_rotation_angle_deg_getter(self, RotationAngle.Deg315), set = lambda self, _: BBP_OT_game_camera.preset_rotation_angle_deg_setter(self, RotationAngle.Deg315) ) # type: ignore custom_rotation_angle: bpy.props.FloatProperty( name = "Custom Rotation Angle", description = "The rotation angle of camera relative to 3D Cursor or Active Object", subtype = 'ANGLE', min = 0, max = math.radians(360), step = 100, # MARK: What the fuck of the precision? # I set it to 2 but it doesn't work so I forcely set it to 100. precision = 100, translation_context = 'BBP_OT_game_camera/property' ) # type: ignore perspective_kind: bpy.props.EnumProperty( name = "Rotation Angle Kind", description = "", items = _g_EnumHelper_PerspectiveKind.generate_items(), default = _g_EnumHelper_PerspectiveKind.to_selection(PerspectiveKind.Ordinary), translation_context = 'BBP_OT_game_camera/property' ) # type: ignore modify_resolution: bpy.props.BoolProperty( name = 'Modify Resolution', description = 'Whether modify the resolution of camera.', default = False, translation_context = 'BBP_OT_game_camera/property' ) # type: ignore resolution_kind: bpy.props.EnumProperty( name = "Resolution Kind", description = "The type of preset resolution.", items = _g_EnumHelper_ResolutionKind.generate_items(), default = _g_EnumHelper_ResolutionKind.to_selection(ResolutionKind.Normal), translation_context = 'BBP_OT_game_camera/property' ) # type: ignore @classmethod def poll(cls, context): # find camera object camera_obj = _find_camera_obj() if camera_obj is None: return False # find active object active_obj = bpy.context.active_object if active_obj is None: return False # camera object should not be active object return camera_obj != active_obj def invoke(self, context, event): # order user enter camera view _enter_camera_view() # then execute following code return self.execute(context) def draw(self, context): layout = self.layout # Show target picker layout.label(text='Target', text_ctxt='BBP_OT_game_camera/draw') layout.row().prop(self, 'target_kind', expand=True) # Show rotation angle according to different types. layout.separator() layout.label(text='Rotation', text_ctxt='BBP_OT_game_camera/draw') layout.row().prop(self, 'rotation_kind', expand=True) rot_kind = _g_EnumHelper_RotationKind.get_selection(self.rotation_kind) match rot_kind: case RotationKind.Preset: # for preset angles, we show a special layout (grid view) subgrid = layout.grid_flow(row_major=True, columns=3, even_columns=True, even_rows=True, align=True) subgrid.prop(self, 'preset_rotation_angle_deg315', toggle = 1) subgrid.prop(self, 'preset_rotation_angle_deg0', toggle = 1) subgrid.prop(self, 'preset_rotation_angle_deg45', toggle = 1) subgrid.prop(self, 'preset_rotation_angle_deg270', toggle = 1) subicon = subgrid.row() subicon.alignment = 'CENTER' subicon.label(text='', icon='MESH_CIRCLE') # show a 3d circle as icon subgrid.prop(self, 'preset_rotation_angle_deg90', toggle = 1) subgrid.prop(self, 'preset_rotation_angle_deg225', toggle = 1) subgrid.prop(self, 'preset_rotation_angle_deg180', toggle = 1) subgrid.prop(self, 'preset_rotation_angle_deg135', toggle = 1) case RotationKind.Custom: layout.prop(self, 'custom_rotation_angle', text='') # Show perspective kind layout.separator() layout.label(text='Perspective', text_ctxt='BBP_OT_game_camera/draw') layout.row().prop(self, 'perspective_kind', expand=True) # Show resolution kind layout.separator() layout.prop(self, 'modify_resolution', text='Resolution', text_ctxt='BBP_OT_game_camera/draw') if self.modify_resolution: layout.row().prop(self, 'resolution_kind', expand=True) def execute(self, context): # fetch angle angle: float rot_kind = _g_EnumHelper_RotationKind.get_selection(self.rotation_kind) match rot_kind: case RotationKind.Preset: rot_angle = _g_EnumHelper_RotationAngle.get_selection(self.preset_rotation_angle) angle = rot_angle.to_radians() case RotationKind.Custom: angle = float(self.custom_rotation_angle) # fetch others camera_obj = typing.cast(bpy.types.Object, _find_camera_obj()) target_kind = _g_EnumHelper_TargetKind.get_selection(self.target_kind) perspective_kind = _g_EnumHelper_PerspectiveKind.get_selection(self.perspective_kind) resolution_kind = _g_EnumHelper_ResolutionKind.get_selection(self.resolution_kind) # setup its transform and properties glob_trans = _fetch_glob_translation(camera_obj, target_kind) _setup_camera_transform(camera_obj, angle, perspective_kind, glob_trans) _setup_camera_properties(camera_obj, resolution_kind) # return return {'FINISHED'} def _find_3d_view_space() -> bpy.types.SpaceView3D | None: # get current area area = bpy.context.area if area is None: return None # check whether it is 3d view if area.type != 'VIEW_3D': return None # get the active space in area space = area.spaces.active if space is None: return None # okey. cast its type and return return typing.cast(bpy.types.SpaceView3D, space) def _enter_camera_view() -> None: space = _find_3d_view_space() if space is None: return region = space.region_3d if region is None: return region.view_perspective = 'CAMERA' def _find_camera_obj() -> bpy.types.Object | None: space = _find_3d_view_space() if space is None: return None return space.camera def _fetch_glob_translation(camobj: bpy.types.Object, target_kind: TargetKind) -> mathutils.Vector: # we have checked any bad cases in "poll", # so we can simply return value in there without any check. match target_kind: case TargetKind.Cursor: return bpy.context.scene.cursor.location case TargetKind.ActiveObject: return bpy.context.active_object.location def _setup_camera_transform(camobj: bpy.types.Object, angle: float, perspective: PerspectiveKind, glob_trans: mathutils.Vector) -> None: # decide the camera offset with ref point ingamecam_pos: mathutils.Vector match perspective: case PerspectiveKind.Ordinary: ingamecam_pos = mathutils.Vector((22, 0, 35)) case PerspectiveKind.Lift: ingamecam_pos = mathutils.Vector((22, 0, 35 + 20)) case PerspectiveKind.EasterEgg: ingamecam_pos = mathutils.Vector((22, 0, 3.86)) # decide the position of ref point refpot_pos: mathutils.Vector match perspective: case PerspectiveKind.EasterEgg: refpot_pos = mathutils.Vector((4.4, 0, 0)) case _: refpot_pos = mathutils.Vector((0, 0, 0)) # perform rotation for both positions player_rot_mat = mathutils.Matrix.Rotation(angle, 4, 'Z') ingamecam_pos = ingamecam_pos @ player_rot_mat refpot_pos = refpot_pos @ player_rot_mat # calculate the rotation of camera # YYC MARK: # Following code are linear algebra required. # # We can calulate the direction of camera by simply substracting 2 vector. # In default, the direction of camera is -Z, up direction is +Y. # So this computed direction is -Z in new cooredinate system. # Now we can compute +Z axis in this new coordinate system. new_z = (ingamecam_pos - refpot_pos) new_z.normalize() # For ballance camera, all camera is +Z up. # So we can use it to compute +X axis in new coordinate system assistant_y = mathutils.Vector((0, 0, 1)) new_x = typing.cast(mathutils.Vector, assistant_y.cross(new_z)) new_x.normalize() # now we calc the final axis new_y = typing.cast(mathutils.Vector, new_z.cross(new_x)) new_y.normalize() # okey, we conbine them as a matrix rot_mat = mathutils.Matrix(( (new_x.x, new_y.x, new_z.x, 0), (new_x.y, new_y.y, new_z.y, 0), (new_x.z, new_y.z, new_z.z, 0), (0, 0, 0, 1) )) # calc the final transform matrix and apply it trans_mat = mathutils.Matrix.Translation(ingamecam_pos) glob_trans_mat = mathutils.Matrix.Translation(glob_trans) camobj.matrix_world = glob_trans_mat @ trans_mat @ rot_mat def _setup_camera_properties(camobj: bpy.types.Object, resolution_kind: ResolutionKind | None) -> None: # fetch camera and its raw data camera = typing.cast(bpy.types.Camera, camobj.data) rawdata = PROP_virtools_camera.get_raw_virtools_camera(camera) # set clipping rawdata.mFrontPlane = 4 rawdata.mBackPlane = 1200 # set FOV and aspect ratio according to presented resolution kind if resolution_kind is not None: match resolution_kind: case ResolutionKind.Normal: rawdata.mFov = math.radians(58) rawdata.mAspectRatio = (4, 3) case ResolutionKind.WideScreen: # prepare input arguments aspect_ratio = (16, 9) fov = math.radians(58) # FOV correction reference: # https://github.com/doyaGu/BallanceModLoaderPlus/blob/c4ab4386fd834af69a960c156fca97237b2fd4c5/src/RenderHook.cpp#L46 aspect = aspect_ratio[0] / aspect_ratio[1] rawdata.mFov = math.atan2(math.tan(fov * 0.5) * 0.75 * aspect, 1.0) * 2.0 rawdata.mAspectRatio = aspect_ratio # rewrite it back PROP_virtools_camera.set_raw_virtools_camera(camera, rawdata) # and apply it into camera and blender scene PROP_virtools_camera.apply_to_blender_camera(camera) PROP_virtools_camera.apply_to_blender_scene_resolution(camera) def register() -> None: bpy.utils.register_class(BBP_OT_game_camera) def unregister() -> None: bpy.utils.unregister_class(BBP_OT_game_camera)