Compare commits

..

5 Commits

Author SHA1 Message Date
Adam Johnson f303244481
Bump all actions to current. 9 months ago
Adam Johnson cd5edb064f
Remove outdated information from the README. 9 months ago
Adam Johnson 46a72a6cd8
Merge pull request #406 from Hoikas/debounce_duplicate_note_popups 9 months ago
Adam Johnson 20ccfa87f9
Ensure note popups don't clobber each other. 9 months ago
Adam Johnson 3c5e2d61d5
Debounce GUI object duplication from the Note Popup modifier. 9 months ago
  1. 10
      .github/workflows/ci_build.yml
  2. 12
      README.md
  3. 6
      korman/exporter/convert.py
  4. 92
      korman/exporter/gui.py
  5. 2
      korman/properties/modifiers/anim.py
  6. 2
      korman/properties/modifiers/avatar.py
  7. 2
      korman/properties/modifiers/game_gui.py
  8. 86
      korman/properties/modifiers/gui.py
  9. 2
      korman/properties/modifiers/logic.py
  10. 4
      korman/properties/modifiers/render.py
  11. 2
      korman/properties/modifiers/sound.py

10
.github/workflows/ci_build.yml

@ -30,12 +30,12 @@ jobs:
runs-on: ${{ matrix.cfg.os }} runs-on: ${{ matrix.cfg.os }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
path: korman path: korman
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v4 uses: actions/setup-python@v5
with: with:
python-version: "3.7" python-version: "3.7"
architecture: ${{ matrix.cfg.python-arch }} architecture: ${{ matrix.cfg.python-arch }}
@ -55,7 +55,7 @@ jobs:
-NoInstaller -NoBlender -NoInstaller -NoBlender
- name: Upload Standalone Korman - name: Upload Standalone Korman
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: korman-standalone-${{ matrix.cfg.str }} name: korman-standalone-${{ matrix.cfg.str }}
path: build/package path: build/package
@ -80,13 +80,13 @@ jobs:
steps: steps:
- name: Checkout Korman - name: Checkout Korman
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
path: korman path: korman
- name: Download Artifacts - name: Download Artifacts
id: download id: download
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
path: artifacts path: artifacts

12
README.md

@ -8,20 +8,14 @@ Dependencies
------------ ------------
- [Blender](http://blender3d.org) - 3D modeling software - [Blender](http://blender3d.org) - 3D modeling software
- [libHSPlasma](https://github.com/H-uru/libhsplasma) - Universal Plasma library used for manipulating data - [libHSPlasma](https://github.com/H-uru/libhsplasma) - Universal Plasma library used for manipulating data
- [PhysX 2.6 SDK](http://www.nvidia.com/object/physx_archives.html) - optional, required only for exporting
ages to the Myst Online: URU Live format with libHSPlasma.
Building Building
-------- --------
Korman is written primarily in Python and therefore requires little in the way of compiling. However, Korman Korman is written primarily in Python and therefore requires little in the way of compiling. However, Korman
depends on the libHSPlasma Python bindings called "PyHSPlasma". Therefore, you will need to compile libHSPlasma depends on the libHSPlasma Python bindings called "PyHSPlasma". Therefore, you will need to compile libHSPlasma
with python bindings for the platform of your choice. You will need to be certain that you use the same version with python bindings for the platform of your choice. A helper script has been provided to compile PyHSPlasma
of Python that ships with your Blender install. Once you have done this, copy the HSPlasma library and PyHSPlasma and all dependency libraries for you on Windows. To build Korman for rapid development, run
python library into Blender's `python/lib/site-packages`. `./build.ps1 -Dev -BlenderDir "<path to blender 2.79>"`
See the installer directory for NSIS scripts. You can make a Windows installer by using `makensis
-DPYTHON_DLL=[pythonDllName] Installer.nsi`. Be sure to provide the Visual C++ redistributable and
libHSPlasma libraries. Prebuilt installers will be provided on the Guild of Writers website.
Installing Installing
---------- ----------

6
korman/exporter/convert.py

@ -229,7 +229,7 @@ class Exporter:
for mod in bl_obj.plasma_modifiers.modifiers: for mod in bl_obj.plasma_modifiers.modifiers:
fn = getattr(mod, "sanity_check", None) fn = getattr(mod, "sanity_check", None)
if fn is not None: if fn is not None:
fn() fn(self)
inc_progress() inc_progress()
self.report.msg("... Age is grinning and holding a spatula. Must be OK, then.") self.report.msg("... Age is grinning and holding a spatula. Must be OK, then.")
@ -502,7 +502,9 @@ class Exporter:
# Wow, recursively generated objects. Aren't you special? # Wow, recursively generated objects. Aren't you special?
with indent(): with indent():
for mod in temporary.plasma_modifiers.modifiers: for mod in temporary.plasma_modifiers.modifiers:
mod.sanity_check() fn = getattr(mod, "sanity_check", None)
if fn is not None:
fn(self)
do_pre_export(temporary) do_pre_export(temporary)
return temporary return temporary

92
korman/exporter/gui.py

@ -32,6 +32,7 @@ from . import utils
if TYPE_CHECKING: if TYPE_CHECKING:
from .convert import Exporter from .convert import Exporter
from .logger import _ExportLogger as ExportLogger from .logger import _ExportLogger as ExportLogger
from ..properties.modifiers.game_gui import *
class Clipping(NamedTuple): class Clipping(NamedTuple):
@ -48,9 +49,13 @@ class GuiConverter:
if TYPE_CHECKING: if TYPE_CHECKING:
_parent: weakref.ref[Exporter] = ... _parent: weakref.ref[Exporter] = ...
_pages: Dict[str, Any] = ...
_mods_exported: Set[str] = ...
def __init__(self, parent: Optional[Exporter] = None): def __init__(self, parent: Optional[Exporter] = None):
self._parent = weakref.ref(parent) if parent is not None else None self._parent = weakref.ref(parent) if parent is not None else None
self._pages = {}
self._mods_exported = set()
# Go ahead and prepare the GUI transparent material for future use. # Go ahead and prepare the GUI transparent material for future use.
if parent is not None: if parent is not None:
@ -203,6 +208,93 @@ class GuiConverter:
w2c[2, i] *= -1.0 w2c[2, i] *= -1.0
return PostEffectModMatrices(c2w, w2c) return PostEffectModMatrices(c2w, w2c)
def check_pre_export(self, name: str, **kwargs):
previous = self._pages.setdefault(name, kwargs)
if previous != kwargs:
diff = set(previous.items()) - set(kwargs.items())
raise ExportError(f"GUI Page '{name}' has target modifiers with conflicting settings:\n{diff}")
def create_note_gui(self, gui_page: str, gui_camera: bpy.types.Object):
if not gui_page in self._mods_exported:
guidialog_object = utils.create_empty_object(f"{gui_page}_NoteDialog")
guidialog_object.plasma_object.enabled = True
guidialog_object.plasma_object.page = gui_page
yield guidialog_object
guidialog_mod: PlasmaGameGuiDialogModifier = guidialog_object.plasma_modifiers.gui_dialog
guidialog_mod.enabled = True
guidialog_mod.is_modal = True
if gui_camera is not None:
guidialog_mod.camera_object = 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"{gui_page}_GUICamera")
camera_object.data.angle = math.radians(45.0)
camera_object.data.lens_unit = "FOV"
visible_objects = [
i for i in self._parent().get_objects(gui_page)
if i.type == "MESH" and i.data.materials
]
camera_object.matrix_world = self.calc_camera_matrix(
bpy.context.scene,
visible_objects,
camera_object.data.angle
)
clipping = self.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"{gui_page}_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 = 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(self.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(self.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
self._mods_exported.add(gui_page)
@contextmanager @contextmanager
def generate_camera_render_settings(self, scene: bpy.types.Scene) -> Iterator[None]: 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 # Set the render info to basically TV NTSC 4:3, which will set Blender's camera

2
korman/properties/modifiers/anim.py

@ -42,7 +42,7 @@ class ActionModifier:
return None return None
raise ExportError("'{}': Object has an animation modifier but is not animated".format(bo.name)) raise ExportError("'{}': Object has an animation modifier but is not animated".format(bo.name))
def sanity_check(self) -> None: def sanity_check(self, exporter) -> None:
if not self.id_data.plasma_object.has_animation_data: if not self.id_data.plasma_object.has_animation_data:
raise ExportError("'{}': Has an animation modifier but no animation data.", self.id_data.name) raise ExportError("'{}': Has an animation modifier but no animation data.", self.id_data.name)

2
korman/properties/modifiers/avatar.py

@ -189,7 +189,7 @@ class PlasmaSittingBehavior(idprops.IDPropObjectMixin, PlasmaModifierProperties,
# This should be an empty, really... # This should be an empty, really...
return True return True
def sanity_check(self): def sanity_check(self, exporter):
# The user absolutely MUST specify a clickable or this won't export worth crap. # The user absolutely MUST specify a clickable or this won't export worth crap.
if self.clickable_object is None: if self.clickable_object is None:
raise ExportError("'{}': Sitting Behavior's clickable object is invalid".format(self.key_name)) raise ExportError("'{}': Sitting Behavior's clickable object is invalid".format(self.key_name))

2
korman/properties/modifiers/game_gui.py

@ -70,7 +70,7 @@ class _GameGuiMixin:
def requires_dyntext(self) -> bool: def requires_dyntext(self) -> bool:
return False return False
def sanity_check(self): def sanity_check(self, exporter):
age: PlasmaAge = bpy.context.scene.world.plasma_age age: PlasmaAge = bpy.context.scene.world.plasma_age
# Game GUI modifiers must be attached to objects in a GUI page, ONLY # Game GUI modifiers must be attached to objects in a GUI page, ONLY

86
korman/properties/modifiers/gui.py

@ -660,7 +660,7 @@ class PlasmaLinkingBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz
share.link_input(share_anim_stage, "stage", "stage_refs") share.link_input(share_anim_stage, "stage", "stage_refs")
share.link_output(linkingnode, "hosts", "shareBookSeek") share.link_output(linkingnode, "hosts", "shareBookSeek")
def sanity_check(self): def sanity_check(self, exporter):
if self.clickable is None: if self.clickable is None:
raise ExportError("{}: Linking Book modifier requires a clickable!", self.id_data.name) raise ExportError("{}: Linking Book modifier requires a clickable!", self.id_data.name)
if self.seek_point is None: if self.seek_point is None:
@ -724,88 +724,18 @@ class PlasmaNotePopupModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz):
if self.id_data.type == "MESH": if self.id_data.type == "MESH":
return self.id_data return self.id_data
def sanity_check(self): def sanity_check(self, exporter: Exporter):
page_type = helpers.get_page_type(self.id_data.plasma_object.page) page_type = helpers.get_page_type(self.id_data.plasma_object.page)
if page_type != "room": if page_type != "room":
raise ExportError(f"Note Popup modifiers should be in a 'room' page, not a '{page_type}' page!") raise ExportError(f"Note Popup modifiers should be in a 'room' page, not a '{page_type}' page!")
# It's OK if multiple note popups point to the same GUI page,
# they just need to have the same camera.
exporter.gui.check_pre_export(self.gui_page, pl_id="note_popup", camera=self.gui_camera)
def pre_export(self, exporter: Exporter, bo: bpy.types.Object): def pre_export(self, exporter: Exporter, bo: bpy.types.Object):
guidialog_object = utils.create_empty_object(f"{self.gui_page}_NoteDialog") # The GUI converter will debounce duplicate GUI dialogs.
guidialog_object.plasma_object.enabled = True yield from exporter.gui.create_note_gui(self.gui_page, self.gui_camera)
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
)
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. # Auto-generate a six-foot cube region around the clickable if none was provided.
if self.clickable_region is None: if self.clickable_region is None:

2
korman/properties/modifiers/logic.py

@ -154,7 +154,7 @@ class PlasmaTelescope(PlasmaModifierProperties, PlasmaModifierLogicWiz):
type=bpy.types.Object, type=bpy.types.Object,
poll=idprops.poll_camera_objects) poll=idprops.poll_camera_objects)
def sanity_check(self): def sanity_check(self, exporter):
if self.camera_object is None: if self.camera_object is None:
raise ExportError(f"'{self.id_data.name}': Telescopes must specify a camera!") raise ExportError(f"'{self.id_data.name}': Telescopes must specify a camera!")

