Browse Source

Add Note GUI Popups

This adds just enough plumbing to be able to export GUI popup notes
using the standard xDialogToggle.py. There are still a number of TODOs
and FIXMEs in the basic stuff, but the design is mostly solid. The idea
is that you'll create a GUI page for any objects that need to appear in
a GUI. The GUI does not require an explicit GUI camera, however. For
now, an automatic GUI camera will be made facing the largest object's -Z
axis.
pull/373/head
Adam Johnson 10 months ago
parent
commit
9780393345
Signed by: Hoikas
GPG Key ID: 0B6515D6FF6F271E
  1. 10
      korman/exporter/convert.py
  2. 210
      korman/exporter/gui.py
  3. 4
      korman/exporter/manager.py
  4. 31
      korman/exporter/utils.py
  5. 13
      korman/helpers.py
  6. 1
      korman/operators/__init__.py
  7. 113
      korman/operators/op_camera.py
  8. 1
      korman/properties/modifiers/__init__.py
  9. 306
      korman/properties/modifiers/game_gui.py
  10. 193
      korman/properties/modifiers/gui.py
  11. 2
      korman/properties/modifiers/region.py
  12. 1
      korman/properties/prop_world.py
  13. 1
      korman/ui/modifiers/__init__.py
  14. 42
      korman/ui/modifiers/game_gui.py
  15. 13
      korman/ui/modifiers/gui.py

10
korman/exporter/convert.py

@ -32,6 +32,7 @@ from .camera import CameraConverter
from .decal import DecalConverter
from . import explosions
from .etlight import LightBaker
from .gui import GuiConverter
from .image import ImageCache
from .locman import LocalizationConverter
from . import logger
@ -45,6 +46,7 @@ from . import utils
class Exporter:
if TYPE_CHECKING:
_objects: List[bpy.types.Object] = ...
actors: Set[str] = ...
want_node_trees: defaultdict[Set[str]] = ...
report: logger._ExportLogger = ...
@ -60,6 +62,7 @@ class Exporter:
locman: LocalizationConverter = ...
decal: DecalConverter = ...
oven: LightBaker = ...
gui: GuiConverter
def __init__(self, op):
self._op = op # Blender export operator
@ -83,6 +86,7 @@ class Exporter:
self.locman = LocalizationConverter(self)
self.decal = DecalConverter(self)
self.oven = LightBaker(mesh=self.mesh, report=self.report)
self.gui = GuiConverter(self)
# Step 0.8: Init the progress mgr
self.mesh.add_progress_presteps(self.report)
@ -371,6 +375,12 @@ class Exporter:
tree.export(self, bo, so)
inc_progress()
def get_objects(self, page: Optional[str]) -> Iterator[bpy.types.Object]:
yield from filter(
lambda x: x.plasma_object.page == page,
self._objects
)
def _harvest_actors(self):
self.report.progress_advance()
self.report.progress_range = len(self._objects) + len(bpy.data.textures)

210
korman/exporter/gui.py

@ -0,0 +1,210 @@
# 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
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 = 1.0,
) -> mathutils.Matrix:
if not objects:
raise ExportError("No objects specified for GUI Camera generation.")
class ObjArea(NamedTuple):
obj: bpy.types.Object
area: float
remove_mesh = bpy.data.meshes.remove
obj_areas: List[ObjArea] = []
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)
obj_areas.append(
ObjArea(i, sum((polygon.area for polygon in mesh.polygons)))
)
largest_obj = max(obj_areas, key=lambda x: x.area)
# GUIs are generally flat planes, which, by default in Blender, have their normal pointing
# in the localspace z axis. Therefore, what we would like to do is have a camera that points
# at the localspace -Z axis. Where up is the localspace +Y axis. We are already pointing at
# -Z with +Y being up from what I can tel. So, just use this matrix.
mat = largest_obj.obj.matrix_world.to_3x3()
# 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))
)
# Calculate the distance from the largest object to the camera. The scale we were given
# will be used to push the camera back in the Z+ direction of that object by scale times.
bvh = mathutils.bvhtree.BVHTree.FromObject(largest_obj.obj, scene)
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

4
korman/exporter/manager.py

@ -265,8 +265,8 @@ class ExportManager:
return self.add_object(pl=pClass, name=name, bl=bl, so=so)
return key.object
def find_object(self, pClass: Type[KeyedT], bl=None, name=None, so=None) -> Optional[KeyedT]:
key = self.find_key(pClass, bl, name, so)
def find_object(self, pClass: Type[KeyedT], bl=None, name=None, so=None, loc=None) -> Optional[KeyedT]:
key = self.find_key(pClass, bl, name, so, loc)
if key is not None:
return key.object
return None

31
korman/exporter/utils.py

