mirror of https://github.com/H-uru/korman.git
24 changed files with 1320 additions and 43 deletions
@ -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 <http://www.gnu.org/licenses/>. |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import bpy |
||||
import mathutils |
||||
|
||||
from contextlib import contextmanager, ExitStack |
||||
import itertools |
||||
import math |
||||
from PyHSPlasma import * |
||||
from typing import * |
||||
import weakref |
||||
|
||||
from .explosions import ExportError |
||||
from .. import helpers |
||||
from . import utils |
||||
|
||||
if TYPE_CHECKING: |
||||
from .convert import Exporter |
||||
from .logger import _ExportLogger as ExportLogger |
||||
|
||||
|
||||
class Clipping(NamedTuple): |
||||
hither: float |
||||
yonder: float |
||||
|
||||
|
||||
class PostEffectModMatrices(NamedTuple): |
||||
c2w: hsMatrix44 |
||||
w2c: hsMatrix44 |
||||
|
||||
|
||||
class GuiConverter: |
||||
|
||||
if TYPE_CHECKING: |
||||
_parent: weakref.ref[Exporter] = ... |
||||
|
||||
def __init__(self, parent: Optional[Exporter] = None): |
||||
self._parent = weakref.ref(parent) if parent is not None else None |
||||
|
||||
# Go ahead and prepare the GUI transparent material for future use. |
||||
if parent is not None: |
||||
self._transp_material = parent.exit_stack.enter_context( |
||||
helpers.TemporaryObject( |
||||
bpy.data.materials.new("GUITransparent"), |
||||
bpy.data.materials.remove |
||||
) |
||||
) |
||||
self._transp_material.diffuse_color = mathutils.Vector((1.0, 1.0, 0.0)) |
||||
self._transp_material.use_mist = False |
||||
|
||||
# Cyan's transparent GUI materials just set an opacity of 0% |
||||
tex_slot = self._transp_material.texture_slots.add() |
||||
tex_slot.texture = parent.exit_stack.enter_context( |
||||
helpers.TemporaryObject( |
||||
bpy.data.textures.new("AutoTransparentLayer", "NONE"), |
||||
bpy.data.textures.remove |
||||
) |
||||
) |
||||
tex_slot.texture.plasma_layer.opacity = 0.0 |
||||
else: |
||||
self._transp_material = None |
||||
|
||||
def calc_camera_matrix( |
||||
self, |
||||
scene: bpy.types.Scene, |
||||
objects: Sequence[bpy.types.Object], |
||||
fov: float, |
||||
scale: float = 0.75 |
||||
) -> mathutils.Matrix: |
||||
if not objects: |
||||
raise ExportError("No objects specified for GUI Camera generation.") |
||||
|
||||
# Generally, GUIs are flat planes. However, we are not Cyan, so artists cannot walk down |
||||
# the hallway to get smacked on the knuckles by programmers. This means that they might |
||||
# give us some three dimensional crap as a GUI. Therefore, to come up with a camera matrix, |
||||
# we'll use the average area-weighted inverse normal of all the polygons they give us. That |
||||
# way, the camera *always* should face the GUI as would be expected. |
||||
remove_mesh = bpy.data.meshes.remove |
||||
avg_normal = mathutils.Vector() |
||||
for i in objects: |
||||
mesh = i.to_mesh(bpy.context.scene, True, "RENDER", calc_tessface=False) |
||||
with helpers.TemporaryObject(mesh, remove_mesh): |
||||
utils.transform_mesh(mesh, i.matrix_world) |
||||
for polygon in mesh.polygons: |
||||
avg_normal += (polygon.normal * polygon.area) |
||||
avg_normal.normalize() |
||||
avg_normal *= -1.0 |
||||
|
||||
# From the inverse area weighted normal we found above, get the rotation from the up axis |
||||
# (that is to say, the +Z axis) and create our rotation matrix. |
||||
axis = mathutils.Vector((avg_normal.x, avg_normal.y, 0.0)) |
||||
axis.normalize() |
||||
angle = math.acos(avg_normal.z) |
||||
mat = mathutils.Matrix.Rotation(angle, 3, axis) |
||||
|
||||
# Now, we know the rotation of the camera. Great! What we need to do now is ensure that all |
||||
# of the objects in question fit within the view of a 4:3 camera rotated as above. Blender |
||||
# helpfully provides us with the localspace bounding boxes of all the objects and an API to |
||||
# fit points into the camera view. |
||||
with ExitStack() as stack: |
||||
stack.enter_context(self.generate_camera_render_settings(scene)) |
||||
|
||||
# Create a TEMPORARY camera object so we can use a certain Blender API. |
||||
camera = stack.enter_context(utils.temporary_camera_object(scene, "GUICameraTemplate")) |
||||
camera.matrix_world = mat.to_4x4() |
||||
camera.data.angle = fov |
||||
camera.data.lens_unit = "FOV" |
||||
|
||||
# Get all of the bounding points and make sure they all fit inside the camera's view frame. |
||||
bound_boxes = [ |
||||
obj.matrix_world * mathutils.Vector(bbox) |
||||
for obj in objects for bbox in obj.bound_box |
||||
] |
||||
co, _ = camera.camera_fit_coords( |
||||
scene, |
||||
# bound_box is a list of vectors of each corner of all the objects' bounding boxes; |
||||
# however, Blender's API wants a sequence of individual channel positions. Therefore, |
||||
# we need to flatten the vectors. |
||||
list(itertools.chain.from_iterable(bound_boxes)) |
||||
) |
||||
|
||||
# This generates a list of 6 faces per bounding box, which we then flatten out and pass |
||||
# into the BVHTree constructor. This is to calculate the distance from the camera to the |
||||
# "entire GUI" - which we can then use to apply the scale given to us. |
||||
if scale != 1.0: |
||||
bvh = mathutils.bvhtree.BVHTree.FromPolygons( |
||||
bound_boxes, |
||||
list(itertools.chain.from_iterable( |
||||
[(i + 0, i + 1, i + 5, i + 4), |
||||
(i + 1, i + 2, i + 5, i + 6), |
||||
(i + 3, i + 2, i + 6, i + 7), |
||||
(i + 0, i + 1, i + 2, i + 3), |
||||
(i + 0, i + 3, i + 7, i + 4), |
||||
(i + 4, i + 5, i + 6, i + 7), |
||||
] for i in range(0, len(bound_boxes), 8) |
||||
)) |
||||
) |
||||
loc, normal, index, distance = bvh.find_nearest(co) |
||||
co += normal * distance * (scale - 1.0) |
||||
|
||||
# ... |
||||
mat.resize_4x4() |
||||
mat.translation = co |
||||
return mat |
||||
|
||||
def calc_clipping( |
||||
self, |
||||
pose: mathutils.Matrix, |
||||
scene: bpy.types.Scene, |
||||
objects: Sequence[bpy.types.Object], |
||||
fov: float |
||||
) -> Clipping: |
||||
with ExitStack() as stack: |
||||
stack.enter_context(self.generate_camera_render_settings(scene)) |
||||
camera = stack.enter_context(utils.temporary_camera_object(scene, "GUICameraTemplate")) |
||||
camera.matrix_world = pose |
||||
camera.data.angle = fov |
||||
camera.data.lens_unit = "FOV" |
||||
|
||||
# Determine the camera plane's normal so we can do a distance check against the |
||||
# bounding boxes of the objects shown in the GUI. |
||||
view_frame = [i * pose for i in camera.data.view_frame(scene)] |
||||
cam_plane = mathutils.geometry.normal(view_frame) |
||||
bound_boxes = ( |
||||
obj.matrix_world * mathutils.Vector(bbox) |
||||
for obj in objects for bbox in obj.bound_box |
||||
) |
||||
pos = pose.to_translation() |
||||
bounds_dists = [ |
||||
abs(mathutils.geometry.distance_point_to_plane(i, pos, cam_plane)) |
||||
for i in bound_boxes |
||||
] |
||||
|
||||
# Offset them by some epsilon to ensure the objects are rendered. |
||||
hither, yonder = min(bounds_dists), max(bounds_dists) |
||||
if yonder - 0.5 < hither: |
||||
hither -= 0.25 |
||||
yonder += 0.25 |
||||
return Clipping(hither, yonder) |
||||
|
||||
def convert_post_effect_matrices(self, camera_matrix: mathutils.Matrix) -> PostEffectModMatrices: |
||||
# PostEffectMod matrices face *away* from the GUI... For some reason. |
||||
# See plPostEffectMod::SetWorldToCamera() |
||||
c2w = utils.matrix44(camera_matrix) |
||||
w2c = utils.matrix44(camera_matrix.inverted()) |
||||
for i in range(4): |
||||
c2w[i, 2] *= -1.0 |
||||
w2c[2, i] *= -1.0 |
||||
return PostEffectModMatrices(c2w, w2c) |
||||
|
||||
@contextmanager |
||||
def generate_camera_render_settings(self, scene: bpy.types.Scene) -> Iterator[None]: |
||||
# Set the render info to basically TV NTSC 4:3, which will set Blender's camera |
||||
# viewport up as a 4:3 thingy to match Plasma. |
||||
with helpers.GoodNeighbor() as toggle: |
||||
toggle.track(scene.render, "resolution_x", 720) |
||||
toggle.track(scene.render, "resolution_y", 486) |
||||
toggle.track(scene.render, "pixel_aspect_x", 10.0) |
||||
toggle.track(scene.render, "pixel_aspect_y", 11.0) |
||||
yield |
||||
|
||||
@property |
||||
def _report(self) -> ExportLogger: |
||||
return self._parent().report |
||||
|
||||
@property |
||||
def transparent_material(self) -> bpy.types.Material: |
||||
assert self._transp_material is not None |
||||
return self._transp_material |
||||
|
@ -0,0 +1,119 @@
|
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import bpy |
||||
from bpy.props import * |
||||
|
||||
import math |
||||
|
||||
from ..exporter.explosions import ExportError |
||||
from ..exporter.gui import GuiConverter |
||||
|
||||
class CameraOperator: |
||||
@classmethod |
||||
def poll(cls, context): |
||||
return context.scene.render.engine == "PLASMA_GAME" |
||||
|
||||
|
||||
class PlasmaGameGuiCameraOperator(CameraOperator, bpy.types.Operator): |
||||
bl_idname = "camera.plasma_create_game_gui_camera" |
||||
bl_label = "Create Game GUI Camera" |
||||
bl_description = "Create a camera looking at all of the objects in this GUI page with a custom scale factor" |
||||
|
||||
fov: float = FloatProperty( |
||||
name="Field of View", |
||||
description="Camera Field of View angle", |
||||
subtype="ANGLE", |
||||
default=math.radians(90.0), |
||||
min=0.0, |
||||
max=math.radians(360.0), |
||||
precision=1, |
||||
options=set() |
||||
) |
||||
gui_page: str = StringProperty( |
||||
name="GUI Page", |
||||
description="", |
||||
options={"HIDDEN"} |
||||
) |
||||
scale: float = FloatProperty( |
||||
name="GUI Scale", |
||||
description="GUI Camera distance scale factor.", |
||||
default=75.0, |
||||
min=0.1, |
||||
soft_max=100.0, |
||||
precision=1, |
||||
subtype="PERCENTAGE", |
||||
options=set() |
||||
) |
||||
|
||||
mod_id: str = StringProperty(options={"HIDDEN"}) |
||||
cam_prop_name: str = StringProperty(options={"HIDDEN"}) |
||||
|
||||
def invoke(self, context, event): |
||||
context.window_manager.invoke_props_dialog(self) |
||||
return {"RUNNING_MODAL"} |
||||
|
||||
def execute(self, context): |
||||
# If the modifier has been given to us, select all of the objects in the |
||||
# given GUI page. |
||||
if self.gui_page: |
||||
for i in context.scene.objects: |
||||
i.select = i.plasma_object.page == self.gui_page |
||||
context.scene.update() |
||||
|
||||
visible_objects = [ |
||||
i for i in context.selected_objects |
||||
if i.type in {"MESH", "FONT"} |
||||
] |
||||
|
||||
gui = GuiConverter() |
||||
try: |
||||
cam_matrix = gui.calc_camera_matrix( |
||||
context.scene, |
||||
visible_objects, |
||||
self.fov, |
||||
self.scale / 100.0 |
||||
) |
||||
except ExportError as e: |
||||
self.report({"ERROR"}, str(e)) |
||||
return {"CANCELLED"} |
||||
|
||||
if self.mod_id and self.cam_prop_name: |
||||
modifier = getattr(context.object.plasma_modifiers, self.mod_id) |
||||
cam_obj = getattr(modifier, self.cam_prop_name) |
||||
else: |
||||
cam_obj = None |
||||
if cam_obj is None: |
||||
if self.gui_page: |
||||
name = f"{self.gui_page}_GUICamera" |
||||
else: |
||||
name = f"{context.object.name}_GUICamera" |
||||
cam_data = bpy.data.cameras.new(name) |
||||
cam_obj = bpy.data.objects.new(name, cam_data) |
||||
context.scene.objects.link(cam_obj) |
||||
|
||||
cam_obj.matrix_world = cam_matrix |
||||
cam_obj.data.angle = self.fov |
||||
cam_obj.data.lens_unit = "FOV" |
||||
|
||||
for i in context.scene.objects: |
||||
i.select = i == cam_obj |
||||
|
||||
if self.mod_id and self.cam_prop_name: |
||||
modifier = getattr(context.object.plasma_modifiers, self.mod_id) |
||||
setattr(modifier, self.cam_prop_name, cam_obj) |
||||
return {"FINISHED"} |
@ -0,0 +1,508 @@
|
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import bpy |
||||
from bpy.props import * |
||||
|
||||
import itertools |
||||
import math |
||||
from typing import * |
||||
|
||||
from PyHSPlasma import * |
||||
|
||||
from ...exporter import ExportError |
||||
from .base import PlasmaModifierProperties |
||||
from ... import idprops |
||||
|
||||
if TYPE_CHECKING: |
||||
from ...exporter import Exporter |
||||
from ..prop_world import PlasmaAge, PlasmaPage |
||||
|
||||
class _GameGuiMixin: |
||||
@property |
||||
def gui_sounds(self) -> Iterable[Tuple[str, int]]: |
||||
"""Overload to automatically export GUI sounds on the control. This should return an iterable |
||||
of tuple attribute name and sound index. |
||||
""" |
||||
return [] |
||||
|
||||
def get_control(self, exporter: Exporter, bo: Optional[bpy.types.Object] = None, so: Optional[plSceneObject] = None) -> Optional[pfGUIControlMod]: |
||||
return None |
||||
|
||||
@property |
||||
def has_gui_proc(self) -> bool: |
||||
return True |
||||
|
||||
def iterate_control_modifiers(self) -> Iterator[_GameGuiMixin]: |
||||
pl_mods = self.id_data.plasma_modifiers |
||||
yield from ( |
||||
getattr(pl_mods, i.pl_id) |
||||
for i in self.iterate_control_subclasses() |
||||
if getattr(pl_mods, i.pl_id).enabled |
||||
) |
||||
|
||||
@classmethod |
||||
def iterate_control_subclasses(cls) -> Iterator[_GameGuiMixin]: |
||||
yield from filter( |
||||
lambda x: x.is_game_gui_control(), |
||||
_GameGuiMixin.__subclasses__() |
||||
) |
||||
|
||||
@classmethod |
||||
def is_game_gui_control(cls) -> bool: |
||||
return True |
||||
|
||||
@property |
||||
def requires_dyntext(self) -> bool: |
||||
return False |
||||
|
||||
def sanity_check(self): |
||||
age: PlasmaAge = bpy.context.scene.world.plasma_age |
||||
|
||||
# Game GUI modifiers must be attached to objects in a GUI page, ONLY |
||||
page_name: str = self.id_data.plasma_object.page |
||||
our_page: Optional[PlasmaPage] = next( |
||||
(i for i in age.pages if i.name == page_name) |
||||
) |
||||
if our_page is None or our_page.page_type != "gui": |
||||
raise ExportError(f"'{self.id_data.name}': {self.bl_label} Modifier must be in a GUI page!") |
||||
|
||||
# Only one Game GUI Control per object. Continuously check this because objects can be |
||||
# generated/mutated during the pre-export phase. |
||||
modifiers = self.id_data.plasma_modifiers |
||||
controls = [i for i in self.iterate_control_subclasses() if getattr(modifiers, i.pl_id).enabled] |
||||
num_controls = len(controls) |
||||
if num_controls > 1: |
||||
raise ExportError(f"'{self.id_data.name}': Only 1 GUI Control modifier is allowed per object. We found {num_controls}.") |
||||
|
||||
# Blow up on invalid sounds |
||||
soundemit = self.id_data.plasma_modifiers.soundemit |
||||
for attr_name, _ in self.gui_sounds: |
||||
sound_name = getattr(self, attr_name) |
||||
if not sound_name: |
||||
continue |
||||
sound = next((i for i in soundemit.sounds if i.name == sound_name), None) |
||||
if sound is None: |
||||
raise ExportError(f"'{self.id_data.name}': Invalid '{attr_name}' GUI Sound '{sound_name}'") |
||||
|
||||
|
||||
class PlasmaGameGuiControlModifier(PlasmaModifierProperties, _GameGuiMixin): |
||||
pl_id = "gui_control" |
||||
pl_page_types = {"gui"} |
||||
|
||||
bl_category = "GUI" |
||||
bl_label = "GUI Control (ex)" |
||||
bl_description = "XXX" |
||||
bl_object_types = {"FONT", "MESH"} |
||||
|
||||
tag_id = IntProperty( |
||||
name="Tag ID", |
||||
description="", |
||||
min=0, |
||||
options=set() |
||||
) |
||||
visible = BoolProperty( |
||||
name="Visible", |
||||
description="", |
||||
default=True, |
||||
options=set() |
||||
) |
||||
proc = EnumProperty( |
||||
name="Notification Procedure", |
||||
description="", |
||||
items=[ |
||||
("default", "[Default]", "Send notifications to the owner's notification procedure."), |
||||
("close_dialog", "Close Dialog", "Close the current Game GUI Dialog."), |
||||
("console_command", "Run Console Command", "Run a Plasma Console command.") |
||||
], |
||||
options=set() |
||||
) |
||||
console_command = StringProperty( |
||||
name="Command", |
||||
description="", |
||||
options=set() |
||||
) |
||||
|
||||
def convert_gui_control(self, exporter: Exporter, ctrl: pfGUIControlMod, bo: bpy.types.Object, so: plSceneObject): |
||||
ctrl.tagID = self.tag_id |
||||
ctrl.visible = self.visible |
||||
if self.proc == "default": |
||||
ctrl.setFlag(pfGUIControlMod.kInheritProcFromDlg, True) |
||||
elif self.proc == "close_dialog": |
||||
ctrl.handler = pfGUICloseDlgProc() |
||||
elif self.proc == "console_command": |
||||
handler = pfGUIConsoleCmdProc() |
||||
handler.command = self.console_command |
||||
ctrl.handler = handler |
||||
|
||||
def convert_gui_sounds(self, exporter: Exporter, ctrl: pfGUIControlMod, ctrl_mod: _GameGuiMixin): |
||||
soundemit = ctrl_mod.id_data.plasma_modifiers.soundemit |
||||
if not ctrl_mod.gui_sounds or not soundemit.enabled: |
||||
return |
||||
|
||||
# This is a lot like the plPhysicalSndGroup where we have a vector behaving as a lookup table. |
||||
# NOTE that zero is a special value here meaning no sound, so we need to offset the sounds |
||||
# that we get from the emitter modifier by +1. |
||||
sound_indices = {} |
||||
for attr_name, gui_sound_idx in ctrl_mod.gui_sounds: |
||||
sound_name = getattr(ctrl_mod, attr_name) |
||||
if not sound_name: |
||||
continue |
||||
sound_keys = soundemit.get_sound_keys(exporter, sound_name) |
||||
sound_key, soundemit_index = next(sound_keys, (None, -1)) |
||||
if sound_key is not None: |
||||
sound_indices[gui_sound_idx] = soundemit_index + 1 |
||||
|
||||
# Compress the list to include only the highest entry we need. |
||||
if sound_indices: |
||||
ctrl.soundIndices = [sound_indices.get(i, 0) for i in range(max(sound_indices) + 1)] |
||||
|
||||
def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): |
||||
ctrl_mods = list(self.iterate_control_modifiers()) |
||||
if not ctrl_mods: |
||||
exporter.report.msg(str(list(self.iterate_control_subclasses()))) |
||||
exporter.report.warn("This modifier has no effect because no GUI control modifiers are present!") |
||||
for ctrl_mod in ctrl_mods: |
||||
ctrl_obj = ctrl_mod.get_control(exporter, bo, so) |
||||
self.convert_gui_control(exporter, ctrl_obj, bo, so) |
||||
self.convert_gui_sounds(exporter, ctrl_obj, ctrl_mod) |
||||
|
||||
@property |
||||
def has_gui_proc(self) -> bool: |
||||
return any((i.has_gui_proc for i in self.iterate_control_modifiers())) |
||||
|
||||
@classmethod |
||||
def is_game_gui_control(cls) -> bool: |
||||
# How is a control not a control, you ask? Because, grasshopper, this modifier does not |
||||
# actually export a GUI control itself. Instead, it holds common properties that may |
||||
# or may not be used by other controls. This just helps fill out the other modifiers. |
||||
return False |
||||
|
||||
|
||||
class GameGuiAnimation(bpy.types.PropertyGroup): |
||||
def _poll_target_object(self, value): |
||||
# Only allow targetting things that are in our GUI page. |
||||
if value.plasma_object.page != self.id_data.plasma_object.page: |
||||
return False |
||||
if self.anim_type == "OBJECT": |
||||
return idprops.poll_animated_objects(self, value) |
||||
else: |
||||
return idprops.poll_drawable_objects(self, value) |
||||
|
||||
def _poll_texture(self, value): |
||||
# must be a legal option... but is it a member of this material... or, if no material, |
||||
# any of the materials attached to the object? |
||||
if self.target_material is not None: |
||||
return value.name in self.target_material.texture_slots |
||||
else: |
||||
target_object = self.target_object if self.target_object is not None else self.id_data |
||||
for i in (slot.material for slot in target_object.material_slots if slot and slot.material): |
||||
if value in (slot.texture for slot in i.texture_slots if slot and slot.texture): |
||||
return True |
||||
return False |
||||
|
||||
def _poll_material(self, value): |
||||
# Don't filter materials by texture - this would (potentially) result in surprising UX |
||||
# in that you would have to clear the texture selection before being able to select |
||||
# certain materials. |
||||
target_object = self.target_object if self.target_object is not None else self.id_data |
||||
object_materials = (slot.material for slot in target_object.material_slots if slot and slot.material) |
||||
return value in object_materials |
||||
|
||||
anim_type: str = EnumProperty( |
||||
name="Type", |
||||
description="Animation type to affect", |
||||
items=[ |
||||
("OBJECT", "Object", "Object Animation"), |
||||
("TEXTURE", "Texture", "Texture Animation"), |
||||
], |
||||
default="OBJECT", |
||||
options=set() |
||||
) |
||||
target_object: bpy.types.Object = PointerProperty( |
||||
name="Object", |
||||
description="Target object", |
||||
poll=_poll_target_object, |
||||
type=bpy.types.Object |
||||
) |
||||
target_material: bpy.types.Material = PointerProperty( |
||||
name="Material", |
||||
description="Target material", |
||||
type=bpy.types.Material, |
||||
poll=_poll_material |
||||
) |
||||
target_texture: bpy.types.Texture = PointerProperty( |
||||
name="Texture", |
||||
description="Target texture", |
||||
type=bpy.types.Texture, |
||||
poll=_poll_texture |
||||
) |
||||
|
||||
|
||||
class GameGuiAnimationGroup(bpy.types.PropertyGroup): |
||||
def _update_animation_name(self, context) -> None: |
||||
if not self.animation_name: |
||||
self.animation_name = "(Entire Animation)" |
||||
|
||||
animations = CollectionProperty( |
||||
name="Animations", |
||||
description="", |
||||
type=GameGuiAnimation, |
||||
options=set() |
||||
) |
||||
|
||||
animation_name: str = StringProperty( |
||||
name="Animation Name", |
||||
description="Name of the animation to play", |
||||
default="(Entire Animation)", |
||||
update=_update_animation_name, |
||||
options=set() |
||||
) |
||||
|
||||
active_anim_index: int = IntProperty(options={"HIDDEN"}) |
||||
show_expanded: bool = BoolProperty(options={"HIDDEN"}) |
||||
|
||||
def export( |
||||
self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject, |
||||
ctrl_obj: pfGUIControlMod, add_func: Callable[[plKey], None], |
||||
anim_name_attr: str |
||||
): |
||||
keys = set() |
||||
for anim in self.animations: |
||||
target_object = anim.target_object if anim.target_object is not None else bo |
||||
if anim.anim_type == "OBJECT": |
||||
keys.add(exporter.animation.get_animation_key(target_object)) |
||||
elif anim.anim_type == "TEXTURE": |
||||
# Layer animations don't respect the name field, so we need to grab exactly the |
||||
# layer animation key that is requested. Cyan's Max plugin does not allow specifying |
||||
# layer animations here as best I can tell, but I don't see why we shouldn't. |
||||
keys.update( |
||||
exporter.mesh.material.get_texture_animation_key( |
||||
target_object, |
||||
anim.target_material, |
||||
anim.target_texture, |
||||
self.animation_name |
||||
) |
||||
) |
||||
else: |
||||
raise RuntimeError() |
||||
|
||||
# This is to make sure that we only waste space in the PRP file with the animation |
||||
# name if we actually have some doggone animations. |
||||
if keys: |
||||
setattr(ctrl_obj, anim_name_attr, self.animation_name) |
||||
for i in keys: |
||||
add_func(i) |
||||
|
||||
|
||||
class PlasmaGameGuiButtonModifier(PlasmaModifierProperties, _GameGuiMixin): |
||||
pl_id = "gui_button" |
||||
pl_depends = {"gui_control"} |
||||
pl_page_types = {"gui"} |
||||
|
||||
bl_category = "GUI" |
||||
bl_label = "GUI Button (ex)" |
||||
bl_description = "XXX" |
||||
bl_object_types = {"FONT", "MESH"} |
||||
|
||||
def _update_notify_type(self, context): |
||||
# It doesn't make sense to have no notify type at all selected, so |
||||
# default to at least one option. |
||||
if not self.notify_type: |
||||
self.notify_type = {"DOWN"} |
||||
|
||||
notify_type = EnumProperty( |
||||
name="Notify On", |
||||
description="When the button should perform its action", |
||||
items=[ |
||||
("UP", "Up", "When the mouse button is down over the GUI button."), |
||||
("DOWN", "Down", "When the mouse button is released over the GUI button."), |
||||
], |
||||
default={"UP"}, |
||||
options={"ENUM_FLAG"}, |
||||
update=_update_notify_type |
||||
) |
||||
|
||||
mouse_over_anims: GameGuiAnimationGroup = PointerProperty(type=GameGuiAnimationGroup) |
||||
mouse_click_anims: GameGuiAnimationGroup = PointerProperty(type=GameGuiAnimationGroup) |
||||
show_expanded_sounds: bool = BoolProperty(options={"HIDDEN"}) |
||||
|
||||
mouse_down_sound: str = StringProperty( |
||||
name="Mouse Down SFX", |
||||
description="Sound played when the mouse button is down", |
||||
options=set() |
||||
) |
||||
|
||||
mouse_up_sound: str = StringProperty( |
||||
name="Mouse Up SFX", |
||||
description="Sound played when the mouse button is released", |
||||
options=set() |
||||
) |
||||
|
||||
mouse_over_sound: str = StringProperty( |
||||
name="Mouse Over SFX", |
||||
description="Sound played when the mouse moves over the GUI button", |
||||
options=set() |
||||
) |
||||
|
||||
mouse_off_sound: str = StringProperty( |
||||
name="Mouse Off SFX", |
||||
description="Sound played when the mouse moves off of the GUI button", |
||||
options=set() |
||||
) |
||||
|
||||
@property |
||||
def gui_sounds(self): |
||||
return ( |
||||
("mouse_down_sound", pfGUIButtonMod.kMouseDown), |
||||
("mouse_up_sound", pfGUIButtonMod.kMouseUp), |
||||
("mouse_over_sound", pfGUIButtonMod.kMouseOver), |
||||
("mouse_off_sound", pfGUIButtonMod.kMouseOff), |
||||
) |
||||
|
||||
def get_control(self, exporter: Exporter, bo: Optional[bpy.types.Object] = None, so: Optional[plSceneObject] = None) -> pfGUIButtonMod: |
||||
return exporter.mgr.find_create_object(pfGUIButtonMod, bl=bo, so=so) |
||||
|
||||
def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): |
||||
ctrl = self.get_control(exporter, bo, so) |
||||
ctrl.setFlag(pfGUIControlMod.kWantsInterest, True) |
||||
|
||||
if self.notify_type == {"UP"}: |
||||
ctrl.notifyType = pfGUIButtonMod.kNotifyOnUp |
||||
elif self.notify_type == {"DOWN"}: |
||||
ctrl.notifyType = pfGUIButtonMod.kNotifyOnDown |
||||
elif self.notify_type == {"UP", "DOWN"}: |
||||
ctrl.notifyType = pfGUIButtonMod.kNotifyOnUpAndDown |
||||
else: |
||||
raise ValueError(self.notify_type) |
||||
|
||||
self.mouse_over_anims.export(exporter, bo, so, ctrl, ctrl.addMouseOverKey, "mouseOverAnimName") |
||||
self.mouse_click_anims.export(exporter, bo, so, ctrl, ctrl.addAnimationKey, "animName") |
||||
|
||||
|
||||
class PlasmaGameGuiDialogModifier(PlasmaModifierProperties, _GameGuiMixin): |
||||
pl_id = "gui_dialog" |
||||
pl_page_types = {"gui"} |
||||
|
||||
bl_category = "GUI" |
||||
bl_label = "GUI Dialog (ex)" |
||||
bl_description = "XXX" |
||||
bl_object_types = {"FONT", "MESH"} |
||||
|
||||
camera_object: bpy.types.Object = PointerProperty( |
||||
name="GUI Camera", |
||||
description="Camera used to project the GUI to screenspace.", |
||||
type=bpy.types.Object, |
||||
poll=idprops.poll_camera_objects, |
||||
options=set() |
||||
) |
||||
is_modal = BoolProperty( |
||||
name="Modal", |
||||
description="", |
||||
default=True, |
||||
options=set() |
||||
) |
||||
|
||||
def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): |
||||
# Find all of the visible objects in the GUI page for use in hither/yon raycast and |
||||
# camera matrix calculations. |
||||
visible_objects = [ |
||||
i for i in exporter.get_objects(bo.plasma_object.page) |
||||
if i.type == "MESH" and i.data.materials |
||||
] |
||||
|
||||
camera_object = self.id_data if self.id_data.type == "CAMERA" else self.camera_object |
||||
if camera_object: |
||||
exporter.report.msg(f"Using camera matrix from camera '{camera_object.name}'") |
||||
if camera_object != self.id_data and camera_object.plasma_object.enabled: |
||||
with exporter.report.indent(): |
||||
exporter.report.warn("The camera object should NOT be a Plasma Object!") |
||||
camera_matrix = camera_object.matrix_world |
||||
|
||||
# Save the clipping info from the camera for later use. |
||||
cam_data = camera_object.data |
||||
fov, hither, yonder = cam_data.angle, cam_data.clip_start, cam_data.clip_end |
||||
else: |
||||
exporter.report.msg(f"Building a camera matrix to view: {', '.join((i.name for i in visible_objects))}") |
||||
fov = math.radians(45.0) |
||||
camera_matrix = exporter.gui.calc_camera_matrix( |
||||
bpy.context.scene, |
||||
visible_objects, |
||||
fov |
||||
) |
||||
|
||||
# There isn't a real camera, so just pretend like the user didn't set the clipping info. |
||||
hither, yonder = 0.0, 0.0 |
||||
with exporter.report.indent(): |
||||
exporter.report.msg(str(camera_matrix)) |
||||
|
||||
# If no hither or yonder was specified on the camera, then we need to determine that ourselves. |
||||
if not hither or not yonder: |
||||
exporter.report.msg(f"Incomplete clipping: H:{hither:.02f} Y:{yonder:.02f}; calculating new...") |
||||
with exporter.report.indent(): |
||||
clipping = exporter.gui.calc_clipping( |
||||
camera_matrix, |
||||
bpy.context.scene, |
||||
visible_objects, |
||||
fov |
||||
) |
||||
exporter.report.msg(f"Calculated: H:{clipping.hither:.02f} Y:{clipping.yonder:.02f}") |
||||
if not hither: |
||||
hither = clipping.hither |
||||
if not yonder: |
||||
yonder = clipping.yonder |
||||
exporter.report.msg(f"Corrected clipping: H:{hither:.02f} Y:{yonder:.02f}") |
||||
|
||||
# Both of the objects we export go into the pool. |
||||
scene_node_key = exporter.mgr.get_scene_node(bl=bo) |
||||
|
||||
post_effect = exporter.mgr.find_create_object(plPostEffectMod, bl=bo) |
||||
post_effect.defaultC2W, post_effect.defaultW2C = exporter.gui.convert_post_effect_matrices(camera_matrix) |
||||
post_effect.fovX = math.degrees(fov) |
||||
post_effect.fovY = math.degrees(fov * (3.0 / 4.0)) |
||||
post_effect.hither = min((hither, yonder)) |
||||
post_effect.yon = max((hither, yonder)) |
||||
post_effect.nodeKey = scene_node_key |
||||
|
||||
dialog_mod = exporter.mgr.find_create_object(pfGUIDialogMod, bl=bo) |
||||
dialog_mod.name = bo.plasma_object.page |
||||
dialog_mod.setFlag(pfGUIDialogMod.kModal, self.is_modal) |
||||
dialog_mod.renderMod = post_effect.key |
||||
dialog_mod.sceneNode = scene_node_key |
||||
|
||||
@property |
||||
def has_gui_proc(self) -> bool: |
||||
return False |
||||
|
||||
@classmethod |
||||
def is_game_gui_control(cls) -> bool: |
||||
return False |
||||
|
||||
def post_export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): |
||||
# All objects have been exported. Now, we can establish linkage to all controls that |
||||
# have been exported. |
||||
dialog = exporter.mgr.find_object(pfGUIDialogMod, bl=bo, so=so) |
||||
control_modifiers: Iterable[_GameGuiMixin] = itertools.chain.from_iterable( |
||||
obj.plasma_modifiers.gui_control.iterate_control_modifiers() |
||||
for obj in exporter.get_objects(bo.plasma_object.page) |
||||
if obj.plasma_modifiers.gui_control.enabled |
||||
) |
||||
for control_modifier in control_modifiers: |
||||
control = control_modifier.get_control(exporter, control_modifier.id_data) |
||||
ctrl_key = control.key |
||||
exporter.report.msg(f"GUIDialog '{bo.name}': [{control.ClassName()}] '{ctrl_key.name}'") |
||||
dialog.addControl(ctrl_key) |
@ -0,0 +1,120 @@
|
||||
# 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 <http://www.gnu.org/licenses/>. |
||||
|
||||
from __future__ import annotations |
||||
from typing import * |
||||
|
||||
import bpy.types |
||||
|
||||
from .. import ui_list |
||||
|
||||
if TYPE_CHECKING: |
||||
from ...properties.modifiers.game_gui import GameGuiAnimation, GameGuiAnimationGroup |
||||
|
||||
class GuiAnimListUI(bpy.types.UIList): |
||||
def _iter_target_names(self, item: GameGuiAnimation): |
||||
if item.target_object is not None: |
||||
yield item.target_object.name |
||||
else: |
||||
yield item.id_data.name |
||||
if item.target_material is not None: |
||||
yield item.target_material.name |
||||
if item.target_texture is not None: |
||||
yield item.target_texture.name |
||||
|
||||
def draw_item( |
||||
self, context, layout, data, item: GameGuiAnimation, icon, active_data, |
||||
active_property, index=0, flt_flag=0 |
||||
): |
||||
if item.anim_type == "OBJECT": |
||||
name = item.target_object.name if item.target_object is not None else item.id_data.name |
||||
layout.label(name, icon="OBJECT_DATA") |
||||
elif item.anim_type == "TEXTURE": |
||||
name_seq = list(self._iter_target_names(item)) |
||||
layout.label(" / ".join(name_seq), icon="TEXTURE") |
||||
else: |
||||
raise RuntimeError() |
||||
|
||||
|
||||
def _gui_anim(name: str, group: GameGuiAnimationGroup, layout, context): |
||||
box = layout.box() |
||||
row = box.row(align=True) |
||||
|
||||
exicon = "TRIA_DOWN" if group.show_expanded else "TRIA_RIGHT" |
||||
row.prop(group, "show_expanded", text="", icon=exicon, emboss=False) |
||||
row.prop(group, "animation_name", text=name, icon="ANIM") |
||||
if not group.show_expanded: |
||||
return |
||||
|
||||
ui_list.draw_modifier_list(box, "GuiAnimListUI", group, "animations", "active_anim_index", rows=2) |
||||
try: |
||||
anim: GameGuiAnimation = group.animations[group.active_anim_index] |
||||
except: |
||||
pass |
||||
else: |
||||
col = box.column() |
||||
col.prop(anim, "anim_type") |
||||
col.prop(anim, "target_object") |
||||
if anim.anim_type == "TEXTURE": |
||||
col.prop(anim, "target_material") |
||||
col.prop(anim, "target_texture") |
||||
|
||||
|
||||
def gui_button(modifier, layout, context): |
||||
row = layout.row() |
||||
row.label("Notify On:") |
||||
row.prop(modifier, "notify_type") |
||||
|
||||
_gui_anim("Mouse Click", modifier.mouse_click_anims, layout, context) |
||||
_gui_anim("Mouse Over", modifier.mouse_over_anims, layout, context) |
||||
|
||||
box = layout.box() |
||||
row = box.row(align=True) |
||||
exicon = "TRIA_DOWN" if modifier.show_expanded_sounds else "TRIA_RIGHT" |
||||
row.prop(modifier, "show_expanded_sounds", text="", icon=exicon, emboss=False) |
||||
row.label("Sound Effects") |
||||
if modifier.show_expanded_sounds: |
||||
col = box.column() |
||||
soundemit = modifier.id_data.plasma_modifiers.soundemit |
||||
col.active = soundemit.enabled |
||||
col.prop_search(modifier, "mouse_down_sound", soundemit, "sounds", text="Mouse Down", icon="SPEAKER") |
||||
col.prop_search(modifier, "mouse_up_sound", soundemit, "sounds", text="Mouse Up", icon="SPEAKER") |
||||
col.prop_search(modifier, "mouse_over_sound", soundemit, "sounds", text="Mouse Over", icon="SPEAKER") |
||||
col.prop_search(modifier, "mouse_off_sound", soundemit, "sounds", text="Mouse Off", icon="SPEAKER") |
||||
|
||||
def gui_control(modifier, layout, context): |
||||
split = layout.split() |
||||
col = split.column() |
||||
col.prop(modifier, "visible") |
||||
|
||||
col = split.column() |
||||
col.prop(modifier, "tag_id") |
||||
|
||||
col = layout.column() |
||||
col.active = modifier.has_gui_proc |
||||
col.prop(modifier, "proc") |
||||
row = col.row() |
||||
row.active = col.active and modifier.proc == "console_command" |
||||
row.prop(modifier, "console_command") |
||||
|
||||
def gui_dialog(modifier, layout, context): |
||||
row = layout.row(align=True) |
||||
row.prop(modifier, "camera_object") |
||||
op = row.operator("camera.plasma_create_game_gui_camera", text="", icon="CAMERA_DATA") |
||||
op.mod_id = modifier.pl_id |
||||
op.cam_prop_name = "camera_object" |
||||
op.gui_page = modifier.id_data.plasma_object.page |
||||
|
||||
layout.prop(modifier, "is_modal") |
Loading…
Reference in new issue