Skip to content

Commit b406a28

Browse files
committed
new dev branch based on pr_array_params + newsubclassing
1 parent 5d0a5c3 commit b406a28

24 files changed

+1983
-20
lines changed

Content/Scripts/fm/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import subclassing

Content/Scripts/fm/common.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
'''
2+
Commonly used stuffs
3+
(1) Try to make this always safe for other modules to do 'from common import *'
4+
(2) Importing this module MUST be safe to do in dev as well as in production
5+
'''
6+
7+
import traceback, time, os, sys, json
8+
import unreal_engine as ue
9+
from unreal_engine import UObject, FVector, FRotator, FTransform, FColor, FLinearColor, FVector2D
10+
11+
class Bag(dict):
12+
def __setattr__(self, k, v): self[k] = v
13+
14+
def __getattr__(self, k):
15+
try: return self[k]
16+
except KeyError: raise AttributeError('No such attribute %r' % k)
17+
18+
def __delattr__(self, k):
19+
try: del self[k]
20+
except KeyError: raise AttributeError('No such attribute %r' % k)
21+
22+
@staticmethod
23+
def FromJSON(j):
24+
return json.loads(j, object_pairs_hook=Bag)
25+
26+
def ToJSON(self, indent=0):
27+
if indent > 0:
28+
return json.dumps(self, indent=indent, sort_keys=True)
29+
return json.dumps(self)
30+
31+
def log(*args):
32+
print(' '.join(str(x) for x in args))
33+
34+
def logTB():
35+
for line in traceback.format_exc().split('\n'):
36+
log(line)
37+
38+
def ModusUserDir():
39+
'''Returns the full path to the user's Modus VR directory under ~/Documents'''
40+
return os.path.join(os.path.abspath(os.path.expanduser('~')), 'Documents', 'Modus VR')
41+
42+
# ENGINE_MODE tells which of the 3 operating modes we might be in:
43+
# COMPILED - running in a built version of modus
44+
# SRC_CLI - running from source, but from the command line (outside of the editor)
45+
# SRC_EDITOR - running from source, inside the editor (including when running via PIE)
46+
import _fmsubclassing as fms
47+
ENGINE_MODE = fms.get_engine_env_mode()
48+
MODE_UNKNOWN, MODE_COMPILED, MODE_SRC_CLI, MODE_SRC_EDITOR = range(4)
49+
del fms
50+
IN_EDITOR = (ENGINE_MODE == MODE_SRC_EDITOR)
51+
52+
# During packaging/cooking, the build will fail with strange errors as it tries to load these modules, so
53+
# prj.py sets a flag for us to let us know that the build is going on
54+
BUILDING = (not not int(os.environ.get('MODUS_IS_BUILDING', '0')))
55+
56+
try:
57+
from unreal_engine.enums import EWorldType
58+
except ImportError:
59+
# Not implemented yet - for some reason EWorldType isn't a UENUM so the automagic importer can't work
60+
class EWorldType:
61+
NONE, Game, Editor, PIE, EditorPreview, GamePreview, Inactive = range(7)
62+
63+
def GetWorld():
64+
'''Returns the best guess of what the "current" world to use is'''
65+
worlds = {} # worldType -> *first* world of that type
66+
for w in ue.all_worlds():
67+
t = w.get_world_type()
68+
if worlds.get(t) is None:
69+
worlds[t] = w
70+
71+
return worlds.get(EWorldType.Game) or worlds.get(EWorldType.PIE) or worlds.get(EWorldType.Editor)
72+
73+
def Spawn(cls, world=None, select=False):
74+
'''General purpose spawn function - spawns an actor and returns it. If no world is provided, finds one
75+
using GetWorld. cls can be:
76+
- the name of the class as a string, in which case it will be imported from unreal_engine.classes
77+
- a class previously imported from unreal_engine.classes
78+
- a Python class created via the fm.subclassing module
79+
If the current world is the editor world and select=True, then the newly spawned actor will be
80+
selected before returning.
81+
'''
82+
world = world or GetWorld()
83+
if isinstance(cls, str):
84+
import unreal_engine.classes as c
85+
cls = getattr(c, cls)
86+
else:
87+
# see if it's one of our subclassing ones
88+
engineClass = getattr(cls, 'engineClass', None)
89+
if engineClass is not None:
90+
cls = engineClass
91+
92+
if select and IN_EDITOR:
93+
ue.editor_deselect_actors()
94+
95+
try:
96+
if IN_EDITOR:
97+
ue.allow_actor_script_execution_in_editor(True)
98+
newObj = world.actor_spawn(cls)
99+
finally:
100+
if IN_EDITOR:
101+
ue.allow_actor_script_execution_in_editor(False)
102+
103+
if select and IN_EDITOR:
104+
ue.editor_select_actor(newObj)
105+
106+
return newObj
107+
108+