4
korman/properties/modifiers/render.py

@ -120,7 +120,7 @@ class PlasmaBlendMod(PlasmaModifierProperties):
for i in (j.blend_onto for j in self.dependencies if j.blend_onto is not None and j.enabled): for i in (j.blend_onto for j in self.dependencies if j.blend_onto is not None and j.enabled):
yield i yield i
def sanity_check(self): def sanity_check(self, exporter):
if self.has_circular_dependency: if self.has_circular_dependency:
raise ExportError("'{}': Circular Render Dependency detected!".format(self.id_data.name)) raise ExportError("'{}': Circular Render Dependency detected!".format(self.id_data.name))
@ -770,7 +770,7 @@ class PlasmaLocalizedTextModifier(PlasmaModifierProperties, PlasmaModifierLogicW
def localization_set(self): def localization_set(self):
return "DynaTexts" return "DynaTexts"
def sanity_check(self): def sanity_check(self, exporter):
if self.texture is None: if self.texture is None:
raise ExportError("'{}': Localized Text modifier requires a texture", self.id_data.name) raise ExportError("'{}': Localized Text modifier requires a texture", self.id_data.name)

2
korman/properties/modifiers/sound.py

@ -533,7 +533,7 @@ class PlasmaSoundEmitter(PlasmaModifierProperties):
stereize_left = PointerProperty(type=bpy.types.Object, options={"HIDDEN", "SKIP_SAVE"}) stereize_left = PointerProperty(type=bpy.types.Object, options={"HIDDEN", "SKIP_SAVE"})
stereize_right = PointerProperty(type=bpy.types.Object, options={"HIDDEN", "SKIP_SAVE"}) stereize_right = PointerProperty(type=bpy.types.Object, options={"HIDDEN", "SKIP_SAVE"})
def sanity_check(self): def sanity_check(self, exporter):
modifiers = self.id_data.plasma_modifiers modifiers = self.id_data.plasma_modifiers
# Sound emitters can potentially export sounds to more than one emitter SceneObject. Currently, # Sound emitters can potentially export sounds to more than one emitter SceneObject. Currently,

Loading…
Cancel
Save