@ -24,6 +24,8 @@ from typing import *
from PyHSPlasma import *
from ..import helpers
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...
@ -106,6 +108,20 @@ class BMeshObject:
return self._obj
def create_empty_object(name: str, owner_object: Optional[bpy.types.Object] = None) -> bpy.types.Object:
empty_object = bpy.data.objects.new(name, None)
if owner_object is not None:
empty_object.plasma_object.enabled = owner_object.plasma_object.enabled
empty_object.plasma_object.page = owner_object.plasma_object.page
bpy.context.scene.objects.link(empty_object)
return empty_object
def create_camera_object(name: str) -> bpy.types.Object:
cam_data = bpy.data.cameras.new(name)
cam_obj = bpy.data.objects.new(name, cam_data)
bpy.context.scene.objects.link(cam_obj)
return cam_obj
def create_cube_region(name: str, size: float, owner_object: bpy.types.Object) -> bpy.types.Object:
"""Create a cube shaped region object"""
region_object = BMeshObject(name)
@ -136,6 +152,21 @@ def pre_export_optional_cube_region(source, attr: str, name: str, size: float, o
# contextlib.contextmanager requires for us to yield. Sad.
yield
@contextmanager
def temporary_camera_object(scene: bpy.types.Scene, name: str) -> bpy.types.Object:
try:
cam_data = bpy.data.cameras.new(name)
cam_obj = bpy.data.objects.new(name, cam_data)
scene.objects.link(cam_obj)
yield cam_obj
finally:
cam_obj = locals().get("cam_obj")
if cam_obj is not None:
bpy.data.objects.remove(cam_obj)
cam_data = locals().get("cam_data")
if cam_data is not None:
bpy.data.cameras.remove(cam_data)
@contextmanager
def temporary_mesh_object(source : bpy.types.Object) -> bpy.types.Object:
"""Creates a temporary mesh object from a nonmesh object that will only exist for the duration

13
korman/helpers.py

@ -137,3 +137,16 @@ def find_modifier(bo, modid):
# if they give us the wrong modid, it is a bug and an AttributeError
return getattr(bo.plasma_modifiers, modid)
return None
def get_page_type(page: str) -> str:
all_pages = bpy.context.scene.world.plasma_age.pages
if page:
page_type = next((i.page_type for i in all_pages if i.name == page), None)
if page_type is None:
raise LookupError(page)
return page_type
else:
# A falsey page name is likely a request for the default page, so look for Page ID 0.
# If it doesn't exist, that's an implicit default page (a "room" type).
page_type = next((i.page_type for i in all_pages if i.seq_suffix == 0), None)
return page_type if page_type is not None else "room"

1
korman/operators/__init__.py

@ -13,6 +13,7 @@
# You should have received a copy of the GNU General Public License
# along with Korman. If not, see <http://www.gnu.org/licenses/>.
from . import op_camera as camera
from . import op_export as exporter
from . import op_image as image
from . import op_lightmap as lightmap

113
korman/operators/op_camera.py

@ -0,0 +1,113 @@
# 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"
fov: float = FloatProperty(
name="Field of View",
description="",
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="",
default=1.02,
min=0.001,
soft_max=2.0,
precision=2,
options=set()
)
mod_id: str = StringProperty(options={"HIDDEN"})
cam_prop_name: str = StringProperty(options={"HIDDEN"})
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
)
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"}

1
korman/properties/modifiers/__init__.py

@ -18,6 +18,7 @@ import bpy
from .base import PlasmaModifierProperties
from .anim import *
from .avatar import *
from .game_gui import *
from .gui import *
from .logic import *
from .physics import *

306
korman/properties/modifiers/game_gui.py

@ -0,0 +1,306 @@
# 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:
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}.")
class PlasmaGameGuiControlModifier(PlasmaModifierProperties, _GameGuiMixin):
pl_id = "gui_control"
bl_category = "GUI"
bl_label = "Ex: Game GUI Control"
bl_description = "XXX"
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 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:
self.convert_gui_control(exporter, ctrl_mod.get_control(exporter, bo, so), bo, so)
@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 PlasmaGameGuiButtonModifier(PlasmaModifierProperties, _GameGuiMixin):
pl_id = "gui_button"
pl_depends = {"gui_control"}
bl_category = "GUI"
bl_label = "Ex: Game GUI Button"
bl_description = "XXX"
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
)
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)
class PlasmaGameGuiDialogModifier(PlasmaModifierProperties, _GameGuiMixin):
pl_id = "gui_dialog"
bl_category = "GUI"
bl_label = "Ex: Game GUI Dialog"
bl_description = "XXX"
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,
1.02 # FIXME
)
# 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)

193
korman/properties/modifiers/gui.py

@ -13,20 +13,28 @@
# 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 bmesh
from bpy.props import *
import mathutils
from contextlib import ExitStack
import itertools
import math
from pathlib import Path
from typing import *
from PyHSPlasma import *
from ...addon_prefs import game_versions
from ...exporter import ExportError, utils
from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz, PlasmaModifierUpgradable
from ... import idprops
from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz
from ... import helpers, idprops
if TYPE_CHECKING:
from ...exporter import Exporter
from .game_gui import PlasmaGameGuiDialogModifier
journal_pfms = {
pvPots : {
@ -665,3 +673,180 @@ class PlasmaLinkingBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz
raise ExportError("{}: Linking Book modifier requires a clickable!", self.id_data.name)
if self.seek_point is None:
raise ExportError("{}: Linking Book modifier requires a seek point!", self.id_data.name)
dialog_toggle = {
"filename": "xDialogToggle.py",
"attribs": (
{ 'id': 1, 'type': "ptAttribActivator", 'name': "Activate" },
{ 'id': 4, 'type': "ptAttribString", 'name': "Vignette" },
)
}
class PlasmaNotePopupModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz):
pl_id = "note_popup"
bl_category = "GUI"
bl_label = "Ex: Note Popup"
bl_description = "XXX"
bl_icon = "MATPLANE"
def _get_gui_pages(self, context):
scene = context.scene if context is not None else bpy.context.scene
return [
(i.name, i.name, i.name)
for i in scene.world.plasma_age.pages
if i.page_type == "gui"
]
clickable: bpy.types.Object = PointerProperty(
name="Clickable",
description="The object the player will click on to activate the GUI",
type=bpy.types.Object,
poll=idprops.poll_mesh_objects
)
clickable_region: bpy.types.Object = PointerProperty(
name="Clickable Region",
description="The region in which the avatar must be standing before they can click on the note",
type=bpy.types.Object,
poll=idprops.poll_mesh_objects
)
gui_page: str = EnumProperty(
name="GUI Page",
description="Page containing all of the objects to display",
items=_get_gui_pages,
options=set()
)
gui_camera: bpy.types.Object = PointerProperty(
name="GUI Camera",
description="",
poll=idprops.poll_camera_objects,
type=bpy.types.Object,
options=set()
)
@property
def clickable_object(self) -> Optional[bpy.types.Object]:
if self.clickable is not None:
return self.clickable
if self.id_data.type == "MESH":
return self.id_data
def sanity_check(self):
page_type = helpers.get_page_type(self.id_data.plasma_object.page)
if page_type != "room":
raise ExportError(f"Note Popup modifiers should be in a 'room' page, not a '{page_type}' page!")
def pre_export(self, exporter: Exporter, bo: bpy.types.Object):
guidialog_object = utils.create_empty_object(f"{self.gui_page}_NoteDialog")
guidialog_object.plasma_object.enabled = True
guidialog_object.plasma_object.page = self.gui_page
yield guidialog_object
guidialog_mod: PlasmaGameGuiDialogModifier = guidialog_object.plasma_modifiers.gui_dialog
guidialog_mod.enabled = True
guidialog_mod.is_modal = True
if self.gui_camera:
guidialog_mod.camera_object = self.gui_camera
else:
# Abuse the GUI Dialog's lookat caLculation to make us a camera that looks at everything
# the artist has placed into the GUI page. We want to do this NOW because we will very
# soon be adding more objects into the GUI page.
camera_object = yield utils.create_camera_object(f"{self.key_name}_GUICamera")
camera_object.data.angle = math.radians(45.0)
camera_object.data.lens_unit = "FOV"
visible_objects = [
i for i in exporter.get_objects(self.gui_page)
if i.type == "MESH" and i.data.materials
]
camera_object.matrix_world = exporter.gui.calc_camera_matrix(
bpy.context.scene,
visible_objects,
camera_object.data.angle,
1.02 # FIXME
)
clipping = exporter.gui.calc_clipping(
camera_object.matrix_world,
bpy.context.scene,
visible_objects,
camera_object.data.angle
)
camera_object.data.clip_start = clipping.hither
camera_object.data.clip_end = clipping.yonder
guidialog_mod.camera_object = camera_object
# Begin creating the object for the clickoff plane. We want to yield it immediately
# to the exporter in case something goes wrong during the export, allowing the stale
# object to be cleaned up.
click_plane_object = utils.BMeshObject(f"{self.key_name}_Exit")
click_plane_object.matrix_world = guidialog_mod.camera_object.matrix_world
click_plane_object.plasma_object.enabled = True
click_plane_object.plasma_object.page = self.gui_page
yield click_plane_object
# We have a camera on guidialog_mod.camera_object. We will now use it to generate the
# points for the click-off plane button.
# TODO: Allow this to be configurable to 4:3, 16:9, or 21:9?
with ExitStack() as stack:
stack.enter_context(exporter.gui.generate_camera_render_settings(bpy.context.scene))
toggle = stack.enter_context(helpers.GoodNeighbor())
# Temporarily adjust the clipping plane out to the farthest point we can find to ensure
# that the click-off button ecompasses everything. This is a bit heavy-handed, but if
# you want more refined control, you won't be using this helper.
clipping = max((guidialog_mod.camera_object.data.clip_start, guidialog_mod.camera_object.data.clip_end))
toggle.track(guidialog_mod.camera_object.data, "clip_start", clipping - 0.1)
view_frame = guidialog_mod.camera_object.data.view_frame(bpy.context.scene)
click_plane_object.data.materials.append(exporter.gui.transparent_material)
with click_plane_object as click_plane_mesh:
verts = [click_plane_mesh.verts.new(i) for i in view_frame]
face = click_plane_mesh.faces.new(verts)
# TODO: Ensure the face is pointing toward the camera!
# I feel like we should be fine by assuming that Blender returns the viewframe
# verts in the correct order, but this is Blender... So test that assumption carefully.
# TODO: Apparently not!
face.normal_flip()
# We've now created the mesh object - handle the GUI Button stuff
click_plane_object.plasma_modifiers.gui_button.enabled = True
# NOTE: We will be using xDialogToggle.py, so we use a special tag ID instead of the
# close dialog procedure.
click_plane_object.plasma_modifiers.gui_control.tag_id = 99
# Auto-generate a six-foot cube region around the clickable if none was provided.
yield utils.pre_export_optional_cube_region(
self, "clickable_region",
f"{self.key_name}_DialogToggle_ClkRgn", 6.0,
self.clickable_object
)
# Everything is ready now - create an xDialogToggle.py in the room page to display the GUI.
yield self.convert_logic(bo)
def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject):
# You're not hallucinating... Everything is done in the pre-export phase.
pass
def logicwiz(self, bo, tree):
nodes = tree.nodes
# xDialogToggle.py PythonFile Node
dialog_node = self._create_python_file_node(
tree,
dialog_toggle["filename"],
dialog_toggle["attribs"]
)
self._create_python_attribute(dialog_node, "Vignette", value=self.gui_page)
# Clickable
clickable_region = nodes.new("PlasmaClickableRegionNode")
clickable_region.region_object = self.clickable_region
clickable = nodes.new("PlasmaClickableNode")
clickable.find_input_socket("facing").allow_simple = False
clickable.clickable_object = self.clickable_object
clickable.link_input(clickable_region, "satisfies", "region")
clickable.link_output(dialog_node, "satisfies", "Activate")

2
korman/properties/modifiers/region.py

@ -327,7 +327,7 @@ class PlasmaSubworldRegion(PlasmaModifierProperties):
def export(self, exporter, bo, so):
# Due to the fact that our subworld modifier can produce both RidingAnimatedPhysical
# and [HK|PX]Subworlds depending on the situation, this could get hairy, fast.
# and [HK|PX]Subworlds depending on the situation, this could get hairy, fast.
# Start by surveying the lay of the land.
from_sub, to_sub = bo.plasma_object.subworld, self.subworld
from_isded = exporter.physics.is_dedicated_subworld(from_sub)

1
korman/properties/prop_world.py

@ -141,6 +141,7 @@ class PlasmaPage(bpy.types.PropertyGroup):
items=[
("room", "Room", "A standard Plasma Room containing scene objects"),
("external", "External", "A page generated by an external process. Korman will not write to this page, ever."),
("gui", "Game GUI", "A page containing Game GUI objects.")
],
default="room",
options=set())

1
korman/ui/modifiers/__init__.py

@ -15,6 +15,7 @@
from .anim import *
from .avatar import *
from .game_gui import *
from .gui import *
from .logic import *
from .physics import *

42
korman/ui/modifiers/game_gui.py

@ -0,0 +1,42 @@
# 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/>.
def gui_button(modifier, layout, context):
layout.prop(modifier, "notify_type")
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")

13
korman/ui/modifiers/gui.py

@ -116,3 +116,16 @@ def linkingbookmod(modifier, layout, context):
row.label("Stamp Position:")
row.prop(modifier, "stamp_x", text="X")
row.prop(modifier, "stamp_y", text="Y")
def note_popup(modifier, layout, context):
layout.prop(modifier, "gui_page")
row = layout.row(align=True)
row.prop(modifier, "gui_camera")
op = row.operator("camera.plasma_create_game_gui_camera", text="", icon="CAMERA_DATA")
op.mod_id = modifier.pl_id
op.cam_prop_name = "gui_camera"
op.gui_page = modifier.gui_page
layout.prop(modifier, "clickable")
layout.prop(modifier, "clickable_region")

Loading…
Cancel
Save