Content/Scripts/fm/subclassing.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
'''
2+
Code for creating and using subclassses of C++ and Blueprint classes
3+
4+
The general starting point and goals we use for subclassing are:
5+
- you have some existing UClass in your project (either a Blueprint class or a C++ class) that has an arbitrarily deep class
6+
ancestry, and you want to create a Python subclass of it
7+
- in Python you want to be able to subclass further as needed
8+
- as much as possible, Python subclasses should be first-class citizens in UE4 - they should be usable anywhere that Blueprint
9+
classes and objects are usable
10+
- at the same time, Python subclasses and objects from them should be as Pythonic as possible
11+
'''
12+
from . common import *
13+
import _fmsubclassing as fms
14+
from unreal_engine import classes as engine_classes
15+
16+
# decorator that is roughly equivalent to UFUNCTION() in C++ - use this on a method in a subclass to cause it to
17+
# be exposed as callable
18+
# TODO: add support for pure=True, etc.
19+
def ufunction(f):
20+
f.ufunction = True
21+
return f
22+
23+
# used for declaring UPROPERTYs. Use when creating class vars: myVar = uproperty(FVector, FVector(1,2,3)). By default, implies BlueprintReadWrite.
24+
# TODO: add support for replication, editanywhere, BPReadOnly, repnotify, and other flags. myVar = uproperty(default, *kwFlags)
25+
class uproperty:
26+
def __init__(self, type, default, is_class=False): # TODO: make default optional, but find a suitable default (e.g. int=0, bool=False, etc. Or, let them give a default and we infer the type in many cases
27+
self.type = type
28+
self.default = default
29+
self.is_class = is_class # i.e. the property holds a UClass not a UObject
30+
31+
def CombinedPropertyDefaults(cls):
32+
'''Recursively collects all uproperty defaults for this and all parent python classes'''
33+
props = {}
34+
for baseClass in cls.__bases__[::-1]: # go farthest back first
35+
props.update(CombinedPropertyDefaults(baseClass))
36+
37+
# Now fold in our defaults
38+
props.update(getattr(cls, '__property_defaults__', {}))
39+
return props
40+
41+
class MetaBase(type):
42+
'''Metaclass used to help in the creation of Python subclasses of engine classes'''
43+
def __new__(metaclass, name, bases, dct):
44+
# remove any uproperties before creating the class - they get processed later
45+
uprops = [] # (name, uprop obj)
46+
for k,v in list(dct.items()):
47+
if isinstance(v, uproperty):
48+
dct.pop(k)
49+
uprops.append((k,v))
50+
51+
interfaces = []
52+
for cls in dct.get('__interfaces__', []):
53+
assert isinstance(cls, UObject), '%r cannot be used as an interface class' % cls
54+
assert cls.class_get_flags() & ue.CLASS_INTERFACE, '%r is not an interface class' % cls
55+
interfaces.append(cls)
56+
57+
newPyClass = super().__new__(metaclass, name, bases, dct)
58+
59+
# TODO: add some checks to bases to verify that there is at most 1 engine class present in the ancestry
60+
# TODO: add some checks to make sure that if __uclass__ is present, it exactly matches the ancestor one (ti's unneeded but harmless)
61+
# (eh, I think we should raise an error if it's present)
62+
dct['get_py_proxy'] = lambda self:fms.get_py_proxy(self)
63+
64+
if name == 'BridgeBase':
65+
pass # No extra processing for this case
66+
else:
67+
assert len(bases) > 0, 'This class must subclass something else'
68+
assert issubclass(newPyClass, BridgeBase), 'MetaBase is only for use with subclassing BridgeBase'
69+
isBridge = bases[0] == BridgeBase # is this a bridge class or some further descendent?
70+
if isBridge:
71+
engineParentClass = getattr(newPyClass, '__uclass__', None)
72+
assert engineParentClass is not None, 'Missing __uclass__ property'
73+
else:
74+
engineParentClass = bases[0].engineClass
75+
76+
# create a new corresponding UClass and set up for UPROPERTY handling
77+
newPyClass.engineClass = fms.create_subclass(name, engineParentClass, newPyClass)
78+
metaclass.SetupProperties(newPyClass, engineParentClass, uprops)
79+
80+
# add in any interfaces this class claims to support (TODO: verify that all necessary methods are implemented)
81+
for cls in interfaces:
82+
fms.add_interface(newPyClass.engineClass, cls)
83+
84+
# Scan the class and process its methods to wire things up with UE4
85+
if isBridge:
86+
metaclass.ProcessBridgeClassMethods(newPyClass, engineParentClass)
87+
else:
88+
metaclass.ProcessBridgeDescendentClassMethods(newPyClass)
89+
90+
return newPyClass
91+
92+
@classmethod
93+
def SetupProperties(metaclass, newPyClass, engineParentClass, upropList):
94+
'''Called from __new__ to get set up for UPROPERTY handling - saves list of property names and registers with the
95+
UClass any properties added by this Python class'''
96+
# Store a list of all known UPROPERTYs - most of property handling is deferred until they are used so at this point
97+
# we just make a note of their names. Due to FNames being case-insensitive, we need to be case-insensitive on the Python
98+
# side for the most part too.
99+
newPyClass.__property_names__ = [x.lower() for x in fms.get_uproperty_names(engineParentClass)]
100+
101+
initialValues = {} # prop name --> default value
102+
for origK,v in upropList:
103+
k = origK.lower()
104+
if not isinstance(v, uproperty):
105+
continue
106+
initialValues[k] = v.default # these will be set during init
107+
108+
if k not in newPyClass.__property_names__:
109+
# This is not a known property, so we need to register it with the UClass. Use the original case to try to
110+
# make it look nice, but it's anybody's guess because if anywhere else in the system an FName of the same name
111+
# has been created already, its capitalization is what will be used (this matters because UE4 likes to take names
112+
# like 'MyIntProp' and display them as 'My Int Prop').
113+
newPyClass.__property_names__.append(k)
114+
fms.add_uproperty(newPyClass.engineClass, origK, v.type, v.is_class)
115+
116+
newPyClass.__property_defaults__ = initialValues
117+
118+
@classmethod
119+
def ProcessBridgeClassMethods(metaclass, newPyClass, engineParentClass):
120+
'''Called from __new__ to handle the case where the class being created is a bridge class. For each UFUNCTION in the class ancestry,
121+
adds a corresponding Python callable to that UFUNCTION that results in a call to the C++ implementation (effectively creating the super()
122+
implementation for each UFUNCTION).'''
123+
for funcName in fms.get_ufunction_names(engineParentClass):
124+
# Make it so that from Python you can use that name to call the UFUNCTION version in UE4
125+
metaclass.AddMethodCaller(engineParentClass, newPyClass, funcName)
126+
127+
@classmethod
128+
def ProcessBridgeDescendentClassMethods(metaclass, newPyClass):
129+
'''Called from __new__ to handle the case where the class being created is a descendent of a bridge class. For each method that is marked
130+
as a ufunction, adds that method to the class but under a different name (_orig_<funcName>), adds a UFUNCTION to the new class on the C++
131+
side to call that method, and adds a Python callable to that UFUNCTION (*not* to the method directly, so that calls like self.Foo will
132+
go through the UE4 layer for things like replication).'''
133+
for k,v in list(newPyClass.__dict__.items()):
134+
if k.startswith('__') or k in ('engineClass',):
135+
continue
136+
if not callable(v) or not getattr(v, 'ufunction', None):
137+
continue
138+
funcName, func = k,v
139+
140+
# Add the method to the class but under a different name so it doesn't get stomped by the stuff below
141+
hiddenFuncName = '_orig_' + funcName
142+
setattr(newPyClass, hiddenFuncName, func)
143+
144+
# Expose a UFUNCTION in C++ that calls this method
145+
fms.add_ufunction(newPyClass.engineClass, funcName, func)
146+
147+
# Make it so that from Python you can use that name to call the UFUNCTION version in UE4 (so that things
148+
# like replication work)
149+
metaclass.AddMethodCaller(newPyClass.engineClass, newPyClass, funcName)
150+
151+
# TODO: somewhere in here we should warn if the user tries to override something that is BPCallable but not BPNativeEvent
152+
# and not BPImeplementableEvent - the engine will allow us but the the results won't work quite right
153+
154+
@classmethod
155+
def AddMethodCaller(metaclass, classWithFunction, newPyClass, funcName):
156+
'''Adds to newPyClass a method that calls the UFUNCTION C++ method of the same name. classWithFunction is the engine class
157+
that has that function (we can't do a dynamic lookup by name later because then super() calls don't work - we have to bind
158+
to the function on the class now).'''
159+
uFunc = fms.get_ufunction_object(classWithFunction, funcName)
160+
def _(self, *args, **kwargs):
161+
return fms.call_ufunction_object(self._instAddr, self, uFunc, args, kwargs)
162+
_.__name__ = funcName
163+
setattr(newPyClass, funcName, _)
164+
165+
class BridgeBase: #(metaclass=MetaBase): - let the metaclass be specified dynamically
166+
'''Base class of all bridge classes we generate'''
167+
def __new__(cls, instAddr, *args, **kwargs): # same convention as with __init__
168+
inst = super(BridgeBase, cls).__new__(cls)
169+
170+
# Set any default property values - we gather the defaults up from parent classes too, although it seems like those
171+
# should happen automatically via the CDO, no?
172+
for propName, default in CombinedPropertyDefaults(cls).items():
173+
try:
174+
fms.set_uproperty_value(instAddr, propName, default, False)
175+
except:
176+
logTB()
177+
return inst
178+
179+
def __init__(self, instAddr):
180+
self._instAddr = instAddr # by convention, the UObject addr is passed to the instance
181+
182+
@property
183+
def uobject(self):
184+
'''gets the UObject that owns self, wrapped as a python object so it can be passed to other APIs'''
185+
return fms.get_ue_inst(self._instAddr)
186+
187+
def __setattr__(self, origK, v):
188+
k = origK.lower()
189+
if k in self.__class__.__property_names__:
190+
fms.set_uproperty_value(self._instAddr, k, v, True)
191+
else:
192+
self.__dict__[origK] = v
193+
194+
def __getattr__(self, origK):
195+
k = origK.lower()
196+
if k in self.__class__.__property_names__:
197+
return fms.get_uproperty_value(self._instAddr, k)
198+
else:
199+
try:
200+
return self.__dict__[origK]
201+
except KeyError:
202+
raise AttributeError('No such attribute %r' % origK)
203+
204+
class BridgeClassGenerator:
205+
'''Dynamically creates the bridge class for any UE4 class'''
206+
def __init__(self, metaclass):
207+
self.cache = {} # class name --> class instance
208+
self.newClassMetaclass = metaclass
209+
210+
def __getattr__(self, className):
211+
try:
212+
# return cached copy if we've already generated it previously
213+
return self.cache[className]
214+
except KeyError:
215+
# generate a new class and cache it
216+
engineClass = getattr(engine_classes, className) # let this raise an error if the class doesn't exist
217+
218+
#from unreal_engine.classes import
219+
dct = dict(
220+
__uclass__ = engineClass
221+
)
222+
meta = self.newClassMetaclass
223+
cls = meta.__new__(meta, className+'___Bridge', (BridgeBase,), dct)
224+
self.cache[className] = cls
225+
return cls
226+
227+
# TODO: make this implicit - i.e. let subclasses pass in an unreal_engine.classes class obj and have metaclass wrap them automatically
228+
bridge = BridgeClassGenerator(MetaBase)
229+

Content/Scripts/fm/tests/__init__.py

Whitespace-only changes.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'''
2+
Testing the use of delegates
3+
- py exposes an event others can bind to
4+
- py fires event and others receive it
5+
- py binds to an event somebody else exposes
6+
- py properly receives event when fired
7+
- py can unbind from event
8+
'''
9+
10+
from fm.common import *
11+
log('== test_subclass_delegates ==')
12+
import fm
13+
bridge = fm.subclassing.bridge
14+
uproperty = fm.subclassing.uproperty
15+
ufunction = fm.subclassing.ufunction
16+
17+
if 1:
18+
log('-- TEST 0 --')
19+
class Foo(bridge.CChild):
20+
@ufunction
21+
def MyDispatcher(self, b:bool, i:int, s:str):
22+
log('Foo.MyDispatcher called for', self, b, i, s)
23+
24+
foo = Spawn(Foo)
25+
from unreal_engine.classes import EventFiringPawn_C
26+
firing = Spawn(EventFiringPawn_C)
27+
firing.bind_event('MyDispatcher', foo.MyDispatcher)

0 commit comments

Comments
 (0)