diff --git a/bbp_ng/OP_OBJECT_game_view.py b/bbp_ng/OP_OBJECT_game_view.py new file mode 100644 index 0000000..52e1505 --- /dev/null +++ b/bbp_ng/OP_OBJECT_game_view.py @@ -0,0 +1,355 @@ +import bpy, mathutils +import typing, enum, math +from . import UTIL_functions + +#region Game Resolution + +# TODO: this is a temporary solution. +# We use Virtools Camera data replace this once we finish it. + +class BBP_OT_game_resolution(bpy.types.Operator): + """Set Blender render resolution to Ballance game""" + bl_idname = "bbp.game_resolution" + bl_label = "Game Resolution" + bl_options = {'REGISTER', 'UNDO'} + bl_translation_context = 'BBP_OT_game_resolution' + + def invoke(self, context, event): + return self.execute(context) + + def draw(self, context): + layout = self.layout + + def execute(self, context): + return {'FINISHED'} + +#endregion + +#region Game Camera + +#region Enum Defines + +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 + +# TODO: update this operator after we finish Camera import and export. + +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) + ) # type: ignore + + rotation_kind: bpy.props.EnumProperty( + name = "Rotation Angle Kind", + description = "", + items = _g_EnumHelper_RotationKind.generate_items(), + default = _g_EnumHelper_RotationKind.to_selection(RotationKind.Preset) + ) # type: ignore + preset_rotation_angle: bpy.props.EnumProperty( + name = "Preset Rotation Angle", + description = "", + items = _g_EnumHelper_RotationAngle.generate_items(), + default = _g_EnumHelper_RotationAngle.to_selection(RotationAngle.Deg0) + ) # type: ignore + custom_rotation_angle: bpy.props.FloatProperty( + name = "Custom Rotation Angle", + description = "The rotation angle of camera relative to 3D Cursor", + 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, + ) # 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) + ) # 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: + layout.prop(self, 'preset_rotation_angle', text='') + 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) + + 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) + + # 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) + + # 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) -> None: + # TODO: use Virtools Camera replace these in future + + # fetch camera + camera = typing.cast(bpy.types.Camera, camobj.data) + + # set clipping + camera.clip_start = 4 + camera.clip_end = 1200 + # set FOV + camera.lens_unit = 'FOV' + camera.angle = math.radians(58) + +#endregion + +def register() -> None: + bpy.utils.register_class(BBP_OT_game_resolution) + bpy.utils.register_class(BBP_OT_game_camera) + +def unregister() -> None: + bpy.utils.unregister_class(BBP_OT_game_camera) + bpy.utils.unregister_class(BBP_OT_game_resolution) + diff --git a/bbp_ng/__init__.py b/bbp_ng/__init__.py index 41f637a..901fb99 100644 --- a/bbp_ng/__init__.py +++ b/bbp_ng/__init__.py @@ -23,7 +23,7 @@ from . import OP_IMPORT_bmfile, OP_EXPORT_bmfile, OP_IMPORT_virtools, OP_EXPORT_ from . import OP_UV_flatten_uv, OP_UV_rail_uv from . import OP_MTL_fix_materials from . import OP_ADDS_component, OP_ADDS_bme, OP_ADDS_rail -from . import OP_OBJECT_legacy_align, OP_OBJECT_virtools_group, OP_OBJECT_snoop_group_then_to_mesh, OP_OBJECT_naming_convention +from . import OP_OBJECT_legacy_align, OP_OBJECT_virtools_group, OP_OBJECT_snoop_group_then_to_mesh, OP_OBJECT_naming_convention, OP_OBJECT_game_view #endregion @@ -170,7 +170,7 @@ class BBP_MT_View3DMenu(bpy.types.Menu): bl_translation_context = 'BBP_MT_View3DMenu' def draw(self, context): - layout = self.layout + layout = typing.cast(bpy.types.UILayout, self.layout) layout.label(text='UV', icon='UV', text_ctxt='BBP_MT_View3DMenu/draw') layout.operator(OP_UV_flatten_uv.BBP_OT_flatten_uv.bl_idname) layout.operator(OP_UV_rail_uv.BBP_OT_rail_uv.bl_idname) @@ -178,6 +178,10 @@ class BBP_MT_View3DMenu(bpy.types.Menu): layout.label(text='Align', icon='SNAP_ON', text_ctxt='BBP_MT_View3DMenu/draw') layout.operator(OP_OBJECT_legacy_align.BBP_OT_legacy_align.bl_idname) layout.separator() + layout.label(text='Camera', icon='CAMERA_DATA', text_ctxt='BBP_MT_View3DMenu/draw') + layout.operator(OP_OBJECT_game_view.BBP_OT_game_resolution.bl_idname) + layout.operator(OP_OBJECT_game_view.BBP_OT_game_camera.bl_idname) + layout.separator() layout.label(text='Select', icon='SELECT_SET', text_ctxt='BBP_MT_View3DMenu/draw') layout.operator(OP_OBJECT_virtools_group.BBP_OT_select_object_by_virtools_group.bl_idname) layout.separator() @@ -346,6 +350,7 @@ def register() -> None: OP_OBJECT_virtools_group.register() OP_OBJECT_snoop_group_then_to_mesh.register() OP_OBJECT_naming_convention.register() + OP_OBJECT_game_view.register() # register other classes for cls in g_BldClasses: @@ -368,6 +373,7 @@ def unregister() -> None: bpy.utils.unregister_class(cls) # unregister modules + OP_OBJECT_game_view.unregister() OP_OBJECT_naming_convention.unregister() OP_OBJECT_snoop_group_then_to_mesh.unregister() OP_OBJECT_virtools_group.unregister()