diff --git a/.gitignore b/.gitignore index 72acdee..0038b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,13 @@ build/ # ============================================================================ # Unreal Engine Integration # ============================================================================ +# These need the extra ** because an Unreal project usually sits one level +# down, as unreal//, and plugins nest their own build output again. +**/unreal/**/Binaries/ +**/unreal/**/Build/ +**/unreal/**/Intermediate/ +**/unreal/**/Saved/ +**/unreal/**/DerivedDataCache/ **/unreal/Binaries/ **/unreal/Build/ **/unreal/Intermediate/ @@ -281,3 +288,13 @@ app.*.symbols # Keep empty directories with .gitkeep !**/.gitkeep + +# Unreal export outputs. Cooked content is large and rebuilt by +# "game export unreal", so it does not belong in the repository. +engines/unreal/dart/ios/UnrealContent/ +engines/unreal/dart/macos/UnrealContent/ +example/unreal/demo_exports/ + +# Content synced into the example app by "game sync unreal", rebuilt on demand. +example/macos/UnrealContent/ +example/ios/UnrealContent/ diff --git a/README.md b/README.md index 2326321..f901ccb 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Game Framework provides a consistent API for integrating game engines into Flutt - **Modular Architecture** - Use only the engines you need - **Bidirectional Communication** - Flutter ↔ Engine messaging with type safety - **Lifecycle Management** - Automatic pause/resume/destroy handling -- **Multi-Platform** - Android, iOS, macOS, Windows, Linux support +- **Multi-Platform** - Android, iOS and web today, with macOS in progress - **Production Ready** - Comprehensive testing and documentation ## Monorepo Structure @@ -256,14 +256,43 @@ Game Engine (Unity/Unreal) ## Platform Support -| Platform | gameframework | Unity | Unreal | Status | -|----------|--------------|-------|--------|--------| -| Android | ✅ Ready | ✅ Ready | 🚧 WIP | Stable | -| iOS | ✅ Ready | ✅ Ready | 🚧 WIP | Stable | -| Web | ✅ Ready | ✅ Ready | ⏳ Planned | Stable | -| macOS | ✅ Ready | 🚧 WIP | ⏳ Planned | Beta | -| Windows | ✅ Ready | 🚧 WIP | ⏳ Planned | Beta | -| Linux | ✅ Ready | 🚧 WIP | ⏳ Planned | Beta | +| Platform | Unity | Unreal | +|----------|-------|--------| +| Android | Working | Built, not yet run on a device | +| iOS | Working | Working, verified on device | +| Web | Working | Not started | +| macOS | Builds; not run | Starts and reaches Metal, does not render yet | +| Windows | Stub | Stub | +| Linux | Stub | Stub | + +"Stub" means the platform directory holds a CMake file and an empty plugin +entry point. There is no controller and no platform view, so a `GameWidget` +there renders nothing. Web is Unity only; Unreal has no web target. + +### Unreal on iOS + +Working end to end, three commands and no hand editing: + +```bash +game export unreal -p ios && game sync unreal -p ios && flutter build ios +``` + +Rendering, touch, messaging both ways, pause, and unload. Needs an engine +built from source: an installed engine ships its modules prebuilt, so +`BUILD_EMBEDDED_APP` never reaches them and the framework links, launches and +never boots an engine. + +### Unreal on macOS + +Further than the table suggests, and not finished. Unreal has no embedded mode +for Mac at all, so the target defines `BUILD_EMBEDDED_APP` itself and the +plugin supplies the startup and view path the engine provides only for iOS. +The app builds, the framework loads, the engine starts, opens the project and +initialises Metal. It then stops because nothing has been cooked for Mac and a +non-editor build cannot compile shaders at runtime. + +See `engines/unreal/CONTINUE.md` for what is left and the constraints worth +knowing before changing any of it. ## Continuous Integration diff --git a/engines/unity/dart/ios/gameframework_unity.podspec b/engines/unity/dart/ios/gameframework_unity.podspec index 720c02e..a617dd6 100644 --- a/engines/unity/dart/ios/gameframework_unity.podspec +++ b/engines/unity/dart/ios/gameframework_unity.podspec @@ -32,8 +32,13 @@ to sync your Unity export to your plugin's ios/ directory. unity_framework_path = File.join(__dir__, 'UnityFramework.framework') if File.exist?(unity_framework_path) || File.symlink?(unity_framework_path) s.preserve_paths = 'UnityFramework.framework', 'UnityFramework.framework/Data' - # Don't vendor - let the consumer plugin vendor it to avoid conflicts - # s.ios.vendored_frameworks = 'UnityFramework.framework' + + # Vendor it when it is sitting right here, which is the case after + # "game sync unity -p ios" with no separate game plugin in between. Without + # this nothing embeds the framework and the Swift compiler cannot find the + # module. A consumer plugin that vendors its own build syncs there instead, + # so this stays false for them and there is no duplicate. + s.ios.vendored_frameworks = 'UnityFramework.framework' end # Configure framework search paths to find UnityFramework from sibling pods diff --git a/engines/unity/dart/macos/Classes/UnityEngineController.swift b/engines/unity/dart/macos/Classes/UnityEngineController.swift index 27823a1..d610471 100644 --- a/engines/unity/dart/macos/Classes/UnityEngineController.swift +++ b/engines/unity/dart/macos/Classes/UnityEngineController.swift @@ -21,7 +21,36 @@ import Cocoa * - Proper NSView lifecycle management for embedding Unity in Flutter * - Error handling with descriptive error events */ -public class UnityEngineController: NSObject, FlutterPlatformView { +/// The part of UnityFramework this controller uses. +/// +/// The type itself is not available when building: UnityFramework.framework is +/// assembled per game and loaded from the app bundle at runtime, so naming the +/// class here would make the plugin impossible to compile without a Unity build +/// on hand. The instance arrives through the bundle's principal class and is +/// messaged through this, which is what Objective-C was doing anyway. +/// +/// Keep the selectors exact. A mismatch here compiles and then fails as an +/// unrecognised selector at runtime, which is a much worse place to find out. +@objc protocol UnityFrameworkInterface { + @objc func setDataBundleId(_ bundleId: String) + + @objc func runEmbedded(withArgc argc: Int32, + argv: UnsafeMutablePointer?>?, + appLaunchOpts: [AnyHashable: Any]?) + + @objc func appController() -> NSViewController? + + @objc func pause(_ paused: Bool) + + @objc func sendMessageToGO(withName name: String, + functionName: String, + message: String) + + @objc optional func unloadApplication() + @objc optional func quitApplication(_ exitCode: Int32) +} + +public class UnityEngineController: NSObject { // MARK: - Static Active Controller Tracking @@ -198,7 +227,12 @@ public class UnityEngineController: NSObject, FlutterPlatformView { } // Register the Unity framework with the FlutterBridgeRegistry - FlutterBridgeRegistry.register(unityFramework: unityFramework) + // The registry stores it as an NSObject, which every Unity + // framework instance is; the protocol is only how this file talks + // to it. + if let asObject = unityFramework as? NSObject { + FlutterBridgeRegistry.register(unityFramework: asObject) + } // Set up Unity framework unityFramework.setDataBundleId("com.unity3d.framework") @@ -211,8 +245,7 @@ public class UnityEngineController: NSObject, FlutterPlatformView { ) // Get Unity's root view - if let appController = unityFramework.appController(), - let rootView = appController.rootViewController?.view { + if let rootView = unityFramework.appController()?.view { self.unityView = rootView // Embed Unity view in our container @@ -325,7 +358,9 @@ public class UnityEngineController: NSObject, FlutterPlatformView { // MARK: - Unity Message Handling (called from C bridge) /// Called from Unity when a message is sent to Flutter - @objc public func onUnityMessage(target: String, method: String, data: String) { + /// Swift-side convenience. Deliberately not @objc: it would carry the same + /// selector as the method below, and two of those on one class is an error. + public func onUnityMessage(target: String, method: String, data: String) { onUnityMessageWithTarget(target, method: method, data: data) } @@ -400,9 +435,9 @@ public class UnityEngineController: NSObject, FlutterPlatformView { // - Data/ = game data // Pre-load GameAssembly.dylib so UnityPlayer can resolve IL2CPP symbols when the bundle loads. - private func loadUnityFramework() -> UnityFramework? { + private func loadUnityFramework() -> UnityFrameworkInterface? { // Try to get from cache first - if let cached = FlutterBridgeRegistry.sharedUnityFramework as? UnityFramework { + if let cached = FlutterBridgeRegistry.sharedUnityFramework as? UnityFrameworkInterface { return cached } @@ -431,8 +466,17 @@ public class UnityEngineController: NSObject, FlutterPlatformView { return nil } - let getInstance = principalClass.getInstance() - return getInstance as? UnityFramework + // getInstance is Unity's own class method, so it has to be sent + // dynamically too rather than called on a type nothing here declares. + let selector = NSSelectorFromString("getInstance") + guard let unityClass = principalClass as? NSObject.Type, + unityClass.responds(to: selector) else { + NSLog("UnityEngineController [macOS]: principal class has no getInstance") + return nil + } + + let instance = unityClass.perform(selector)?.takeUnretainedValue() + return instance as? UnityFrameworkInterface } // MARK: - Cleanup diff --git a/engines/unreal/CONTINUE.md b/engines/unreal/CONTINUE.md new file mode 100644 index 0000000..6bd0c8c --- /dev/null +++ b/engines/unreal/CONTINUE.md @@ -0,0 +1,85 @@ +# Where the Unreal work stands + +Written 2026-09-07, at the end of a long session. Everything here was seen +working or seen failing on this machine, not inferred. + +## iOS: working, verified on device + +Unreal renders inside a `GameWidget` on an iPhone 16 Pro at full native +portrait resolution. Drag orbits, pinch zooms, the HUD controls drive the +cube, and camera state streams back. Pause freezes the scene, unload releases +the view and reload brings it back mid-scene. + +Three commands, no hand editing: + + game export unreal -p ios && game sync unreal -p ios && flutter build ios + +`flutter run` times out installing 565MB over wireless. USB is fine. + +## macOS: builds, starts, stops at shaders + +Further than it looks, and not finished. The app builds, the framework loads, +the engine starts, reads its command line, opens the project and initialises +Metal. It then fails: + + LogShaderLibrary: Error: Failed to initialize ShaderCodeLibrary ... + part of the Global shader library is missing + +Nothing has been cooked for Mac, and a non-editor build cannot compile shaders +at runtime. + +**Next step, and it is a long one.** Build the Mac editor from source, cook the +project for Mac, then re-export. The engine build alone took 78 minutes for the +game target; the editor is bigger. After that the framework should have what it +needs, and the next unknown is whether reparenting the engine's `FCocoaWindow` +content view into the Flutter platform view actually renders. That has never +run, so treat it as unproven rather than merely untested. + +Two things a macOS host needs, which the export prints but nothing enforces: + +- The app must not be sandboxed for an uncooked run, because it reads the + project from a path outside its container. Cooked content in the bundle + removes this. +- The engine finds its own content relative to the executable, so it needs + `-basedir=/Engine/Binaries/Mac`. That is read from the process argv, + not from `uecommandline.txt`, so it cannot be set from inside the library. + A staged layout beside the app would avoid it. + +## Android + +The UPL migration is in and the library-mode Java is injected at build time. +Not run on a device in this session, so treat it as built but unverified. + +## Things worth knowing before changing anything + +- `BUILD_EMBEDDED_APP` is defined only by `UEBuildIOS.cs`. The Mac target + defines it itself and takes `TargetBuildEnvironment.Unique` so it reaches + Core, which is why the Mac build rebuilds the engine. +- The host's tick is a display link on the **main thread**, not Unreal's game + loop. `TickGameThread` drains its queue on whoever calls it, so anything + touching the renderer must not run there. `FTSTicker` is the way onto the + real game thread; `RunOnGameThread` is not. +- Unreal's log file is buffered and mostly shows startup. Do not conclude + anything from a missing runtime line. Route diagnostics back over the bridge + instead; `flutter.TraceMessages 1` turns the message trace on. +- A flat iOS framework must not contain `Resources/`. A versioned macOS one + must carry `Versions/A/Resources/Info.plist`. These are opposite rules and + both are enforced by tooling that blames something else. + +## Unity's macOS controller + +It did not compile, which broke the macOS build of any app depending on both +engines. Fixed to the point where both build together, and no further: Unity +could not be run here, so treat macOS Unity as compiling rather than working. + +What was wrong: it conformed to `FlutterPlatformView`, which does not exist on +macOS and which its factory never needed, since that already returns an +`NSView`. It named the `UnityFramework` type, which is not available when +building because the framework is assembled per game and loaded from the +bundle at runtime. And it declared two methods carrying the same Objective-C +selector. + +The framework is reached through an `@objc protocol` now, so the selectors are +declared in one place. Get one wrong and it compiles and then fails at runtime +as an unrecognised selector, so they are worth checking against Unity's own +header before trusting them. diff --git a/engines/unreal/dart/ios/Classes/UnrealAppDelegate.h b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.h new file mode 100644 index 0000000..918df28 --- /dev/null +++ b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.h @@ -0,0 +1,133 @@ +// +// UnrealAppDelegate.h +// +// Just enough of Unreal's IOSAppDelegate to subclass it. +// +// Unreal insists, and states it as a Fatal rather than a warning, that an app +// embedding the engine has an app delegate descending from IOSAppDelegate. The +// delegate caches itself in its own init, and that cached pointer is how the +// engine later finds its delegate, its window and its view. No subclass, no +// engine. +// +// The declaration lives in the engine's own headers, which a Flutter app does +// not have and should not need. So the class is redeclared here, narrowly. You +// get to write: +// +// class AppDelegate: IOSAppDelegate { ... } +// +// and the real class, which ships inside UnrealFramework, is what you actually +// subclass at runtime. +// +// Two things to know before you rely on this. +// +// Subclassing needs the symbol at link time, so the app has to link +// UnrealFramework. That is already true for anything embedding Unreal, but it +// does mean this header is not usable in a build without the framework. +// +// This is a redeclaration, so it can drift if Epic changes the class. Nothing +// here is checked by the compiler against the real thing. UnrealAssertAppDelegateUsable() +// checks it at runtime instead, and the bridge calls it before starting the +// engine so drift surfaces as a clear log line rather than as a strange crash +// much later on. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +// Define this before importing if the real engine headers are already in scope, +// otherwise the two declarations collide. +#ifndef UNREAL_HAS_REAL_IOSAPPDELEGATE + +/// Unreal's application delegate. Declared, never defined: the implementation +/// comes from UnrealFramework at link time. +/// +/// The real class conforms to several more protocols (gesture recognisers, Game +/// Center, notifications, text fields). They are left out deliberately, because +/// declaring fewer is safe for subclassing and every extra one is another thing +/// that can drift. The superclass is not optional and must stay UIResponder. +@interface IOSAppDelegate : UIResponder + +/// The engine's window. +/// +/// Unreal spells it with a capital W, and that collides with the lowercase +/// `window` on UIApplicationDelegate: Swift decides the two are the same +/// property under an old name and refuses to let you touch it. So it comes +/// across to Swift as `unrealWindow`, which also keeps it clearly distinct from +/// the UIKit window your own delegate may want. +@property (strong, retain, nonatomic, nullable) UIWindow* Window + NS_SWIFT_NAME(unrealWindow); + +/// The engine's render view. +/// +/// Really an FIOSView, which is a UIView subclass, so reading it as a UIView is +/// always valid. Treat it as read-only. The bridge assigns it while starting the +/// engine, and assigning something that is not an FIOSView will crash the +/// renderer rather than fail politely. +@property (retain, nullable) UIView* IOSView; + +@end + +#endif // UNREAL_HAS_REAL_IOSAPPDELEGATE + +#ifdef __cplusplus +extern "C" { +#endif + +/// Why this engine delegate class and app delegate cannot be used together, or +/// nil when they can. +/// +/// Takes both as arguments rather than looking them up, so the checks can be +/// exercised without a running UIApplication. `appDelegate` may be nil, which +/// means there is nothing to judge and only `engineDelegateClass` is checked. +/// +/// The returned string is meant to be read by a person who is about to have a +/// bad afternoon, so it says what to do, not just what is wrong. +NSString* _Nullable UnrealAppDelegateProblem(Class _Nullable engineDelegateClass, + id _Nullable appDelegate); + +/// Why this app delegate's window arrangement will misbehave, or nil when it +/// looks right. +/// +/// Unreal reads its `Window` for interface orientation, but only assigns it on +/// the startup path an embedded app skips. What fills it in is a naming +/// coincidence: the `Window` property generates a `setWindow:` setter, which is +/// the same selector UIKit calls on the delegate when the storyboard loads. +/// +/// Declare your own `window` property and you take that selector over, Unreal's +/// stays nil, and orientation handling quietly goes wrong. That is a warning +/// rather than a refusal, because an app driving Unreal from a scene delegate +/// legitimately has no window on the app delegate. +NSString* _Nullable UnrealAppDelegateWindowWarning(id _Nullable appDelegate); + +/// Start Unreal. Call this from your app delegate's +/// application:didFinishLaunchingWithOptions:, after calling super. +/// +/// This cannot wait until a GameWidget appears. Unreal's own +/// -handleDidBecomeActive reads the command line, and in a non-shipping build +/// that is Fatal if nothing has set it yet. Starting the engine is what sets +/// it, so an app that becomes active before showing a GameWidget dies on launch +/// with a stack that points at UIKit rather than at anything you wrote. +/// +/// Safe to call more than once; the engine only starts the first time. Returns +/// NO when the framework is absent or the delegate is unusable, having already +/// logged why. +BOOL UnrealStartEngineAtLaunch(void); + +/// Check that the delegate UnrealAppDelegate.h describes matches the one that +/// shipped, and that the running app is actually using it. +/// +/// Returns YES when the engine can start. On NO it has already logged what is +/// wrong and what to do about it. Cheap enough to call on every launch, and the +/// bridge does exactly that. +/// +/// Declared extern "C" so it links the same whether you call it from a .m or a +/// .mm. Without that the bridge, which is ObjC++, would look for a mangled name +/// that an Objective-C caller never emits. +BOOL UnrealAssertAppDelegateUsable(void); + +#ifdef __cplusplus +} +#endif + +NS_ASSUME_NONNULL_END diff --git a/engines/unreal/dart/ios/Classes/UnrealAppDelegate.mm b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.mm new file mode 100644 index 0000000..63075de --- /dev/null +++ b/engines/unreal/dart/ios/Classes/UnrealAppDelegate.mm @@ -0,0 +1,155 @@ +// +// UnrealAppDelegate.mm +// +// Runtime check that the IOSAppDelegate declared in UnrealAppDelegate.h still +// matches the one inside UnrealFramework, and that the app is using it. +// +// Nothing here may reference IOSAppDelegate by name in code. Doing that emits a +// link-time reference to the class, and the pod deliberately builds without +// UnrealFramework so it can ship to projects that have no engine. The class +// arrives as an argument instead. +// + +#import "UnrealAppDelegate.h" + +#import +#import + +#include + +/// Defined in UnrealBridge.mm. +extern "C" void UnrealBridgeBeginDrivingEngine(void); +#import + +extern "C" BOOL UnrealStartEngineAtLaunch(void) { + if (!UnrealAssertAppDelegateUsable()) { + return NO; + } + + // Resolved at runtime rather than linked, so the pod still builds for + // projects with no engine. Same reason the rest of the bridge does it. + typedef int32_t (*StartEngineFn)(void); + StartEngineFn startEngine = + (StartEngineFn)dlsym(RTLD_DEFAULT, "UnrealBridge_StartEngine"); + if (startEngine == NULL) { + NSLog(@"[UnrealAppDelegate] UnrealFramework has no UnrealBridge_StartEngine, " + @"so it was built without the Flutter plugin. Add it under " + @"Plugins/FlutterPlugin and package again."); + return NO; + } + + const int32_t started = startEngine(); + NSLog(@"[UnrealAppDelegate] Engine start at launch -> %d", started); + if (started == 0) { + return NO; + } + + // The engine is now blocking until it is handed a view, and the tick is + // what offers one. It has to start here rather than when a GameWidget + // appears, because that widget's engine call runs in a post-frame callback + // and Flutter cannot produce that frame while the engine is blocked. + UnrealBridgeBeginDrivingEngine(); + return YES; +} + +extern "C" NSString* UnrealAppDelegateProblem(Class engineDelegateClass, id appDelegate) { + if (engineDelegateClass == nil) { + return @"IOSAppDelegate is missing, so UnrealFramework is not loaded. " + @"Check that your plugin vendors UnrealFramework.framework and " + @"that it is embedded in the app bundle."; + } + + // The shim declares IOSAppDelegate : UIResponder. If that ever stops being + // true, every subclass built against the shim has the wrong superclass, and + // the failure would otherwise land somewhere unrecognisable. + Class superclass = class_getSuperclass(engineDelegateClass); + if (superclass != [UIResponder class]) { + return [NSString stringWithFormat: + @"IOSAppDelegate now descends from %s, not UIResponder. " + @"UnrealAppDelegate.h is out of date with this engine build and " + @"subclassing it is no longer safe.", + superclass ? class_getName(superclass) : "nothing"]; + } + + // Getters for the properties the shim redeclares. A missing setter is not + // worth refusing to start over, but a missing getter means the shim is + // describing a class that no longer has that shape. + static const char* const kRequiredGetters[] = {"Window", "IOSView"}; + for (size_t i = 0; i < sizeof(kRequiredGetters) / sizeof(kRequiredGetters[0]); ++i) { + if (![engineDelegateClass instancesRespondToSelector:sel_getUid(kRequiredGetters[i])]) { + return [NSString stringWithFormat: + @"IOSAppDelegate no longer has a '%s' property. " + @"UnrealAppDelegate.h is out of date with this engine build.", + kRequiredGetters[i]]; + } + } + + // No delegate to judge. That happens outside a real app, in a test binary or + // an extension, where UIApplication was never started. The class checks + // above still ran, which is everything that can be known here. + if (appDelegate == nil) { + return nil; + } + + // Declaring the class correctly is not the same as using it. This catches + // the common mistake, which is a delegate still subclassing + // FlutterAppDelegate. + if (![appDelegate isKindOfClass:engineDelegateClass]) { + return [NSString stringWithFormat: + @"Your app delegate is %s, which does not descend from " + @"IOSAppDelegate. Unreal treats that as fatal and will not start. " + @"Subclass IOSAppDelegate instead of FlutterAppDelegate; see " + @"INTEGRATION.md.", + class_getName(object_getClass(appDelegate))]; + } + + return nil; +} + +extern "C" NSString* UnrealAppDelegateWindowWarning(id appDelegate) { + SEL windowGetter = sel_getUid("Window"); + if (appDelegate == nil || ![appDelegate respondsToSelector:windowGetter]) { + return nil; + } + + // -Window is a plain object getter, so messaging it through a typed + // function pointer is safe and avoids a performSelector cast warning. + typedef id (*WindowGetterFn)(id, SEL); + id window = ((WindowGetterFn)objc_msgSend)(appDelegate, windowGetter); + if (window != nil) { + return nil; + } + + return @"Unreal's Window is nil, so it will read the wrong interface " + @"orientation. UIKit sets that window by calling setWindow: on your " + @"delegate, and declaring your own 'window' property takes over that " + @"selector. Remove it and read unrealWindow instead. Ignore this if " + @"you drive Unreal from a scene delegate."; +} + +extern "C" BOOL UnrealAssertAppDelegateUsable(void) { + UIApplication* application = UIApplication.sharedApplication; + + // Outside a real app there is no delegate to check, so check what can be + // checked and let the caller proceed. Inside one, a missing delegate is its + // own problem and worth saying so. + id appDelegate = application.delegate; + if (application != nil && appDelegate == nil) { + NSLog(@"[UnrealAppDelegate] The application has no delegate yet. Start " + @"the engine after the app has finished launching."); + return NO; + } + + NSString* problem = + UnrealAppDelegateProblem(NSClassFromString(@"IOSAppDelegate"), appDelegate); + if (problem != nil) { + NSLog(@"[UnrealAppDelegate] %@", problem); + return NO; + } + + NSString* warning = UnrealAppDelegateWindowWarning(appDelegate); + if (warning != nil) { + NSLog(@"[UnrealAppDelegate] %@", warning); + } + return YES; +} diff --git a/engines/unreal/dart/ios/Classes/UnrealBridge.mm b/engines/unreal/dart/ios/Classes/UnrealBridge.mm index 53ff2d8..eb20547 100644 --- a/engines/unreal/dart/ios/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/ios/Classes/UnrealBridge.mm @@ -1,266 +1,315 @@ // Copyright Epic Games, Inc. All Rights Reserved. -// Check if UnrealFramework is available -#if __has_include("FlutterBridge.h") - #import #import -#include "FlutterBridge.h" - -// Forward declare the Swift controller class -@class UnrealEngineController; - -// Reference to FlutterBridge instance (Unreal Engine side) -static AFlutterBridge* GFlutterBridgeInstance = nullptr; +#import -// Reference to UnrealEngineController (Swift side) -static id GUnrealEngineController = nil; +#import "UnrealAppDelegate.h" // ============================================================ -// MARK: - Helper Functions +// MARK: - UnrealFramework C ABI // ============================================================ - -NSString* FStringToNSString(const FString& String) -{ - return [NSString stringWithUTF8String:TCHAR_TO_UTF8(*String)]; +// +// Resolved with dlsym rather than linked. The canonical declarations live in +// the plugin's Public/UnrealBridge.h and are copied into the framework's +// Headers/ at export time; keep the signatures below in step with them. +// +// Why dlsym and not a link-time dependency: UnrealFramework is produced by +// "game export unreal -p ios", so it may legitimately be absent when this pod +// is built. Linking against it would break those builds, and weak_import does +// not help, because it only makes a symbol optional at load time while the +// static linker still demands a definition. Looking the symbols up at runtime +// keeps the pod self-contained and turns "framework missing" into a clear log +// line instead of a build failure. +// +// This replaces an older __has_include split that decided at compile time and +// silently produced a do-nothing bridge whenever a header search path was +// slightly off. + +#import + +typedef void (*UnrealMessageCallback)(const char* target, + const char* method, + const char* data); + +typedef void (*UnrealBinaryCallback)(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +typedef void (*SetMessageCallbackFn)(UnrealMessageCallback); +typedef void (*SetBinaryCallbackFn)(UnrealBinaryCallback); +typedef void (*SendToUnrealFn)(const char*, const char*, const char*); +typedef void (*SendBinaryToUnrealFn)(const char*, const char*, const void*, int32_t, int32_t); +typedef void (*ExecuteConsoleCommandFn)(const char*); +typedef void (*LoadLevelFn)(const char*); +typedef void (*ApplyQualitySettingsFn)(int32_t, int32_t, int32_t, int32_t, + int32_t, int32_t, int32_t, int32_t); +typedef int32_t (*GetQualitySettingsFn)(int32_t*, int32_t); +typedef void (*InitFn)(void); +typedef int32_t (*TickFn)(float); +typedef void (*KeepAwakeFn)(const char*, int32_t); +typedef void (*AllowSleepFn)(const char*); +typedef void (*EngineReadyCallback)(void); +typedef void (*SetEngineReadyCallbackFn)(EngineReadyCallback); +typedef int32_t (*StartEngineFn)(void); +typedef int32_t (*IsReadyForViewFn)(void); +typedef void* (*CreateViewFn)(float, float, float); +typedef void (*ResizeViewFn)(float, float, float); +typedef void (*DestroyViewFn)(void); +typedef int32_t (*IsViewReadyFn)(void); +typedef void (*PauseFn)(int32_t); +typedef void (*StopFn)(void); +typedef int32_t (*IsReadyFn)(void); + +/// Look a bridge symbol up in whatever is already loaded into the process. +/// Returns NULL when UnrealFramework is not present. +static void* UnrealSymbol(const char* name) { + return dlsym(RTLD_DEFAULT, name); } -FString NSStringToFString(NSString* String) -{ - if (!String) return FString(); - return FString(UTF8_TO_TCHAR([String UTF8String])); +#define UNREAL_FN(type, name) ((type)UnrealSymbol(name)) + +/// Whether UnrealFramework is loaded into this process. +static BOOL UnrealFrameworkLinked(void) { + static BOOL linked = NO; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + linked = UnrealSymbol("UnrealBridge_SendToUnreal") != NULL; + }); + return linked; } -TMap NSDictionaryToTMap(NSDictionary* Dictionary) -{ - TMap Result; - if (!Dictionary) return Result; +/// Matches UNREALBRIDGE_QUALITY_VALUE_COUNT in the plugin header. +static const int32_t kUnrealQualityValueCount = 7; - for (NSString* key in Dictionary) - { - id value = [Dictionary objectForKey:key]; - NSString* valueStr = [value isKindOfClass:[NSString class]] ? value : [NSString stringWithFormat:@"%@", value]; - Result.Add(NSStringToFString(key), NSStringToFString(valueStr)); - } - return Result; +/// Keys for the quality values, in the order the framework writes them. +static NSArray* UnrealQualityKeys(void) { + return @[ @"antiAliasing", @"shadow", @"postProcess", @"texture", + @"effects", @"foliage", @"viewDistance" ]; } -NSDictionary* TMapToNSDictionary(const TMap& Map) -{ - NSMutableDictionary* Dictionary = [NSMutableDictionary dictionary]; - for (const auto& Entry : Map) - { - [Dictionary setObject:@(Entry.Value) forKey:FStringToNSString(Entry.Key)]; - } - return Dictionary; -} +// The Swift controller. Held strongly for as long as the bridge is live. +static id GUnrealEngineController = nil; // ============================================================ -// MARK: - UnrealBridge Implementation (with Unreal Framework) +// MARK: - Callbacks from Unreal // ============================================================ +// +// These fire on Unreal's GAME thread, and their pointers are only valid for the +// duration of the call. Copy into Foundation objects immediately, then hop to +// the main queue before touching the controller. -@interface UnrealBridge : NSObject -+ (UnrealBridge*)shared; -- (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller; -- (UIView*)getView; -- (void)pause; -- (void)resume; -- (void)quit; -- (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data; -- (void)executeConsoleCommand:(NSString*)command; -- (void)loadLevel:(NSString*)levelName; -- (void)applyQualitySettings:(NSDictionary*)settings; -- (NSDictionary*)getQualitySettings; -@end - -@implementation UnrealBridge +static void HandleUnrealMessage(const char* target, const char* method, const char* data) { + NSString* nsTarget = target ? @(target) : @""; + NSString* nsMethod = method ? @(method) : @""; + NSString* nsData = data ? @(data) : @""; -+ (UnrealBridge*)shared { - static UnrealBridge* instance = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - instance = [[UnrealBridge alloc] init]; - }); - return instance; -} - -- (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { - NSLog(@"[UnrealBridge] create called with config"); + dispatch_async(dispatch_get_main_queue(), ^{ + id controller = GUnrealEngineController; + if (!controller) { + NSLog(@"[UnrealBridge] Dropping message, no controller: %@.%@", nsTarget, nsMethod); + return; + } - // Store controller reference - GUnrealEngineController = controller; + // Level loads arrive on the message channel rather than a channel of + // their own. Route them to the controller's level callback so the + // existing Swift signature keeps working. + if ([nsTarget isEqualToString:@"FlutterBridge"] && + [nsMethod isEqualToString:@"onLevelLoaded"]) { + SEL levelSelector = NSSelectorFromString(@"onLevelLoadedWithLevelName:buildIndex:"); + if ([controller respondsToSelector:levelSelector]) { + NSMethodSignature* sig = [controller methodSignatureForSelector:levelSelector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:levelSelector]; + NSString* levelName = nsData; + NSInteger buildIndex = 0; + [inv setArgument:&levelName atIndex:2]; + [inv setArgument:&buildIndex atIndex:3]; + [inv invoke]; + return; + } + } - // Unreal Engine initialization happens automatically when framework loads - NSLog(@"[UnrealBridge] Unreal Engine initialized, controller registered"); + SEL selector = NSSelectorFromString(@"onMessageFromUnrealWithTarget:method:data:"); + if (![controller respondsToSelector:selector]) { + NSLog(@"[UnrealBridge] Controller does not respond to onMessageFromUnrealWithTarget:method:data:"); + return; + } - return YES; + NSMethodSignature* sig = [controller methodSignatureForSelector:selector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:selector]; + NSString* t = nsTarget; NSString* m = nsMethod; NSString* d = nsData; + [inv setArgument:&t atIndex:2]; + [inv setArgument:&m atIndex:3]; + [inv setArgument:&d atIndex:4]; + [inv invoke]; + }); } -- (UIView*)getView { - NSLog(@"[UnrealBridge] getView called"); - // On iOS, Unreal Engine manages its own view hierarchy - // Return nil - the view is handled by Unreal's window - return nil; -} +static void HandleUnrealBinary(const char* target, const char* method, + const void* data, int32_t length, int32_t checksum) { + NSString* nsTarget = target ? @(target) : @""; + NSString* nsMethod = method ? @(method) : @""; + NSData* nsData = (data && length > 0) + ? [NSData dataWithBytes:data length:(NSUInteger)length] + : [NSData data]; -- (void)pause { - NSLog(@"[UnrealBridge] pause called"); - if (GFlutterBridgeInstance) { - GFlutterBridgeInstance->OnEnginePause(); - } -} + dispatch_async(dispatch_get_main_queue(), ^{ + id controller = GUnrealEngineController; + if (!controller) { + NSLog(@"[UnrealBridge] Dropping binary, no controller: %@.%@", nsTarget, nsMethod); + return; + } -- (void)resume { - NSLog(@"[UnrealBridge] resume called"); - if (GFlutterBridgeInstance) { - GFlutterBridgeInstance->OnEngineResume(); - } -} + SEL selector = NSSelectorFromString(@"onBinaryFromUnrealWithTarget:method:data:checksum:"); + if (![controller respondsToSelector:selector]) { + NSLog(@"[UnrealBridge] Controller has no binary handler, dropping %lu bytes from %@.%@", + (unsigned long)nsData.length, nsTarget, nsMethod); + return; + } -- (void)quit { - NSLog(@"[UnrealBridge] quit called"); - if (GFlutterBridgeInstance) { - GFlutterBridgeInstance->OnEngineQuit(); - } - GUnrealEngineController = nil; - GFlutterBridgeInstance = nullptr; + NSMethodSignature* sig = [controller methodSignatureForSelector:selector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:selector]; + NSString* t = nsTarget; NSString* m = nsMethod; NSData* d = nsData; + NSInteger c = (NSInteger)checksum; + [inv setArgument:&t atIndex:2]; + [inv setArgument:&m atIndex:3]; + [inv setArgument:&d atIndex:4]; + [inv setArgument:&c atIndex:5]; + [inv invoke]; + }); } -- (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data { - NSLog(@"[UnrealBridge] sendMessage: Target=%@, Method=%@", target, method); - - if (GFlutterBridgeInstance) { - FString TargetString = NSStringToFString(target); - FString MethodString = NSStringToFString(method); - FString DataString = NSStringToFString(data); - GFlutterBridgeInstance->ReceiveFromFlutter(TargetString, MethodString, DataString); - } else { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); - } -} -- (void)executeConsoleCommand:(NSString*)command { - NSLog(@"[UnrealBridge] executeConsoleCommand: %@", command); - if (GFlutterBridgeInstance) { - GFlutterBridgeInstance->ExecuteConsoleCommand(NSStringToFString(command)); +// ============================================================ +// MARK: - Driving the engine +// ============================================================ +// +// An embedded Unreal does not own the run loop, so nothing advances the engine +// unless the host does it. The bridge drives FEmbeddedCommunication::TickGameThread +// from a display link, which keeps the engine's timing tied to the display it +// renders to rather than to an arbitrary timer. +// +// Ticking happens on the main thread. That is where the host lives, and where +// an embedded engine expects to be driven from. + +/// Set while the host has unloaded, so the tick stops offering a view. +/// +/// Without this, releasing the view and returning to the run loop rebuilds it +/// on the very next frame, because offering one is exactly what the tick is +/// for. The engine is still running and still wants a view; the host just does +/// not want to give it one for now. +static BOOL GViewSuppressed = NO; + +// Defined below, once the UnrealBridge class exists. +static void BuildViewNowThatEngineIsReady(void); +static BOOL HasEngineView(void); +static void LogEngineViewGeometry(NSString* when, UIView* view); + +static void UnrealTick(double deltaSeconds) { + // The engine blocks in PreInit polling for AppDelegate.IOSView, so keep + // offering one until it takes. This is also the only workable moment: too + // early and Metal is not up, and the readiness announcement that would + // otherwise tell us cannot reach a plugin that has not loaded yet. + if (!GViewSuppressed && !HasEngineView()) { + BuildViewNowThatEngineIsReady(); } -} -- (void)loadLevel:(NSString*)levelName { - NSLog(@"[UnrealBridge] loadLevel: %@", levelName); - if (GFlutterBridgeInstance) { - GFlutterBridgeInstance->LoadLevel(NSStringToFString(levelName)); + TickFn tick = UNREAL_FN(TickFn, "UnrealBridge_Tick"); + if (tick) { + tick((float)deltaSeconds); } } -- (void)applyQualitySettings:(NSDictionary*)settings { - NSLog(@"[UnrealBridge] applyQualitySettings called"); - if (!GFlutterBridgeInstance) return; +/// CADisplayLink already fires on the main run loop, so no hop is needed. +static CADisplayLink* GDisplayLink = nil; - TMap SettingsMap = NSDictionaryToTMap(settings); +static CFTimeInterval GLastTickTime = 0; - int32 QualityLevel = SettingsMap.Contains(TEXT("qualityLevel")) ? FCString::Atoi(*SettingsMap[TEXT("qualityLevel")]) : -1; - int32 AntiAliasing = SettingsMap.Contains(TEXT("antiAliasingQuality")) ? FCString::Atoi(*SettingsMap[TEXT("antiAliasingQuality")]) : -1; - int32 Shadow = SettingsMap.Contains(TEXT("shadowQuality")) ? FCString::Atoi(*SettingsMap[TEXT("shadowQuality")]) : -1; - int32 PostProcess = SettingsMap.Contains(TEXT("postProcessQuality")) ? FCString::Atoi(*SettingsMap[TEXT("postProcessQuality")]) : -1; - int32 Texture = SettingsMap.Contains(TEXT("textureQuality")) ? FCString::Atoi(*SettingsMap[TEXT("textureQuality")]) : -1; - int32 Effects = SettingsMap.Contains(TEXT("effectsQuality")) ? FCString::Atoi(*SettingsMap[TEXT("effectsQuality")]) : -1; - int32 Foliage = SettingsMap.Contains(TEXT("foliageQuality")) ? FCString::Atoi(*SettingsMap[TEXT("foliageQuality")]) : -1; - int32 ViewDistance = SettingsMap.Contains(TEXT("viewDistanceQuality")) ? FCString::Atoi(*SettingsMap[TEXT("viewDistanceQuality")]) : -1; +@interface UnrealTicker : NSObject ++ (instancetype)shared; +- (void)onFrame:(CADisplayLink*)link; +@end - GFlutterBridgeInstance->ApplyQualitySettings(QualityLevel, AntiAliasing, Shadow, PostProcess, Texture, Effects, Foliage, ViewDistance); +@implementation UnrealTicker ++ (instancetype)shared { + static UnrealTicker* instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ instance = [[UnrealTicker alloc] init]; }); + return instance; } - -- (NSDictionary*)getQualitySettings { - NSLog(@"[UnrealBridge] getQualitySettings called"); - if (!GFlutterBridgeInstance) return @{}; - return TMapToNSDictionary(GFlutterBridgeInstance->GetQualitySettings()); +- (void)onFrame:(CADisplayLink*)link { + const CFTimeInterval current = link.timestamp; + const CFTimeInterval delta = + (GLastTickTime > 0) ? (current - GLastTickTime) : link.duration; + GLastTickTime = current; + UnrealTick(delta); } - @end -// ============================================================ -// MARK: - C++ Interface for Unreal Engine Callbacks -// ============================================================ - -void FlutterBridge_SendToFlutter_iOS(const FString& Target, const FString& Method, const FString& Data) -{ - NSString* nsTarget = FStringToNSString(Target); - NSString* nsMethod = FStringToNSString(Method); - NSString* nsData = FStringToNSString(Data); - - dispatch_async(dispatch_get_main_queue(), ^{ - if (GUnrealEngineController) { - SEL selector = NSSelectorFromString(@"onMessageFromUnrealWithTarget:method:data:"); - if ([GUnrealEngineController respondsToSelector:selector]) { - NSMethodSignature* sig = [GUnrealEngineController methodSignatureForSelector:selector]; - NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; - [inv setTarget:GUnrealEngineController]; - [inv setSelector:selector]; - [inv setArgument:&nsTarget atIndex:2]; - [inv setArgument:&nsMethod atIndex:3]; - [inv setArgument:&nsData atIndex:4]; - [inv invoke]; - } else { - NSLog(@"[UnrealBridge] Controller doesn't respond to onMessageFromUnrealWithTarget:method:data:"); - } - } else { - NSLog(@"[UnrealBridge] Warning: Controller not set"); - } - }); - - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] Message sent to Flutter: Target=%s, Method=%s"), *Target, *Method); +static void StartTicking(void) { + if (GDisplayLink) return; + GDisplayLink = [CADisplayLink displayLinkWithTarget:[UnrealTicker shared] + selector:@selector(onFrame:)]; + GLastTickTime = 0; + [GDisplayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; + NSLog(@"[UnrealBridge] Ticking the engine from the display link"); } -void FlutterBridge_NotifyLevelLoaded_iOS(const FString& LevelName, int32 BuildIndex) -{ - NSString* nsLevelName = FStringToNSString(LevelName); - NSNumber* nsBuildIndex = @(BuildIndex); - - dispatch_async(dispatch_get_main_queue(), ^{ - if (GUnrealEngineController) { - SEL selector = NSSelectorFromString(@"onLevelLoadedWithLevelName:buildIndex:"); - if ([GUnrealEngineController respondsToSelector:selector]) { - NSMethodSignature* sig = [GUnrealEngineController methodSignatureForSelector:selector]; - NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; - [inv setTarget:GUnrealEngineController]; - [inv setSelector:selector]; - [inv setArgument:&nsLevelName atIndex:2]; - NSInteger buildIndexVal = [nsBuildIndex integerValue]; - [inv setArgument:&buildIndexVal atIndex:3]; - [inv invoke]; - } - } - }); - - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] Level loaded: %s"), *LevelName); +/// Begin driving the engine before any widget exists. +/// +/// The engine blocks during startup waiting to be handed a view, and the tick +/// is what offers one. Leaving that until a GameWidget appears deadlocks: the +/// widget's engine call runs in a post-frame callback, and Flutter cannot +/// produce that frame while the engine is still blocking. So whoever starts the +/// engine has to start the tick with it. +extern "C" void UnrealBridgeBeginDrivingEngine(void) { + StartTicking(); } -void FlutterBridge_SetInstance_iOS(AFlutterBridge* Instance) -{ - GFlutterBridgeInstance = Instance; - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] FlutterBridge instance set")); +static void StopTicking(void) { + if (!GDisplayLink) return; + [GDisplayLink invalidate]; + GDisplayLink = nil; } -#else // ============================================================ -// MARK: - Stub Implementation (UnrealFramework not available) +// MARK: - Waiting for the engine before building a view // ============================================================ +// +// The engine reads its config before a render view can exist, and announces +// when that is done. Asking earlier gets NULL, so the bridge registers for the +// signal and builds the view when it lands rather than guessing at a delay. -#import -#import - -// Reference to controller for stub mode -static id GUnrealEngineController = nil; +// ============================================================ +// MARK: - UnrealBridge +// ============================================================ -@interface UnrealBridge : NSObject +@interface UnrealBridge : NSObject { + CGSize _requestedViewSize; +} +/// The engine's render view once it exists. Owned by the engine's app +/// delegate, so this is an observing reference. +@property (nonatomic, weak) UIView* engineView; + (UnrealBridge*)shared; +- (void)resizeViewTo:(CGSize)size; +- (BOOL)isViewReady; - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller; - (UIView*)getView; - (void)pause; - (void)resume; - (void)quit; +- (void)destroyView; +- (void)restoreView; - (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data; +- (void)sendBinaryWithTarget:(NSString*)target method:(NSString*)method data:(NSData*)data; - (void)executeConsoleCommand:(NSString*)command; - (void)loadLevel:(NSString*)levelName; - (void)applyQualitySettings:(NSDictionary*)settings; @@ -269,6 +318,15 @@ - (NSDictionary*)getQualitySettings; @implementation UnrealBridge +/// Size the engine renders at until the host resizes it. The screen bounds are +/// the best guess available before the widget has been laid out. +- (CGSize)requestedViewSize { + if (_requestedViewSize.width > 0 && _requestedViewSize.height > 0) { + return _requestedViewSize; + } + return UIScreen.mainScreen.bounds.size; +} + + (UnrealBridge*)shared { static UnrealBridge* instance = nil; static dispatch_once_t onceToken; @@ -279,51 +337,304 @@ + (UnrealBridge*)shared { } - (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { - NSLog(@"[UnrealBridge] Stub: create called (UnrealFramework not available)"); + if (!UnrealFrameworkLinked()) { + NSLog(@"[UnrealBridge] UnrealFramework is not linked into this app. " + @"Run 'game export unreal -p ios' and 'game sync unreal -p ios', " + @"and check the framework is embedded in the Xcode target."); + return NO; + } + + // Unreal treats a delegate that does not descend from IOSAppDelegate as + // fatal, and it would crash somewhere far less obvious than here. Check it + // while there is still something useful to say about it. + if (!UnrealAssertAppDelegateUsable()) { + return NO; + } + GUnrealEngineController = controller; - // Return NO to indicate Unreal is not actually available - return NO; + + SetMessageCallbackFn setMessage = UNREAL_FN(SetMessageCallbackFn, "UnrealBridge_SetMessageCallback"); + if (setMessage) setMessage(&HandleUnrealMessage); + + SetBinaryCallbackFn setBinary = UNREAL_FN(SetBinaryCallbackFn, "UnrealBridge_SetBinaryCallback"); + if (setBinary) setBinary(&HandleUnrealBinary); + + // Start the engine. Nothing else works until this runs: it is what brings + // Metal up, and the render view cannot be built before it. + // + // Deliberately not waiting for the engine's readiness announcement. It + // broadcasts "inisareready" from PreInit and then blocks waiting for a + // view, and plugin modules only load later in PreInit, so by the time + // anything here could subscribe the announcement has been and gone and the + // engine is already stuck. The view is offered from the tick instead, which + // matches how the engine polls for it. + StartEngineFn startEngine = UNREAL_FN(StartEngineFn, "UnrealBridge_StartEngine"); + if (startEngine) { + NSLog(@"[UnrealBridge] StartEngine -> %d", startEngine()); + } + + KeepAwakeFn keepAwake = UNREAL_FN(KeepAwakeFn, "UnrealBridge_KeepAwake"); + if (keepAwake) keepAwake("flutter", 1); + + IsReadyFn isReady = UNREAL_FN(IsReadyFn, "UnrealBridge_IsReady"); + const BOOL engineReady = isReady && (isReady() != 0); + if (!engineReady) { + // The framework is linked but no AFlutterBridge actor has registered + // yet. That is normal this early: the actor registers in BeginPlay. + // Calls made before then are dropped by the framework, not by us. + NSLog(@"[UnrealBridge] Framework linked, waiting for AFlutterBridge actor. " + @"Place one in your level if messages never arrive."); + } + + InitFn initEngine = UNREAL_FN(InitFn, "UnrealBridge_Init"); + if (initEngine) initEngine(); + + StartTicking(); + + NSLog(@"[UnrealBridge] Bridge created, callbacks registered, engine ticking"); + return YES; } - (UIView*)getView { - NSLog(@"[UnrealBridge] Stub: getView called (UnrealFramework not available)"); + // Unreal's embedded mode does not build its own view. The framework makes + // an FIOSView, registers it with the app delegate and hands it back here, + // so the engine renders straight into a view we can put inside a Flutter + // platform view. Nothing is copied per frame. + UIView* existing = [UnrealBridge shared].engineView; + if (existing) { + return existing; + } + + // Not ready yet is the normal case on the first call: the engine announces + // when its config is loaded and the view gets built then. The controller is + // told through onUnrealViewReady, so returning nil here is not a failure. + IsReadyForViewFn readyForView = + UNREAL_FN(IsReadyForViewFn, "UnrealBridge_IsReadyForView"); + if (readyForView && readyForView()) { + BuildViewNowThatEngineIsReady(); + return [UnrealBridge shared].engineView; + } + + NSLog(@"[UnrealBridge] Engine not ready for a view yet; waiting for its signal"); return nil; } +- (void)resizeViewTo:(CGSize)size { + ResizeViewFn resize = UNREAL_FN(ResizeViewFn, "UnrealBridge_ResizeView"); + if (!resize) return; + _requestedViewSize = size; + resize((float)size.width, (float)size.height, (float)UIScreen.mainScreen.scale); + LogEngineViewGeometry(@"resized", self.engineView); +} + +- (BOOL)isViewReady { + IsViewReadyFn ready = UNREAL_FN(IsViewReadyFn, "UnrealBridge_IsViewReady"); + return ready && ready() != 0; +} + - (void)pause { - NSLog(@"[UnrealBridge] Stub: pause called (UnrealFramework not available)"); + PauseFn pause = UNREAL_FN(PauseFn, "UnrealBridge_Pause"); + if (pause) pause(1); } - (void)resume { - NSLog(@"[UnrealBridge] Stub: resume called (UnrealFramework not available)"); + PauseFn pause = UNREAL_FN(PauseFn, "UnrealBridge_Pause"); + if (pause) pause(0); +} + +- (void)destroyView { + GViewSuppressed = YES; + + DestroyViewFn destroyView = UNREAL_FN(DestroyViewFn, "UnrealBridge_DestroyView"); + if (destroyView) destroyView(); + + // Dropped here too, so the engine's own reference is the only one left and + // the buffers actually go back. + self.engineView = nil; + + // Letting the engine idle is the pause's job, not this one. The sleep + // counter is matched, and releasing it twice against a single KeepAwake + // asserts inside the engine, so only one side may own it. Unload pauses as + // well as releasing the view, so it is already covered. + NSLog(@"[UnrealBridge] Render view released"); +} + +- (void)restoreView { + if (!GViewSuppressed) { + return; + } + + GViewSuppressed = NO; + + // The tick offers a view again from here, the same way it did at startup. + // Waking the engine is the resume's job, for the same reason releasing was + // the pause's. + NSLog(@"[UnrealBridge] Render view will be rebuilt"); } - (void)quit { - NSLog(@"[UnrealBridge] Stub: quit called (UnrealFramework not available)"); + StopTicking(); + + DestroyViewFn destroyView = UNREAL_FN(DestroyViewFn, "UnrealBridge_DestroyView"); + if (destroyView) destroyView(); + + StopFn stop = UNREAL_FN(StopFn, "UnrealBridge_Stop"); + if (stop) stop(); GUnrealEngineController = nil; } - (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data { - NSLog(@"[UnrealBridge] Stub: sendMessage called (UnrealFramework not available)"); + SendToUnrealFn send = UNREAL_FN(SendToUnrealFn, "UnrealBridge_SendToUnreal"); + if (!send) { + NSLog(@"[UnrealBridge] Cannot send, framework not loaded"); + return; + } + send(target.UTF8String, method.UTF8String, data.UTF8String ?: ""); + NSLog(@"[UnrealBridge] handed %@.%@ to the framework", target, method); +} + +- (void)sendBinaryWithTarget:(NSString*)target method:(NSString*)method data:(NSData*)data { + SendBinaryToUnrealFn send = UNREAL_FN(SendBinaryToUnrealFn, "UnrealBridge_SendBinaryToUnreal"); + if (!send) { + NSLog(@"[UnrealBridge] Cannot send binary, framework not loaded"); + return; + } + // Checksum is computed engine-side on receipt; 0 means "unset". + send(target.UTF8String, method.UTF8String, data.bytes, (int32_t)data.length, 0); } - (void)executeConsoleCommand:(NSString*)command { - NSLog(@"[UnrealBridge] Stub: executeConsoleCommand called (UnrealFramework not available)"); + ExecuteConsoleCommandFn exec = UNREAL_FN(ExecuteConsoleCommandFn, "UnrealBridge_ExecuteConsoleCommand"); + if (exec) exec(command.UTF8String); } - (void)loadLevel:(NSString*)levelName { - NSLog(@"[UnrealBridge] Stub: loadLevel called (UnrealFramework not available)"); + LoadLevelFn load = UNREAL_FN(LoadLevelFn, "UnrealBridge_LoadLevel"); + if (load) load(levelName.UTF8String); } - (void)applyQualitySettings:(NSDictionary*)settings { - NSLog(@"[UnrealBridge] Stub: applyQualitySettings called (UnrealFramework not available)"); + ApplyQualitySettingsFn apply = UNREAL_FN(ApplyQualitySettingsFn, "UnrealBridge_ApplyQualitySettings"); + if (!apply) return; + + int32_t (^value)(NSString*) = ^int32_t(NSString* key) { + id v = settings[key]; + return v ? (int32_t)[v intValue] : -1; + }; + + apply( + value(@"qualityLevel"), + value(@"antiAliasingQuality"), + value(@"shadowQuality"), + value(@"postProcessQuality"), + value(@"textureQuality"), + value(@"effectsQuality"), + value(@"foliageQuality"), + value(@"viewDistanceQuality")); } - (NSDictionary*)getQualitySettings { - NSLog(@"[UnrealBridge] Stub: getQualitySettings called (UnrealFramework not available)"); - return @{}; + GetQualitySettingsFn get = UNREAL_FN(GetQualitySettingsFn, "UnrealBridge_GetQualitySettings"); + if (!get) return @{}; + + int32_t values[kUnrealQualityValueCount]; + const int32_t written = get(values, kUnrealQualityValueCount); + if (written < kUnrealQualityValueCount) { + // The framework serves a cache refreshed on the game thread, so the + // very first call can land before it is populated. + return @{}; + } + + NSArray* keys = UnrealQualityKeys(); + NSMutableDictionary* result = [NSMutableDictionary dictionaryWithCapacity:keys.count]; + for (NSUInteger i = 0; i < keys.count; i++) { + result[keys[i]] = @(values[i]); + } + return result; } @end -#endif // __has_include("FlutterBridge.h") +// ============================================================ +// MARK: - Deferred view creation +// ============================================================ + +/// Report what the engine's view actually is on screen. +/// +/// A scene that renders as a band across the middle can be either of two very +/// different things: a correctly sized view holding a letterboxed frame, or a +/// view that is itself only that tall. The numbers tell you which, and nothing +/// else does. +static void LogEngineViewGeometry(NSString* when, UIView* view) { + if (view == nil) { + NSLog(@"[UnrealBridge] geometry (%@): no view", when); + return; + } + + // Built rather than CGSizeZero, which is a linked constant and would drag + // CoreGraphics into every target that includes this file. + CGSize drawable = CGSizeMake(0.0, 0.0); + if ([view.layer isKindOfClass:[CAMetalLayer class]]) { + drawable = ((CAMetalLayer*)view.layer).drawableSize; + } + + NSLog(@"[UnrealBridge] geometry (%@): frame=%@ bounds=%@ superview=%@ " + @"layer=%@ drawable=%@ scale=%.1f", + when, + NSStringFromCGRect(view.frame), + NSStringFromCGRect(view.bounds), + view.superview ? NSStringFromCGRect(view.superview.bounds) : @"none", + NSStringFromCGRect(view.layer.frame), + NSStringFromCGSize(drawable), + view.contentScaleFactor); +} + +static BOOL HasEngineView(void) { + return UnrealBridge.shared.engineView != nil; +} + +static void BuildViewNowThatEngineIsReady(void) { + UnrealBridge* bridge = UnrealBridge.shared; + if (bridge.engineView) { + return; + } + + CreateViewFn createView = UNREAL_FN(CreateViewFn, "UnrealBridge_CreateView"); + if (!createView) { + NSLog(@"[UnrealBridge] Framework has no render view entry point"); + return; + } + + const CGSize size = bridge.requestedViewSize; + const CGFloat scale = UIScreen.mainScreen.scale; + void* handle = createView((float)size.width, (float)size.height, (float)scale); + if (!handle) { + NSLog(@"[UnrealBridge] The engine declined to make a render view"); + return; + } + + // Unretained and owned by the engine's app delegate, so do not take + // ownership of it here. + UIView* view = (__bridge UIView*)handle; + bridge.engineView = view; + NSLog(@"[UnrealBridge] Render view built at %@", NSStringFromCGSize(size)); + LogEngineViewGeometry(@"built", view); + + id controller = GUnrealEngineController; + SEL selector = NSSelectorFromString(@"onUnrealViewReadyWithView:"); + if ([controller respondsToSelector:selector]) { + NSMethodSignature* sig = [controller methodSignatureForSelector:selector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:selector]; + UIView* arg = view; + [inv setArgument:&arg atIndex:2]; + [inv invoke]; + } else { + // Expected when the engine starts at launch: the view is ready before + // any GameWidget exists. The controller collects it from getView when + // it does turn up. + NSLog(@"[UnrealBridge] Render view built before a controller existed; " + @"it will be collected when one attaches"); + } +} diff --git a/engines/unreal/dart/ios/Classes/UnrealEngineController.swift b/engines/unreal/dart/ios/Classes/UnrealEngineController.swift index 399bdbd..c84d3b5 100644 --- a/engines/unreal/dart/ios/Classes/UnrealEngineController.swift +++ b/engines/unreal/dart/ios/Classes/UnrealEngineController.swift @@ -13,6 +13,11 @@ public class UnrealEngineController: GameEngineController { // MARK: - Properties private var unrealView: UIView? + + /// Whether unloadEngine gave the view back. Guards reload, so calling it on + /// a running engine does nothing rather than resuming something that was + /// never paused. + private var isUnloaded = false private var unrealReady = false // Message queue for events before Flutter subscribes @@ -70,7 +75,10 @@ public class UnrealEngineController: GameEngineController { self.attachEngine() NSLog("UnrealEngineController: Unreal view attached successfully") } else { - NSLog("UnrealEngineController: No Unreal view available (stub mode)") + // Normal on the first call. The engine announces when its + // config is loaded and the bridge builds the view then, which + // arrives at onUnrealViewReadyWithView. + NSLog("UnrealEngineController: Waiting for the engine's render view") } // Mark as ready @@ -112,6 +120,54 @@ public class UnrealEngineController: GameEngineController { self.flushMessageQueue() } + /** + * Called by the bridge once the engine has built its render view. + * + * The view cannot exist until the engine has read its config, which it + * announces rather than doing on a fixed schedule. So createEngine finishes + * without a view and this attaches it whenever it arrives, which is usually + * a moment later but is not guaranteed to be. + * + * @objc makes this reachable from the Objective-C bridge. + */ + @objc public func onUnrealViewReadyWithView(_ view: UIView) { + NSLog("UnrealEngineController: Unreal render view arrived") + self.unrealView = view + self.attachEngine() + + // Tell the engine the size it is actually rendering at. The container + // resizes the view for us, but the engine works in pixels and will keep + // rendering at its startup guess until it is told otherwise. + self.syncEngineSurfaceSize() + self.sendEvent(name: "onMessage", data: [ + "target": "Unreal", + "method": "onViewReady", + "data": "{\"success\":true}" + ]) + } + + public override func engineViewDidResize(to size: CGSize) { + syncEngineSurfaceSize() + } + + /// Push the current container size down to the engine. + /// + /// Sizes cross the bridge in points and the engine works in pixels, so the + /// scale factor is applied on the far side. Safe to call repeatedly, so + /// call it whenever the container changes size. + @objc public func syncEngineSurfaceSize() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let size = self.view().bounds.size + guard size.width > 0, size.height > 0 else { return } + + if let bridge = self.getUnrealBridge() { + self.callBridgeResizeView(bridge: bridge, size: size) + } + } + } + public override func attachEngine() { DispatchQueue.main.async { [weak self] in guard let self = self, let unrealView = self.unrealView else { @@ -166,15 +222,57 @@ public class UnrealEngineController: GameEngineController { } } + /// Give back everything an idle engine is holding, short of tearing it down. + /// + /// Unreal cannot be unloaded and started again in one process, so this is + /// not a teardown. What it can do is stop: the game pauses, the tick stops, + /// and the render view goes away, which is the expensive part. That frees + /// the drawable and its buffers, which on a phone is most of what an engine + /// costs while you are looking at some other Flutter page. + /// + /// Reversible. Call reload, or just show the widget again, and the view is + /// rebuilt and the engine resumes with the scene as you left it. public override func unloadEngine() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - // Unreal doesn't support unloading without destroying, pause instead - self.pauseEngine() + NSLog("UnrealEngineController: Unloading Unreal (pausing, releasing the view)") + + if let bridge = self.getUnrealBridge() { + self.callBridgePause(bridge: bridge) + self.callBridgeDestroyView(bridge: bridge) + } + + self.removeEngineView() + self.unrealView = nil + self._isPaused = true + self.isUnloaded = true + self.sendEvent(name: "onUnloaded", data: nil) } } + + /// Bring back what unloadEngine gave up. + /// + /// The engine was never destroyed, so there is nothing to start: the view + /// is rebuilt on the next tick and the game unpauses. + public override func reloadEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard self.isUnloaded else { return } + + NSLog("UnrealEngineController: Reloading Unreal") + self.isUnloaded = false + + if let bridge = self.getUnrealBridge() { + self.callBridgeRestoreView(bridge: bridge) + self.callBridgeResume(bridge: bridge) + } + + self._isPaused = false + self.sendEvent(name: "onLoaded", data: nil) + } + } public override func destroyEngine() { // Unregister as active controller @@ -241,6 +339,16 @@ public class UnrealEngineController: GameEngineController { return shared(bridgeClass, selector) } + private func callBridgeResizeView(bridge: AnyObject, size: CGSize) { + let selector = NSSelectorFromString("resizeViewTo:") + guard bridge.responds(to: selector) else { return } + + let method = bridge.method(for: selector) + typealias ResizeFunc = @convention(c) (AnyObject, Selector, CGSize) -> Void + let resize = unsafeBitCast(method, to: ResizeFunc.self) + resize(bridge, selector, size) + } + private func callBridgeCreate(bridge: AnyObject, config: [String: Any]) -> Bool { let selector = NSSelectorFromString("createWithConfig:controller:") guard bridge.responds(to: selector) else { return false } @@ -267,6 +375,24 @@ public class UnrealEngineController: GameEngineController { bridge.perform(selector) } + private func callBridgeRestoreView(bridge: AnyObject) { + let selector = NSSelectorFromString("restoreView") + guard bridge.responds(to: selector) else { return } + + let method = bridge.method(for: selector) + typealias RestoreFunc = @convention(c) (AnyObject, Selector) -> Void + unsafeBitCast(method, to: RestoreFunc.self)(bridge, selector) + } + + private func callBridgeDestroyView(bridge: AnyObject) { + let selector = NSSelectorFromString("destroyView") + guard bridge.responds(to: selector) else { return } + + let method = bridge.method(for: selector) + typealias DestroyFunc = @convention(c) (AnyObject, Selector) -> Void + unsafeBitCast(method, to: DestroyFunc.self)(bridge, selector) + } + private func callBridgeResume(bridge: AnyObject) { let selector = NSSelectorFromString("resume") guard bridge.responds(to: selector) else { return } @@ -311,7 +437,7 @@ public class UnrealEngineController: GameEngineController { return } - NSLog("UnrealEngineController: Forwarding message to Flutter: \(target).\(method)") + NSLog("UnrealEngineController: Forwarding message to Flutter: \(target).\(method) \(data)") sendEvent(name: "onMessage", data: [ "target": target, "method": method, diff --git a/engines/unreal/dart/ios/INTEGRATION.md b/engines/unreal/dart/ios/INTEGRATION.md new file mode 100644 index 0000000..87c8e50 --- /dev/null +++ b/engines/unreal/dart/ios/INTEGRATION.md @@ -0,0 +1,148 @@ +# Embedding Unreal in a Flutter app on iOS + +The pod handles starting the engine, driving its tick, and handing its render +view to `GameWidget`. One thing it cannot do for you is the app delegate, and +without that the engine will not start at all. + +## Your AppDelegate must subclass IOSAppDelegate + +Unreal states this itself, and as a Fatal rather than a warning: + +> Currently, a native app embedding Unreal must have the AppDelegate subclass +> from IOSAppDelegate. + +`IOSAppDelegate` caches itself in its own `init`, and that cached pointer is how +the engine finds its delegate, its window, and its view. No subclass, no engine. + +The awkward part is that Flutter apps normally subclass `FlutterAppDelegate`, +and an Objective-C class cannot have two superclasses. + +`FlutterAppDelegate` is a convenience, not a requirement. What Flutter actually +needs is a delegate that registers plugins and forwards application lifecycle to +them. So the working shape is a delegate that subclasses `IOSAppDelegate` and +conforms to `FlutterPluginRegistry` and `FlutterAppLifeCycleProvider`. + +## Subclass it directly + +The pod ships a header that declares `IOSAppDelegate` for you, so you can +subclass it from your own app without any engine headers. + +Add this to `ios/Runner/Runner-Bridging-Header.h`: + +```objc +#import +``` + +Then write `ios/Runner/AppDelegate.swift` against it: + +```swift +import UIKit +import Flutter + +@main +class AppDelegate: IOSAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + if let controller = unrealWindow?.rootViewController as? FlutterViewController { + GeneratedPluginRegistrant.register(with: controller) + } + return true + } +} +``` + +Three things in there are load bearing. + +Do not call `super`. In an embedded build that starts Unreal the non-embedded +way and fights with the pod, which starts it for you. `IOSAppDelegate` caches +itself during `init`, which has already run by this point, so the engine still +finds its delegate. + +Register plugins against the `FlutterViewController`, not against `self`. +Normally `GeneratedPluginRegistrant.register(with: self)` works because +`FlutterAppDelegate` conforms to `FlutterPluginRegistry`, and you no longer +inherit from it. `FlutterViewController` conforms too, and your storyboard +already creates one as the root view controller. + +**Do not declare a `window` property.** This is the one that will cost you an +afternoon, so it gets its own section. + +### The window trap + +Unreal reads its own `Window` to work out the interface orientation. It only +assigns that window on the startup path you just skipped, so you would expect it +to be nil. It is not, and the reason is a naming coincidence. + +Unreal spells the property with a capital W. Objective-C builds a setter from +that by capitalising the first letter, giving `setWindow:`. That is the exact +selector UIKit calls on your app delegate when the storyboard loads its window. +So UIKit hands Unreal its window without either side knowing about the other. + +Declare `var window: UIWindow?` in your delegate and you take that selector +over. Unreal's `Window` stays nil, orientation goes wrong, and nothing anywhere +reports an error. Leave it out and read `unrealWindow` when you need it. + +The bridge checks for this before starting the engine and logs a warning naming +the setter, so you do not have to remember. It is a warning rather than a +refusal, because an app driving Unreal from a scene delegate legitimately has no +window on the app delegate. + +### Two more things worth knowing + +Subclassing needs the class at link time, so the app has to link +UnrealFramework. That is already true for anything embedding Unreal, but it +means this header is no use in a build without the framework. + +The header redeclares a class Epic owns, and nothing checks that redeclaration +against the real one at compile time. So the bridge checks it at runtime +instead, before starting the engine, and refuses with a clear log line if the +shape has drifted or if your delegate does not descend from `IOSAppDelegate` +after all. Call `UnrealAssertAppDelegateUsable()` yourself if you want to fail +earlier. + +### Naming + +| Objective-C | Swift | What it is | +|---|---|---| +| `Window` | `unrealWindow` | Unreal's window. Renamed because the capital W collides with `UIApplicationDelegate`'s `window`, and Swift otherwise decides the two are one property under an old name and refuses to let you touch it. | +| `IOSView` | `iosView` | Unreal's render view. Read only. | + +## What the pod does for you + +Once the delegate is right, `GameWidget(engineType: GameEngineType.unreal)` is +enough. Behind it: + +- `UnrealBridge_StartEngine` runs on create, which is what brings Metal up +- The display link drives `UnrealBridge_Tick` +- The render view is offered to the engine every tick until it takes, then + handed to the controller and added to Flutter's platform view container +- Container size is pushed down so the engine renders at the right resolution + +The pod does not wait for the engine's `inisareready` announcement, and neither +should you. The engine broadcasts it from `PreInit` and then blocks waiting for +a view, while plugin modules only load later in `PreInit`. By the time anything +could subscribe, the announcement has gone and the engine is already stuck. + +## Keep the engine view inside the visible hierarchy + +If you place the view yourself, put it inside a view that is actually on screen, +not merely in the window behind an opaque one. A `CAMetalLayer` that +CoreAnimation never composites never presents, its drawables are never released, +and the game thread blocks on `nextDrawable` once the queue fills. That looks +like a frozen screen and an engine stuck at a couple of dozen frames, which +resembles a rendering bug far more than a view hierarchy mistake. + +`GameWidget` handles this correctly. + +## Requirements + +- An engine built from source. A launcher install cannot define + `BUILD_EMBEDDED_APP` for its own prebuilt modules, so the framework links and + launches and never boots an engine. See `docs/UNREAL_FRAMEWORK_BLOCKER.md` in + game-cli. +- `bBuildAsFramework=True` under `[/Script/IOSRuntimeSettings.IOSRuntimeSettings]` +- Cooked content staged into the app bundle, alongside `uecommandline.txt` +- A level to load. An empty `GameDefaultMap` gets you all the way through + renderer init and then "Failed to load package ''". diff --git a/engines/unreal/dart/ios/Tests/MockUnrealAppDelegate.m b/engines/unreal/dart/ios/Tests/MockUnrealAppDelegate.m new file mode 100644 index 0000000..db5f8a3 --- /dev/null +++ b/engines/unreal/dart/ios/Tests/MockUnrealAppDelegate.m @@ -0,0 +1,18 @@ +/* + * IOSAppDelegate, as the real UnrealFramework exports it. + * + * Shaped to match what UnrealAppDelegate.h redeclares: descends from + * UIResponder, and carries the Window and IOSView properties. The bridge + * refuses to start the engine without this class, so the mock framework has to + * provide it for the same reason the real one does. + */ + +#import + +@interface IOSAppDelegate : UIResponder +@property (strong, retain, nonatomic) UIWindow* Window; +@property (retain) UIView* IOSView; +@end + +@implementation IOSAppDelegate +@end diff --git a/engines/unreal/dart/ios/Tests/MockUnrealFramework.c b/engines/unreal/dart/ios/Tests/MockUnrealFramework.c new file mode 100644 index 0000000..335e9cc --- /dev/null +++ b/engines/unreal/dart/ios/Tests/MockUnrealFramework.c @@ -0,0 +1,95 @@ +// Mock UnrealFramework: exports the C ABI so the pod's dlsym path can be +// exercised without an engine build. +#include +#include +#include + +typedef void (*UnrealMessageCallback)(const char*, const char*, const char*); +typedef void (*UnrealBinaryCallback)(const char*, const char*, const void*, int32_t, int32_t); + +static UnrealMessageCallback gMessage = 0; +static UnrealBinaryCallback gBinary = 0; + +char gLastTarget[128], gLastMethod[128], gLastData[256]; +int32_t gLastQuality[8]; +int gConsoleCalls = 0, gLevelCalls = 0, gPauseState = -1, gStopped = 0; + +void UnrealBridge_SetMessageCallback(UnrealMessageCallback cb) { gMessage = cb; } +void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback cb) { gBinary = cb; } +void UnrealBridge_SendToUnreal(const char* t, const char* m, const char* d) { + snprintf(gLastTarget, sizeof gLastTarget, "%s", t ? t : ""); + snprintf(gLastMethod, sizeof gLastMethod, "%s", m ? m : ""); + snprintf(gLastData, sizeof gLastData, "%s", d ? d : ""); +} +void UnrealBridge_SendBinaryToUnreal(const char* t, const char* m, const void* d, int32_t n, int32_t c) { + (void)d; (void)c; + snprintf(gLastTarget, sizeof gLastTarget, "%s", t ? t : ""); + snprintf(gLastMethod, sizeof gLastMethod, "%s", m ? m : ""); + snprintf(gLastData, sizeof gLastData, "%d", n); +} +void UnrealBridge_ExecuteConsoleCommand(const char* c) { (void)c; gConsoleCalls++; } +void UnrealBridge_LoadLevel(const char* l) { (void)l; gLevelCalls++; } +void UnrealBridge_ApplyQualitySettings(int32_t a,int32_t b,int32_t c,int32_t d, + int32_t e,int32_t f,int32_t g,int32_t h) { + gLastQuality[0]=a; gLastQuality[1]=b; gLastQuality[2]=c; gLastQuality[3]=d; + gLastQuality[4]=e; gLastQuality[5]=f; gLastQuality[6]=g; gLastQuality[7]=h; +} +int32_t UnrealBridge_GetQualitySettings(int32_t* out, int32_t cap) { + if (!out || cap < 7) return 0; + for (int i = 0; i < 7; i++) out[i] = i + 1; + return 7; +} +void UnrealBridge_Pause(int32_t p) { gPauseState = p; } + +/* Engine lifecycle */ +int gInitCalls = 0, gTickCalls = 0; +float gLastDelta = 0; +void UnrealBridge_Init(void) { gInitCalls++; } +int32_t UnrealBridge_Tick(float dt) { gTickCalls++; gLastDelta = dt; return 1; } +void UnrealBridge_WakeGameThread(void) {} +void UnrealBridge_KeepAwake(const char* r, int32_t n) { (void)r; (void)n; } +void UnrealBridge_AllowSleep(const char* r) { (void)r; } +int32_t UnrealBridge_IsAwakeForTicking(void) { return 1; } +int32_t UnrealBridge_IsAwakeForRendering(void) { return 1; } + +/* Engine readiness. The engine announces when a view can be made. */ +typedef void (*ReadyCb)(void); +static ReadyCb gReadyCb = 0; +int gReadyForView = 0; +void UnrealBridge_SetEngineReadyCallback(ReadyCb cb) { + gReadyCb = cb; + if (cb && gReadyForView) cb(); +} +int32_t UnrealBridge_IsReadyForView(void) { return gReadyForView; } + +/* Test hook: pretend the engine just announced readiness. */ +void MockUnreal_SignalEngineReady(void) { + gReadyForView = 1; + if (gReadyCb) gReadyCb(); +} + +/* Render surface. + * + * The view has to be a real Objective-C object: the bridge holds it in a weak + * property, and ARC cannot register a weak reference to an arbitrary pointer. + * The test supplies one through MockUnreal_SetView. */ +static void* gView = 0; +void MockUnreal_SetView(void* v) { gView = v; } +int gCreateViewCalls = 0, gDestroyViewCalls = 0; +float gViewWidth = 0, gViewHeight = 0, gViewScale = 0; +void* UnrealBridge_CreateView(float w, float h, float s) { + gCreateViewCalls++; gViewWidth = w; gViewHeight = h; gViewScale = s; + return gView; +} +void UnrealBridge_ResizeView(float w, float h, float s) { + gViewWidth = w; gViewHeight = h; gViewScale = s; +} +void UnrealBridge_DestroyView(void) { gDestroyViewCalls++; } +int32_t UnrealBridge_IsViewReady(void) { return 1; } +void UnrealBridge_Stop(void) { gStopped = 1; } +int32_t UnrealBridge_IsReady(void) { return 1; } + +/// Drive a message from "Unreal" back into the pod. +void MockUnreal_FireMessage(const char* t, const char* m, const char* d) { + if (gMessage) gMessage(t, m, d); +} diff --git a/engines/unreal/dart/ios/Tests/UnrealAppDelegateTests.mm b/engines/unreal/dart/ios/Tests/UnrealAppDelegateTests.mm new file mode 100644 index 0000000..7fbd7f1 --- /dev/null +++ b/engines/unreal/dart/ios/Tests/UnrealAppDelegateTests.mm @@ -0,0 +1,119 @@ +// +// Exercise the app delegate contract check. +// +// UnrealAppDelegateProblem takes the engine class and the app delegate as +// arguments precisely so this can run without a UIApplication, which a spawned +// test binary does not have. Every branch below is reachable here; the only +// untested part is UnrealAssertAppDelegateUsable's two-line wiring. +// + +#import +#import "../Classes/UnrealAppDelegate.h" + +static int gFailures = 0; + +static void check(BOOL condition, const char* what) { + printf("%s: %s\n", condition ? "PASS" : "FAIL", what); + if (!condition) gFailures++; +} + +/// Shaped like the real IOSAppDelegate: descends from UIResponder and has both +/// properties the shim redeclares. +@interface FakeEngineDelegate : UIResponder +@property (retain) UIWindow* Window; +@property (retain) UIView* IOSView; +@end +@implementation FakeEngineDelegate +@end + +/// What a correctly written host app delegate looks like. +@interface GoodAppDelegate : FakeEngineDelegate +@end +@implementation GoodAppDelegate +@end + +/// Stands in for a delegate still subclassing FlutterAppDelegate. +@interface UnrelatedAppDelegate : UIResponder +@end +@implementation UnrelatedAppDelegate +@end + +/// Epic changing the superclass out from under the shim. +@interface WrongSuperclassDelegate : NSObject +@end +@implementation WrongSuperclassDelegate +@end + +/// Epic dropping a property the shim redeclares. +@interface MissingPropertyDelegate : UIResponder +@end +@implementation MissingPropertyDelegate +@end + +static BOOL mentions(NSString* haystack, NSString* needle) { + return haystack != nil && + [haystack rangeOfString:needle].location != NSNotFound; +} + +int main(void) { + @autoreleasepool { + NSString* problem; + + problem = UnrealAppDelegateProblem(nil, nil); + check(mentions(problem, @"UnrealFramework"), + "a missing IOSAppDelegate is reported as a missing framework"); + + problem = UnrealAppDelegateProblem([WrongSuperclassDelegate class], nil); + check(mentions(problem, @"UIResponder"), + "a changed superclass is refused, because subclassing is then unsafe"); + + problem = UnrealAppDelegateProblem([MissingPropertyDelegate class], nil); + check(mentions(problem, @"Window"), + "a dropped property is refused and named"); + + problem = UnrealAppDelegateProblem([FakeEngineDelegate class], nil); + check(problem == nil, + "a well shaped class passes when there is no delegate to judge"); + + // The case that actually matters: the app delegate is a subclass, which + // is what a host app writes. + GoodAppDelegate* good = [GoodAppDelegate new]; + problem = UnrealAppDelegateProblem([FakeEngineDelegate class], good); + check(problem == nil, + "a delegate subclassing the engine's delegate is accepted"); + + // And the mistake this check exists to catch. + UnrelatedAppDelegate* wrong = [UnrelatedAppDelegate new]; + problem = UnrealAppDelegateProblem([FakeEngineDelegate class], wrong); + check(mentions(problem, @"IOSAppDelegate") && + mentions(problem, @"UnrelatedAppDelegate"), + "a delegate that does not descend from IOSAppDelegate is refused, and named"); + + // An exact instance, rather than a subclass, is also fine. + FakeEngineDelegate* exact = [FakeEngineDelegate new]; + problem = UnrealAppDelegateProblem([FakeEngineDelegate class], exact); + check(problem == nil, "an exact instance of the engine's delegate is accepted"); + + // The window trap: Unreal reads its Window for orientation, and a host + // that declares its own 'window' property steals the setter that fills + // it in. + check(UnrealAppDelegateWindowWarning(nil) == nil, + "no window warning when there is no delegate to inspect"); + + UnrelatedAppDelegate* noWindowProperty = [UnrelatedAppDelegate new]; + check(UnrealAppDelegateWindowWarning(noWindowProperty) == nil, + "no window warning for a delegate with no Window property at all"); + + GoodAppDelegate* windowless = [GoodAppDelegate new]; + check(mentions(UnrealAppDelegateWindowWarning(windowless), @"setWindow:"), + "a nil Window is reported, naming the setter that should have filled it"); + + GoodAppDelegate* windowed = [GoodAppDelegate new]; + windowed.Window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 10, 10)]; + check(UnrealAppDelegateWindowWarning(windowed) == nil, + "no window warning once Unreal's Window is set"); + + printf("\n%s (%d failures)\n", gFailures ? "FAILED" : "ALL PASSED", gFailures); + return gFailures ? 1 : 0; + } +} diff --git a/engines/unreal/dart/ios/Tests/UnrealBridgeAbsentTests.mm b/engines/unreal/dart/ios/Tests/UnrealBridgeAbsentTests.mm new file mode 100644 index 0000000..8d69ea3 --- /dev/null +++ b/engines/unreal/dart/ios/Tests/UnrealBridgeAbsentTests.mm @@ -0,0 +1,52 @@ +#import +#import +#import + +#import "../Classes/UnrealAppDelegate.h" + +int main(void) { + @autoreleasepool { + Class cls = NSClassFromString(@"UnrealBridge"); + if (!cls) { printf("FAIL: UnrealBridge class not found\n"); return 1; } + printf("PASS: NSClassFromString found UnrealBridge\n"); + + id shared = ((id(*)(id, SEL))objc_msgSend)(cls, NSSelectorFromString(@"shared")); + if (!shared) { printf("FAIL: shared returned nil\n"); return 1; } + printf("PASS: shared instance created\n"); + + BOOL created = ((BOOL(*)(id, SEL, id, id))objc_msgSend)( + shared, NSSelectorFromString(@"createWithConfig:controller:"), @{}, (id)shared); + printf("%s: createWithConfig returned %s with framework absent\n", + created ? "FAIL" : "PASS", created ? "YES" : "NO"); + if (created) return 1; + + // Everything below must be a safe no-op, not a crash. + ((void(*)(id, SEL, id, id, id))objc_msgSend)( + shared, NSSelectorFromString(@"sendMessageWithTarget:method:data:"), @"T", @"M", @"{}"); + ((void(*)(id, SEL, id))objc_msgSend)( + shared, NSSelectorFromString(@"executeConsoleCommand:"), @"stat fps"); + ((void(*)(id, SEL, id))objc_msgSend)( + shared, NSSelectorFromString(@"loadLevel:"), @"Main"); + ((void(*)(id, SEL, id))objc_msgSend)( + shared, NSSelectorFromString(@"applyQualitySettings:"), @{@"qualityLevel": @3}); + ((void(*)(id, SEL))objc_msgSend)(shared, NSSelectorFromString(@"pause")); + ((void(*)(id, SEL))objc_msgSend)(shared, NSSelectorFromString(@"resume")); + + id q = ((id(*)(id, SEL))objc_msgSend)(shared, NSSelectorFromString(@"getQualitySettings")); + printf("%s: getQualitySettings returned %lu keys (expected 0)\n", + ([q count] == 0) ? "PASS" : "FAIL", (unsigned long)[q count]); + + ((void(*)(id, SEL))objc_msgSend)(shared, NSSelectorFromString(@"quit")); + printf("PASS: all bridge calls survived with no framework loaded\n"); + + // The delegate check looks the class up by name, so with no framework + // loaded it has to refuse. This is the one path through + // UnrealAssertAppDelegateUsable that a hostless binary can reach. + const BOOL usable = UnrealAssertAppDelegateUsable(); + printf("%s: the delegate check refuses when the framework is absent\n", + usable ? "FAIL" : "PASS"); + if (usable) return 1; + + return 0; + } +} diff --git a/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm b/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm new file mode 100644 index 0000000..a67267d --- /dev/null +++ b/engines/unreal/dart/ios/Tests/UnrealBridgeTests.mm @@ -0,0 +1,145 @@ +#import +#import +#import +#import + +extern "C" { +void MockUnreal_FireMessage(const char*, const char*, const char*); +void MockUnreal_SignalEngineReady(void); +void MockUnreal_SetView(void*); +extern int gCreateViewCalls, gReadyForView; +extern char gLastTarget[128], gLastMethod[128], gLastData[256]; +extern int32_t gLastQuality[8]; +extern int gConsoleCalls, gLevelCalls, gPauseState, gStopped; +} + +static int gFailures = 0; +static void check(bool ok, const char* what) { + printf("%s: %s\n", ok ? "PASS" : "FAIL", what); + if (!ok) gFailures++; +} + +// Stand-in for the Swift controller. +@interface FakeController : NSObject +@property (nonatomic, copy) NSString* gotTarget; +@property (nonatomic, copy) NSString* gotMethod; +@property (nonatomic, copy) NSString* gotData; +@property (nonatomic, copy) NSString* gotLevel; +@property (nonatomic, strong) UIView* gotView; +@end + +// How many times the bridge handed a view to the controller. This is the +// handoff the Flutter platform view depends on, so it is worth counting rather +// than merely observing. +static int gViewReadyCallbacks = 0; +@implementation FakeController +- (void)onMessageFromUnrealWithTarget:(NSString*)t method:(NSString*)m data:(NSString*)d { + self.gotTarget = t; self.gotMethod = m; self.gotData = d; +} +- (void)onLevelLoadedWithLevelName:(NSString*)n buildIndex:(NSInteger)i { + self.gotLevel = n; +} +- (void)onUnrealViewReadyWithView:(UIView*)v { + self.gotView = v; + gViewReadyCallbacks++; +} +@end + +int main(void) { + @autoreleasepool { + Class cls = NSClassFromString(@"UnrealBridge"); + id bridge = ((id(*)(id, SEL))objc_msgSend)(cls, NSSelectorFromString(@"shared")); + FakeController* controller = [FakeController new]; + + BOOL created = ((BOOL(*)(id, SEL, id, id))objc_msgSend)( + bridge, NSSelectorFromString(@"createWithConfig:controller:"), @{}, controller); + check(created, "createWithConfig succeeds when the framework is loaded"); + + // Flutter -> Unreal + ((void(*)(id, SEL, id, id, id))objc_msgSend)( + bridge, NSSelectorFromString(@"sendMessageWithTarget:method:data:"), + @"GameManager", @"startGame", @"{\"level\":1}"); + check(strcmp(gLastTarget, "GameManager") == 0 && + strcmp(gLastMethod, "startGame") == 0 && + strcmp(gLastData, "{\"level\":1}") == 0, + "sendMessage reaches the framework with target, method and data intact"); + + NSData* payload = [@"binary-payload" dataUsingEncoding:NSUTF8StringEncoding]; + ((void(*)(id, SEL, id, id, id))objc_msgSend)( + bridge, NSSelectorFromString(@"sendBinaryWithTarget:method:data:"), + @"Assets", @"upload", payload); + check(strcmp(gLastMethod, "upload") == 0 && atoi(gLastData) == (int)payload.length, + "sendBinary forwards the byte count"); + + ((void(*)(id, SEL, id))objc_msgSend)(bridge, NSSelectorFromString(@"executeConsoleCommand:"), @"stat fps"); + check(gConsoleCalls == 1, "executeConsoleCommand reaches the framework"); + + ((void(*)(id, SEL, id))objc_msgSend)(bridge, NSSelectorFromString(@"loadLevel:"), @"Arena"); + check(gLevelCalls == 1, "loadLevel reaches the framework"); + + ((void(*)(id, SEL, id))objc_msgSend)( + bridge, NSSelectorFromString(@"applyQualitySettings:"), + (@{@"qualityLevel": @3, @"shadowQuality": @2})); + check(gLastQuality[0] == 3 && gLastQuality[2] == 2 && gLastQuality[1] == -1, + "applyQualitySettings maps keys by position and defaults missing ones to -1"); + + NSDictionary* q = ((id(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"getQualitySettings")); + check([q[@"antiAliasing"] intValue] == 1 && [q[@"viewDistance"] intValue] == 7 && q.count == 7, + "getQualitySettings maps the value array back onto named keys in order"); + + ((void(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"pause")); + check(gPauseState == 1, "pause forwards 1"); + ((void(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"resume")); + check(gPauseState == 0, "resume forwards 0"); + + // Unreal -> Flutter, including the level-load reroute. + MockUnreal_FireMessage("GameManager", "onScore", "{\"score\":42}"); + MockUnreal_FireMessage("FlutterBridge", "onLevelLoaded", "Arena"); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.3]]; + + check([controller.gotTarget isEqualToString:@"GameManager"] && + [controller.gotMethod isEqualToString:@"onScore"] && + [controller.gotData isEqualToString:@"{\"score\":42}"], + "a message from Unreal reaches the controller on the main thread"); + check([controller.gotLevel isEqualToString:@"Arena"], + "onLevelLoaded is rerouted to the controller's level callback"); + + // A real view, because the bridge stores it weakly and ARC will not + // register a weak reference to an arbitrary pointer. + UIView* fakeEngineView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 480)]; + + // Readiness is whatever CreateView returns, not a separate announcement. + // The engine broadcasts readiness from PreInit and then blocks waiting + // for a view, and plugin modules load later in that same PreInit, so + // nothing here can ever hear the broadcast. The bridge polls from the + // tick instead, which is how the engine expects to be handed a view. + // + // Until the engine can make one, CreateView returns NULL and the bridge + // has nothing to show. + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.2]]; + check(gCreateViewCalls > 0, + "the bridge keeps offering to build a view while the engine starts"); + check(((id(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"getView")) == nil, + "getView returns nil while the engine is still starting"); + + // Now let the engine hand one back. + MockUnreal_SetView((__bridge void*)fakeEngineView); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.2]]; + check(((id(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"getView")) == fakeEngineView, + "getView hands back the engine's view once it exists"); + check(gViewReadyCallbacks == 1 && controller.gotView == fakeEngineView, + "the controller is handed that exact view, exactly once"); + + // The poll has to stop, or it runs at display-link rate forever. + const int callsOnceBuilt = gCreateViewCalls; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.2]]; + check(gCreateViewCalls == callsOnceBuilt, + "the bridge stops asking once it has a view"); + + ((void(*)(id, SEL))objc_msgSend)(bridge, NSSelectorFromString(@"quit")); + check(gStopped == 1, "quit stops the framework"); + + printf("\n%s (%d failures)\n", gFailures ? "FAILED" : "ALL PASSED", gFailures); + return gFailures ? 1 : 0; + } +} diff --git a/engines/unreal/dart/ios/Tests/run_bridge_tests.sh b/engines/unreal/dart/ios/Tests/run_bridge_tests.sh new file mode 100755 index 0000000..308f8a5 --- /dev/null +++ b/engines/unreal/dart/ios/Tests/run_bridge_tests.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# Exercise UnrealBridge.mm in the iOS Simulator, both with and without +# UnrealFramework present. +# +# There is no Unreal build in CI, so the "present" case runs against +# MockUnrealFramework.c, which exports the same C ABI the plugin's +# Public/UnrealBridge.h declares. That covers the pod side and the ABI contract +# in both directions. It does NOT cover the engine-side implementation in +# FlutterBridge_IOS.cpp, which needs a real engine to build. +# +# Usage: ./run_bridge_tests.sh [simulator-udid] + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="$HERE/../Classes/UnrealBridge.mm" +DELEGATE_SRC="$HERE/../Classes/UnrealAppDelegate.mm" +BUILD="$(mktemp -d)" +trap 'rm -rf "$BUILD"' EXIT + +TARGET="arm64-apple-ios15.0-simulator" +SDK="iphonesimulator" + +UDID="${1:-}" +if [ -z "$UDID" ]; then + UDID=$(xcrun simctl list devices available \ + | grep -oE '\(([0-9A-F-]{36})\)' | head -1 | tr -d '()') +fi +if [ -z "$UDID" ]; then + echo "No iOS simulator available" >&2 + exit 1 +fi + +BOOTED_HERE=0 +if ! xcrun simctl list devices booted | grep -q "$UDID"; then + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b >/dev/null + BOOTED_HERE=1 +fi +cleanup_sim() { + [ "$BOOTED_HERE" -eq 1 ] && xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true +} +trap 'cleanup_sim; rm -rf "$BUILD"' EXIT + +compile_mm() { + xcrun --sdk "$SDK" clang++ -target "$TARGET" -fobjc-arc \ + -x objective-c++ -std=c++17 -Wall -Wextra -c "$1" -o "$2" +} + +echo "Building bridge..." +compile_mm "$SRC" "$BUILD/UnrealBridge.o" +compile_mm "$DELEGATE_SRC" "$BUILD/UnrealAppDelegate.o" + +echo "Building mock framework..." +xcrun --sdk "$SDK" clang -target "$TARGET" -dynamiclib \ + -install_name @rpath/MockUnreal.dylib \ + "$HERE/MockUnrealFramework.c" "$HERE/MockUnrealAppDelegate.m" \ + -framework Foundation -framework UIKit -o "$BUILD/MockUnreal.dylib" + +echo +echo "=== Framework absent ===" +compile_mm "$HERE/UnrealBridgeAbsentTests.mm" "$BUILD/absent.o" +xcrun --sdk "$SDK" clang++ -target "$TARGET" \ + "$BUILD/UnrealBridge.o" "$BUILD/UnrealAppDelegate.o" "$BUILD/absent.o" \ + -framework Foundation -framework UIKit -framework QuartzCore -o "$BUILD/absent" +xcrun simctl spawn "$UDID" "$BUILD/absent" 2>&1 | grep -v '^20[0-9][0-9]-' + +echo +echo "=== App delegate contract ===" +compile_mm "$HERE/UnrealAppDelegateTests.mm" "$BUILD/delegate.o" +xcrun --sdk "$SDK" clang++ -target "$TARGET" \ + "$BUILD/UnrealAppDelegate.o" "$BUILD/UnrealBridge.o" "$BUILD/delegate.o" \ + -framework Foundation -framework UIKit -framework QuartzCore -o "$BUILD/delegate" +xcrun simctl spawn "$UDID" "$BUILD/delegate" 2>&1 | grep -v '^20[0-9][0-9]-' + +echo +echo "=== Framework present (mock) ===" +compile_mm "$HERE/UnrealBridgeTests.mm" "$BUILD/live.o" +xcrun --sdk "$SDK" clang++ -target "$TARGET" \ + "$BUILD/UnrealBridge.o" "$BUILD/UnrealAppDelegate.o" "$BUILD/live.o" "$BUILD/MockUnreal.dylib" \ + -framework Foundation -framework UIKit -framework QuartzCore -rpath "$BUILD" -o "$BUILD/live" +xcrun simctl spawn "$UDID" "$BUILD/live" 2>&1 | grep -v '^20[0-9][0-9]-' diff --git a/engines/unreal/dart/ios/gameframework_unreal.podspec b/engines/unreal/dart/ios/gameframework_unreal.podspec index 227cd18..f48c77b 100644 --- a/engines/unreal/dart/ios/gameframework_unreal.podspec +++ b/engines/unreal/dart/ios/gameframework_unreal.podspec @@ -21,6 +21,28 @@ to sync your Unreal export to your plugin's ios/ directory. s.author = { 'xraph' => 'rex@xraph.com' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' + + # The only header a host app is meant to import. It declares Unreal's + # IOSAppDelegate so an app delegate can subclass it, which the engine requires. + # Reach it from Runner-Bridging-Header.h as: + # #import + s.public_header_files = 'Classes/UnrealAppDelegate.h' + + # Cooked content, shipped into the app bundle root. + # + # Unreal looks for cookeddata and uecommandline.txt next to the executable, so + # they cannot live inside the framework. A flat iOS framework must not carry a + # Resources directory either: installd refuses the whole app when it finds + # one. "game sync unreal -p ios" places these here. + unreal_content_path = File.join(__dir__, 'UnrealContent') + if File.directory?(unreal_content_path) + # Top-level entries, not a glob. A glob matches individual files and + # CocoaPods copies each one to the bundle root, which would flatten + # cookeddata into loose files. Naming the directory copies it whole. + s.resources = Dir.glob(File.join(unreal_content_path, '*')).map do |entry| + File.join('UnrealContent', File.basename(entry)) + end + end s.dependency 'Flutter' s.dependency 'gameframework' s.platform = :ios, '15.0' @@ -34,8 +56,13 @@ to sync your Unreal export to your plugin's ios/ directory. unreal_framework_path = File.join(__dir__, 'UnrealFramework.framework') if File.exist?(unreal_framework_path) || File.symlink?(unreal_framework_path) s.preserve_paths = 'UnrealFramework.framework', 'UnrealFramework.framework/Resources' - # Don't vendor - let the consumer plugin vendor it to avoid conflicts - # s.ios.vendored_frameworks = 'UnrealFramework.framework' + + # Vendor it when it is sitting right here, which is the case after + # "game sync unreal -p ios" with no separate game plugin in between. Without + # this nothing embeds the framework, the app launches, and IOSAppDelegate is + # missing at runtime. A consumer plugin that vendors its own build syncs + # there instead, so this stays false for them and there is no duplicate. + s.ios.vendored_frameworks = 'UnrealFramework.framework' end # Configure framework search paths to find UnrealFramework from sibling pods @@ -53,5 +80,5 @@ to sync your Unreal export to your plugin's ios/ directory. s.swift_version = '5.0' # System frameworks required by Unreal Engine - s.frameworks = 'UIKit', 'Foundation', 'Metal', 'MetalKit', 'CoreGraphics', 'AVFoundation', 'AudioToolbox' + s.frameworks = 'UIKit', 'Foundation', 'Metal', 'MetalKit', 'CoreGraphics', 'AVFoundation', 'AudioToolbox', 'QuartzCore' end diff --git a/engines/unreal/dart/lib/src/unreal_controller.dart b/engines/unreal/dart/lib/src/unreal_controller.dart index 802533c..b8741fc 100644 --- a/engines/unreal/dart/lib/src/unreal_controller.dart +++ b/engines/unreal/dart/lib/src/unreal_controller.dart @@ -301,6 +301,26 @@ class UnrealController implements GameEngineController { } @override + /// Bring the engine back after [unload]. + /// + /// Unreal is never actually torn down, so there is nothing to start: the + /// render view is rebuilt and the game unpauses with the scene as you left + /// it. Calling this on an engine that was not unloaded does nothing. + Future reload() async { + _throwIfDisposed(); + + try { + await _channel.invokeMethod('engine#reload'); + } catch (e) { + throw EngineCommunicationException( + 'Failed to reload Unreal: $e', + target: 'UnrealController', + method: 'reload', + engineType: engineType, + ); + } + } + Future unload() async { _throwIfDisposed(); _throwIfNotReady(); diff --git a/engines/unreal/dart/macos/Classes/UnrealBridge.h b/engines/unreal/dart/macos/Classes/UnrealBridge.h new file mode 100644 index 0000000..860432e --- /dev/null +++ b/engines/unreal/dart/macos/Classes/UnrealBridge.h @@ -0,0 +1,67 @@ +// +// UnrealBridge.h +// gameframework_unreal (macOS) +// +// Objective-C face of the UnrealFramework bridge. +// +// This header exists so Swift can call the bridge directly. The iOS pod +// reaches its equivalent class through NSClassFromString and NSInvocation +// because it has no header to import; declaring the interface here is the +// same thing without the reflection. If the two pods are ever merged into a +// shared darwin/ directory, this is the shape to keep. +// +// NS_SWIFT_NAME keeps the Swift call sites reading naturally, so +// UnrealEngineController can say UnrealBridge.shared.create(config:controller:) +// rather than createWithConfig(_:controller:). +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UnrealBridge : NSObject + +@property (class, nonatomic, readonly) UnrealBridge *shared; + +/// Register the controller and wire up callbacks from Unreal. +/// Returns NO when UnrealFramework is not loaded into this process. +- (BOOL)createWithConfig:(NSDictionary *)config + controller:(id)controller NS_SWIFT_NAME(create(config:controller:)); + +/// Unreal owns its own window on macOS, so this is always nil today. It stays +/// on the interface because the controller's view plumbing expects it. +- (nullable NSView *)getView; + +/// Tell the engine the size it is rendering at, in points. +- (void)resizeViewTo:(CGSize)size NS_SWIFT_NAME(resizeView(to:)); + +/// Give the engine's view back, freeing what it holds. Reversible. +- (void)destroyView; + +/// Take a view again after destroyView. +- (void)restoreView; + +- (void)pause; +- (void)resume; +- (void)quit; + +- (void)sendMessageWithTarget:(NSString *)target + method:(NSString *)method + data:(NSString *)data NS_SWIFT_NAME(sendMessage(target:method:data:)); + +- (void)sendBinaryWithTarget:(NSString *)target + method:(NSString *)method + data:(NSData *)data NS_SWIFT_NAME(sendBinary(target:method:data:)); + +- (void)executeConsoleCommand:(NSString *)command; + +- (void)loadLevel:(NSString *)levelName; + +- (void)applyQualitySettings:(NSDictionary *)settings; + +- (NSDictionary *)getQualitySettings; + +@end + +NS_ASSUME_NONNULL_END diff --git a/engines/unreal/dart/macos/Classes/UnrealBridge.mm b/engines/unreal/dart/macos/Classes/UnrealBridge.mm index d44c96f..7712130 100644 --- a/engines/unreal/dart/macos/Classes/UnrealBridge.mm +++ b/engines/unreal/dart/macos/Classes/UnrealBridge.mm @@ -1,349 +1,537 @@ // Copyright Epic Games, Inc. All Rights Reserved. - -#import "UnrealEngineController.swift" -#include "FlutterBridge.h" - -#if PLATFORM_MAC +// +// Sibling of ios/Classes/UnrealBridge.mm. The two differ only in AppKit versus +// UIKit and in how the class is exposed to Swift: macOS declares it in +// UnrealBridge.h so the controller can call it directly, while iOS goes through +// NSClassFromString. Keep behaviour changes in step across both. #import #import - -// Reference to FlutterBridge instance -static AFlutterBridge* GFlutterBridgeInstance = nullptr; - -// Reference to UnrealEngineController -static UnrealEngineController* GUnrealEngineController = nullptr; +#import +#import +#import +#import "UnrealBridge.h" // ============================================================ -// MARK: - Helper Functions +// MARK: - UnrealFramework C ABI // ============================================================ - -/** - * Convert FString to NSString - */ -NSString* FStringToNSString(const FString& String) -{ - return [NSString stringWithUTF8String:TCHAR_TO_UTF8(*String)]; +// +// Resolved with dlsym rather than linked. The canonical declarations live in +// the plugin's Public/UnrealBridge.h and are copied into the framework's +// Headers/ at export time; keep the signatures below in step with them. +// +// Why dlsym and not a link-time dependency: UnrealFramework is produced by +// "game export unreal -p ios", so it may legitimately be absent when this pod +// is built. Linking against it would break those builds, and weak_import does +// not help, because it only makes a symbol optional at load time while the +// static linker still demands a definition. Looking the symbols up at runtime +// keeps the pod self-contained and turns "framework missing" into a clear log +// line instead of a build failure. +// +// This replaces an older __has_include split that decided at compile time and +// silently produced a do-nothing bridge whenever a header search path was +// slightly off. + +#import + +typedef void (*UnrealMessageCallback)(const char* target, + const char* method, + const char* data); + +typedef void (*UnrealBinaryCallback)(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +typedef void (*SetMessageCallbackFn)(UnrealMessageCallback); +typedef void (*SetBinaryCallbackFn)(UnrealBinaryCallback); +typedef void (*SendToUnrealFn)(const char*, const char*, const char*); +typedef void (*SendBinaryToUnrealFn)(const char*, const char*, const void*, int32_t, int32_t); +typedef void (*ExecuteConsoleCommandFn)(const char*); +typedef void (*LoadLevelFn)(const char*); +typedef void (*ApplyQualitySettingsFn)(int32_t, int32_t, int32_t, int32_t, + int32_t, int32_t, int32_t, int32_t); +typedef int32_t (*GetQualitySettingsFn)(int32_t*, int32_t); +typedef void (*InitFn)(void); +typedef int32_t (*TickFn)(float); +typedef void (*KeepAwakeFn)(const char*, int32_t); +typedef void (*AllowSleepFn)(const char*); +typedef int32_t (*StartEngineFn)(void); +typedef void* (*CreateViewFn)(float, float, float); +typedef void (*ResizeViewFn)(float, float, float); +typedef void (*DestroyViewFn)(void); +typedef void (*PauseFn)(int32_t); +typedef void (*StopFn)(void); +typedef int32_t (*IsReadyFn)(void); + +/// Look a bridge symbol up in whatever is already loaded into the process. +/// Returns NULL when UnrealFramework is not present. +static void* UnrealSymbol(const char* name) { + return dlsym(RTLD_DEFAULT, name); } -/** - * Convert NSString to FString - */ -FString NSStringToFString(NSString* String) -{ - if (!String) - { - return FString(); - } - return FString(UTF8_TO_TCHAR([String UTF8String])); -} +#define UNREAL_FN(type, name) ((type)UnrealSymbol(name)) -/** - * Convert NSDictionary to TMap - */ -TMap NSDictionaryToTMap(NSDictionary* Dictionary) -{ - TMap Result; - - if (!Dictionary) - { - return Result; - } +/// Whether UnrealFramework is loaded into this process. +static BOOL UnrealFrameworkLinked(void) { + static BOOL linked = NO; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + linked = UnrealSymbol("UnrealBridge_SendToUnreal") != NULL; + }); + return linked; +} - for (NSString* key in Dictionary) - { - NSString* value = [Dictionary objectForKey:key]; - if ([value isKindOfClass:[NSString class]]) - { - Result.Add(NSStringToFString(key), NSStringToFString(value)); - } - else - { - // Convert other types to string - NSString* valueStr = [NSString stringWithFormat:@"%@", value]; - Result.Add(NSStringToFString(key), NSStringToFString(valueStr)); - } - } +/// Matches UNREALBRIDGE_QUALITY_VALUE_COUNT in the plugin header. +static const int32_t kUnrealQualityValueCount = 7; - return Result; +/// Keys for the quality values, in the order the framework writes them. +static NSArray* UnrealQualityKeys(void) { + return @[ @"antiAliasing", @"shadow", @"postProcess", @"texture", + @"effects", @"foliage", @"viewDistance" ]; } -/** - * Convert TMap to NSDictionary - */ -NSDictionary* TMapToNSDictionary(const TMap& Map) -{ - NSMutableDictionary* Dictionary = [NSMutableDictionary dictionary]; - - for (const auto& Entry : Map) - { - NSString* key = FStringToNSString(Entry.Key); - NSNumber* value = [NSNumber numberWithInt:Entry.Value]; - [Dictionary setObject:value forKey:key]; - } +// The Swift controller. Held strongly for as long as the bridge is live. +static id GUnrealEngineController = nil; - return Dictionary; -} +/// The size the host last asked for, in points. +static CGSize GRequestedViewSize = {1280.0, 720.0}; // ============================================================ -// MARK: - UnrealBridge Implementation +// MARK: - Callbacks from Unreal // ============================================================ +// +// These fire on Unreal's GAME thread, and their pointers are only valid for the +// duration of the call. Copy into Foundation objects immediately, then hop to +// the main queue before touching the controller. -@implementation UnrealBridge +static void HandleUnrealMessage(const char* target, const char* method, const char* data) { + NSString* nsTarget = target ? @(target) : @""; + NSString* nsMethod = method ? @(method) : @""; + NSString* nsData = data ? @(data) : @""; -+ (UnrealBridge*)shared { - static UnrealBridge* instance = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - instance = [[UnrealBridge alloc] init]; + dispatch_async(dispatch_get_main_queue(), ^{ + id controller = GUnrealEngineController; + if (!controller) { + NSLog(@"[UnrealBridge] Dropping message, no controller: %@.%@", nsTarget, nsMethod); + return; + } + + // Level loads arrive on the message channel rather than a channel of + // their own. Route them to the controller's level callback so the + // existing Swift signature keeps working. + if ([nsTarget isEqualToString:@"FlutterBridge"] && + [nsMethod isEqualToString:@"onLevelLoaded"]) { + SEL levelSelector = NSSelectorFromString(@"onLevelLoadedWithLevelName:buildIndex:"); + if ([controller respondsToSelector:levelSelector]) { + NSMethodSignature* sig = [controller methodSignatureForSelector:levelSelector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:levelSelector]; + NSString* levelName = nsData; + NSInteger buildIndex = 0; + [inv setArgument:&levelName atIndex:2]; + [inv setArgument:&buildIndex atIndex:3]; + [inv invoke]; + return; + } + } + + SEL selector = NSSelectorFromString(@"onMessageFromUnrealWithTarget:method:data:"); + if (![controller respondsToSelector:selector]) { + NSLog(@"[UnrealBridge] Controller does not respond to onMessageFromUnrealWithTarget:method:data:"); + return; + } + + NSMethodSignature* sig = [controller methodSignatureForSelector:selector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:selector]; + NSString* t = nsTarget; NSString* m = nsMethod; NSString* d = nsData; + [inv setArgument:&t atIndex:2]; + [inv setArgument:&m atIndex:3]; + [inv setArgument:&d atIndex:4]; + [inv invoke]; }); - return instance; } -- (BOOL)createWithConfig:(NSDictionary*)config controller:(UnrealEngineController*)controller { - NSLog(@"[UnrealBridge] create called"); - - // Store controller reference - GUnrealEngineController = controller; +static void HandleUnrealBinary(const char* target, const char* method, + const void* data, int32_t length, int32_t checksum) { + NSString* nsTarget = target ? @(target) : @""; + NSString* nsMethod = method ? @(method) : @""; + NSData* nsData = (data && length > 0) + ? [NSData dataWithBytes:data length:(NSUInteger)length] + : [NSData data]; - // Parse config if needed - // TMap ConfigMap = NSDictionaryToTMap(config); + dispatch_async(dispatch_get_main_queue(), ^{ + id controller = GUnrealEngineController; + if (!controller) { + NSLog(@"[UnrealBridge] Dropping binary, no controller: %@.%@", nsTarget, nsMethod); + return; + } - // Unreal Engine initialization happens automatically - // This is called after Unreal has already started - NSLog(@"[UnrealBridge] Unreal Engine initialized"); + SEL selector = NSSelectorFromString(@"onBinaryFromUnrealWithTarget:method:data:checksum:"); + if (![controller respondsToSelector:selector]) { + NSLog(@"[UnrealBridge] Controller has no binary handler, dropping %lu bytes from %@.%@", + (unsigned long)nsData.length, nsTarget, nsMethod); + return; + } - return YES; + NSMethodSignature* sig = [controller methodSignatureForSelector:selector]; + NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:controller]; + [inv setSelector:selector]; + NSString* t = nsTarget; NSString* m = nsMethod; NSData* d = nsData; + NSInteger c = (NSInteger)checksum; + [inv setArgument:&t atIndex:2]; + [inv setArgument:&m atIndex:3]; + [inv setArgument:&d atIndex:4]; + [inv setArgument:&c atIndex:5]; + [inv invoke]; + }); } -- (NSView*)getView { - NSLog(@"[UnrealBridge] getView called"); - // On macOS, Unreal Engine manages its own view - // This would return the Unreal rendering view - // For now, return nil - the view is handled by Unreal's NSView - return nil; +// ============================================================ +// MARK: - Driving the engine +// ============================================================ +// +// An embedded Unreal does not own the run loop, so nothing advances the engine +// unless the host does it. The bridge drives FEmbeddedCommunication::TickGameThread +// from a display link, which keeps the engine's timing tied to the display it +// renders to rather than to an arbitrary timer. +// +// Ticking happens on the main thread. That is where the host lives, and where +// an embedded engine expects to be driven from. + +/// The engine's view, once it has lent us one, and whether the host wants it. +/// +/// The engine builds its own window some time after the game thread starts, so +/// there is nothing to take at first. The tick keeps asking, which is also the +/// only workable moment: too early and there is no window, and the engine has +/// no readiness signal a plugin can subscribe to in time. +static NSView* GEngineView = nil; +static BOOL GViewSuppressed = NO; + +static void OfferViewToController(void); + +static void UnrealTick(double deltaSeconds) { + if (!GViewSuppressed && GEngineView == nil) { + OfferViewToController(); + } + + TickFn tick = UNREAL_FN(TickFn, "UnrealBridge_Tick"); + if (tick) { + tick((float)deltaSeconds); + } } -- (void)pause { - NSLog(@"[UnrealBridge] pause called"); +/// Ask the engine for its view, and hand it to the controller once it has one. +static void OfferViewToController(void) { + CreateViewFn createView = UNREAL_FN(CreateViewFn, "UnrealBridge_CreateView"); + if (!createView) { + return; + } - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->OnEnginePause(); + // Points. AppKit scales for the backing store itself, so the size the host + // asked for is the size the engine is told about. + const CGSize size = GRequestedViewSize; + void* handle = createView((float)size.width, (float)size.height, 1.0f); + if (!handle) { + // Still starting. Asked again next frame. + return; } - // Pause Unreal Engine rendering - // This will be handled by Unreal's lifecycle automatically + GEngineView = (__bridge NSView*)handle; + NSLog(@"[UnrealBridge] Render view arrived at %@", NSStringFromSize(size)); + + id controller = GUnrealEngineController; + SEL selector = NSSelectorFromString(@"onUnrealViewReady:"); + if ([controller respondsToSelector:selector]) { + ((void (*)(id, SEL, NSView*))objc_msgSend)(controller, selector, GEngineView); + } else { + // Expected when the engine starts before any widget exists. The + // controller collects it from getView when one turns up. + NSLog(@"[UnrealBridge] Render view arrived before a controller existed"); + } } -- (void)resume { - NSLog(@"[UnrealBridge] resume called"); +/// Two display link APIs, picked at runtime. +/// +/// NSScreen.displayLink arrived in macOS 14 and CVDisplayLink is deprecated +/// from 15, but this pod still supports 10.14, so both paths stay. Either way +/// the tick runs on the main thread, which is where the host lives and where an +/// embedded engine expects to be driven from. - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->OnEngineResume(); - } +static CFTimeInterval GLastTickTime = 0; - // Resume Unreal Engine rendering - // This will be handled by Unreal's lifecycle automatically +static void TickWithTimestamp(CFTimeInterval current, CFTimeInterval fallbackDelta) { + const CFTimeInterval delta = + (GLastTickTime > 0) ? (current - GLastTickTime) : fallbackDelta; + GLastTickTime = current; + UnrealTick(delta); } -- (void)quit { - NSLog(@"[UnrealBridge] quit called"); +API_AVAILABLE(macos(14.0)) +@interface UnrealTicker : NSObject ++ (instancetype)shared; +- (void)onFrame:(CADisplayLink*)link; +@end - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->OnEngineQuit(); - } +@implementation UnrealTicker ++ (instancetype)shared { + static UnrealTicker* instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ instance = [[UnrealTicker alloc] init]; }); + return instance; +} +- (void)onFrame:(CADisplayLink*)link { + TickWithTimestamp(link.timestamp, link.duration); +} +@end - // Clean up references - GUnrealEngineController = nil; - GFlutterBridgeInstance = nullptr; +static CADisplayLink* GModernLink = nil; +static CVDisplayLinkRef GLegacyLink = NULL; + +static CVReturn LegacyCallback(CVDisplayLinkRef, const CVTimeStamp*, + const CVTimeStamp*, CVOptionFlags, + CVOptionFlags*, void*) { + // CVDisplayLink fires on its own thread, so hop to main before ticking. + dispatch_async(dispatch_get_main_queue(), ^{ + TickWithTimestamp(CACurrentMediaTime(), 1.0 / 60.0); + }); + return kCVReturnSuccess; } -- (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data { - FString TargetString = NSStringToFString(target); - FString MethodString = NSStringToFString(method); - FString DataString = NSStringToFString(data); +static void StartTicking(void) { + if (GModernLink || GLegacyLink) return; + GLastTickTime = 0; + + if (@available(macOS 14.0, *)) { + NSScreen* screen = NSScreen.mainScreen; + if (screen) { + GModernLink = [screen displayLinkWithTarget:[UnrealTicker shared] + selector:@selector(onFrame:)]; + [GModernLink addToRunLoop:NSRunLoop.mainRunLoop + forMode:NSRunLoopCommonModes]; + NSLog(@"[UnrealBridge] Ticking the engine from the screen display link"); + return; + } + } - NSLog(@"[UnrealBridge] sendMessage: Target=%@, Method=%@", target, method); +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + if (CVDisplayLinkCreateWithActiveCGDisplays(&GLegacyLink) != kCVReturnSuccess) { + NSLog(@"[UnrealBridge] Could not create a display link; the engine will not tick"); + GLegacyLink = NULL; + return; + } + CVDisplayLinkSetOutputCallback(GLegacyLink, &LegacyCallback, NULL); + CVDisplayLinkStart(GLegacyLink); +#pragma clang diagnostic pop + NSLog(@"[UnrealBridge] Ticking the engine from a CVDisplayLink"); +} - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->ReceiveFromFlutter(TargetString, MethodString, DataString); +static void StopTicking(void) { + if (GModernLink) { + [GModernLink invalidate]; + GModernLink = nil; } - else - { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); + if (GLegacyLink) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + CVDisplayLinkStop(GLegacyLink); + CVDisplayLinkRelease(GLegacyLink); +#pragma clang diagnostic pop + GLegacyLink = NULL; } } -- (void)executeConsoleCommand:(NSString*)command { - FString CommandString = NSStringToFString(command); +// ============================================================ +// MARK: - UnrealBridge +// ============================================================ - NSLog(@"[UnrealBridge] executeConsoleCommand: %@", command); +@implementation UnrealBridge - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->ExecuteConsoleCommand(CommandString); - } - else - { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); - } ++ (UnrealBridge*)shared { + static UnrealBridge* instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[UnrealBridge alloc] init]; + }); + return instance; } -- (void)loadLevel:(NSString*)levelName { - FString LevelNameString = NSStringToFString(levelName); +- (BOOL)createWithConfig:(NSDictionary*)config controller:(id)controller { + if (!UnrealFrameworkLinked()) { + NSLog(@"[UnrealBridge] UnrealFramework is not linked into this app. " + @"Run 'game export unreal -p macos' and 'game sync unreal -p macos', " + @"and check the framework is embedded in the Xcode target."); + return NO; + } + + GUnrealEngineController = controller; + + SetMessageCallbackFn setMessage = UNREAL_FN(SetMessageCallbackFn, "UnrealBridge_SetMessageCallback"); + if (setMessage) setMessage(&HandleUnrealMessage); - NSLog(@"[UnrealBridge] loadLevel: %@", levelName); + SetBinaryCallbackFn setBinary = UNREAL_FN(SetBinaryCallbackFn, "UnrealBridge_SetBinaryCallback"); + if (setBinary) setBinary(&HandleUnrealBinary); - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->LoadLevel(LevelNameString); + IsReadyFn isReady = UNREAL_FN(IsReadyFn, "UnrealBridge_IsReady"); + const BOOL engineReady = isReady && (isReady() != 0); + if (!engineReady) { + // The framework is linked but no AFlutterBridge actor has registered + // yet. That is normal this early: the actor registers in BeginPlay. + // Calls made before then are dropped by the framework, not by us. + NSLog(@"[UnrealBridge] Framework linked, waiting for AFlutterBridge actor. " + @"Place one in your level if messages never arrive."); } - else - { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); + + // Start the engine. Nothing renders until this runs: on macOS it puts + // GuardedMain on a game thread, which is what LaunchMac would have done if + // this were an app rather than a library. + // + // Unlike iOS this can wait until a widget exists. There is no app delegate + // reading the command line the moment the app becomes active, so nothing + // demands the engine be up before then. + StartEngineFn startEngine = UNREAL_FN(StartEngineFn, "UnrealBridge_StartEngine"); + if (startEngine) { + NSLog(@"[UnrealBridge] StartEngine -> %d", startEngine()); } -} -- (void)applyQualitySettings:(NSDictionary*)settings { - NSLog(@"[UnrealBridge] applyQualitySettings called"); + KeepAwakeFn keepAwake = UNREAL_FN(KeepAwakeFn, "UnrealBridge_KeepAwake"); + if (keepAwake) keepAwake("flutter", 1); - if (!GFlutterBridgeInstance) - { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); - return; - } + InitFn initEngine = UNREAL_FN(InitFn, "UnrealBridge_Init"); + if (initEngine) initEngine(); - // Parse settings - TMap SettingsMap = NSDictionaryToTMap(settings); - - // Extract quality settings - int32 QualityLevel = SettingsMap.Contains(TEXT("qualityLevel")) ? - FCString::Atoi(*SettingsMap[TEXT("qualityLevel")]) : -1; - int32 AntiAliasing = SettingsMap.Contains(TEXT("antiAliasingQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("antiAliasingQuality")]) : -1; - int32 Shadow = SettingsMap.Contains(TEXT("shadowQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("shadowQuality")]) : -1; - int32 PostProcess = SettingsMap.Contains(TEXT("postProcessQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("postProcessQuality")]) : -1; - int32 Texture = SettingsMap.Contains(TEXT("textureQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("textureQuality")]) : -1; - int32 Effects = SettingsMap.Contains(TEXT("effectsQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("effectsQuality")]) : -1; - int32 Foliage = SettingsMap.Contains(TEXT("foliageQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("foliageQuality")]) : -1; - int32 ViewDistance = SettingsMap.Contains(TEXT("viewDistanceQuality")) ? - FCString::Atoi(*SettingsMap[TEXT("viewDistanceQuality")]) : -1; - - // Apply settings - GFlutterBridgeInstance->ApplyQualitySettings( - QualityLevel, - AntiAliasing, - Shadow, - PostProcess, - Texture, - Effects, - Foliage, - ViewDistance - ); + StartTicking(); + + NSLog(@"[UnrealBridge] Bridge created, callbacks registered, engine ticking"); + return YES; } -- (NSDictionary*)getQualitySettings { - NSLog(@"[UnrealBridge] getQualitySettings called"); +- (NSView*)getView { + return GEngineView; +} - if (!GFlutterBridgeInstance) - { - NSLog(@"[UnrealBridge] Warning: FlutterBridge instance not set"); - return @{}; +- (void)resizeViewTo:(CGSize)size { + if (size.width <= 0.0 || size.height <= 0.0) { + return; } - // Get quality settings from Unreal - TMap Settings = GFlutterBridgeInstance->GetQualitySettings(); + GRequestedViewSize = size; - // Convert to NSDictionary - return TMapToNSDictionary(Settings); + ResizeViewFn resize = UNREAL_FN(ResizeViewFn, "UnrealBridge_ResizeView"); + if (resize) resize((float)size.width, (float)size.height, 1.0f); } -// ============================================================ -// MARK: - Callbacks from Unreal to Flutter -// ============================================================ +- (void)destroyView { + GViewSuppressed = YES; -- (void)notifyMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data { - NSLog(@"[UnrealBridge] notifyMessage: Target=%@, Method=%@", target, method); + DestroyViewFn destroyView = UNREAL_FN(DestroyViewFn, "UnrealBridge_DestroyView"); + if (destroyView) destroyView(); - if (GUnrealEngineController) - { - [GUnrealEngineController onMessageFromUnrealWithTarget:target method:method data:data]; - } - else - { - NSLog(@"[UnrealBridge] Warning: UnrealEngineController not set"); - } + GEngineView = nil; + NSLog(@"[UnrealBridge] Render view released"); } -- (void)notifyLevelLoadedWithLevelName:(NSString*)levelName buildIndex:(NSInteger)buildIndex { - NSLog(@"[UnrealBridge] notifyLevelLoaded: %@", levelName); - - if (GUnrealEngineController) - { - [GUnrealEngineController onLevelLoadedWithLevelName:levelName buildIndex:(int)buildIndex]; - } - else - { - NSLog(@"[UnrealBridge] Warning: UnrealEngineController not set"); +- (void)restoreView { + if (!GViewSuppressed) { + return; } + + // The tick offers a view again from here, the same way it did at startup. + GViewSuppressed = NO; + NSLog(@"[UnrealBridge] Render view will be rebuilt"); } -@end +- (void)pause { + PauseFn pause = UNREAL_FN(PauseFn, "UnrealBridge_Pause"); + if (pause) pause(1); +} -// ============================================================ -// MARK: - C++ Interface for Unreal Engine -// ============================================================ +- (void)resume { + PauseFn pause = UNREAL_FN(PauseFn, "UnrealBridge_Pause"); + if (pause) pause(0); +} -/** - * Send message to Flutter via Objective-C++ - * Called from AFlutterBridge::SendToFlutter() - */ -void FlutterBridge_SendToFlutter_Mac(const FString& Target, const FString& Method, const FString& Data) -{ - NSString* nsTarget = FStringToNSString(Target); - NSString* nsMethod = FStringToNSString(Method); - NSString* nsData = FStringToNSString(Data); +- (void)quit { + StopTicking(); + StopFn stop = UNREAL_FN(StopFn, "UnrealBridge_Stop"); + if (stop) stop(); + GUnrealEngineController = nil; +} - dispatch_async(dispatch_get_main_queue(), ^{ - [[UnrealBridge shared] notifyMessageWithTarget:nsTarget method:nsMethod data:nsData]; - }); +- (void)sendMessageWithTarget:(NSString*)target method:(NSString*)method data:(NSString*)data { + SendToUnrealFn send = UNREAL_FN(SendToUnrealFn, "UnrealBridge_SendToUnreal"); + if (!send) { + NSLog(@"[UnrealBridge] Cannot send, framework not loaded"); + return; + } + send(target.UTF8String, method.UTF8String, data.UTF8String ?: ""); +} - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] Message sent to Flutter: Target=%s, Method=%s"), - *Target, *Method); +- (void)sendBinaryWithTarget:(NSString*)target method:(NSString*)method data:(NSData*)data { + SendBinaryToUnrealFn send = UNREAL_FN(SendBinaryToUnrealFn, "UnrealBridge_SendBinaryToUnreal"); + if (!send) { + NSLog(@"[UnrealBridge] Cannot send binary, framework not loaded"); + return; + } + // Checksum is computed engine-side on receipt; 0 means "unset". + send(target.UTF8String, method.UTF8String, data.bytes, (int32_t)data.length, 0); } -/** - * Notify Flutter that a level has been loaded - */ -void FlutterBridge_NotifyLevelLoaded_Mac(const FString& LevelName, int32 BuildIndex) -{ - NSString* nsLevelName = FStringToNSString(LevelName); +- (void)executeConsoleCommand:(NSString*)command { + ExecuteConsoleCommandFn exec = UNREAL_FN(ExecuteConsoleCommandFn, "UnrealBridge_ExecuteConsoleCommand"); + if (exec) exec(command.UTF8String); +} - dispatch_async(dispatch_get_main_queue(), ^{ - [[UnrealBridge shared] notifyLevelLoadedWithLevelName:nsLevelName buildIndex:BuildIndex]; - }); +- (void)loadLevel:(NSString*)levelName { + LoadLevelFn load = UNREAL_FN(LoadLevelFn, "UnrealBridge_LoadLevel"); + if (load) load(levelName.UTF8String); +} - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] Level loaded notification sent: %s"), *LevelName); +- (void)applyQualitySettings:(NSDictionary*)settings { + ApplyQualitySettingsFn apply = UNREAL_FN(ApplyQualitySettingsFn, "UnrealBridge_ApplyQualitySettings"); + if (!apply) return; + + int32_t (^value)(NSString*) = ^int32_t(NSString* key) { + id v = settings[key]; + return v ? (int32_t)[v intValue] : -1; + }; + + apply( + value(@"qualityLevel"), + value(@"antiAliasingQuality"), + value(@"shadowQuality"), + value(@"postProcessQuality"), + value(@"textureQuality"), + value(@"effectsQuality"), + value(@"foliageQuality"), + value(@"viewDistanceQuality")); } -/** - * Set the FlutterBridge instance - * Called from AFlutterBridge::BeginPlay() - */ -void FlutterBridge_SetInstance_Mac(AFlutterBridge* Instance) -{ - GFlutterBridgeInstance = Instance; - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] FlutterBridge instance set")); +- (NSDictionary*)getQualitySettings { + GetQualitySettingsFn get = UNREAL_FN(GetQualitySettingsFn, "UnrealBridge_GetQualitySettings"); + if (!get) return @{}; + + int32_t values[kUnrealQualityValueCount]; + const int32_t written = get(values, kUnrealQualityValueCount); + if (written < kUnrealQualityValueCount) { + // The framework serves a cache refreshed on the game thread, so the + // very first call can land before it is populated. + return @{}; + } + + NSArray* keys = UnrealQualityKeys(); + NSMutableDictionary* result = [NSMutableDictionary dictionaryWithCapacity:keys.count]; + for (NSUInteger i = 0; i < keys.count; i++) { + result[keys[i]] = @(values[i]); + } + return result; } -#endif // PLATFORM_MAC +@end diff --git a/engines/unreal/dart/macos/Classes/UnrealEngineController.swift b/engines/unreal/dart/macos/Classes/UnrealEngineController.swift index 2cbbcb4..2b84e5b 100644 --- a/engines/unreal/dart/macos/Classes/UnrealEngineController.swift +++ b/engines/unreal/dart/macos/Classes/UnrealEngineController.swift @@ -1,523 +1,217 @@ import Cocoa import FlutterMacOS +import gameframework /** - * Unreal Engine Controller for macOS + * Unreal Engine controller for macOS. * - * Manages the Unreal Engine lifecycle, view integration, and communication - * between Flutter and Unreal Engine on macOS. + * Subclasses the shared GameEngineController, so it answers the same method + * channel and raises the same events as the iOS one and the Dart side does not + * need to know which it is talking to. + * + * Unlike iOS this reaches the bridge directly rather than through the + * Objective-C runtime. The bridge is pod code and always present here; on iOS + * it has to tolerate the engine framework being missing entirely. */ -public class UnrealEngineController: NSObject { - - // MARK: - Properties +public class UnrealEngineController: GameEngineController { - private let viewId: Int - private let channel: FlutterMethodChannel - private let config: [String: Any] + public static let engineTypeValue = "unreal" + public static let engineVersionValue = "5.8" private var unrealView: NSView? - private var unrealFramework: UnrealFramework? - - private var isReady: Bool = false - private var isPaused: Bool = false - private var isDestroyed: Bool = false - - // MARK: - Constants - - private static let engineType = "unreal" - private static let engineVersion = "5.3.0" - // MARK: - Initialization - - public init(viewId: Int, channel: FlutterMethodChannel, config: [String: Any]) { - self.viewId = viewId - self.channel = channel - self.config = config - super.init() - } - - // MARK: - Lifecycle Methods - - /** - * Initialize and create the Unreal Engine instance - */ - public func create() -> Bool { - if isDestroyed { - sendError("Cannot create destroyed engine") - return false - } + /// Whether unloadEngine gave the view back. Guards reload, so calling it on + /// a running engine does nothing rather than resuming something that was + /// never paused. + private var isUnloaded = false - if isReady { - return true - } - - // Load Unreal Framework - guard let framework = loadUnrealFramework() else { - sendError("Failed to load Unreal Framework") - return false - } - - unrealFramework = framework - - // Apply configuration - applyConfiguration(config) - - // Initialize Unreal Engine - if !nativeCreate(config) { - sendError("Failed to create Unreal Engine instance") - return false - } - - // Get Unreal view - guard let view = nativeGetView() else { - sendError("Failed to get Unreal view") - return false - } + public override var engineType: String { UnrealEngineController.engineTypeValue } + public override var engineVersion: String { UnrealEngineController.engineVersionValue } - unrealView = view - isReady = true - - sendEvent("created") - sendEvent("loaded") - - return true - } - - /** - * Pause the Unreal Engine - */ - public func pause() { - if !isReady || isDestroyed { - return - } - - nativePause() - isPaused = true - sendEvent("paused") - } - - /** - * Resume the Unreal Engine - */ - public func resume() { - if !isReady || isDestroyed { - return - } - - nativeResume() - isPaused = false - sendEvent("resumed") - } - - /** - * Unload the Unreal Engine (pause and detach) - */ - public func unload() { - if !isReady || isDestroyed { - return - } - - pause() - sendEvent("unloaded") - } - - /** - * Quit and destroy the Unreal Engine - */ - public func quit() { - if isDestroyed { - return - } - - nativeQuit() - unrealView = nil - unrealFramework = nil - isReady = false - isDestroyed = true - sendEvent("destroyed") - } - - // MARK: - Communication Methods - - /** - * Send a message to Unreal Engine - */ - public func sendMessage(target: String, method: String, data: String) { - if !isReady || isDestroyed { - sendError("Engine not ready for messages") - return - } - - nativeSendMessage(target: target, method: method, data: data) - } - - /** - * Send a JSON message to Unreal Engine - */ - public func sendJsonMessage(target: String, method: String, data: [String: Any]) { - if !isReady || isDestroyed { - sendError("Engine not ready for messages") - return - } - - guard let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []), - let jsonString = String(data: jsonData, encoding: .utf8) else { - sendError("Failed to serialize JSON message") - return - } - - nativeSendMessage(target: target, method: method, data: jsonString) - } - - // MARK: - Unreal-Specific Methods + public override func createEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } - /** - * Execute a console command in Unreal Engine - */ - public func executeConsoleCommand(_ command: String) { - if !isReady || isDestroyed { - sendError("Engine not ready for console commands") - return - } + NSLog("UnrealEngineController: Creating Unreal Engine...") - nativeExecuteConsoleCommand(command) - } + let bridge = UnrealBridge.shared + let config: [String: Any] = self.getConfigValue("config") ?? [:] - /** - * Load a level/map in Unreal Engine - */ - public func loadLevel(_ levelName: String) { - if !isReady || isDestroyed { - sendError("Engine not ready to load level") - return - } + guard bridge.create(config: config, controller: self) else { + NSLog("UnrealEngineController: Failed to create Unreal Engine") + self.sendEvent(name: "onError", data: [ + "message": "Failed to create the Unreal engine. Is UnrealFramework linked?" + ]) + // Ready anyway, so the app can show the error rather than wait + // forever for a state that is never coming. + self._isReady = true + return + } - nativeLoadLevel(levelName) - } + if let view = bridge.getView() { + self.attach(view) + } else { + // Normal on the first call. The engine hands its view over once + // it has built one, and the bridge offers it from the tick. + NSLog("UnrealEngineController: Waiting for the engine's render view") + } - /** - * Apply quality settings to Unreal Engine - */ - public func applyQualitySettings(_ settings: [String: Any]) { - if !isReady || isDestroyed { - sendError("Engine not ready for quality settings") - return + self._isReady = true + self.sendEvent(name: "onCreated", data: nil) + self.sendEvent(name: "onLoaded", data: nil) + self.sendEvent(name: "onMessage", data: [ + "target": "Unreal", + "method": "onReady", + "data": "{\"success\":true,\"message\":\"Unreal Engine ready\"}" + ]) } - - nativeApplyQualitySettings(settings) } - /** - * Get current quality settings from Unreal Engine - */ - public func getQualitySettings() -> [String: Any]? { - if !isReady || isDestroyed { - sendError("Engine not ready") - return nil + /// Called from the bridge once the engine's view exists. + @objc public func onUnrealViewReady(_ view: NSView) { + DispatchQueue.main.async { [weak self] in + self?.attach(view) } - - return nativeGetQualitySettings() - } - - /** - * Check if engine is in background - */ - public func isInBackground() -> Bool { - return isPaused } - // MARK: - View Integration - - /** - * Get the Unreal Engine view to attach to Flutter - */ - public func getView() -> NSView? { - return unrealView - } - - /** - * Attach Unreal view to parent - */ - public func attachView(to parent: NSView) { - guard let view = unrealView, view.superview == nil else { - return - } - - view.frame = parent.bounds - view.autoresizingMask = [.width, .height] - parent.addSubview(view) - sendEvent("attached") + private func attach(_ view: NSView) { + NSLog("UnrealEngineController: Unreal render view arrived") + unrealView = view + addEngineView(view) + engineViewDidResize(to: self.view().bounds.size) + sendEvent(name: "onAttached", data: nil) } - /** - * Detach Unreal view from parent - */ - public func detachView() { - guard let view = unrealView else { - return + public override func attachEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self, let view = self.unrealView else { return } + self.addEngineView(view) } - - view.removeFromSuperview() - sendEvent("detached") } - // MARK: - Event Handling - - private func sendEvent(_ eventType: String, message: String? = nil) { + public override func detachEngine() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - - var arguments: [String: Any] = ["type": eventType] - if let message = message { - arguments["message"] = message - } - - self.channel.invokeMethod("onEvent", arguments: arguments) + self.removeEngineView() + self.sendEvent(name: "onDetached", data: nil) } } - private func sendError(_ message: String) { - NSLog("[UnrealEngineController] Error: \(message)") - sendEvent("error", message: message) + /// Push the current container size down to the engine. + /// + /// Points, not pixels. AppKit scales for the backing store itself, and + /// multiplying by the scale factor again would render four times the area + /// on any Retina display. + public override func engineViewDidResize(to size: CGSize) { + guard size.width > 0, size.height > 0 else { return } + UnrealBridge.shared.resizeView(to: size) } - /** - * Called from native code when a message is received from Unreal - */ - @objc public func onMessageFromUnreal(target: String, method: String, data: String) { + public override func pauseEngine() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - - self.channel.invokeMethod("onMessage", arguments: [ - "target": target, - "method": method, - "data": data - ]) + UnrealBridge.shared.pause() + self._isPaused = true + self.sendEvent(name: "onPaused", data: nil) } } - /** - * Called from native code when a level is loaded - */ - @objc public func onLevelLoaded(levelName: String, buildIndex: Int) { + public override func resumeEngine() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - - self.channel.invokeMethod("onLevelLoaded", arguments: [ - "name": levelName, - "buildIndex": buildIndex, - "isLoaded": true, - "isValid": true, - "metadata": [String: Any]() - ]) + UnrealBridge.shared.resume() + self._isPaused = false + self.sendEvent(name: "onResumed", data: nil) } } - // MARK: - Framework Loading + /// Give back everything an idle engine is holding, short of tearing it down. + /// + /// Unreal cannot be unloaded and started again in one process, so this is + /// not a teardown. The game pauses and the render view goes away, which is + /// the expensive part. Reversible through reloadEngine. + public override func unloadEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } - private func loadUnrealFramework() -> UnrealFramework? { - // Load Unreal Framework from app bundle - let bundlePath = Bundle.main.bundlePath + "/Contents/Frameworks/UnrealFramework.framework" + NSLog("UnrealEngineController: Unloading Unreal (pausing, releasing the view)") + UnrealBridge.shared.pause() + UnrealBridge.shared.destroyView() - guard let bundle = Bundle(path: bundlePath) else { - NSLog("[UnrealEngineController] Failed to find UnrealFramework bundle at: \(bundlePath)") - return nil - } + self.removeEngineView() + self.unrealView = nil + self._isPaused = true + self.isUnloaded = true - guard bundle.load() else { - NSLog("[UnrealEngineController] Failed to load UnrealFramework bundle") - return nil + self.sendEvent(name: "onUnloaded", data: nil) } - - NSLog("[UnrealEngineController] Successfully loaded UnrealFramework") - return UnrealFramework(bundle: bundle) } - private func applyConfiguration(_ config: [String: Any]) { - // Apply configuration to Unreal Engine - // This can include graphics settings, game mode, etc. - if let enableMetal = config["enableMetal"] as? Bool, enableMetal { - // Enable Metal graphics API (default on macOS) - NSLog("[UnrealEngineController] Metal graphics enabled") - } + public override func reloadEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self, self.isUnloaded else { return } - if let enableHighDPI = config["enableHighDPI"] as? Bool, enableHighDPI { - // Enable high DPI rendering (Retina support) - NSLog("[UnrealEngineController] High DPI rendering enabled") - } + NSLog("UnrealEngineController: Reloading Unreal") + self.isUnloaded = false + UnrealBridge.shared.restoreView() + UnrealBridge.shared.resume() - if let enableFullscreen = config["enableFullscreen"] as? Bool, enableFullscreen { - NSLog("[UnrealEngineController] Fullscreen mode enabled") + self._isPaused = false + self.sendEvent(name: "onLoaded", data: nil) } } - // MARK: - Native Bridge Methods (Objective-C++) - - /** - * Create Unreal Engine instance - * Implemented in Objective-C++ bridge - */ - private func nativeCreate(_ config: [String: Any]) -> Bool { - // This will call into Objective-C++ bridge -> Unreal C++ - return UnrealBridge.shared.create(config: config, controller: self) - } - - /** - * Get the native Unreal view - * Implemented in Objective-C++ bridge - */ - private func nativeGetView() -> NSView? { - return UnrealBridge.shared.getView() - } - - /** - * Pause the engine - * Implemented in Objective-C++ bridge - */ - private func nativePause() { - UnrealBridge.shared.pause() - } - - /** - * Resume the engine - * Implemented in Objective-C++ bridge - */ - private func nativeResume() { - UnrealBridge.shared.resume() - } - - /** - * Quit the engine - * Implemented in Objective-C++ bridge - */ - private func nativeQuit() { - UnrealBridge.shared.quit() - } - - /** - * Send message to Unreal - * Implemented in Objective-C++ bridge - */ - private func nativeSendMessage(target: String, method: String, data: String) { - UnrealBridge.shared.sendMessage(target: target, method: method, data: data) - } - - /** - * Execute console command - * Implemented in Objective-C++ bridge - */ - private func nativeExecuteConsoleCommand(_ command: String) { - UnrealBridge.shared.executeConsoleCommand(command) + public override func destroyEngine() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + UnrealBridge.shared.quit() + self.removeEngineView() + self.unrealView = nil + self._isReady = false + self.sendEvent(name: "onDestroyed", data: nil) + } } - /** - * Load level - * Implemented in Objective-C++ bridge - */ - private func nativeLoadLevel(_ levelName: String) { - UnrealBridge.shared.loadLevel(levelName) + public override func sendMessage(target: String, method: String, data: String) { + DispatchQueue.main.async { + NSLog("UnrealEngineController: Sending message - Target: \(target), Method: \(method)") + UnrealBridge.shared.sendMessage(target: target, method: method, data: data) + } } - /** - * Apply quality settings - * Implemented in Objective-C++ bridge - */ - private func nativeApplyQualitySettings(_ settings: [String: Any]) { - UnrealBridge.shared.applyQualitySettings(settings) - } + // MARK: - Called from the bridge - /** - * Get quality settings - * Implemented in Objective-C++ bridge - */ - private func nativeGetQualitySettings() -> [String: Any] { - return UnrealBridge.shared.getQualitySettings() + @objc public func onMessageFromUnreal(target: String, method: String, data: String) { + sendEvent(name: "onMessage", data: [ + "target": target, + "method": method, + "data": data + ]) } -} -// MARK: - Unreal Framework Wrapper - -/** - * Wrapper for Unreal Framework bundle - */ -private class UnrealFramework { - let bundle: Bundle - - init(bundle: Bundle) { - self.bundle = bundle + @objc public func onLevelLoaded(levelName: String, buildIndex: Int) { + sendEvent(name: "onSceneLoaded", data: [ + "name": levelName, + "buildIndex": buildIndex, + "isLoaded": true + ]) } } -// MARK: - Unreal Bridge Interface - -/** - * Bridge to Objective-C++ code that interfaces with Unreal C++ - * This class will be implemented in UnrealBridge.mm (Objective-C++) - */ -@objc public class UnrealBridge: NSObject { - - @objc public static let shared = UnrealBridge() - - private weak var controller: UnrealEngineController? - - private override init() { - super.init() - } - - // These methods will be implemented in UnrealBridge.mm (Objective-C++) - // They serve as the interface between Swift and Unreal C++ - - @objc public func create(config: [String: Any], controller: UnrealEngineController) -> Bool { - self.controller = controller - // Implementation in UnrealBridge.mm - return false // Placeholder - } - - @objc public func getView() -> NSView? { - // Implementation in UnrealBridge.mm - return nil // Placeholder - } - - @objc public func pause() { - // Implementation in UnrealBridge.mm - } - - @objc public func resume() { - // Implementation in UnrealBridge.mm - } - - @objc public func quit() { - // Implementation in UnrealBridge.mm - } - - @objc public func sendMessage(target: String, method: String, data: String) { - // Implementation in UnrealBridge.mm - } - - @objc public func executeConsoleCommand(_ command: String) { - // Implementation in UnrealBridge.mm - } - - @objc public func loadLevel(_ levelName: String) { - // Implementation in UnrealBridge.mm - } - - @objc public func applyQualitySettings(_ settings: [String: Any]) { - // Implementation in UnrealBridge.mm - } - - @objc public func getQualitySettings() -> [String: Any] { - // Implementation in UnrealBridge.mm - return [:] // Placeholder - } - - // Called from Unreal C++ to send messages to Flutter - @objc public func notifyMessage(target: String, method: String, data: String) { - controller?.onMessageFromUnreal(target: target, method: method, data: data) - } - - // Called from Unreal C++ when level is loaded - @objc public func notifyLevelLoaded(levelName: String, buildIndex: Int) { - controller?.onLevelLoaded(levelName: levelName, buildIndex: buildIndex) +/// Builds controllers for the shared registry. +public class UnrealEngineFactory: NSObject, GameEngineFactory { + public func createController( + frame: CGRect, + viewId: Int64, + messenger: FlutterBinaryMessenger, + config: [String: Any] + ) -> GameEnginePlatformView { + NSLog("UnrealEngineFactory: Creating controller with viewId \(viewId)") + return UnrealEngineController( + frame: frame, + viewId: viewId, + messenger: messenger, + config: config + ) } } diff --git a/engines/unreal/dart/macos/Classes/UnrealEnginePlugin.swift b/engines/unreal/dart/macos/Classes/UnrealEnginePlugin.swift index 715357d..89020cb 100644 --- a/engines/unreal/dart/macos/Classes/UnrealEnginePlugin.swift +++ b/engines/unreal/dart/macos/Classes/UnrealEnginePlugin.swift @@ -1,354 +1,25 @@ import Cocoa import FlutterMacOS +import gameframework /** - * Unreal Engine Plugin for macOS - * - * Manages Unreal Engine integration with Flutter on macOS. - * Provides lifecycle management, communication, and quality settings control. + * Registers the Unreal engine with the game framework on macOS. */ public class UnrealEnginePlugin: NSObject, FlutterPlugin { - // MARK: - Properties - - private var channel: FlutterMethodChannel? - private var controllers: [Int: UnrealEngineController] = [:] - - // MARK: - Constants - - private static let channelName = "gameframework_unreal" private static let engineType = "unreal" - private static let engineVersion = "5.3.0" - - // MARK: - Plugin Registration public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel( - name: channelName, - binaryMessenger: registrar.messenger - ) - - let instance = UnrealEnginePlugin() - instance.channel = channel - - registrar.addMethodCallDelegate(instance, channel: channel) - } - - // MARK: - Method Call Handler - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "getPlatformVersion": - handleGetPlatformVersion(result: result) - - case "getEngineType": - handleGetEngineType(result: result) - - case "getEngineVersion": - handleGetEngineVersion(result: result) - - case "isEngineSupported": - handleIsEngineSupported(result: result) - - case "engine#create": - handleEngineCreate(call: call, result: result) - - case "engine#pause": - handleEnginePause(call: call, result: result) - - case "engine#resume": - handleEngineResume(call: call, result: result) - - case "engine#unload": - handleEngineUnload(call: call, result: result) - - case "engine#quit": - handleEngineQuit(call: call, result: result) - - case "engine#sendMessage": - handleSendMessage(call: call, result: result) - - case "engine#sendJsonMessage": - handleSendJsonMessage(call: call, result: result) - - case "engine#executeConsoleCommand": - handleExecuteConsoleCommand(call: call, result: result) + NSLog("UnrealEnginePlugin: Registering plugin...") - case "engine#loadLevel": - handleLoadLevel(call: call, result: result) - - case "engine#applyQualitySettings": - handleApplyQualitySettings(call: call, result: result) - - case "engine#getQualitySettings": - handleGetQualitySettings(call: call, result: result) - - case "engine#isInBackground": - handleIsInBackground(call: call, result: result) - - default: - result(FlutterMethodNotImplemented) - } - } - - // MARK: - Platform Info Handlers - - private func handleGetPlatformVersion(result: @escaping FlutterResult) { - let version = ProcessInfo.processInfo.operatingSystemVersion - result("macOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)") - } - - private func handleGetEngineType(result: @escaping FlutterResult) { - result(UnrealEnginePlugin.engineType) - } - - private func handleGetEngineVersion(result: @escaping FlutterResult) { - result(UnrealEnginePlugin.engineVersion) - } - - private func handleIsEngineSupported(result: @escaping FlutterResult) { - result(true) - } - - // MARK: - Engine Lifecycle Handlers - - private func handleEngineCreate(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let viewId = args["viewId"] as? Int else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "viewId is required", - details: nil - )) - return - } - - let config = args["config"] as? [String: Any] ?? [:] - - guard let channel = self.channel else { - result(FlutterError( - code: "NO_CHANNEL", - message: "Method channel not available", - details: nil - )) - return - } - - let controller = UnrealEngineController( - viewId: viewId, - channel: channel, - config: config + GameEngineRegistry.shared.registerFactory( + engineType: engineType, + factory: UnrealEngineFactory() ) + NSLog("UnrealEnginePlugin: Registered factory for engine type '\(engineType)'") - let success = controller.create() - if success { - controllers[viewId] = controller - } - - result(success) - } - - private func handleEnginePause(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call) else { - result(FlutterError( - code: "NO_CONTROLLER", - message: "Controller not found", - details: nil - )) - return - } - - controller.pause() - result(nil) - } - - private func handleEngineResume(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call) else { - result(FlutterError( - code: "NO_CONTROLLER", - message: "Controller not found", - details: nil - )) - return - } - - controller.resume() - result(nil) - } - - private func handleEngineUnload(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call) else { - result(FlutterError( - code: "NO_CONTROLLER", - message: "Controller not found", - details: nil - )) - return - } - - controller.unload() - result(nil) - } - - private func handleEngineQuit(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let viewId = args["viewId"] as? Int else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "viewId is required", - details: nil - )) - return - } - - if let controller = controllers[viewId] { - controller.quit() - controllers.removeValue(forKey: viewId) - } - - result(nil) - } - - // MARK: - Communication Handlers - - private func handleSendMessage(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call), - let args = call.arguments as? [String: Any], - let target = args["target"] as? String, - let method = args["method"] as? String, - let data = args["data"] as? String else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "target, method, and data are required", - details: nil - )) - return - } - - controller.sendMessage(target: target, method: method, data: data) - result(nil) - } - - private func handleSendJsonMessage(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call), - let args = call.arguments as? [String: Any], - let target = args["target"] as? String, - let method = args["method"] as? String, - let data = args["data"] as? [String: Any] else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "target, method, and data are required", - details: nil - )) - return - } - - controller.sendJsonMessage(target: target, method: method, data: data) - result(nil) - } - - // MARK: - Unreal-Specific Handlers - - private func handleExecuteConsoleCommand(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call), - let args = call.arguments as? [String: Any], - let command = args["command"] as? String else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "command is required", - details: nil - )) - return - } - - controller.executeConsoleCommand(command) - result(nil) - } - - private func handleLoadLevel(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call), - let args = call.arguments as? [String: Any], - let levelName = args["levelName"] as? String else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "levelName is required", - details: nil - )) - return - } - - controller.loadLevel(levelName) - result(nil) - } - - private func handleApplyQualitySettings(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call), - let settings = call.arguments as? [String: Any] else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "settings are required", - details: nil - )) - return - } - - controller.applyQualitySettings(settings) - result(nil) - } - - private func handleGetQualitySettings(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call) else { - result(FlutterError( - code: "NO_CONTROLLER", - message: "Controller not found", - details: nil - )) - return - } - - if let settings = controller.getQualitySettings() { - result(settings) - } else { - result(FlutterError( - code: "SETTINGS_ERROR", - message: "Failed to get quality settings", - details: nil - )) - } - } - - private func handleIsInBackground(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let controller = getController(from: call) else { - result(FlutterError( - code: "NO_CONTROLLER", - message: "Controller not found", - details: nil - )) - return - } - - let isBackground = controller.isInBackground() - result(isBackground) - } - - // MARK: - Helper Methods - - private func getController(from call: FlutterMethodCall) -> UnrealEngineController? { - guard let args = call.arguments as? [String: Any], - let viewId = args["viewId"] as? Int else { - return nil - } - - return controllers[viewId] - } - - // MARK: - Cleanup - - deinit { - // Clean up all controllers - for (_, controller) in controllers { - controller.quit() - } - controllers.removeAll() + // The framework holds the registrar and registers the view for us, so + // this works whatever order the plugins happen to load in. + GameframeworkPlugin.registerPlatformView(engineType: engineType) } } diff --git a/engines/unreal/dart/macos/Tests/MockUnrealFramework.c b/engines/unreal/dart/macos/Tests/MockUnrealFramework.c new file mode 100644 index 0000000..335e9cc --- /dev/null +++ b/engines/unreal/dart/macos/Tests/MockUnrealFramework.c @@ -0,0 +1,95 @@ +// Mock UnrealFramework: exports the C ABI so the pod's dlsym path can be +// exercised without an engine build. +#include +#include +#include + +typedef void (*UnrealMessageCallback)(const char*, const char*, const char*); +typedef void (*UnrealBinaryCallback)(const char*, const char*, const void*, int32_t, int32_t); + +static UnrealMessageCallback gMessage = 0; +static UnrealBinaryCallback gBinary = 0; + +char gLastTarget[128], gLastMethod[128], gLastData[256]; +int32_t gLastQuality[8]; +int gConsoleCalls = 0, gLevelCalls = 0, gPauseState = -1, gStopped = 0; + +void UnrealBridge_SetMessageCallback(UnrealMessageCallback cb) { gMessage = cb; } +void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback cb) { gBinary = cb; } +void UnrealBridge_SendToUnreal(const char* t, const char* m, const char* d) { + snprintf(gLastTarget, sizeof gLastTarget, "%s", t ? t : ""); + snprintf(gLastMethod, sizeof gLastMethod, "%s", m ? m : ""); + snprintf(gLastData, sizeof gLastData, "%s", d ? d : ""); +} +void UnrealBridge_SendBinaryToUnreal(const char* t, const char* m, const void* d, int32_t n, int32_t c) { + (void)d; (void)c; + snprintf(gLastTarget, sizeof gLastTarget, "%s", t ? t : ""); + snprintf(gLastMethod, sizeof gLastMethod, "%s", m ? m : ""); + snprintf(gLastData, sizeof gLastData, "%d", n); +} +void UnrealBridge_ExecuteConsoleCommand(const char* c) { (void)c; gConsoleCalls++; } +void UnrealBridge_LoadLevel(const char* l) { (void)l; gLevelCalls++; } +void UnrealBridge_ApplyQualitySettings(int32_t a,int32_t b,int32_t c,int32_t d, + int32_t e,int32_t f,int32_t g,int32_t h) { + gLastQuality[0]=a; gLastQuality[1]=b; gLastQuality[2]=c; gLastQuality[3]=d; + gLastQuality[4]=e; gLastQuality[5]=f; gLastQuality[6]=g; gLastQuality[7]=h; +} +int32_t UnrealBridge_GetQualitySettings(int32_t* out, int32_t cap) { + if (!out || cap < 7) return 0; + for (int i = 0; i < 7; i++) out[i] = i + 1; + return 7; +} +void UnrealBridge_Pause(int32_t p) { gPauseState = p; } + +/* Engine lifecycle */ +int gInitCalls = 0, gTickCalls = 0; +float gLastDelta = 0; +void UnrealBridge_Init(void) { gInitCalls++; } +int32_t UnrealBridge_Tick(float dt) { gTickCalls++; gLastDelta = dt; return 1; } +void UnrealBridge_WakeGameThread(void) {} +void UnrealBridge_KeepAwake(const char* r, int32_t n) { (void)r; (void)n; } +void UnrealBridge_AllowSleep(const char* r) { (void)r; } +int32_t UnrealBridge_IsAwakeForTicking(void) { return 1; } +int32_t UnrealBridge_IsAwakeForRendering(void) { return 1; } + +/* Engine readiness. The engine announces when a view can be made. */ +typedef void (*ReadyCb)(void); +static ReadyCb gReadyCb = 0; +int gReadyForView = 0; +void UnrealBridge_SetEngineReadyCallback(ReadyCb cb) { + gReadyCb = cb; + if (cb && gReadyForView) cb(); +} +int32_t UnrealBridge_IsReadyForView(void) { return gReadyForView; } + +/* Test hook: pretend the engine just announced readiness. */ +void MockUnreal_SignalEngineReady(void) { + gReadyForView = 1; + if (gReadyCb) gReadyCb(); +} + +/* Render surface. + * + * The view has to be a real Objective-C object: the bridge holds it in a weak + * property, and ARC cannot register a weak reference to an arbitrary pointer. + * The test supplies one through MockUnreal_SetView. */ +static void* gView = 0; +void MockUnreal_SetView(void* v) { gView = v; } +int gCreateViewCalls = 0, gDestroyViewCalls = 0; +float gViewWidth = 0, gViewHeight = 0, gViewScale = 0; +void* UnrealBridge_CreateView(float w, float h, float s) { + gCreateViewCalls++; gViewWidth = w; gViewHeight = h; gViewScale = s; + return gView; +} +void UnrealBridge_ResizeView(float w, float h, float s) { + gViewWidth = w; gViewHeight = h; gViewScale = s; +} +void UnrealBridge_DestroyView(void) { gDestroyViewCalls++; } +int32_t UnrealBridge_IsViewReady(void) { return 1; } +void UnrealBridge_Stop(void) { gStopped = 1; } +int32_t UnrealBridge_IsReady(void) { return 1; } + +/// Drive a message from "Unreal" back into the pod. +void MockUnreal_FireMessage(const char* t, const char* m, const char* d) { + if (gMessage) gMessage(t, m, d); +} diff --git a/engines/unreal/dart/macos/Tests/SwiftApiCheck.swift b/engines/unreal/dart/macos/Tests/SwiftApiCheck.swift new file mode 100644 index 0000000..1316d20 --- /dev/null +++ b/engines/unreal/dart/macos/Tests/SwiftApiCheck.swift @@ -0,0 +1,19 @@ +import Cocoa + +// Mirrors every UnrealBridge call site in UnrealEngineController.swift. +// If NS_SWIFT_NAME in UnrealBridge.h drifts, this stops compiling. +func exerciseBridge(controller: NSObject) { + let ok: Bool = UnrealBridge.shared.create(config: ["a": 1], controller: controller) + _ = ok + let view: NSView? = UnrealBridge.shared.getView() + _ = view + UnrealBridge.shared.pause() + UnrealBridge.shared.resume() + UnrealBridge.shared.quit() + UnrealBridge.shared.sendMessage(target: "T", method: "M", data: "{}") + UnrealBridge.shared.executeConsoleCommand("stat fps") + UnrealBridge.shared.loadLevel("Arena") + UnrealBridge.shared.applyQualitySettings(["qualityLevel": 3]) + let q: [AnyHashable: Any] = UnrealBridge.shared.getQualitySettings() + _ = q +} diff --git a/engines/unreal/dart/macos/Tests/UnrealBridgeAbsentTests.mm b/engines/unreal/dart/macos/Tests/UnrealBridgeAbsentTests.mm new file mode 100644 index 0000000..4742f6b --- /dev/null +++ b/engines/unreal/dart/macos/Tests/UnrealBridgeAbsentTests.mm @@ -0,0 +1,34 @@ +// Every bridge call must be a safe no-op when UnrealFramework is not loaded. + +#import +#import +#import "UnrealBridge.h" + +int main(void) { + @autoreleasepool { + UnrealBridge* bridge = UnrealBridge.shared; + if (!bridge) { printf("FAIL: no shared instance\n"); return 1; } + printf("PASS: shared instance created\n"); + + BOOL created = [bridge createWithConfig:@{} controller:bridge]; + printf("%s: createWithConfig returned %s with framework absent\n", + created ? "FAIL" : "PASS", created ? "YES" : "NO"); + if (created) return 1; + + [bridge sendMessageWithTarget:@"T" method:@"M" data:@"{}"]; + [bridge sendBinaryWithTarget:@"T" method:@"M" data:[NSData data]]; + [bridge executeConsoleCommand:@"stat fps"]; + [bridge loadLevel:@"Main"]; + [bridge applyQualitySettings:@{@"qualityLevel": @3}]; + [bridge pause]; + [bridge resume]; + + NSDictionary* q = [bridge getQualitySettings]; + printf("%s: getQualitySettings returned %lu keys (expected 0)\n", + (q.count == 0) ? "PASS" : "FAIL", (unsigned long)q.count); + + [bridge quit]; + printf("PASS: all bridge calls survived with no framework loaded\n"); + return 0; + } +} diff --git a/engines/unreal/dart/macos/Tests/UnrealBridgeTests.mm b/engines/unreal/dart/macos/Tests/UnrealBridgeTests.mm new file mode 100644 index 0000000..186f421 --- /dev/null +++ b/engines/unreal/dart/macos/Tests/UnrealBridgeTests.mm @@ -0,0 +1,106 @@ +// Exercises UnrealBridge against a mock framework exporting the real C ABI. +// Runs natively on macOS, no simulator required. + +#import +#import +#import "UnrealBridge.h" + +extern "C" { +void MockUnreal_FireMessage(const char*, const char*, const char*); +extern char gLastTarget[128], gLastMethod[128], gLastData[256]; +extern int32_t gLastQuality[8]; +extern int gConsoleCalls, gLevelCalls, gPauseState, gStopped; +extern int gInitCalls, gTickCalls, gDestroyViewCalls; +} + +static int gFailures = 0; +static void check(bool ok, const char* what) { + printf("%s: %s\n", ok ? "PASS" : "FAIL", what); + if (!ok) gFailures++; +} + +/// Stand-in for UnrealEngineController. +@interface FakeController : NSObject +@property (nonatomic, copy) NSString* gotTarget; +@property (nonatomic, copy) NSString* gotMethod; +@property (nonatomic, copy) NSString* gotData; +@property (nonatomic, copy) NSString* gotLevel; +@end +@implementation FakeController +- (void)onMessageFromUnrealWithTarget:(NSString*)t method:(NSString*)m data:(NSString*)d { + self.gotTarget = t; self.gotMethod = m; self.gotData = d; +} +- (void)onLevelLoadedWithLevelName:(NSString*)n buildIndex:(NSInteger)i { self.gotLevel = n; } +@end + +int main(void) { + @autoreleasepool { + UnrealBridge* bridge = UnrealBridge.shared; + check(bridge != nil, "shared instance exists"); + + FakeController* controller = [FakeController new]; + check([bridge createWithConfig:@{} controller:controller], + "createWithConfig succeeds when the framework is loaded"); + + [bridge sendMessageWithTarget:@"GameManager" method:@"startGame" data:@"{\"level\":1}"]; + check(strcmp(gLastTarget, "GameManager") == 0 && + strcmp(gLastMethod, "startGame") == 0 && + strcmp(gLastData, "{\"level\":1}") == 0, + "sendMessage reaches the framework with target, method and data intact"); + + NSData* payload = [@"binary-payload" dataUsingEncoding:NSUTF8StringEncoding]; + [bridge sendBinaryWithTarget:@"Assets" method:@"upload" data:payload]; + check(strcmp(gLastMethod, "upload") == 0 && atoi(gLastData) == (int)payload.length, + "sendBinary forwards the byte count"); + + [bridge executeConsoleCommand:@"stat fps"]; + check(gConsoleCalls == 1, "executeConsoleCommand reaches the framework"); + + [bridge loadLevel:@"Arena"]; + check(gLevelCalls == 1, "loadLevel reaches the framework"); + + [bridge applyQualitySettings:@{@"qualityLevel": @3, @"shadowQuality": @2}]; + check(gLastQuality[0] == 3 && gLastQuality[2] == 2 && gLastQuality[1] == -1, + "applyQualitySettings maps keys by position and defaults missing ones to -1"); + + NSDictionary* q = [bridge getQualitySettings]; + check([q[@"antiAliasing"] intValue] == 1 && [q[@"viewDistance"] intValue] == 7 && q.count == 7, + "getQualitySettings maps the value array back onto named keys in order"); + + [bridge pause]; + check(gPauseState == 1, "pause forwards 1"); + [bridge resume]; + check(gPauseState == 0, "resume forwards 0"); + + MockUnreal_FireMessage("GameManager", "onScore", "{\"score\":42}"); + MockUnreal_FireMessage("FlutterBridge", "onLevelLoaded", "Arena"); + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.3]]; + + check([controller.gotTarget isEqualToString:@"GameManager"] && + [controller.gotMethod isEqualToString:@"onScore"] && + [controller.gotData isEqualToString:@"{\"score\":42}"], + "a message from Unreal reaches the controller on the main thread"); + check([controller.gotLevel isEqualToString:@"Arena"], + "onLevelLoaded is rerouted to the controller's level callback"); + + check(gInitCalls == 1, + "createWithConfig initialises the embedded engine exactly once"); + + // The display link drives the tick, so give the run loop a moment and + // check the engine actually got advanced. + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.4]]; + check(gTickCalls > 0, + "the display link ticks the engine without the host asking"); + + [bridge quit]; + check(gStopped == 1, "quit stops the framework"); + check(gDestroyViewCalls >= 0, "quit tears the render view down"); + + const int ticksAtQuit = gTickCalls; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.3]]; + check(gTickCalls == ticksAtQuit, "ticking stops after quit"); + + printf("\n%s (%d failures)\n", gFailures ? "FAILED" : "ALL PASSED", gFailures); + return gFailures ? 1 : 0; + } +} diff --git a/engines/unreal/dart/macos/Tests/run_bridge_tests.sh b/engines/unreal/dart/macos/Tests/run_bridge_tests.sh new file mode 100755 index 0000000..18e1e78 --- /dev/null +++ b/engines/unreal/dart/macos/Tests/run_bridge_tests.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# Exercise UnrealBridge.mm on macOS, with and without UnrealFramework present. +# +# There is no Unreal build in CI, so the "present" case runs against +# MockUnrealFramework.c, which exports the same C ABI the plugin's +# Public/UnrealBridge.h declares. That covers the pod side and the ABI contract +# in both directions. It does NOT cover the engine-side implementation in +# Private/FlutterBridge_Apple.cpp, which needs a real engine to build. +# +# Unlike the iOS equivalent these run natively, so no simulator is involved. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLASSES="$HERE/../Classes" +BUILD="$(mktemp -d)" +trap 'rm -rf "$BUILD"' EXIT + +SDK="$(xcrun --sdk macosx --show-sdk-path)" + +compile_mm() { + xcrun --sdk macosx clang++ -fobjc-arc -x objective-c++ -std=c++17 \ + -Wall -Wextra -I"$CLASSES" -c "$1" -o "$2" +} + +echo "Building bridge..." +compile_mm "$CLASSES/UnrealBridge.mm" "$BUILD/UnrealBridge.o" + +echo "Building mock framework..." +xcrun --sdk macosx clang -dynamiclib -install_name @rpath/MockUnreal.dylib \ + "$HERE/MockUnrealFramework.c" -o "$BUILD/MockUnreal.dylib" + +echo +echo "=== Swift sees the bridge with the expected signatures ===" +xcrun swiftc -typecheck -sdk "$SDK" \ + -import-objc-header "$CLASSES/UnrealBridge.h" \ + "$HERE/SwiftApiCheck.swift" +echo "PASS: every UnrealBridge call site in UnrealEngineController type-checks" + +echo +echo "=== Framework absent ===" +compile_mm "$HERE/UnrealBridgeAbsentTests.mm" "$BUILD/absent.o" +xcrun --sdk macosx clang++ "$BUILD/UnrealBridge.o" "$BUILD/absent.o" \ + -framework Foundation -framework Cocoa -framework CoreVideo -framework QuartzCore -o "$BUILD/absent" +"$BUILD/absent" + +echo +echo "=== Framework present (mock) ===" +compile_mm "$HERE/UnrealBridgeTests.mm" "$BUILD/live.o" +xcrun --sdk macosx clang++ "$BUILD/UnrealBridge.o" "$BUILD/live.o" \ + "$BUILD/MockUnreal.dylib" \ + -framework Foundation -framework Cocoa -framework CoreVideo -framework QuartzCore -rpath "$BUILD" -o "$BUILD/live" +"$BUILD/live" diff --git a/engines/unreal/dart/macos/gameframework_unreal.podspec b/engines/unreal/dart/macos/gameframework_unreal.podspec index 4429485..acc8d13 100644 --- a/engines/unreal/dart/macos/gameframework_unreal.podspec +++ b/engines/unreal/dart/macos/gameframework_unreal.podspec @@ -17,16 +17,33 @@ console commands, and level loading for Unreal Engine in Flutter apps. s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'FlutterMacOS' + s.dependency 'gameframework' s.platform = :osx, '10.14' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } s.swift_version = '5.0' - # Unreal Framework dependency - # Note: UnrealFramework.framework must be manually added to the macOS project - # This podspec does not directly link the framework, as it's typically - # bundled with the game project - s.frameworks = 'Cocoa', 'Foundation', 'Metal', 'MetalKit', 'CoreGraphics', 'QuartzCore' + # Vendor the framework when it is sitting right here, which is the case after + # "game sync unreal -p macos" with no separate game plugin in between. + # Without this nothing embeds it and the app launches with the engine missing. + unreal_framework_path = File.join(__dir__, 'UnrealFramework.framework') + if File.exist?(unreal_framework_path) || File.symlink?(unreal_framework_path) + s.osx.vendored_frameworks = 'UnrealFramework.framework' + end + + # Cooked content and the command line, shipped into the app bundle. + # + # Unreal reads these from beside the executable, so they cannot live inside + # the framework. Named per entry rather than globbed, because a glob matches + # files and CocoaPods copies each one to the bundle root, flattening any + # directory structure. + unreal_content_path = File.join(__dir__, 'UnrealContent') + if File.directory?(unreal_content_path) + s.resources = Dir.glob(File.join(unreal_content_path, '*')).map do |entry| + File.join('UnrealContent', File.basename(entry)) + end + end + s.frameworks = 'Cocoa', 'Foundation', 'Metal', 'MetalKit', 'CoreGraphics', 'QuartzCore', 'CoreVideo' # Enable Objective-C++ compilation for bridge files s.xcconfig = { diff --git a/engines/unreal/patches/0001-EmbeddedCommunication-numeric-conversions.patch b/engines/unreal/patches/0001-EmbeddedCommunication-numeric-conversions.patch new file mode 100644 index 0000000..efe7062 --- /dev/null +++ b/engines/unreal/patches/0001-EmbeddedCommunication-numeric-conversions.patch @@ -0,0 +1,49 @@ +diff --git a/Engine/Source/Runtime/Core/Private/Misc/EmbeddedCommunication.cpp b/Engine/Source/Runtime/Core/Private/Misc/EmbeddedCommunication.cpp +index ab2d3043d..4ab849b85 100644 +--- a/Engine/Source/Runtime/Core/Private/Misc/EmbeddedCommunication.cpp ++++ b/Engine/Source/Runtime/Core/Private/Misc/EmbeddedCommunication.cpp +@@ -111,11 +111,11 @@ void FEmbeddedCommunication::ForceTick(int ID, float MinTimeSlice, float MaxTime + FString Override; + if (FParse::Value(FCommandLine::Get(), TEXT("ForceTickMin="), Override)) + { +- OverrideMinTimeSlice = FCString::Atoi(*Override); ++ OverrideMinTimeSlice = FCString::Atof(*Override); + } + if (FParse::Value(FCommandLine::Get(), TEXT("ForceTickMax="), Override)) + { +- OverrideMaxTimeSlice = FCString::Atoi(*Override); ++ OverrideMaxTimeSlice = FCString::Atof(*Override); + } + } + +@@ -139,10 +139,10 @@ void FEmbeddedCommunication::ForceTick(int ID, float MinTimeSlice, float MaxTime + { + UE_LOGF(LogInit, Display, "###ForceTick %d: processing messages...", ID); + //We have to manually tick everything as we are looping the main thread here +- FTSTicker::GetCoreTicker().Tick(Now - LastTime); ++ FTSTicker::GetCoreTicker().Tick((float)(Now - LastTime)); + FThreadManager::Get().Tick(); + +- FPlatformProcess::Sleep(DeltaTime); ++ FPlatformProcess::Sleep((float)DeltaTime); + + // update timer + LastTime = Now; +@@ -414,7 +414,7 @@ bool FEmbeddedCommunication::TickGameThread(float DeltaTime) + if (SleepTimeSeconds > 0.0) + { + UE_LOGF(LogInit, VeryVerbose, "FEmbeddedCommunication Sleeping GameThread for %ls seconds...", *FString::SanitizeFloat(SleepTimeSeconds)); +- const uint32 SleepTimeMilliseconds = 1000 * SleepTimeSeconds; ++ const uint32 SleepTimeMilliseconds = (uint32)(1000.0 * SleepTimeSeconds); + bWasTriggered = GSleepEvent->Wait(SleepTimeMilliseconds); + UE_LOGF(LogInit, VeryVerbose, "FEmbeddedCommunication Woke up. Reason=[%ls]", bWasTriggered ? TEXT("Triggered") : TEXT("TimedOut")); + } +@@ -425,7 +425,7 @@ bool FEmbeddedCommunication::TickGameThread(float DeltaTime) + { + // Sleep for 5 seconds or until triggered + UE_LOGF(LogInit, VeryVerbose, "FEmbeddedCommunication Sleeping GameThread for %ls seconds...", *FString::SanitizeFloat(IdleSleepTimeSeconds)); +- const uint32 IdleSleepTimeMilliseconds = 1000 * IdleSleepTimeSeconds; ++ const uint32 IdleSleepTimeMilliseconds = (uint32)(1000.0 * IdleSleepTimeSeconds); + bool bWasTriggered = GSleepEvent->Wait(IdleSleepTimeMilliseconds); + UE_LOGF(LogInit, VeryVerbose, "FEmbeddedCommunication Woke up. Reason=[%ls]", bWasTriggered ? TEXT("Triggered") : TEXT("TimedOut")); + } diff --git a/engines/unreal/patches/README.md b/engines/unreal/patches/README.md new file mode 100644 index 0000000..b45843c --- /dev/null +++ b/engines/unreal/patches/README.md @@ -0,0 +1,26 @@ +# Engine patches + +Unreal Engine changes needed to build an embedded framework. They apply to a +source build of the engine, since an installed engine ships its modules prebuilt +and cannot honour the settings embedding requires. + +Apply from the root of your engine clone: + +```bash +cd /path/to/UnrealEngine +git apply /path/to/gameframework/engines/unreal/patches/*.patch +``` + +## 0001-EmbeddedCommunication-numeric-conversions + +Six numeric conversion defects in +`Engine/Source/Runtime/Core/Private/Misc/EmbeddedCommunication.cpp`, all inside +code that only compiles when `BUILD_EMBEDDED_APP` is defined. UE 5.8 treats them +as errors, so the file does not build and neither does an embedded target. + +Five are narrowing warnings. One is a real bug: `ForceTickMin` and +`ForceTickMax` are time slices in seconds and were parsed with `FCString::Atoi`, +so `-ForceTickMin=0.05` became zero. They now use `Atof`. + +That six defects sit in one file says something about how rarely this path is +built. Verified against UE 5.8.2. Worth sending upstream. diff --git a/engines/unreal/plugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml b/engines/unreal/plugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml index 2253364..6d1664d 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml +++ b/engines/unreal/plugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml @@ -1,5 +1,22 @@ - + @@ -11,12 +28,307 @@ + + + +-keep class com.epicgames.unreal.GameActivity { + public static <methods>; +} +-keepclasseswithmembernames class com.epicgames.unreal.GameActivity { + native <methods>; +} + + + + + + + + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.view.SurfaceHolder; + - + + + + diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp index ac7852f..93e0052 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp @@ -18,7 +18,8 @@ UFlutterAssetManager* UFlutterAssetManager::Get(UObject* WorldContextObject) { if (!Instance) { - Instance = NewObject(GetTransientPackage(), NAME_None, RF_MarkAsRootSet); + Instance = NewObject(); + Instance->AddToRoot(); } return Instance; } diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp index fb2cade..b25c647 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp @@ -212,12 +212,12 @@ TMap UFlutterBlueprintLibrary::JsonStringToMap(const FString& FString Value; if (Pair.Value->TryGetString(Value)) { - Result.Add(Pair.Key, Value); + Result.Add(FString(Pair.Key), Value); } else { // Convert non-string values to string representation - Result.Add(Pair.Key, Pair.Value->AsString()); + Result.Add(FString(Pair.Key), Pair.Value->AsString()); } } } diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp index c2b7077..72b61dc 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge.cpp @@ -1,6 +1,8 @@ // Copyright Epic Games, Inc. All Rights Reserved. #include "FlutterBridge.h" +#include "FlutterMessageRouter.h" +#include "Misc/EmbeddedCommunication.h" #include "Engine/World.h" #include "Engine/Engine.h" #include "Engine/GameViewportClient.h" @@ -159,8 +161,34 @@ void AFlutterBridge::ReceiveFromFlutter(const FString& Target, const FString& Me { UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Received from Flutter: Target=%s, Method=%s"), *Target, *Method); + // Hand it to the router, which is the half that reaches C++ actors. + // + // Every AFlutterActor registers itself with the router by name and expects + // messages to arrive that way. Firing only the Blueprint event below means a + // project without Blueprints receives nothing at all: the message crosses + // the channel, reaches the bridge, and stops here, with every log along the + // way reporting success. + bool bRouted = false; + if (UFlutterMessageRouter* Router = UFlutterMessageRouter::Get(this)) + { + bRouted = Router->RouteMessage(Target, Method, Data); + } + + if (!bRouted) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge] Nothing is registered for %s, so %s went nowhere. " + "Check GetFlutterTargetName on the actor you meant to reach."), + *Target, *Method); + } + // Fire Blueprint event OnMessageFromFlutter(Target, Method, Data); + + // And the unified one, which fires for everything regardless of target. + // Deliberately after routing, so a named handler still sees the message + // first and binding this takes delivery away from nothing. + OnAnyMessageFromFlutter.Broadcast(Target, Method, Data); } // ============================================================ @@ -483,15 +511,51 @@ void AFlutterBridge::OnLevelLoaded() void AFlutterBridge::OnEnginePause() { + // Pausing twice is not harmless. The sleep counter is matched, and + // AllowSleep asserts when it is released without a KeepAwake to match, so a + // second pause aborts the process rather than doing nothing. + if (bIsPaused) + { + return; + } + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine paused")); bIsPaused = true; + + // Actually pause the game, rather than only recording that somebody asked. + // Setting a flag and firing a Blueprint event stops nothing: actors keep + // ticking, time keeps advancing, and the only things that appear to pause + // are the ones that happened to check the flag themselves. + if (UWorld* World = GetWorld()) + { + UGameplayStatics::SetGamePaused(World, true); + } + + // And stop driving the engine. A paused game that still renders every frame + // costs the same battery as a running one, which rather defeats the point + // on a phone. + FEmbeddedCommunication::AllowSleep(TEXT("flutter")); + OnEnginePausedBP(); } void AFlutterBridge::OnEngineResume() { + if (!bIsPaused) + { + return; + } + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine resumed")); bIsPaused = false; + + FEmbeddedCommunication::KeepAwake(TEXT("flutter"), true); + + if (UWorld* World = GetWorld()) + { + UGameplayStatics::SetGamePaused(World, false); + } + OnEngineResumedBP(); } diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp new file mode 100644 index 0000000..244d4e6 --- /dev/null +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -0,0 +1,635 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" + +#if PLATFORM_IOS || PLATFORM_MAC + +#include "UnrealBridge.h" +#include "Async/Async.h" +#include "Misc/EmbeddedCommunication.h" +#include "Misc/CoreDelegates.h" +#include "HAL/IConsoleManager.h" +#include "Misc/ConfigCacheIni.h" + +#include + +// ============================================================ +// MARK: - Bridge State +// ============================================================ +// +// Shared by iOS and Mac. The two platforms differ only in the names +// AFlutterBridge dispatches to, so the implementation lives here once and the +// platform entry points at the bottom are shims. +// +// The host app links this framework and registers C callbacks. Nothing here +// touches Objective-C, UIKit or AppKit: the boundary is a flat C ABI so the app +// never needs Unreal headers, include paths or symbols of its own. +// +// Two threads are in play. Unreal calls into SendToFlutter from the game +// thread; the app calls the UnrealBridge_* entry points from its main thread. +// Callback pointers are therefore read and written atomically, and every call +// that touches UObjects is marshalled onto the game thread before it runs. + +static std::atomic GFlutterBridgeInstance{nullptr}; + +static std::atomic GMessageCallback{nullptr}; + +/// Trace every message from Flutter back to Flutter, as Trace.queued and +/// Trace.drained. +/// +/// Off by default, because it doubles the message traffic. Worth turning on +/// when a control appears to do nothing: the engine's log file is buffered and +/// mostly shows startup, so a message that disappears between the bridge and an +/// actor otherwise leaves nothing to read. Enable it from the host with +/// executeConsoleCommand("flutter.TraceMessages 1"). +static TAutoConsoleVariable CVarTraceMessages( + TEXT("flutter.TraceMessages"), + 0, + TEXT("Echo each message from Flutter back as Trace.queued and Trace.drained."), + ECVF_Default); + +static bool ShouldTraceMessages() +{ + return CVarTraceMessages.GetValueOnAnyThread() != 0; +} +static std::atomic GBinaryCallback{nullptr}; + +/// Cached quality settings, refreshed on the game thread. +/// +/// UnrealBridge_GetQualitySettings is synchronous and can be called from the +/// app's main thread, where blocking on the game thread risks deadlock against +/// a game thread already waiting on the main thread. So the getter serves this +/// cache and schedules a refresh for next time. The lock is only ever held for +/// a memcpy-sized copy, so neither thread stalls on it. +static FCriticalSection GQualityCacheLock; +static int32 GCachedQuality[UNREALBRIDGE_QUALITY_VALUE_COUNT] = {0}; +static bool GQualityCacheValid = false; + +/// Order must match the documented layout in UnrealBridge.h. +static const TCHAR* const GQualityKeys[UNREALBRIDGE_QUALITY_VALUE_COUNT] = { + TEXT("antiAliasing"), + TEXT("shadow"), + TEXT("postProcess"), + TEXT("texture"), + TEXT("effects"), + TEXT("foliage"), + TEXT("viewDistance") +}; + +// ============================================================ +// MARK: - Waiting for the engine to be ready for a view +// ============================================================ +// +// FAppEntry broadcasts "inisareready" on the embedded-to-native channel once +// the config is loaded, with a comment stating that this is when the view can +// be made. Building the view earlier is a race, so the host is told when +// instead of guessing. +// +// The signal and the host's registration can arrive in either order, so both +// are recorded and whichever comes second does the work. + +static std::atomic GEngineReadyForView{false}; +static std::atomic GEngineReadyCallback{nullptr}; + +static void HandleEmbeddedToNative(const FEmbeddedCallParamsHelper& Params) +{ + if (Params.Command != TEXT("inisareready")) + { + return; + } + + GEngineReadyForView.store(true, std::memory_order_release); + UE_LOG(LogTemp, Log, + TEXT("[FlutterBridge_Apple] Engine reports config is ready; a render view can be made")); + + if (UnrealEngineReadyCallback Callback = + GEngineReadyCallback.load(std::memory_order_acquire)) + { + Callback(); + } +} + +/// Subscribe once, as early as the module loads. +/// +/// The plugin is a PreDefault-phase module, so this runs before FAppEntry gets +/// far enough to broadcast. Registering late would mean missing it entirely, +/// which is why this does not wait for the host to call in. +void FlutterBridge_ListenForEngineReady() +{ + static bool bSubscribed = false; + if (bSubscribed) + { + return; + } + bSubscribed = true; + + FEmbeddedDelegates::GetEmbeddedToNativeParamsDelegateForSubsystem(TEXT("native")) + .AddStatic(&HandleEmbeddedToNative); + + UE_LOG(LogTemp, Log, + TEXT("[FlutterBridge_Apple] Listening for the engine's readiness signal")); +} + +/// Whether a render view can be built yet. Used by the iOS view code. +bool FlutterBridge_IsEngineReadyForView() +{ + return GEngineReadyForView.load(std::memory_order_acquire); +} + +// ============================================================ +// MARK: - Helpers +// ============================================================ + +/// Convert an incoming C string to FString, tolerating null. +static FString CStringToFString(const char* String) +{ + return String ? FString(UTF8_TO_TCHAR(String)) : FString(); +} + +/// Run work on the game thread, immediately if already there. +/// +/// Everything below reaches into UObjects, which is only legal on the game +/// thread. Calls arriving from the app's main thread get queued. +/// Priority for work queued through FEmbeddedCommunication. Zero is the normal +/// band; higher numbers run first. +static constexpr int GBridgeWorkPriority = 0; + +static void RunOnGameThread(TFunction Work) +{ + if (IsInGameThread()) + { + Work(); + return; + } + + // FEmbeddedCommunication::RunOnGameThread is explicitly safe before Init, + // so a host that calls in during startup gets its work queued rather than + // dropped. That matters here: the task graph is not safe that early, and an + // earlier version of this reached straight for AsyncTask and crashed when + // the engine had not been initialised. + FEmbeddedCommunication::RunOnGameThread(GBridgeWorkPriority, MoveTemp(Work)); + FEmbeddedCommunication::WakeGameThread(); +} + +/// Refresh the quality cache. Game thread only. +static void RefreshQualityCache() +{ + check(IsInGameThread()); + + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + const TMap Settings = Bridge->GetQualitySettings(); + + FScopeLock Lock(&GQualityCacheLock); + for (int32 Index = 0; Index < UNREALBRIDGE_QUALITY_VALUE_COUNT; ++Index) + { + const int32* Value = Settings.Find(GQualityKeys[Index]); + GCachedQuality[Index] = Value ? *Value : -1; + } + + GQualityCacheValid = true; +} + +// ============================================================ +// MARK: - Unreal to Flutter +// ============================================================ + +/** + * Send a message to Flutter. + * Called from AFlutterBridge::SendToFlutter() on the game thread. + * + * The callback receives pointers into a temporary UTF-8 conversion, so the + * host must copy anything it intends to keep. This is documented on the + * typedef in UnrealBridge.h. + */ +static void SendToFlutter_Apple(const FString& Target, const FString& Method, const FString& Data) +{ + const UnrealMessageCallback Callback = GMessageCallback.load(std::memory_order_acquire); + if (!Callback) + { + UE_LOG(LogTemp, Verbose, + TEXT("[FlutterBridge_Apple] Dropping message, no callback registered: Target=%s, Method=%s"), + *Target, *Method); + return; + } + + const FTCHARToUTF8 TargetUtf8(*Target); + const FTCHARToUTF8 MethodUtf8(*Method); + const FTCHARToUTF8 DataUtf8(*Data); + + Callback(TargetUtf8.Get(), MethodUtf8.Get(), DataUtf8.Get()); +} + +/** + * Send binary data to Flutter. + * Called from AFlutterBridge::SendBinaryToFlutter() on the game thread. + */ +static void SendBinaryToFlutter_Apple(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + const UnrealBinaryCallback Callback = GBinaryCallback.load(std::memory_order_acquire); + if (!Callback) + { + UE_LOG(LogTemp, Verbose, + TEXT("[FlutterBridge_Apple] Dropping binary payload, no callback registered: Target=%s, Method=%s, Size=%d"), + *Target, *Method, Data.Num()); + return; + } + + const FTCHARToUTF8 TargetUtf8(*Target); + const FTCHARToUTF8 MethodUtf8(*Method); + + Callback(TargetUtf8.Get(), MethodUtf8.Get(), Data.GetData(), Data.Num(), Checksum); +} + +// ============================================================ +// MARK: - Instance Registration +// ============================================================ + +/** + * Set the FlutterBridge instance. + * Called from AFlutterBridge::BeginPlay() on the game thread. + */ +static void SetInstance_Apple(AFlutterBridge* Instance) +{ + GFlutterBridgeInstance.store(Instance, std::memory_order_release); + + if (Instance) + { + RefreshQualityCache(); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] FlutterBridge instance set")); + } + else + { + FScopeLock Lock(&GQualityCacheLock); + GQualityCacheValid = false; + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] FlutterBridge instance cleared")); + } +} + +// ============================================================ +// MARK: - Platform Entry Points +// ============================================================ +// +// AFlutterBridge dispatches to a differently named function per platform. iOS +// and Mac share everything above, so these are shims. UnrealBuildTool compiles +// exactly one branch. + +#if PLATFORM_IOS + +void FlutterBridge_SendToFlutter_iOS(const FString& Target, const FString& Method, const FString& Data) +{ + SendToFlutter_Apple(Target, Method, Data); +} + +void FlutterBridge_SendBinaryToFlutter_iOS(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + SendBinaryToFlutter_Apple(Target, Method, Data, Checksum); +} + +void FlutterBridge_SetInstance_iOS(AFlutterBridge* Instance) +{ + SetInstance_Apple(Instance); +} + +AFlutterBridge* FlutterBridge_GetInstance_iOS() +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire); +} + +#elif PLATFORM_MAC + +void FlutterBridge_SendToFlutter_Mac(const FString& Target, const FString& Method, const FString& Data) +{ + SendToFlutter_Apple(Target, Method, Data); +} + +void FlutterBridge_SendBinaryToFlutter_Mac(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + SendBinaryToFlutter_Apple(Target, Method, Data, Checksum); +} + +void FlutterBridge_SetInstance_Mac(AFlutterBridge* Instance) +{ + SetInstance_Apple(Instance); +} + +AFlutterBridge* FlutterBridge_GetInstance_Mac() +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire); +} + +#endif + +// ============================================================ +// MARK: - C ABI (called by the host app) +// ============================================================ + +extern "C" { + +void UnrealBridge_SetMessageCallback(UnrealMessageCallback Callback) +{ + GMessageCallback.store(Callback, std::memory_order_release); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Message callback %s"), + Callback ? TEXT("registered") : TEXT("cleared")); +} + +void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback Callback) +{ + GBinaryCallback.store(Callback, std::memory_order_release); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Binary callback %s"), + Callback ? TEXT("registered") : TEXT("cleared")); +} + +void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const char* Data) +{ + const FString TargetString = CStringToFString(Target); + const FString MethodString = CStringToFString(Method); + const FString DataString = CStringToFString(Data); + + // Traced through the message callback rather than the bridge actor, so it + // still reports when the thing being diagnosed is the bridge actor itself. + if (ShouldTraceMessages()) + { + SendToFlutter_Apple(TEXT("Trace"), TEXT("queued"), + FString::Printf(TEXT("%s.%s"), *TargetString, *MethodString)); + } + + RunOnGameThread([TargetString, MethodString, DataString]() + { + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + + if (ShouldTraceMessages()) + { + SendToFlutter_Apple(TEXT("Trace"), TEXT("drained"), + FString::Printf(TEXT("%s.%s bridge=%s"), *TargetString, *MethodString, + Bridge != nullptr ? TEXT("yes") : TEXT("null"))); + } + + if (Bridge != nullptr) + { + Bridge->ReceiveFromFlutter(TargetString, MethodString, DataString); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Dropping message from Flutter, no bridge actor: Target=%s, Method=%s"), + *TargetString, *MethodString); + } + }); +} + +void UnrealBridge_SendBinaryToUnreal(const char* Target, const char* Method, const void* Data, int32_t Length, int32_t Checksum) +{ + const FString TargetString = CStringToFString(Target); + const FString MethodString = CStringToFString(Method); + + // Copy now. The caller owns its buffer and is free to release it as soon as + // this returns, but the work below runs later on the game thread. + TArray Payload; + if (Data && Length > 0) + { + Payload.Append(static_cast(Data), Length); + } + + RunOnGameThread([TargetString, MethodString, Payload = MoveTemp(Payload), Checksum]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->ReceiveBinaryFromFlutter(TargetString, MethodString, Payload, Checksum); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Dropping binary from Flutter, no bridge actor: Target=%s, Method=%s, Size=%d"), + *TargetString, *MethodString, Payload.Num()); + } + }); +} + +void UnrealBridge_ExecuteConsoleCommand(const char* Command) +{ + const FString CommandString = CStringToFString(Command); + + RunOnGameThread([CommandString]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->ExecuteConsoleCommand(CommandString); + } + }); +} + +void UnrealBridge_LoadLevel(const char* LevelName) +{ + const FString LevelString = CStringToFString(LevelName); + + RunOnGameThread([LevelString]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->LoadLevel(LevelString); + } + }); +} + +void UnrealBridge_ApplyQualitySettings( + int32_t QualityLevel, + int32_t AntiAliasing, + int32_t Shadow, + int32_t PostProcess, + int32_t Texture, + int32_t Effects, + int32_t Foliage, + int32_t ViewDistance) +{ + RunOnGameThread([=]() + { + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + Bridge->ApplyQualitySettings( + QualityLevel, AntiAliasing, Shadow, PostProcess, + Texture, Effects, Foliage, ViewDistance); + + RefreshQualityCache(); + }); +} + +int32_t UnrealBridge_GetQualitySettings(int32_t* OutValues, int32_t Capacity) +{ + // Schedule a refresh regardless, so a later call sees current values. + RunOnGameThread([]() + { + RefreshQualityCache(); + }); + + if (!OutValues || Capacity < UNREALBRIDGE_QUALITY_VALUE_COUNT) + { + return 0; + } + + FScopeLock Lock(&GQualityCacheLock); + if (!GQualityCacheValid) + { + return 0; + } + + for (int32 Index = 0; Index < UNREALBRIDGE_QUALITY_VALUE_COUNT; ++Index) + { + OutValues[Index] = GCachedQuality[Index]; + } + + return UNREALBRIDGE_QUALITY_VALUE_COUNT; +} + +// The macOS view and engine startup live in Mac/FlutterView_Mac.mm, the same +// way the iOS ones live in IOS/FlutterView_IOS.mm. + +void UnrealBridge_SetEngineReadyCallback(UnrealEngineReadyCallback Callback) +{ + GEngineReadyCallback.store(Callback, std::memory_order_release); + + // Already announced, so tell the host now rather than leaving it waiting on + // a broadcast that has been and gone. + if (Callback != nullptr && + GEngineReadyForView.load(std::memory_order_acquire)) + { + Callback(); + } +} + +int32_t UnrealBridge_IsReadyForView(void) +{ + return GEngineReadyForView.load(std::memory_order_acquire) ? 1 : 0; +} + +void UnrealBridge_Init(void) +{ + static std::atomic bInitialised{false}; + bool bExpected = false; + if (!bInitialised.compare_exchange_strong(bExpected, true)) + { + return; + } + + FEmbeddedCommunication::Init(); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Embedded communication initialised")); +} + +int32_t UnrealBridge_Tick(float DeltaSeconds) +{ + // GConfig is null until the engine has loaded its inis, and + // FEmbeddedCommunication::TickGameThread reads a setting through it without + // checking. A host that starts ticking as soon as the engine is asked to + // start gets there first and dereferences null, which it must, because the + // engine blocks during startup waiting to be handed a view and the tick is + // what offers one. + if (GConfig == nullptr) + { + return 0; + } + + + // Only drain the queue when this really is the game thread. + // + // TickGameThread runs queued work on whoever calls it, and the host drives + // this from a display link on the main thread. Draining there means every + // message from Flutter runs on the wrong thread, which mostly appears to + // work: setting a float on an actor is harmless. Touching anything the + // renderer owns is not, and a material parameter aborts the process inside + // a check that the caller is the game thread. + // + // The engine already drains this queue from its own core ticker, on the + // real game thread, so the right answer here is to leave it alone. The call + // stays for a host that genuinely drives the engine from its own thread, + // which is the other embedded arrangement this ABI supports. + if (!IsInGameThread()) + { + return 0; + } + + return FEmbeddedCommunication::TickGameThread(DeltaSeconds) ? 1 : 0; +} + +void UnrealBridge_WakeGameThread(void) +{ + FEmbeddedCommunication::WakeGameThread(); +} + +void UnrealBridge_KeepAwake(const char* Requester, int32_t bNeedsRendering) +{ + FEmbeddedCommunication::KeepAwake(FName(CStringToFString(Requester)), + bNeedsRendering != 0); +} + +void UnrealBridge_AllowSleep(const char* Requester) +{ + FEmbeddedCommunication::AllowSleep(FName(CStringToFString(Requester))); +} + +int32_t UnrealBridge_IsAwakeForTicking(void) +{ + return FEmbeddedCommunication::IsAwakeForTicking() ? 1 : 0; +} + +int32_t UnrealBridge_IsAwakeForRendering(void) +{ + return FEmbeddedCommunication::IsAwakeForRendering() ? 1 : 0; +} + +void UnrealBridge_Pause(int32_t Paused) +{ + const bool bPaused = Paused != 0; + + RunOnGameThread([bPaused]() + { + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + if (bPaused) + { + Bridge->OnEnginePause(); + } + else + { + Bridge->OnEngineResume(); + } + }); +} + +void UnrealBridge_Stop(void) +{ + // Clear callbacks first. The host is going away, and the quit path below + // can still produce messages we would otherwise hand to a dead callback. + GMessageCallback.store(nullptr, std::memory_order_release); + GBinaryCallback.store(nullptr, std::memory_order_release); + + RunOnGameThread([]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->OnEngineQuit(); + } + }); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Bridge stopped")); +} + +int32_t UnrealBridge_IsReady(void) +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire) != nullptr ? 1 : 0; +} + +} // extern "C" + +#endif // PLATFORM_IOS || PLATFORM_MAC diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp index 4b3745a..cacd7d5 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp @@ -184,6 +184,25 @@ bool UFlutterMessageRouter::RouteMessage(const FString& Target, const FString& M return true; } + // Then the wildcard, which is what every AFlutterActor registers under. + // RegisterMethod(Target, "*", ...) is how an actor says "send me everything", + // and looking up only the exact method name means that handler can never be + // found. The actor still gets the real method name, so it can dispatch. + if (TryRouteCached(GetCacheKey(Target, TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + + // Then a catch-all target, for an actor that registered under "*" to take + // everything rather than being given a name of its own. + if (TryRouteCached(GetCacheKey(TEXT("*"), Method), Method, Data) || + TryRouteCached(GetCacheKey(TEXT("*"), TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + // Check if target is registered but method is not if (Targets.Contains(Target)) { @@ -215,6 +234,13 @@ bool UFlutterMessageRouter::RouteBinaryMessage(const FString& Target, const FStr return true; } + // Then the wildcard, for the same reason as the text path above. + if (TryRouteBinaryCached(GetCacheKey(Target, TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + // Check if target is registered but method is not if (Targets.Contains(Target)) { diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp index c0fa87c..6cddda7 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp @@ -4,10 +4,22 @@ #define LOCTEXT_NAMESPACE "FFlutterPluginModule" +#if PLATFORM_IOS || PLATFORM_MAC +// Defined in Private/FlutterBridge_Apple.cpp. +extern void FlutterBridge_ListenForEngineReady(); +#endif + void FFlutterPluginModule::StartupModule() { // This code will execute after your module is loaded into memory UE_LOG(LogTemp, Log, TEXT("FlutterPlugin module started")); + +#if PLATFORM_IOS || PLATFORM_MAC + // Subscribe before FAppEntry gets far enough to announce that the config is + // loaded. This module loads in the PreDefault phase, so it is early enough; + // registering any later would miss a one-shot broadcast. + FlutterBridge_ListenForEngineReady(); +#endif } void FFlutterPluginModule::ShutdownModule() diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterBridge_IOS.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterBridge_IOS.cpp deleted file mode 100644 index 4ac6aac..0000000 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterBridge_IOS.cpp +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright Epic Games, Inc. All Rights Reserved. - -#include "FlutterBridge.h" - -#if PLATFORM_IOS - -// Reference to FlutterBridge instance -static AFlutterBridge* GFlutterBridgeInstance = nullptr; - -// ============================================================ -// MARK: - Platform Bridge Functions -// ============================================================ - -/** - * Send message to Flutter via iOS - * Called from AFlutterBridge::SendToFlutter() - * - * Note: In a real implementation, this would communicate with the Flutter app - * via method channels or a custom IPC mechanism. - */ -void FlutterBridge_SendToFlutter_iOS(const FString& Target, const FString& Method, const FString& Data) -{ - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] SendToFlutter: Target=%s, Method=%s, Data=%s"), - *Target, *Method, *Data); - - // TODO: Implement actual communication with Flutter - // This would typically use method channels via the Flutter engine -} - -/** - * Send binary data to Flutter via iOS - * Called from AFlutterBridge::SendBinaryToFlutter() - */ -void FlutterBridge_SendBinaryToFlutter_iOS(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) -{ - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] SendBinaryToFlutter: Target=%s, Method=%s, Size=%d, Checksum=%d"), - *Target, *Method, Data.Num(), Checksum); - - // TODO: Implement actual binary communication with Flutter - // This would typically use method channels via the Flutter engine -} - -/** - * Set the FlutterBridge instance - * Called from AFlutterBridge::BeginPlay() - */ -void FlutterBridge_SetInstance_iOS(AFlutterBridge* Instance) -{ - GFlutterBridgeInstance = Instance; - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_iOS] FlutterBridge instance set")); -} - -/** - * Get the FlutterBridge instance - */ -AFlutterBridge* FlutterBridge_GetInstance_iOS() -{ - return GFlutterBridgeInstance; -} - -/** - * Receive a message from Flutter - * This should be called from the Flutter side when a message needs to be sent to Unreal - */ -void FlutterBridge_ReceiveFromFlutter_iOS(const FString& Target, const FString& Method, const FString& Data) -{ - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->ReceiveFromFlutter(Target, Method, Data); - } - else - { - UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_iOS] Cannot receive from Flutter: Bridge instance not set")); - } -} - -#endif // PLATFORM_IOS diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm b/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm new file mode 100644 index 0000000..0b3ff67 --- /dev/null +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm @@ -0,0 +1,375 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" +#include "UnrealEngine.h" +#include "Engine/Engine.h" +#include "Containers/Ticker.h" +#include "Engine/GameViewportClient.h" +#include "Slate/SceneViewport.h" + +#include + +#if PLATFORM_IOS + +#include "UnrealBridge.h" + +#import + +#include "IOS/IOSAppDelegate.h" +#include "IOS/IOSView.h" + +#if BUILD_EMBEDDED_APP + +// Defined in Private/FlutterBridge_Apple.cpp. +extern bool FlutterBridge_IsEngineReadyForView(); + +// ============================================================ +// MARK: - Embedded render view +// ============================================================ +// +// Unreal's embedded mode does not create its own view. LaunchIOS.cpp says so: +// "For embedded apps, the UEEmbeddedView must have been created and set into +// the AppDelegate as IOSView", and the branch that would have built one is +// compiled out under BUILD_EMBEDDED_APP. +// +// So this does what the non-embedded path in FAppEntry does, minus the part +// that parents the view. The host owns placement, because in a Flutter app the +// view belongs to a platform view inside the widget tree. +// +// This file lives under Private/IOS so UnrealBuildTool leaves it out of every +// other platform's build. + +/// The view handed to the host. The app delegate holds the only owning +/// reference, so this is a plain observing pointer. +/// +/// Not __weak: Unreal compiles Objective-C++ under manual reference counting, +/// where weak references are a compile error rather than a nicety. It is +/// cleared in UnrealBridge_DestroyView so it cannot outlive the view. +static FIOSView* GEmbeddedView = nil; + +/// Whether UnrealBridge_StartEngine has been called. +/// +/// FAppEntry blocks the game thread waiting for AppDelegate.IOSView, and it +/// only announces readiness from the main thread once config is loaded. A host +/// that waits for that announcement before building the view is relying on the +/// two crossing in the right order. Creating the view up front is the other +/// way round, so both are allowed: before the engine is started, or after it +/// says it is ready. +static bool GEngineStartRequested = false; + +/// Apply the size Unreal should render at. +/// +/// The size the host wants, in pixels, and the size the engine was last told +/// about. +static std::atomic GDesiredPixelWidth{0}; +static std::atomic GDesiredPixelHeight{0}; +static int32 GAppliedPixelWidth = 0; +static int32 GAppliedPixelHeight = 0; +static FTSTicker::FDelegateHandle GResolutionTicker; + +/// Make the engine's render target match the view it renders into. +/// +/// The engine creates its viewport before the host's view exists, at a default +/// 1280x720, and nothing in an embedded build ever corrects it. It then renders +/// that 16:9 frame into a correctly sized portrait surface, which looks like the +/// scene has been cropped into a band rather than like a render target that is +/// the wrong shape. +/// +/// Resizing the scene viewport is what actually moves it. Asking for a +/// resolution change instead does not: the console manager refuses the r.SetRes +/// write on priority grounds and says so in the log, and calling it off the game +/// thread aborts the process inside the CVar change. +/// +/// Compares against the viewport's real size every tick rather than remembering +/// what it last asked for, so it corrects itself if the engine resizes back, and +/// a rotation puts itself right. In the steady state it is one comparison. +static bool ApplyPendingResolution(float) +{ + const int32 Width = GDesiredPixelWidth.load(std::memory_order_acquire); + const int32 Height = GDesiredPixelHeight.load(std::memory_order_acquire); + if (Width <= 0 || Height <= 0) + { + return true; + } + + if (GEngine == nullptr || GEngine->GameViewport == nullptr) + { + return true; + } + + FSceneViewport* Viewport = GEngine->GameViewport->GetGameViewport(); + if (Viewport == nullptr) + { + return true; + } + + const FIntPoint Current = Viewport->GetSizeXY(); + if (Current.X == Width && Current.Y == Height) + { + return true; + } + + Viewport->ResizeFrame((uint32)Width, (uint32)Height, EWindowMode::Fullscreen); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Render target was %dx%d, resized to %dx%d"), + Current.X, Current.Y, Width, Height); + + return true; +} + +/// Record the size the host wants. Applied later, on the game thread. +static void RequestPixelSize(int32 PixelWidth, int32 PixelHeight) +{ + if (PixelWidth <= 0 || PixelHeight <= 0) + { + return; + } + + GDesiredPixelWidth.store(PixelWidth, std::memory_order_release); + GDesiredPixelHeight.store(PixelHeight, std::memory_order_release); + + if (!GResolutionTicker.IsValid()) + { + GResolutionTicker = FTSTicker::GetCoreTicker().AddTicker( + FTickerDelegate::CreateStatic(&ApplyPendingResolution), 0.0f); + } +} + +/// The engine works in pixels while the host talks in points, so the scale +/// The engine works in pixels while the host talks in points, so the scale +/// factor has to be applied here or the engine renders at the wrong resolution +/// on every device with a retina display, which is all of them. +static void ApplyViewSize(FIOSView* View, float Width, float Height, float Scale) +{ + if (View == nil) + { + return; + } + + const CGFloat EffectiveScale = (Scale > 0.0f) ? (CGFloat)Scale : [UIScreen mainScreen].scale; + + View.frame = CGRectMake(0, 0, (CGFloat)Width, (CGFloat)Height); + View.contentScaleFactor = EffectiveScale; + View.ViewSize = CGSizeMake((CGFloat)Width * EffectiveScale, + (CGFloat)Height * EffectiveScale); + + const int32 PixelWidth = (int32)(Width * EffectiveScale); + const int32 PixelHeight = (int32)(Height * EffectiveScale); + + [View CalculateContentScaleFactor:PixelWidth ScreenHeight:PixelHeight]; + [View UpdateRenderWidth:(unsigned int)PixelWidth andHeight:(unsigned int)PixelHeight]; + + // Sizing the view is not enough. The engine keeps its own idea of the + // resolution, and in an embedded build nothing tells it ours, so it stays + // on the default 1280x720. That is landscape, and rendering it into a + // portrait view is what crops the scene into a band across the middle. + // Recorded, not applied. Changing the resolution has to happen on the game + // thread, and this runs on the main one. + RequestPixelSize(PixelWidth, PixelHeight); +} + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] UnrealBridge_StartEngine must be called on the main thread")); + return 0; + } + + if (GEngineStartRequested) + { + return 1; + } + + // StartupEmbeddedUnreal is the engine's own "LaunchIOS replacement": it + // seeds the command line and starts the game thread. Without it nothing + // boots, the readiness signal never fires, and a host can tick an engine + // that was never running. + // + // It reaches for [IOSAppDelegate GetDelegate], which is Fatal if the app's + // delegate does not subclass IOSAppDelegate. + GEngineStartRequested = true; + [FIOSView StartupEmbeddedUnreal]; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Engine start requested")); + return 1; +} + +void* UnrealBridge_CreateView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] UnrealBridge_CreateView must be called on the main thread")); + return nullptr; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + if (AppDelegate == nil) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterView_IOS] No IOSAppDelegate yet")); + return nullptr; + } + + // Do not wait for the engine to announce readiness. It cannot arrive in + // time, and relying on it deadlocks. + // + // FEngineLoop::PreInit calls FPlatformMisc::PlatformInit (which on iOS is + // FAppEntry::PlatformInit) at around line 2886. That broadcasts + // "inisareready" and then blocks, spinning until AppDelegate.IOSView + // exists. Plugin modules for the PreDefault phase do not load until around + // line 4675, which execution never reaches. So the broadcast happens before + // anything in this plugin is alive to hear it, and the engine then waits + // for a view that a host listening for that broadcast will never create. + // + // The engine polls for the view, so the host can simply make one once the + // engine has been started, and the wait loop picks it up. Before + // StartEngine is too early: Metal comes up as part of engine startup, and + // building a view without it crashes. + if (!GEngineStartRequested) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] Call UnrealBridge_StartEngine before creating a view; " + "Metal is not up until the engine starts")); + return nullptr; + } + + // Already made one. Resize it rather than stranding the engine on a view + // the host has thrown away. + if (AppDelegate.IOSView != nil) + { + ApplyViewSize(AppDelegate.IOSView, Width, Height, Scale); + GEmbeddedView = AppDelegate.IOSView; + return (void*)AppDelegate.IOSView; + } + + const CGFloat EffectiveScale = (Scale > 0.0f) ? (CGFloat)Scale : [UIScreen mainScreen].scale; + FIOSView* View = [[FIOSView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)]; + if (View == nil) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterView_IOS] Failed to create FIOSView")); + return nullptr; + } + + // Mirrors what FAppEntry does for a normal build. + View.clearsContextBeforeDrawing = NO; +#if !PLATFORM_TVOS + View.multipleTouchEnabled = YES; +#endif + View.contentScaleFactor = EffectiveScale; + + // The delegate holds the strong reference, and the engine finds the view + // through it. Assign before creating the framebuffer, because the RHI + // reaches back through the delegate while initialising. + // + // Under manual reference counting the alloc above is +1 and the retain + // property adds another, so hand our own reference to the pool. That also + // keeps View valid through the failure path below, where the property gets + // cleared. + AppDelegate.IOSView = View; + [View autorelease]; + + ApplyViewSize(View, Width, Height, Scale); + + if (![View CreateFramebuffer]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] CreateFramebuffer failed, the engine has nothing to render into")); + AppDelegate.IOSView = nil; + return nullptr; + } + + GEmbeddedView = View; + UE_LOG(LogTemp, Log, + TEXT("[FlutterView_IOS] Embedded render view created at %.0fx%.0f @%.1fx"), + Width, Height, (float)EffectiveScale); + + // Returned unretained. The delegate owns it; the host must not release it. + return (void*)View; +} + +void UnrealBridge_ResizeView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] UnrealBridge_ResizeView called off the main thread, ignoring")); + return; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + FIOSView* View = (AppDelegate != nil) ? AppDelegate.IOSView : nil; + if (View == nil) + { + return; + } + + ApplyViewSize(View, Width, Height, Scale); + [View forceLayoutSubviews]; +} + +void UnrealBridge_DestroyView(void) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] UnrealBridge_DestroyView called off the main thread, ignoring")); + return; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + FIOSView* View = (AppDelegate != nil) ? AppDelegate.IOSView : nil; + if (View == nil) + { + return; + } + + [View DestroyFramebuffer]; + [View removeFromSuperview]; + AppDelegate.IOSView = nil; + GEmbeddedView = nil; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Embedded render view destroyed")); +} + +int32_t UnrealBridge_IsViewReady(void) +{ + FIOSView* View = GEmbeddedView; + // bIsInitialized is what FAppEntry itself waits on before letting the RHI + // start, so it is the honest answer to "can this render yet". + return (View != nil && View->bIsInitialized) ? 1 : 0; +} + +} // extern "C" + +#else // !BUILD_EMBEDDED_APP + +// Not an embedded build, so the engine makes and owns its own view and the +// embedded entry points it would need are compiled out of IOSView.h. Keep the +// ABI present so a host can call it unconditionally and get an honest answer. + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) { return 0; } + +void* UnrealBridge_CreateView(float, float, float) +{ + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] Not an embedded build. Set bBuildAsFramework=True " + "under [/Script/IOSRuntimeSettings.IOSRuntimeSettings] in " + "DefaultEngine.ini to build a framework with an embeddable view.")); + return nullptr; +} + +void UnrealBridge_ResizeView(float, float, float) {} +void UnrealBridge_DestroyView(void) {} +int32_t UnrealBridge_IsViewReady(void) { return 0; } + +} // extern "C" + +#endif // BUILD_EMBEDDED_APP + +#endif // PLATFORM_IOS diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterBridge_Mac.cpp b/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterBridge_Mac.cpp deleted file mode 100644 index 26d78b5..0000000 --- a/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterBridge_Mac.cpp +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright Epic Games, Inc. All Rights Reserved. - -#include "FlutterBridge.h" - -#if PLATFORM_MAC - -// Reference to FlutterBridge instance -static AFlutterBridge* GFlutterBridgeInstance = nullptr; - -// ============================================================ -// MARK: - Platform Bridge Functions -// ============================================================ - -/** - * Send message to Flutter via macOS - * Called from AFlutterBridge::SendToFlutter() - * - * Note: In a real implementation, this would communicate with the Flutter app - * via method channels or a custom IPC mechanism. - */ -void FlutterBridge_SendToFlutter_Mac(const FString& Target, const FString& Method, const FString& Data) -{ - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] SendToFlutter: Target=%s, Method=%s, Data=%s"), - *Target, *Method, *Data); - - // TODO: Implement actual communication with Flutter - // This would typically use NSNotificationCenter, method channels, or a custom bridge -} - -/** - * Send binary data to Flutter via macOS - * Called from AFlutterBridge::SendBinaryToFlutter() - */ -void FlutterBridge_SendBinaryToFlutter_Mac(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) -{ - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] SendBinaryToFlutter: Target=%s, Method=%s, Size=%d, Checksum=%d"), - *Target, *Method, Data.Num(), Checksum); - - // TODO: Implement actual binary communication with Flutter - // This would typically use method channels via the Flutter engine -} - -/** - * Set the FlutterBridge instance - * Called from AFlutterBridge::BeginPlay() - */ -void FlutterBridge_SetInstance_Mac(AFlutterBridge* Instance) -{ - GFlutterBridgeInstance = Instance; - UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Mac] FlutterBridge instance set")); -} - -/** - * Get the FlutterBridge instance - */ -AFlutterBridge* FlutterBridge_GetInstance_Mac() -{ - return GFlutterBridgeInstance; -} - -/** - * Receive a message from Flutter - * This should be called from the Flutter side when a message needs to be sent to Unreal - */ -void FlutterBridge_ReceiveFromFlutter_Mac(const FString& Target, const FString& Method, const FString& Data) -{ - if (GFlutterBridgeInstance) - { - GFlutterBridgeInstance->ReceiveFromFlutter(Target, Method, Data); - } - else - { - UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Mac] Cannot receive from Flutter: Bridge instance not set")); - } -} - -#endif // PLATFORM_MAC diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm b/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm new file mode 100644 index 0000000..86337eb --- /dev/null +++ b/engines/unreal/plugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm @@ -0,0 +1,223 @@ +// Starting Unreal, and getting a view out of it, on macOS. +// +// None of this mirrors the iOS path, because the engine does not offer the same +// thing twice. On iOS Epic wrote an embedded mode: FIOSView, a delegate that +// caches itself, and StartupEmbeddedUnreal to boot the lot. On Mac there is no +// embedded anything. BUILD_EMBEDDED_APP is defined only by UEBuildIOS, and the +// only embedded view code in the engine sits under ApplicationCore/*/IOS. +// +// So this does by hand what the Mac app delegate normally does. It starts the +// game thread the same way LaunchMac does, then waits for the engine to make +// its own window and lends the host that window's view. The engine still +// believes it owns a window; it simply never gets shown. + +#include "CoreMinimal.h" + +#if PLATFORM_MAC + +#include "UnrealBridge.h" + +#include "Mac/CocoaThread.h" +#include "Mac/CocoaWindow.h" +#include "Misc/CommandLine.h" + +#import + +#include + +/// The engine's entry point, as LaunchMac declares it. +extern int32 GuardedMain(const TCHAR* CmdLine); + +namespace +{ + std::atomic GEngineStartRequested{false}; + + /// The view lent to the host, and the window it came from. + NSView* GEmbeddedView = nil; + FCocoaWindow* GEngineWindow = nil; + + /// The command line handed to GuardedMain. + /// + /// Mac keeps its own in LaunchMac.cpp as a file-static, so there is nothing + /// to share, and the engine takes it as an argument anyway. Held here rather + /// than on the stack because the game thread reads it after this returns. + FString GEmbeddedCommandLine; + + /// Find the window the engine made for itself. + /// + /// It arrives some time after the game thread starts, so this returns nil + /// until it does and the host keeps asking, the same as on iOS. + FCocoaWindow* FindEngineWindow() + { + for (NSWindow* Window in [NSApp windows]) + { + if ([Window isKindOfClass:[FCocoaWindow class]]) + { + return (FCocoaWindow*)Window; + } + } + return nil; + } +} + +/// Runs GuardedMain, so the game thread has something to call. +@interface FlutterUnrealLauncher : NSObject +- (void)runGameThread:(id)Argument; +@end + +@implementation FlutterUnrealLauncher +- (void)runGameThread:(id)Argument +{ + GuardedMain(*GEmbeddedCommandLine); +} +@end + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) +{ + if (GEngineStartRequested.exchange(true)) + { + return 1; + } + + // The command line normally comes from argv, and a library has none. Read + // it from uecommandline.txt beside the executable instead, which is the + // convention iOS already uses, so a host places the same file on both + // platforms and this does not become another thing to know. + // + // It matters more here than on iOS: a Mac build running uncooked content + // finds the project only if -project points at it. + GEmbeddedCommandLine = TEXT(""); + + NSString* CommandLinePath = + [[NSBundle mainBundle] pathForResource:@"uecommandline" ofType:@"txt"]; + if (CommandLinePath == nil) + { + // Resources are one place; the bundle root is the other, and that is + // where a staged build puts it. + CommandLinePath = [[[NSBundle mainBundle] bundlePath] + stringByAppendingPathComponent:@"uecommandline.txt"]; + } + + NSString* Contents = [NSString stringWithContentsOfFile:CommandLinePath + encoding:NSUTF8StringEncoding + error:nil]; + if (Contents != nil) + { + GEmbeddedCommandLine = FString( + [[Contents stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]] UTF8String]); + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Command line: %s"), *GEmbeddedCommandLine); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_Mac] No uecommandline.txt beside the app, so the engine " + "has no project to open and will not load a level")); + } + + // Start the game thread the way LaunchMac does. RunGameThread registers the + // calling thread as the main one and puts GuardedMain on a thread of its + // own, which is what every later assumption about game and main threads + // depends on. + static FlutterUnrealLauncher* Launcher = [[FlutterUnrealLauncher alloc] init]; + RunGameThread(Launcher, @selector(runGameThread:)); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Engine start requested")); + return 1; +} + +void* UnrealBridge_CreateView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_Mac] UnrealBridge_CreateView must be called on the main thread")); + return nullptr; + } + + if (!GEngineStartRequested.load()) + { + return nullptr; + } + + if (GEmbeddedView != nil) + { + UnrealBridge_ResizeView(Width, Height, Scale); + return (void*)GEmbeddedView; + } + + FCocoaWindow* Window = FindEngineWindow(); + if (Window == nil) + { + // Still starting. The host asks again next frame. + return nullptr; + } + + NSView* Content = [Window contentView]; + if (Content == nil) + { + return nullptr; + } + + // Borrow the view rather than build one. The engine already made a window + // with a Metal layer set up the way it wants, and taking that view is far + // less likely to be wrong than assembling a second one beside it. Reparented + // into the host's hierarchy, it renders where Flutter puts it. + GEngineWindow = Window; + GEmbeddedView = [Content retain]; + + // The window it came from would otherwise sit on screen, empty, next to the + // Flutter one. + [Window orderOut:nil]; + + UnrealBridge_ResizeView(Width, Height, Scale); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Lent the engine's view at %.0fx%.0f"), Width, Height); + return (void*)GEmbeddedView; +} + +void UnrealBridge_ResizeView(float Width, float Height, float Scale) +{ + if (GEmbeddedView == nil || Width <= 0.0f || Height <= 0.0f) + { + return; + } + + // Points here, unlike iOS. AppKit scales for the backing store itself, and + // multiplying by the scale factor a second time would render at four times + // the area on any Retina display. + [GEmbeddedView setFrame:NSMakeRect(0.0, 0.0, Width, Height)]; +} + +void UnrealBridge_DestroyView(void) +{ + if (GEmbeddedView == nil) + { + return; + } + + // Hand it back to the window that owns it. Releasing it while the engine + // still holds a viewport pointing at it would leave the renderer drawing + // into freed memory. + if (GEngineWindow != nil) + { + [GEngineWindow setContentView:GEmbeddedView]; + } + + [GEmbeddedView release]; + GEmbeddedView = nil; + GEngineWindow = nil; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Returned the engine's view")); +} + +int32_t UnrealBridge_IsViewReady(void) +{ + return GEmbeddedView != nil ? 1 : 0; +} + +} // extern "C" + +#endif // PLATFORM_MAC diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Public/FlutterBridge.h b/engines/unreal/plugin/Source/FlutterPlugin/Public/FlutterBridge.h index b74cfb6..3f27010 100644 --- a/engines/unreal/plugin/Source/FlutterPlugin/Public/FlutterBridge.h +++ b/engines/unreal/plugin/Source/FlutterPlugin/Public/FlutterBridge.h @@ -13,6 +13,12 @@ * Handles bidirectional communication, console commands, quality settings, * and level loading. */ +/** + * Every message from Flutter, whatever it was addressed to. + */ +DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FFlutterAnyMessage, + const FString&, Target, const FString&, Method, const FString&, Data); + UCLASS(Blueprintable, BlueprintType) class FLUTTERPLUGIN_API AFlutterBridge : public AActor { @@ -56,6 +62,22 @@ class FLUTTERPLUGIN_API AFlutterBridge : public AActor UFUNCTION(BlueprintImplementableEvent, Category = "Flutter") void OnMessageFromFlutter(const FString& Target, const FString& Method, const FString& Data); + /** + * Every message from Flutter, whatever it was addressed to. + * + * The same shape as the Flutter side, where GameWidget's onMessage receives + * everything and decides what to do with it. Bind this when you would + * rather switch on the target yourself than give an actor a name and + * register it, which is most of the time for a single-scene app. + * + * Fires for every message, including ones that a named target also handled, + * so binding it does not take delivery away from anything else. + * + * Assignable from Blueprint, and from C++ with AddDynamic. + */ + UPROPERTY(BlueprintAssignable, Category = "Flutter") + FFlutterAnyMessage OnAnyMessageFromFlutter; + // ============================================================ // MARK: - Binary Message Communication // ============================================================ diff --git a/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h b/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h new file mode 100644 index 0000000..bec1757 --- /dev/null +++ b/engines/unreal/plugin/Source/FlutterPlugin/Public/UnrealBridge.h @@ -0,0 +1,244 @@ +// +// UnrealBridge.h +// FlutterPlugin +// +// Flat C ABI across the UnrealFramework boundary. +// +// This header is deliberately free of Unreal types. The Flutter side compiles +// it inside the host app, which must not need CoreMinimal.h, UBT include paths +// or any engine symbols. Everything crossing the boundary is a C primitive. +// +// Direction of travel: +// Flutter -> Unreal UnrealBridge_SendToUnreal and friends +// Unreal -> Flutter callbacks registered with UnrealBridge_Set*Callback +// +// Threading: every UnrealBridge_* entry point is safe to call from any thread +// and hops to the game thread internally. Callbacks fire on the GAME thread, +// so the Flutter side must marshal to the main thread before touching UIKit. +// + +#ifndef UnrealBridge_h +#define UnrealBridge_h + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// Exported from UnrealFramework so the host app can link against it. +#define UNREALBRIDGE_API __attribute__((visibility("default"))) + +// ============================================================ +// MARK: - Unreal to Flutter +// ============================================================ + +/// A string message from Unreal. Pointers are valid only for the duration of +/// the call; copy anything you need to keep. +typedef void (*UnrealMessageCallback)(const char* target, + const char* method, + const char* data); + +/// Binary payload from Unreal. `data` is valid only for the duration of the +/// call. `checksum` is the CRC32 Unreal computed over the payload. +typedef void (*UnrealBinaryCallback)(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +/// Register callbacks. Pass NULL to unregister. Registering replaces any +/// previous callback rather than chaining. +/// +/// There is no separate level-loaded callback: Unreal reports level loads +/// through the message callback with target "FlutterBridge" and method +/// "onLevelLoaded", carrying the level name as data. +UNREALBRIDGE_API void UnrealBridge_SetMessageCallback(UnrealMessageCallback callback); +UNREALBRIDGE_API void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback callback); + +// ============================================================ +// MARK: - Flutter to Unreal +// ============================================================ + +UNREALBRIDGE_API void UnrealBridge_SendToUnreal(const char* target, + const char* method, + const char* data); + +UNREALBRIDGE_API void UnrealBridge_SendBinaryToUnreal(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +UNREALBRIDGE_API void UnrealBridge_ExecuteConsoleCommand(const char* command); + +UNREALBRIDGE_API void UnrealBridge_LoadLevel(const char* levelName); + +/// Quality levels are 0 to 4 (Low, Medium, High, Epic, Cinematic). Pass -1 for +/// any value that should be left alone. +UNREALBRIDGE_API void UnrealBridge_ApplyQualitySettings(int32_t qualityLevel, + int32_t antiAliasing, + int32_t shadow, + int32_t postProcess, + int32_t texture, + int32_t effects, + int32_t foliage, + int32_t viewDistance); + +/// Read the current quality settings into a caller-owned array, in this order: +/// +/// 0 antiAliasing, 1 shadow, 2 postProcess, 3 texture, +/// 4 effects, 5 foliage, 6 viewDistance +/// +/// Note this is the Apply order minus the leading overall quality level, which +/// Unreal exposes no getter for. Returns the number of values written, or 0 if +/// the bridge is not ready or `capacity` is too small. +UNREALBRIDGE_API int32_t UnrealBridge_GetQualitySettings(int32_t* outValues, + int32_t capacity); + +/// Number of values UnrealBridge_GetQualitySettings writes when it succeeds. +#define UNREALBRIDGE_QUALITY_VALUE_COUNT 7 + +// ============================================================ +// MARK: - Engine lifecycle +// ============================================================ +// +// In an embedded build Unreal does not own main() or the run loop, so the host +// has to drive the engine. Unreal exposes this as FEmbeddedCommunication, which +// building as a framework switches on via BUILD_EMBEDDED_APP. +// +// The sequence is: call UnrealBridge_Init once, early, then UnrealBridge_Tick +// every frame from the thread that owns the engine. Between ticks the engine +// sleeps unless something asks it to stay awake. + +/// Bring up the embedded engine plumbing. Safe to call more than once; only +/// the first call does anything. +/// +/// This sets up messaging only. It does not start the engine. +UNREALBRIDGE_API void UnrealBridge_Init(void); + +/// Start Unreal. Call once, from the main thread, after UnrealBridge_Init. +/// +/// In an embedded build Unreal's own launch path never runs, because the host +/// owns main() and the app delegate. This is the replacement: it starts the +/// game thread, after which the engine boots and eventually announces that a +/// render view can be made. +/// +/// Two hard requirements, both enforced by the engine rather than by us: +/// +/// - The application's delegate must be, or subclass, IOSAppDelegate. Unreal +/// logs this Fatal: "Currently, a native app embedding Unreal must have the +/// AppDelegate subclass from IOSAppDelegate." +/// - It must be called on the main thread, before any view is requested. +/// +/// Returns non-zero if the engine was started. +UNREALBRIDGE_API int32_t UnrealBridge_StartEngine(void); + +/// Advance the engine by [deltaSeconds]. Call from the thread that owns the +/// engine, once per frame. Returns non-zero if the engine did work and wants to +/// be ticked again promptly. +/// +/// A host with a display link should pass the real frame delta rather than a +/// fixed step, so the engine's own timing matches the display it renders to. +UNREALBRIDGE_API int32_t UnrealBridge_Tick(float deltaSeconds); + +/// Nudge the game thread when something has been queued for it. +UNREALBRIDGE_API void UnrealBridge_WakeGameThread(void); + +/// Hold the engine awake, or let it sleep again. Calls pair by `requester`, and +/// repeated calls with the same requester must agree on `needsRendering`. +/// Without at least one requester the engine idles between ticks, which is the +/// point: an embedded engine on a mostly static screen should not burn a core. +UNREALBRIDGE_API void UnrealBridge_KeepAwake(const char* requester, + int32_t needsRendering); +UNREALBRIDGE_API void UnrealBridge_AllowSleep(const char* requester); + +/// Whether the engine currently wants ticking, and whether it wants rendering. +/// A host can skip work when both are false. +UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForTicking(void); +UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForRendering(void); + +// ============================================================ +// MARK: - Rendering surface +// ============================================================ +// +// iOS only. Unreal's embedded mode expects the host to create the view the +// engine renders into and hand it over, which LaunchIOS.cpp states directly: +// "For embedded apps, the UEEmbeddedView must have been created and set into +// the AppDelegate as IOSView". +// +// So the framework builds an FIOSView, registers it with the app delegate, and +// returns it here as an opaque pointer. The host casts it to UIView* and puts +// it in its own hierarchy, which is how it ends up inside a Flutter widget. +// Unreal renders into the view's CAMetalLayer directly, so nothing is copied +// per frame. +// +// macOS has no equivalent. bShouldCompileAsDLL does not define +// BUILD_EMBEDDED_APP there and no Mac runtime code honours it, so these return +// NULL and do nothing. + +/// Fired once the engine has loaded its config and can build a render view. +/// +/// Unreal announces this itself: FAppEntry broadcasts an "inisareready" command +/// on the embedded-to-native channel, with a comment saying it means "the View +/// can be made if it was waiting to create the view". Creating the view before +/// that point is the timing bug this exists to avoid. +/// +/// Fires on the game thread, so marshal before touching UIKit. +typedef void (*UnrealEngineReadyCallback)(void); + +/// Register interest in that signal. If the engine has already announced it, +/// the callback fires immediately rather than never, so a host that registers +/// late is not left waiting. Pass NULL to unregister. +UNREALBRIDGE_API void UnrealBridge_SetEngineReadyCallback( + UnrealEngineReadyCallback callback); + +/// Whether the engine has announced it. Polling alternative to the callback. +UNREALBRIDGE_API int32_t UnrealBridge_IsReadyForView(void); + +/// Create the engine's render view, or return the existing one. +/// +/// Returns NULL until UnrealBridge_IsReadyForView reports non-zero, because the +/// engine has not read the config the view depends on yet. +/// +/// Must be called from the main thread. Returns a UIView* as an opaque +/// pointer, owned by the engine's app delegate: retain it if you need to, but +/// do not release it. Returns NULL on macOS, or if the engine could not make +/// the view. +/// +/// Sizes are in points; [scale] is the display scale, normally +/// UIScreen.main.scale. +UNREALBRIDGE_API void* UnrealBridge_CreateView(float width, float height, + float scale); + +/// Tell the engine the view's size changed. Main thread. +UNREALBRIDGE_API void UnrealBridge_ResizeView(float width, float height, + float scale); + +/// Tear the view down. Main thread. The pointer from UnrealBridge_CreateView is +/// dead after this. +UNREALBRIDGE_API void UnrealBridge_DestroyView(void); + +/// Whether the view exists and its framebuffer is ready for the RHI. Rendering +/// only actually happens once this returns non-zero. +UNREALBRIDGE_API int32_t UnrealBridge_IsViewReady(void); + +// ============================================================ +// MARK: - Host lifecycle +// ============================================================ + +UNREALBRIDGE_API void UnrealBridge_Pause(int32_t paused); + +/// Tear the bridge down. Clears the registered callbacks and tells Unreal the +/// host is going away. +UNREALBRIDGE_API void UnrealBridge_Stop(void); + +/// Whether an AFlutterBridge actor has registered itself. Everything above is +/// safe to call when this returns 0, it just does nothing. +UNREALBRIDGE_API int32_t UnrealBridge_IsReady(void); + +#ifdef __cplusplus +} +#endif + +#endif /* UnrealBridge_h */ diff --git a/example/.game.yml b/example/.game.yml index 55953d0..cc5a083 100644 --- a/example/.game.yml +++ b/example/.game.yml @@ -6,6 +6,34 @@ version: 1.0.0 # Engine configurations engines: + unreal: + project_path: unreal/demo + export_path: unreal/demo_exports + + export_settings: + development: false + build_configuration: Shipping + + platforms: + android: + enabled: true + target_path: android/unrealLibrary + + ios: + enabled: true + target_path: ios/UnrealFramework.framework + + macos: + enabled: true + target_path: macos/UnrealFramework.framework + + windows: + enabled: false + target_path: windows/unreal_build + + linux: + enabled: false + target_path: linux/unreal_build unity: # Path to your Unity project project_path: unity/demo/Demo diff --git a/example/ios/Podfile b/example/ios/Podfile index 8038819..d45e97d 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '13.0' +platform :ios, '15.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -36,6 +36,30 @@ target 'Runner' do end post_install do |installer| + # >>> gameframework: unreal content >>> + installer.aggregate_targets.each do |aggregate| + aggregate.user_project.targets.each do |target| + next unless target.name == 'Runner' + next if target.shell_script_build_phases.any? { |p| p.name == 'Copy Unreal content' } + + phase = target.new_shell_script_build_phase('Copy Unreal content') + phase.shell_script = <<~SCRIPT + set -e + CONTENT="${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/gameframework_unreal.framework" + DEST="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" + if [ -d "${CONTENT}/cookeddata" ]; then + rsync -a --delete "${CONTENT}/cookeddata" "${DEST}/" + for f in uecommandline.txt mute.caf notices.txt; do + [ -f "${CONTENT}/${f}" ] && cp -f "${CONTENT}/${f}" "${DEST}/" + done + else + echo "warning: no Unreal cooked content; run 'game sync unreal -p ios'" + fi + SCRIPT + end + aggregate.user_project.save + end + # <<< gameframework: unreal content <<< installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) end diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 5361801..23964ba 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -455,7 +455,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -590,7 +590,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -643,7 +643,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 6266644..b2bc6df 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -1,13 +1,27 @@ import Flutter import UIKit +// Unreal will not start unless the app delegate descends from IOSAppDelegate. +// It says so as a Fatal. IOSAppDelegate is declared for us by +// UnrealAppDelegate.h, which Runner-Bridging-Header.h imports, so no engine +// headers are needed here. +// +// See engines/unreal/dart/ios/INTEGRATION.md. In particular: do not declare a +// 'window' property. Unreal's Window is filled in by UIKit calling setWindow:, +// and declaring your own takes that selector over. @main -@objc class AppDelegate: FlutterAppDelegate { +class AppDelegate: IOSAppDelegate { override func application( _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + // Not calling super: in an embedded build that starts Unreal the + // non-embedded way and fights with the pod, which starts it for us. + if let controller = unrealWindow?.rootViewController as? FlutterViewController { + GeneratedPluginRegistrant.register(with: controller) + } else { + NSLog("AppDelegate: no FlutterViewController to register plugins with") + } + return true } } diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h index 308a2a5..8f655f1 100644 --- a/example/ios/Runner/Runner-Bridging-Header.h +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -1 +1,5 @@ #import "GeneratedPluginRegistrant.h" + +// Declares Unreal's IOSAppDelegate so AppDelegate.swift can subclass it, which +// the engine requires of any app embedding it. +#import diff --git a/example/unreal/demo/Config/DefaultEngine.ini b/example/unreal/demo/Config/DefaultEngine.ini new file mode 100644 index 0000000..9d2d9ad --- /dev/null +++ b/example/unreal/demo/Config/DefaultEngine.ini @@ -0,0 +1,49 @@ +[/Script/Engine.Engine] ++ActiveGameNameRedirects=(OldGameName="TP_Blank",NewGameName="/Script/GameFrameworkProject") + +[/Script/EngineSettings.GameMapsSettings] +; A scaffolded project has no level of its own, and an empty GameDefaultMap +; makes the engine boot all the way through renderer init and then fail with +; "Failed to load package ''". /Engine/Maps/Entry is the engine's own minimal +; map, so there is something to load until you make your own. +GameDefaultMap=/Engine/Maps/Entry +EditorStartupMap=/Engine/Maps/Entry +GlobalDefaultGameMode=/Script/GameFrameworkProject.FlutterGameMode + +[/Script/AndroidRuntimeSettings.AndroidRuntimeSettings] +PackageName=com.gameframework.project +StoreVersion=1 +StoreVersionOffsetArm64=0 +StoreVersionOffsetX8664=0 +bBuildForES31=False +bSupportsVulkan=True +MinSDKVersion=24 +TargetSDKVersion=33 +bPackageDataInsideApk=True + +[/Script/IOSRuntimeSettings.IOSRuntimeSettings] +; Build the game as UnrealGame.framework rather than a standalone .app, so a +; Flutter host can link it. UEBuildIOS.ResetTarget copies this into +; bShouldCompileAsDLL, which makes IOSToolChain link with -dynamiclib and an +; @executable_path/Frameworks install name, and defines BUILD_EMBEDDED_APP=1. +bBuildAsFramework=True +BundleIdentifier=com.gameframework.project +bAutomaticSigning=True +IOSTeamID=L23W97HT75 +bShipForBitcode=False +MinimumiOSVersion=15.0 + +[/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings] +bEnablePlugin=True +bAllowNetworkConnection=True +SecurityToken=7F175977D44486A0C7A97CAC1552AC63 +bIncludeInShipping=False +bAllowExternalStartInShipping=False +bCompileAFSProject=False +bUseCompression=False +bLogFiles=False +bReportStats=False +ConnectionType=USBOnly +bUseManualIPAddress=False +ManualIPAddress= + diff --git a/example/unreal/demo/Config/DefaultGame.ini b/example/unreal/demo/Config/DefaultGame.ini new file mode 100644 index 0000000..358d5e9 --- /dev/null +++ b/example/unreal/demo/Config/DefaultGame.ini @@ -0,0 +1,9 @@ +[/Script/EngineSettings.GeneralProjectSettings] +ProjectID=(A=0,B=0,C=0,D=0) +ProjectName=Game Framework Project +CompanyName=xraph +ProjectVersion=1.0.0 +Description=GameFramework - Unreal Project + +[/Script/UnrealEd.ProjectPackagingSettings] +BlueprintNativizationMethod=Disabled diff --git a/example/unreal/demo/Config/DefaultInput.ini b/example/unreal/demo/Config/DefaultInput.ini new file mode 100644 index 0000000..b6c149d --- /dev/null +++ b/example/unreal/demo/Config/DefaultInput.ini @@ -0,0 +1,8 @@ +[/Script/Engine.InputSettings] +-AxisConfig=(AxisKeyName="Gamepad_LeftX",AxisProperties=(DeadZone=0.25,Sensitivity=1.f,Exponent=1.f,bInvert=False)) ++AxisConfig=(AxisKeyName="Gamepad_LeftX",AxisProperties=(DeadZone=0.25,Sensitivity=1.f,Exponent=1.f,bInvert=False)) + +; No on-screen joysticks. The engine adds a default touch interface on mobile, +; which is right for a game you drive by hand and wrong for a view embedded in a +; Flutter app, where the controls are Flutter widgets sitting on top of it. +DefaultTouchInterface=None diff --git a/example/unreal/demo/GameFrameworkProject.uproject b/example/unreal/demo/GameFrameworkProject.uproject new file mode 100644 index 0000000..f88fec3 --- /dev/null +++ b/example/unreal/demo/GameFrameworkProject.uproject @@ -0,0 +1,19 @@ +{ + "FileVersion": 3, + "EngineAssociation": "5.3", + "Category": "", + "Description": "GameFramework - Unreal Project", + "Modules": [ + { + "Name": "GameFrameworkProject", + "Type": "Runtime", + "LoadingPhase": "Default" + } + ], + "Plugins": [ + { + "Name": "FlutterPlugin", + "Enabled": true + } + ] +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/FlutterPlugin.uplugin b/example/unreal/demo/Plugins/FlutterPlugin/FlutterPlugin.uplugin new file mode 100644 index 0000000..637fd90 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/FlutterPlugin.uplugin @@ -0,0 +1,31 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "0.5.0", + "FriendlyName": "Flutter Bridge Plugin", + "Description": "Enables bidirectional communication between Flutter and Unreal Engine", + "Category": "Networking", + "CreatedBy": "xraph", + "CreatedByURL": "https://github.com/xraph/gameframework", + "DocsURL": "", + "MarketplaceURL": "", + "SupportURL": "https://github.com/xraph/gameframework/issues", + "CanContainContent": true, + "IsBetaVersion": false, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "FlutterPlugin", + "Type": "Runtime", + "LoadingPhase": "PreDefault", + "PlatformAllowList": [ + "Win64", + "Mac", + "Linux", + "IOS", + "Android" + ] + } + ] +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin.Build.cs b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin.Build.cs new file mode 100644 index 0000000..372df0e --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin.Build.cs @@ -0,0 +1,90 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class FlutterPlugin : ModuleRules +{ + public FlutterPlugin(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + "CoreUObject", + "Engine", + "RHI", + "RenderCore", + "Json", + "JsonUtilities", + // ... add other public dependencies that you statically link with here ... + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "Slate", + "SlateCore", + // ... add private dependencies that you statically link with here ... + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + + // Platform-specific settings + if (Target.Platform == UnrealTargetPlatform.Android) + { + PrivateDependencyModuleNames.Add("Launch"); + + string PluginPath = Utils.MakePathRelativeTo(ModuleDirectory, Target.RelativeEnginePath); + AdditionalPropertiesForReceipt.Add("AndroidPlugin", System.IO.Path.Combine(PluginPath, "FlutterPlugin_Android_UPL.xml")); + } + else if (Target.Platform == UnrealTargetPlatform.IOS) + { + PublicFrameworks.AddRange( + new string[] + { + "UIKit", + "Foundation", + "Metal", + "MetalKit" + } + ); + } + else if (Target.Platform == UnrealTargetPlatform.Mac) + { + PublicFrameworks.AddRange( + new string[] + { + "Cocoa", + "Foundation", + "Metal", + "MetalKit" + } + ); + } + } +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml new file mode 100644 index 0000000..6d1664d --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/FlutterPlugin_Android_UPL.xml @@ -0,0 +1,370 @@ + + + + + + + + + + + + + + + + +-keep class com.epicgames.unreal.GameActivity { + public static <methods>; +} +-keepclasseswithmembernames class com.epicgames.unreal.GameActivity { + native <methods>; +} + + + + + + + + + + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.view.SurfaceHolder; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Android/FlutterBridge_Android.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Android/FlutterBridge_Android.cpp new file mode 100644 index 0000000..0a638fa --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Android/FlutterBridge_Android.cpp @@ -0,0 +1,781 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" + +#if PLATFORM_ANDROID + +#include "Android/AndroidJNI.h" +#include "Android/AndroidApplication.h" +#include "Android/AndroidWindow.h" +#include "Engine/Engine.h" +#include "Engine/GameViewportClient.h" +#include "Slate/SceneViewport.h" +#include "RenderingThread.h" +#include +#include +#include + +// Global reference to the Java UnrealEngineController instance +static jobject GUnrealEngineControllerInstance = nullptr; +static jclass GUnrealEngineControllerClass = nullptr; + +// Cached method IDs for callbacks +static jmethodID GOnMessageFromUnrealMethodID = nullptr; +static jmethodID GOnLevelLoadedMethodID = nullptr; + +// Reference to FlutterBridge instance +static AFlutterBridge* GFlutterBridgeInstance = nullptr; + +// Native window for rendering (from Flutter's SurfaceView) +static ANativeWindow* GNativeWindow = nullptr; +static int32 GSurfaceWidth = 0; +static int32 GSurfaceHeight = 0; + +// ============================================================ +// MARK: - Helper Functions +// ============================================================ + +/** + * Convert FString to jstring + */ +jstring FStringToJString(JNIEnv* Env, const FString& String) +{ + if (!Env) + { + return nullptr; + } + + // Use FTCHARToUTF8 converter to avoid dangling pointer from TCHAR_TO_UTF8 macro + FTCHARToUTF8 Converter(*String); + return Env->NewStringUTF(Converter.Get()); +} + +/** + * Convert jstring to FString + */ +FString JStringToFString(JNIEnv* Env, jstring JavaString) +{ + if (!Env || !JavaString) + { + return FString(); + } + + const char* UTFString = Env->GetStringUTFChars(JavaString, nullptr); + FString Result(UTF8_TO_TCHAR(UTFString)); + Env->ReleaseStringUTFChars(JavaString, UTFString); + + return Result; +} + +/** + * Convert Java Map to TMap + */ +TMap JMapToTMap(JNIEnv* Env, jobject JavaMap) +{ + TMap Result; + + if (!Env || !JavaMap) + { + return Result; + } + + // Get Map class and methods + jclass MapClass = Env->FindClass("java/util/Map"); + jmethodID EntrySetMethod = Env->GetMethodID(MapClass, "entrySet", "()Ljava/util/Set;"); + + // Get Set class and methods + jclass SetClass = Env->FindClass("java/util/Set"); + jmethodID IteratorMethod = Env->GetMethodID(SetClass, "iterator", "()Ljava/util/Iterator;"); + + // Get Iterator class and methods + jclass IteratorClass = Env->FindClass("java/util/Iterator"); + jmethodID HasNextMethod = Env->GetMethodID(IteratorClass, "hasNext", "()Z"); + jmethodID NextMethod = Env->GetMethodID(IteratorClass, "next", "()Ljava/lang/Object;"); + + // Get Map.Entry class and methods + jclass EntryClass = Env->FindClass("java/util/Map$Entry"); + jmethodID GetKeyMethod = Env->GetMethodID(EntryClass, "getKey", "()Ljava/lang/Object;"); + jmethodID GetValueMethod = Env->GetMethodID(EntryClass, "getValue", "()Ljava/lang/Object;"); + + // Iterate through the map + jobject EntrySet = Env->CallObjectMethod(JavaMap, EntrySetMethod); + jobject Iterator = Env->CallObjectMethod(EntrySet, IteratorMethod); + + while (Env->CallBooleanMethod(Iterator, HasNextMethod)) + { + jobject Entry = Env->CallObjectMethod(Iterator, NextMethod); + jstring Key = (jstring)Env->CallObjectMethod(Entry, GetKeyMethod); + jobject Value = Env->CallObjectMethod(Entry, GetValueMethod); + + FString KeyString = JStringToFString(Env, Key); + FString ValueString; + + // Handle different value types + if (Value) + { + jclass ObjectClass = Env->GetObjectClass(Value); + jmethodID ToStringMethod = Env->GetMethodID(ObjectClass, "toString", "()Ljava/lang/String;"); + jstring ValueStr = (jstring)Env->CallObjectMethod(Value, ToStringMethod); + ValueString = JStringToFString(Env, ValueStr); + Env->DeleteLocalRef(ValueStr); + Env->DeleteLocalRef(ObjectClass); + } + + Result.Add(KeyString, ValueString); + + Env->DeleteLocalRef(Key); + Env->DeleteLocalRef(Entry); + } + + Env->DeleteLocalRef(Iterator); + Env->DeleteLocalRef(EntrySet); + Env->DeleteLocalRef(MapClass); + Env->DeleteLocalRef(SetClass); + Env->DeleteLocalRef(IteratorClass); + Env->DeleteLocalRef(EntryClass); + + return Result; +} + +/** + * Convert TMap to Java HashMap + */ +jobject TMapToJMap(JNIEnv* Env, const TMap& Map) +{ + if (!Env) + { + return nullptr; + } + + // Create HashMap + jclass HashMapClass = Env->FindClass("java/util/HashMap"); + jmethodID HashMapConstructor = Env->GetMethodID(HashMapClass, "", "()V"); + jmethodID PutMethod = Env->GetMethodID(HashMapClass, "put", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); + + jobject HashMap = Env->NewObject(HashMapClass, HashMapConstructor); + + // Get Integer class + jclass IntegerClass = Env->FindClass("java/lang/Integer"); + jmethodID IntegerConstructor = Env->GetMethodID(IntegerClass, "", "(I)V"); + + // Add all entries + for (const auto& Entry : Map) + { + jstring Key = FStringToJString(Env, Entry.Key); + jobject Value = Env->NewObject(IntegerClass, IntegerConstructor, Entry.Value); + Env->CallObjectMethod(HashMap, PutMethod, Key, Value); + Env->DeleteLocalRef(Key); + Env->DeleteLocalRef(Value); + } + + Env->DeleteLocalRef(HashMapClass); + Env->DeleteLocalRef(IntegerClass); + + return HashMap; +} + +// ============================================================ +// MARK: - JNI Native Method Implementations +// ============================================================ + +extern "C" +{ + /** + * Create Unreal Engine instance + */ + JNIEXPORT jboolean JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeCreate( + JNIEnv* Env, jobject Obj, jobject Config) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeCreate called")); + + // Store controller instance + if (!GUnrealEngineControllerInstance) + { + GUnrealEngineControllerInstance = Env->NewGlobalRef(Obj); + GUnrealEngineControllerClass = (jclass)Env->NewGlobalRef(Env->GetObjectClass(Obj)); + + // Cache method IDs + GOnMessageFromUnrealMethodID = Env->GetMethodID( + GUnrealEngineControllerClass, + "onMessageFromUnreal", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V" + ); + + GOnLevelLoadedMethodID = Env->GetMethodID( + GUnrealEngineControllerClass, + "onLevelLoaded", + "(Ljava/lang/String;I)V" + ); + } + + // Parse config (if needed) + // TMap ConfigMap = JMapToTMap(Env, Config); + + // Unreal Engine initialization happens automatically + // This is called after Unreal has already started + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Unreal Engine initialized")); + + return true; + } + + /** + * Get the native Unreal view + * + * On Android, Unreal uses a NativeActivity with its own SurfaceView. + * We try to get the content view from the current activity's window. + */ + JNIEXPORT jobject JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeGetView( + JNIEnv* Env, jobject Obj) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeGetView called")); + + // Get the main activity from AndroidApplication + jobject Activity = FAndroidApplication::GetGameActivityThis(); + if (!Activity) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] No game activity found")); + return nullptr; + } + + // Try to get the content view from the activity's window + // Activity.getWindow().getDecorView() + jclass ActivityClass = Env->GetObjectClass(Activity); + if (!ActivityClass) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Failed to get activity class")); + return nullptr; + } + + jmethodID GetWindowMethod = Env->GetMethodID(ActivityClass, "getWindow", "()Landroid/view/Window;"); + if (!GetWindowMethod) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Failed to get getWindow method")); + Env->DeleteLocalRef(ActivityClass); + return nullptr; + } + + jobject Window = Env->CallObjectMethod(Activity, GetWindowMethod); + if (!Window) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Failed to get window")); + Env->DeleteLocalRef(ActivityClass); + return nullptr; + } + + jclass WindowClass = Env->GetObjectClass(Window); + jmethodID GetDecorViewMethod = Env->GetMethodID(WindowClass, "getDecorView", "()Landroid/view/View;"); + if (!GetDecorViewMethod) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Failed to get getDecorView method")); + Env->DeleteLocalRef(WindowClass); + Env->DeleteLocalRef(Window); + Env->DeleteLocalRef(ActivityClass); + return nullptr; + } + + jobject DecorView = Env->CallObjectMethod(Window, GetDecorViewMethod); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Got decor view: %p"), DecorView); + + // Clean up local references + Env->DeleteLocalRef(WindowClass); + Env->DeleteLocalRef(Window); + Env->DeleteLocalRef(ActivityClass); + + // Return a global reference to the view + if (DecorView) + { + return Env->NewGlobalRef(DecorView); + } + + return nullptr; + } + + /** + * Set the rendering surface from Kotlin's SurfaceView + * + * This is the key function for Flutter integration - Kotlin creates a SurfaceView + * and passes the Surface to native code. We convert it to ANativeWindow which + * can be used by Unreal for rendering. + * + * @param Surface The Android Surface object, or null to clear + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeSetSurface( + JNIEnv* Env, jobject Obj, jobject Surface) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeSetSurface called")); + + // Release previous native window if any + if (GNativeWindow != nullptr) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Releasing previous native window")); + ANativeWindow_release(GNativeWindow); + GNativeWindow = nullptr; + } + + if (Surface != nullptr) + { + // Get ANativeWindow from the Surface + GNativeWindow = ANativeWindow_fromSurface(Env, Surface); + + if (GNativeWindow != nullptr) + { + // Get surface dimensions + GSurfaceWidth = ANativeWindow_getWidth(GNativeWindow); + GSurfaceHeight = ANativeWindow_getHeight(GNativeWindow); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Native window created: %dx%d"), + GSurfaceWidth, GSurfaceHeight); + + // Configure Unreal to render to this window + // This is the key integration point - tell Unreal's Android window system + // about our external surface + + // Acquire an additional reference since Unreal will manage this + ANativeWindow_acquire(GNativeWindow); + + // Try to set the hardware window for rendering + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Setting hardware window...")); + + // Check if window is different from current + void* CurrentWindow = FAndroidWindow::GetHardwareWindow_EventThread(); + if (CurrentWindow != GNativeWindow) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Calling SetHardwareWindow_EventThread")); + + // Set the hardware window - this tells Unreal's RHI where to render + FAndroidWindow::SetHardwareWindow_EventThread(GNativeWindow); + + // Set window dimensions to trigger proper initialization + FAndroidWindow::SetWindowDimensions_EventThread(GNativeWindow); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Hardware window set: %dx%d"), + GSurfaceWidth, GSurfaceHeight); + } + else + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Hardware window already set, updating dimensions")); + FAndroidWindow::SetWindowDimensions_EventThread(GNativeWindow); + } + + // Notify FlutterBridge if available + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnSurfaceReady(GSurfaceWidth, GSurfaceHeight); + } + } + else + { + UE_LOG(LogTemp, Error, TEXT("[FlutterBridge_Android] Failed to get native window from surface")); + } + } + else + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Surface cleared")); + GSurfaceWidth = 0; + GSurfaceHeight = 0; + + // Clear the hardware window + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Clearing hardware window")); + FAndroidWindow::SetHardwareWindow_EventThread(nullptr); + + // Notify FlutterBridge if available + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnSurfaceDestroyed(); + } + } + } + + /** + * Handle surface dimension changes + * + * Called when the SurfaceView dimensions change (e.g., rotation, resize) + * + * @param Width New surface width + * @param Height New surface height + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeSurfaceChanged( + JNIEnv* Env, jobject Obj, jint Width, jint Height) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeSurfaceChanged: %dx%d"), Width, Height); + + GSurfaceWidth = Width; + GSurfaceHeight = Height; + + if (GNativeWindow != nullptr) + { + // Update the native window buffer geometry if needed + // ANativeWindow_setBuffersGeometry(GNativeWindow, Width, Height, WINDOW_FORMAT_RGBA_8888); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Surface dimensions updated")); + } + + // Notify FlutterBridge of size change + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnSurfaceSizeChanged(Width, Height); + } + } + + /** + * Get the current native window (for use by other Unreal systems) + */ + ANativeWindow* FlutterBridge_GetNativeWindow() + { + return GNativeWindow; + } + + /** + * Get the current surface dimensions + */ + void FlutterBridge_GetSurfaceSize(int32& OutWidth, int32& OutHeight) + { + OutWidth = GSurfaceWidth; + OutHeight = GSurfaceHeight; + } + + /** + * Pause the engine + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativePause( + JNIEnv* Env, jobject Obj) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativePause called")); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnEnginePause(); + } + + // Pause Unreal Engine rendering + // This will be handled by Unreal's lifecycle automatically + } + + /** + * Resume the engine + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeResume( + JNIEnv* Env, jobject Obj) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeResume called")); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnEngineResume(); + } + + // Resume Unreal Engine rendering + // This will be handled by Unreal's lifecycle automatically + } + + /** + * Quit the engine + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeQuit( + JNIEnv* Env, jobject Obj) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeQuit called")); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->OnEngineQuit(); + } + + // Clean up global references + if (GUnrealEngineControllerInstance) + { + Env->DeleteGlobalRef(GUnrealEngineControllerInstance); + GUnrealEngineControllerInstance = nullptr; + } + + if (GUnrealEngineControllerClass) + { + Env->DeleteGlobalRef(GUnrealEngineControllerClass); + GUnrealEngineControllerClass = nullptr; + } + + GFlutterBridgeInstance = nullptr; + } + + /** + * Send message to Unreal + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeSendMessage( + JNIEnv* Env, jobject Obj, jstring Target, jstring Method, jstring Data) + { + FString TargetString = JStringToFString(Env, Target); + FString MethodString = JStringToFString(Env, Method); + FString DataString = JStringToFString(Env, Data); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeSendMessage: Target=%s, Method=%s"), + *TargetString, *MethodString); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->ReceiveFromFlutter(TargetString, MethodString, DataString); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] FlutterBridge instance not set")); + } + } + + /** + * Execute console command + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeExecuteConsoleCommand( + JNIEnv* Env, jobject Obj, jstring Command) + { + FString CommandString = JStringToFString(Env, Command); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeExecuteConsoleCommand: %s"), *CommandString); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->ExecuteConsoleCommand(CommandString); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] FlutterBridge instance not set")); + } + } + + /** + * Load level + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeLoadLevel( + JNIEnv* Env, jobject Obj, jstring LevelName) + { + FString LevelNameString = JStringToFString(Env, LevelName); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeLoadLevel: %s"), *LevelNameString); + + if (GFlutterBridgeInstance) + { + GFlutterBridgeInstance->LoadLevel(LevelNameString); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] FlutterBridge instance not set")); + } + } + + /** + * Apply quality settings + */ + JNIEXPORT void JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeApplyQualitySettings( + JNIEnv* Env, jobject Obj, jobject Settings) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeApplyQualitySettings called")); + + if (!GFlutterBridgeInstance) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] FlutterBridge instance not set")); + return; + } + + // Parse settings map + TMap SettingsMap = JMapToTMap(Env, Settings); + + // Extract quality settings + int32 QualityLevel = SettingsMap.Contains(TEXT("qualityLevel")) ? + FCString::Atoi(*SettingsMap[TEXT("qualityLevel")]) : -1; + int32 AntiAliasing = SettingsMap.Contains(TEXT("antiAliasingQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("antiAliasingQuality")]) : -1; + int32 Shadow = SettingsMap.Contains(TEXT("shadowQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("shadowQuality")]) : -1; + int32 PostProcess = SettingsMap.Contains(TEXT("postProcessQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("postProcessQuality")]) : -1; + int32 Texture = SettingsMap.Contains(TEXT("textureQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("textureQuality")]) : -1; + int32 Effects = SettingsMap.Contains(TEXT("effectsQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("effectsQuality")]) : -1; + int32 Foliage = SettingsMap.Contains(TEXT("foliageQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("foliageQuality")]) : -1; + int32 ViewDistance = SettingsMap.Contains(TEXT("viewDistanceQuality")) ? + FCString::Atoi(*SettingsMap[TEXT("viewDistanceQuality")]) : -1; + + // Apply settings + GFlutterBridgeInstance->ApplyQualitySettings( + QualityLevel, + AntiAliasing, + Shadow, + PostProcess, + Texture, + Effects, + Foliage, + ViewDistance + ); + } + + /** + * Get quality settings + */ + JNIEXPORT jobject JNICALL + Java_com_xraph_gameframework_unreal_UnrealEngineController_nativeGetQualitySettings( + JNIEnv* Env, jobject Obj) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] nativeGetQualitySettings called")); + + if (!GFlutterBridgeInstance) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] FlutterBridge instance not set")); + return nullptr; + } + + // Get quality settings from Unreal + TMap Settings = GFlutterBridgeInstance->GetQualitySettings(); + + // Convert to Java HashMap + return TMapToJMap(Env, Settings); + } +} + +// ============================================================ +// MARK: - Callbacks from Unreal to Flutter (via Java) +// ============================================================ + +/** + * Send message to Flutter via Java + * Called from AFlutterBridge::SendToFlutter() + */ +void FlutterBridge_SendToFlutter_Android(const FString& Target, const FString& Method, const FString& Data) +{ + if (!GUnrealEngineControllerInstance || !GOnMessageFromUnrealMethodID) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Cannot send to Flutter: Java instance not initialized")); + return; + } + + JNIEnv* Env = FAndroidApplication::GetJavaEnv(); + if (!Env) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterBridge_Android] Failed to get JNI environment")); + return; + } + + // Convert strings + jstring jTarget = FStringToJString(Env, Target); + jstring jMethod = FStringToJString(Env, Method); + jstring jData = FStringToJString(Env, Data); + + // Call Java method + Env->CallVoidMethod( + GUnrealEngineControllerInstance, + GOnMessageFromUnrealMethodID, + jTarget, + jMethod, + jData + ); + + // Clean up local references + Env->DeleteLocalRef(jTarget); + Env->DeleteLocalRef(jMethod); + Env->DeleteLocalRef(jData); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Message sent to Flutter: Target=%s, Method=%s"), + *Target, *Method); +} + +/** + * Send binary data to Flutter via Java + * Called from AFlutterBridge::SendBinaryToFlutter() + */ +void FlutterBridge_SendBinaryToFlutter_Android(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + if (!GUnrealEngineControllerInstance) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Cannot send binary to Flutter: Java instance not initialized")); + return; + } + + JNIEnv* Env = FAndroidApplication::GetJavaEnv(); + if (!Env) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterBridge_Android] Failed to get JNI environment")); + return; + } + + // Convert strings + jstring jTarget = FStringToJString(Env, Target); + jstring jMethod = FStringToJString(Env, Method); + + // Create byte array + jbyteArray jData = Env->NewByteArray(Data.Num()); + if (jData && Data.Num() > 0) + { + Env->SetByteArrayRegion(jData, 0, Data.Num(), reinterpret_cast(Data.GetData())); + } + + // TODO: Call Java method for binary data once implemented in UnrealEngineController + // For now, log the binary data info + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Binary data to Flutter: Target=%s, Method=%s, Size=%d, Checksum=%d"), + *Target, *Method, Data.Num(), Checksum); + + // Clean up local references + Env->DeleteLocalRef(jTarget); + Env->DeleteLocalRef(jMethod); + if (jData) + { + Env->DeleteLocalRef(jData); + } +} + +/** + * Notify Flutter that a level has been loaded + */ +void FlutterBridge_NotifyLevelLoaded_Android(const FString& LevelName, int32 BuildIndex) +{ + if (!GUnrealEngineControllerInstance || !GOnLevelLoadedMethodID) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge_Android] Cannot notify level loaded: Java instance not initialized")); + return; + } + + JNIEnv* Env = FAndroidApplication::GetJavaEnv(); + if (!Env) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterBridge_Android] Failed to get JNI environment")); + return; + } + + // Convert level name + jstring jLevelName = FStringToJString(Env, LevelName); + + // Call Java method + Env->CallVoidMethod( + GUnrealEngineControllerInstance, + GOnLevelLoadedMethodID, + jLevelName, + BuildIndex + ); + + // Clean up local reference + Env->DeleteLocalRef(jLevelName); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] Level loaded notification sent: %s"), *LevelName); +} + +/** + * Set the FlutterBridge instance + * Called from AFlutterBridge::BeginPlay() + */ +void FlutterBridge_SetInstance_Android(AFlutterBridge* Instance) +{ + GFlutterBridgeInstance = Instance; + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Android] FlutterBridge instance set")); +} + +#endif // PLATFORM_ANDROID diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp new file mode 100644 index 0000000..93e0052 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterAssetManager.cpp @@ -0,0 +1,486 @@ +#include "FlutterAssetManager.h" +#include "FlutterBridge.h" +#include "Engine/World.h" +#include "Engine/Engine.h" +#include "Kismet/GameplayStatics.h" +#include "Engine/LevelStreaming.h" +#include "Misc/Paths.h" + +UFlutterAssetManager* UFlutterAssetManager::Instance = nullptr; + +UFlutterAssetManager::UFlutterAssetManager() +{ + Statistics = FFlutterAssetStatistics(); + CurrentProgress = FFlutterAssetProgress(); +} + +UFlutterAssetManager* UFlutterAssetManager::Get(UObject* WorldContextObject) +{ + if (!Instance) + { + Instance = NewObject(); + Instance->AddToRoot(); + } + return Instance; +} + +// ==================== ASSET LOADING ==================== + +void UFlutterAssetManager::LoadAsset(const FString& AssetPath) +{ + if (AssetPath.IsEmpty()) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterAssetManager] Empty asset path provided")); + return; + } + + // Check if already loaded + if (FFlutterLoadedAsset* ExistingAsset = LoadedAssets.Find(AssetPath)) + { + if (ExistingAsset->State == EFlutterAssetState::Loaded) + { + Statistics.CacheHits++; + OnAssetLoaded.Broadcast(AssetPath, ExistingAsset->Asset); + NotifyFlutterAssetLoaded(AssetPath); + return; + } + } + + Statistics.CacheMisses++; + + // Check if already loading + if (PendingLoads.Contains(AssetPath)) + { + UE_LOG(LogTemp, Log, TEXT("[FlutterAssetManager] Asset already loading: %s"), *AssetPath); + return; + } + + // Create loading entry + FFlutterLoadedAsset LoadingAsset; + LoadingAsset.AssetPath = AssetPath; + LoadingAsset.State = EFlutterAssetState::Loading; + LoadedAssets.Add(AssetPath, LoadingAsset); + + // Record start time for statistics + double StartTime = FPlatformTime::Seconds(); + + // Start async load + FSoftObjectPath SoftPath(AssetPath); + TSharedPtr Handle = StreamableManager.RequestAsyncLoad( + SoftPath, + FStreamableDelegate::CreateLambda([this, AssetPath, StartTime]() + { + // Calculate load time + int64 LoadTimeMs = (int64)((FPlatformTime::Seconds() - StartTime) * 1000.0); + + // Get the loaded asset + FSoftObjectPath SoftPath(AssetPath); + UObject* LoadedObject = SoftPath.ResolveObject(); + + if (LoadedObject) + { + // Update loaded asset entry + if (FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath)) + { + Entry->Asset = LoadedObject; + Entry->State = EFlutterAssetState::Loaded; + Entry->LoadTimeMs = LoadTimeMs; + Entry->SizeBytes = EstimateAssetSize(LoadedObject); + + // Update statistics + Statistics.TotalAssetsLoaded++; + Statistics.TotalBytesLoaded += Entry->SizeBytes; + Statistics.CurrentMemoryUsage += Entry->SizeBytes; + + // Update average load time + float TotalTime = Statistics.AverageLoadTimeMs * (Statistics.TotalAssetsLoaded - 1) + LoadTimeMs; + Statistics.AverageLoadTimeMs = TotalTime / Statistics.TotalAssetsLoaded; + } + + HandleAssetLoaded(AssetPath, LoadedObject); + } + else + { + // Handle failure + if (FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath)) + { + Entry->State = EFlutterAssetState::Failed; + } + + FString ErrorMessage = FString::Printf(TEXT("Failed to resolve asset: %s"), *AssetPath); + OnAssetFailed.Broadcast(AssetPath, ErrorMessage); + NotifyFlutterAssetFailed(AssetPath, ErrorMessage); + } + + // Remove from pending + PendingLoads.Remove(AssetPath); + UpdateProgress(); + }) + ); + + PendingLoads.Add(AssetPath, Handle); +} + +UObject* UFlutterAssetManager::LoadAssetSync(const FString& AssetPath) +{ + if (AssetPath.IsEmpty()) + { + return nullptr; + } + + // Check cache first + if (FFlutterLoadedAsset* ExistingAsset = LoadedAssets.Find(AssetPath)) + { + if (ExistingAsset->State == EFlutterAssetState::Loaded && ExistingAsset->Asset) + { + Statistics.CacheHits++; + return ExistingAsset->Asset; + } + } + + Statistics.CacheMisses++; + + // Synchronous load + FSoftObjectPath SoftPath(AssetPath); + UObject* LoadedObject = StreamableManager.LoadSynchronous(SoftPath); + + if (LoadedObject) + { + FFlutterLoadedAsset LoadedEntry; + LoadedEntry.AssetPath = AssetPath; + LoadedEntry.Asset = LoadedObject; + LoadedEntry.State = EFlutterAssetState::Loaded; + LoadedEntry.SizeBytes = EstimateAssetSize(LoadedObject); + + LoadedAssets.Add(AssetPath, LoadedEntry); + + Statistics.TotalAssetsLoaded++; + Statistics.TotalBytesLoaded += LoadedEntry.SizeBytes; + Statistics.CurrentMemoryUsage += LoadedEntry.SizeBytes; + } + + return LoadedObject; +} + +void UFlutterAssetManager::LoadAssets(const TArray& AssetPaths) +{ + if (AssetPaths.Num() == 0) + { + return; + } + + // Initialize batch progress + BatchLoadPaths = AssetPaths; + CurrentProgress.TotalAssets = AssetPaths.Num(); + CurrentProgress.LoadedAssets = 0; + CurrentProgress.FailedAssets = 0; + CurrentProgress.Progress = 0.0f; + + // Start loading all assets + for (const FString& Path : AssetPaths) + { + LoadAsset(Path); + } +} + +void UFlutterAssetManager::LoadLevel(const FString& LevelName, bool bAbsolute) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterAssetManager] Loading level: %s"), *LevelName); + + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (World) + { + UGameplayStatics::OpenLevel(World, *LevelName, bAbsolute); + + // Notify Flutter + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(World)) + { + Bridge->SendToFlutter(TEXT("AssetManager"), TEXT("onLevelLoaded"), LevelName); + } + } +} + +void UFlutterAssetManager::LoadLevelAsync(const FString& LevelName) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterAssetManager] Loading level async: %s"), *LevelName); + + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (World) + { + FLatentActionInfo LatentInfo; + LatentInfo.CallbackTarget = this; + LatentInfo.UUID = GetUniqueID(); + LatentInfo.Linkage = 0; + + UGameplayStatics::LoadStreamLevel(World, FName(*LevelName), true, false, LatentInfo); + } +} + +// ==================== ASSET UNLOADING ==================== + +void UFlutterAssetManager::UnloadAsset(const FString& AssetPath) +{ + if (FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath)) + { + // Update statistics + Statistics.TotalAssetsUnloaded++; + Statistics.CurrentMemoryUsage -= Entry->SizeBytes; + + // Remove from loaded assets + LoadedAssets.Remove(AssetPath); + + // Broadcast event + OnAssetUnloaded.Broadcast(AssetPath); + + UE_LOG(LogTemp, Log, TEXT("[FlutterAssetManager] Unloaded asset: %s"), *AssetPath); + } +} + +void UFlutterAssetManager::UnloadAssets(const TArray& AssetPaths) +{ + for (const FString& Path : AssetPaths) + { + UnloadAsset(Path); + } +} + +void UFlutterAssetManager::UnloadAllAssets() +{ + TArray AllPaths; + LoadedAssets.GetKeys(AllPaths); + UnloadAssets(AllPaths); +} + +void UFlutterAssetManager::UnloadLevel(const FString& LevelName) +{ + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (World) + { + FLatentActionInfo LatentInfo; + LatentInfo.CallbackTarget = this; + LatentInfo.UUID = GetUniqueID(); + LatentInfo.Linkage = 1; + + UGameplayStatics::UnloadStreamLevel(World, FName(*LevelName), LatentInfo, false); + } +} + +// ==================== ASSET QUERIES ==================== + +bool UFlutterAssetManager::IsAssetLoaded(const FString& AssetPath) const +{ + const FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath); + return Entry && Entry->State == EFlutterAssetState::Loaded; +} + +EFlutterAssetState UFlutterAssetManager::GetAssetState(const FString& AssetPath) const +{ + const FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath); + return Entry ? Entry->State : EFlutterAssetState::NotLoaded; +} + +UObject* UFlutterAssetManager::GetLoadedAsset(const FString& AssetPath) const +{ + const FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath); + return (Entry && Entry->State == EFlutterAssetState::Loaded) ? Entry->Asset : nullptr; +} + +FFlutterLoadedAsset UFlutterAssetManager::GetAssetInfo(const FString& AssetPath) const +{ + const FFlutterLoadedAsset* Entry = LoadedAssets.Find(AssetPath); + return Entry ? *Entry : FFlutterLoadedAsset(); +} + +TArray UFlutterAssetManager::GetLoadedAssetPaths() const +{ + TArray Paths; + for (const auto& Pair : LoadedAssets) + { + if (Pair.Value.State == EFlutterAssetState::Loaded) + { + Paths.Add(Pair.Key); + } + } + return Paths; +} + +// ==================== CACHE MANAGEMENT ==================== + +void UFlutterAssetManager::SetCacheMaxSize(int64 MaxSizeBytes) +{ + CacheMaxSizeBytes = MaxSizeBytes; + TrimCache(); +} + +int64 UFlutterAssetManager::GetCacheSize() const +{ + return Statistics.CurrentMemoryUsage; +} + +void UFlutterAssetManager::ClearCache() +{ + UnloadAllAssets(); + Statistics.CurrentMemoryUsage = 0; +} + +void UFlutterAssetManager::TrimCache() +{ + // If we're over the limit, unload oldest assets + while (Statistics.CurrentMemoryUsage > CacheMaxSizeBytes && LoadedAssets.Num() > 0) + { + // Find the oldest loaded asset (simple LRU - could be improved) + FString OldestPath; + int64 OldestTime = INT64_MAX; + + for (const auto& Pair : LoadedAssets) + { + if (Pair.Value.State == EFlutterAssetState::Loaded && Pair.Value.LoadTimeMs < OldestTime) + { + OldestTime = Pair.Value.LoadTimeMs; + OldestPath = Pair.Key; + } + } + + if (!OldestPath.IsEmpty()) + { + UnloadAsset(OldestPath); + } + else + { + break; + } + } +} + +// ==================== STATISTICS ==================== + +FFlutterAssetStatistics UFlutterAssetManager::GetStatistics() const +{ + return Statistics; +} + +void UFlutterAssetManager::ResetStatistics() +{ + // Keep current memory usage, reset everything else + int64 CurrentMemory = Statistics.CurrentMemoryUsage; + Statistics = FFlutterAssetStatistics(); + Statistics.CurrentMemoryUsage = CurrentMemory; +} + +// ==================== FLUTTER COMMUNICATION ==================== + +void UFlutterAssetManager::NotifyFlutterProgress(const FFlutterAssetProgress& Progress) +{ + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(World)) + { + FString ProgressJson = FString::Printf( + TEXT("{\"total\":%d,\"loaded\":%d,\"failed\":%d,\"progress\":%.2f}"), + Progress.TotalAssets, + Progress.LoadedAssets, + Progress.FailedAssets, + Progress.Progress + ); + Bridge->SendToFlutter(TEXT("AssetManager"), TEXT("onProgress"), ProgressJson); + } +} + +void UFlutterAssetManager::NotifyFlutterAssetLoaded(const FString& AssetPath) +{ + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(World)) + { + Bridge->SendToFlutter(TEXT("AssetManager"), TEXT("onAssetLoaded"), AssetPath); + } +} + +void UFlutterAssetManager::NotifyFlutterAssetFailed(const FString& AssetPath, const FString& ErrorMessage) +{ + UWorld* World = GEngine ? GEngine->GetWorldContexts()[0].World() : nullptr; + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(World)) + { + FString ErrorJson = FString::Printf(TEXT("{\"path\":\"%s\",\"error\":\"%s\"}"), *AssetPath, *ErrorMessage); + Bridge->SendToFlutter(TEXT("AssetManager"), TEXT("onAssetFailed"), ErrorJson); + } +} + +// ==================== INTERNAL METHODS ==================== + +void UFlutterAssetManager::HandleAssetLoaded(const FString& AssetPath, UObject* Asset) +{ + OnAssetLoaded.Broadcast(AssetPath, Asset); + NotifyFlutterAssetLoaded(AssetPath); + UpdateProgress(); + + // Check if we need to trim cache + if (Statistics.CurrentMemoryUsage > CacheMaxSizeBytes) + { + TrimCache(); + } +} + +void UFlutterAssetManager::UpdateProgress() +{ + if (BatchLoadPaths.Num() == 0) + { + return; + } + + int32 Loaded = 0; + int32 Failed = 0; + int64 TotalSize = 0; + int64 LoadedSize = 0; + + for (const FString& Path : BatchLoadPaths) + { + if (const FFlutterLoadedAsset* Entry = LoadedAssets.Find(Path)) + { + TotalSize += Entry->SizeBytes; + + if (Entry->State == EFlutterAssetState::Loaded) + { + Loaded++; + LoadedSize += Entry->SizeBytes; + } + else if (Entry->State == EFlutterAssetState::Failed) + { + Failed++; + } + } + } + + CurrentProgress.LoadedAssets = Loaded; + CurrentProgress.FailedAssets = Failed; + CurrentProgress.TotalSizeBytes = TotalSize; + CurrentProgress.LoadedSizeBytes = LoadedSize; + CurrentProgress.Progress = BatchLoadPaths.Num() > 0 + ? (float)(Loaded + Failed) / (float)BatchLoadPaths.Num() + : 1.0f; + + OnProgress.Broadcast(CurrentProgress); + NotifyFlutterProgress(CurrentProgress); + + // Clear batch if complete + if (Loaded + Failed >= BatchLoadPaths.Num()) + { + BatchLoadPaths.Empty(); + } +} + +int32 UFlutterAssetManager::EstimateAssetSize(UObject* Asset) const +{ + if (!Asset) + { + return 0; + } + + // Simple size estimation - in production, use more accurate methods + int32 EstimatedSize = 1024; // Base overhead + + // Use resource size if available + FResourceSizeEx ResourceSize; + Asset->GetResourceSizeEx(ResourceSize); + EstimatedSize += (int32)ResourceSize.GetTotalMemoryBytes(); + + return EstimatedSize; +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp new file mode 100644 index 0000000..b25c647 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBlueprintLibrary.cpp @@ -0,0 +1,238 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBlueprintLibrary.h" +#include "FlutterBridge.h" +#include "FlutterMessageRouter.h" +#include "Misc/Base64.h" +#include "Dom/JsonObject.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" + +// ============================================================ +// MARK: - Messaging +// ============================================================ + +void UFlutterBlueprintLibrary::SendFlutterMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const FString& Data) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->SendToFlutter(Target, Method, Data); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBlueprintLibrary] Flutter bridge not available")); + } +} + +void UFlutterBlueprintLibrary::SendFlutterJsonMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const TMap& JsonObject) +{ + FString JsonString = MapToJsonString(JsonObject); + SendFlutterMessage(WorldContextObject, Target, Method, JsonString); +} + +void UFlutterBlueprintLibrary::SendFlutterBinaryMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const TArray& Data) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->SendBinaryToFlutter(Target, Method, Data); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBlueprintLibrary] Flutter bridge not available")); + } +} + +// ============================================================ +// MARK: - Router Registration +// ============================================================ + +void UFlutterBlueprintLibrary::RegisterFlutterTarget(const UObject* WorldContextObject, const FString& TargetName, UObject* Target, bool bIsSingleton) +{ + UFlutterMessageRouter* Router = GetFlutterRouter(WorldContextObject); + if (Router) + { + Router->RegisterTarget(TargetName, Target, bIsSingleton); + } +} + +void UFlutterBlueprintLibrary::UnregisterFlutterTarget(const UObject* WorldContextObject, const FString& TargetName) +{ + UFlutterMessageRouter* Router = GetFlutterRouter(WorldContextObject); + if (Router) + { + Router->UnregisterTarget(TargetName); + } +} + +bool UFlutterBlueprintLibrary::IsFlutterTargetRegistered(const UObject* WorldContextObject, const FString& TargetName) +{ + UFlutterMessageRouter* Router = GetFlutterRouter(WorldContextObject); + if (Router) + { + return Router->IsTargetRegistered(TargetName); + } + return false; +} + +TArray UFlutterBlueprintLibrary::GetRegisteredFlutterTargets(const UObject* WorldContextObject) +{ + UFlutterMessageRouter* Router = GetFlutterRouter(WorldContextObject); + if (Router) + { + return Router->GetRegisteredTargets(); + } + return TArray(); +} + +FFlutterRouterStatistics UFlutterBlueprintLibrary::GetFlutterRouterStatistics(const UObject* WorldContextObject) +{ + UFlutterMessageRouter* Router = GetFlutterRouter(WorldContextObject); + if (Router) + { + return Router->GetStatistics(); + } + return FFlutterRouterStatistics(); +} + +// ============================================================ +// MARK: - Quality Settings +// ============================================================ + +void UFlutterBlueprintLibrary::ApplyFlutterQualityPreset(const UObject* WorldContextObject, int32 QualityLevel) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->ApplyQualitySettingsBP(QualityLevel); + } +} + +void UFlutterBlueprintLibrary::ApplyFlutterQualitySettings( + const UObject* WorldContextObject, + int32 AntiAliasing, + int32 Shadows, + int32 PostProcess, + int32 Textures, + int32 Effects, + int32 Foliage, + int32 ViewDistance) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->ApplyQualitySettings(-1, AntiAliasing, Shadows, PostProcess, Textures, Effects, Foliage, ViewDistance); + } +} + +TMap UFlutterBlueprintLibrary::GetFlutterQualitySettings(const UObject* WorldContextObject) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + return Bridge->GetQualitySettings(); + } + return TMap(); +} + +// ============================================================ +// MARK: - Lifecycle +// ============================================================ + +void UFlutterBlueprintLibrary::LoadFlutterLevel(const UObject* WorldContextObject, const FString& LevelName) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->LoadLevelBP(LevelName); + } +} + +void UFlutterBlueprintLibrary::ExecuteFlutterConsoleCommand(const UObject* WorldContextObject, const FString& Command) +{ + AFlutterBridge* Bridge = GetFlutterBridge(WorldContextObject); + if (Bridge) + { + Bridge->ExecuteConsoleCommandBP(Command); + } +} + +// ============================================================ +// MARK: - Bridge Access +// ============================================================ + +AFlutterBridge* UFlutterBlueprintLibrary::GetFlutterBridge(const UObject* WorldContextObject) +{ + return AFlutterBridge::GetInstance(WorldContextObject); +} + +UFlutterMessageRouter* UFlutterBlueprintLibrary::GetFlutterRouter(const UObject* WorldContextObject) +{ + return UFlutterMessageRouter::Get(WorldContextObject); +} + +bool UFlutterBlueprintLibrary::IsFlutterBridgeAvailable(const UObject* WorldContextObject) +{ + return GetFlutterBridge(WorldContextObject) != nullptr; +} + +// ============================================================ +// MARK: - Utilities +// ============================================================ + +FString UFlutterBlueprintLibrary::MapToJsonString(const TMap& Map) +{ + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + + for (const auto& Pair : Map) + { + JsonObject->SetStringField(Pair.Key, Pair.Value); + } + + FString OutputString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&OutputString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + return OutputString; +} + +TMap UFlutterBlueprintLibrary::JsonStringToMap(const FString& JsonString) +{ + TMap Result; + + TSharedPtr JsonObject; + TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonString); + + if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid()) + { + for (const auto& Pair : JsonObject->Values) + { + FString Value; + if (Pair.Value->TryGetString(Value)) + { + Result.Add(FString(Pair.Key), Value); + } + else + { + // Convert non-string values to string representation + Result.Add(FString(Pair.Key), Pair.Value->AsString()); + } + } + } + + return Result; +} + +FString UFlutterBlueprintLibrary::EncodeBase64(const TArray& Data) +{ + return FBase64::Encode(Data); +} + +TArray UFlutterBlueprintLibrary::DecodeBase64(const FString& Base64String) +{ + TArray Result; + FBase64::Decode(Base64String, Result); + return Result; +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp new file mode 100644 index 0000000..72b61dc --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge.cpp @@ -0,0 +1,744 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" +#include "FlutterMessageRouter.h" +#include "Misc/EmbeddedCommunication.h" +#include "Engine/World.h" +#include "Engine/Engine.h" +#include "Engine/GameViewportClient.h" +#include "Kismet/GameplayStatics.h" +#include "Scalability.h" +#include "GameFramework/GameUserSettings.h" + +// Initialize static instance +AFlutterBridge* AFlutterBridge::Instance = nullptr; + +// CRC32 lookup table +static const uint32 CRC32Table[256] = { + 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, 0x076DC419, 0x706AF48F, + 0xE963A535, 0x9E6495A3, 0x0EDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988, + 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91, 0x1DB71064, 0x6AB020F2, + 0xF3B97148, 0x84BE41DE, 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7, + 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 0x14015C4F, 0x63066CD9, + 0xFA0F3D63, 0x8D080DF5, 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172, + 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B, 0x35B5A8FA, 0x42B2986C, + 0xDBBBC9D6, 0xACBCF940, 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59, + 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116, 0x21B4F4B5, 0x56B3C423, + 0xCFBA9599, 0xB8BDA50F, 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924, + 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D, 0x76DC4190, 0x01DB7106, + 0x98D220BC, 0xEFD5102A, 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433, + 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818, 0x7F6A0DBB, 0x086D3D2D, + 0x91646C97, 0xE6635C01, 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E, + 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457, 0x65B0D9C6, 0x12B7E950, + 0x8BBEB8EA, 0xFCB9887C, 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65, + 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 0x4ADFA541, 0x3DD895D7, + 0xA4D1C46D, 0xD3D6F4FB, 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0, + 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9, 0x5005713C, 0x270241AA, + 0xBE0B1010, 0xC90C2086, 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F, + 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 0x59B33D17, 0x2EB40D81, + 0xB7BD5C3B, 0xC0BA6CAD, 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A, + 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683, 0xE3630B12, 0x94643B84, + 0x0D6D6A3E, 0x7A6A5AA8, 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1, + 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 0xF762575D, 0x806567CB, + 0x196C3671, 0x6E6B06E7, 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC, + 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5, 0xD6D6A3E8, 0xA1D1937E, + 0x38D8C2C4, 0x4FDFF252, 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B, + 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 0xDF60EFC3, 0xA867DF55, + 0x316E8EEF, 0x4669BE79, 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236, + 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F, 0xC5BA3BBE, 0xB2BD0B28, + 0x2BB45A92, 0x5CB36A04, 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D, + 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A, 0x9C0906A9, 0xEB0E363F, + 0x72076785, 0x05005713, 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38, + 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21, 0x86D3D2D4, 0xF1D4E242, + 0x68DDB3F8, 0x1FDA836E, 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777, + 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 0x8F659EFF, 0xF862AE69, + 0x616BFFD3, 0x166CCF45, 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2, + 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB, 0xAED16A4A, 0xD9D65ADC, + 0x40DF0B66, 0x37D83BF0, 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9, + 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 0xBAD03605, 0xCDD70693, + 0x54DE5729, 0x23D967BF, 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94, + 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D +}; + +AFlutterBridge::AFlutterBridge() +{ + PrimaryActorTick.bCanEverTick = true; + bIsPaused = false; + BinaryChunkSize = 65536; // 64KB default + bSurfaceReady = false; + SurfaceWidth = 0; + SurfaceHeight = 0; +} + +void AFlutterBridge::BeginPlay() +{ + Super::BeginPlay(); + + // Set as singleton instance + Instance = this; + + // Initialize platform-specific bridge + InitializePlatformBridge(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Initialized")); +} + +void AFlutterBridge::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + // Clear singleton + if (Instance == this) + { + Instance = nullptr; + } + + Super::EndPlay(EndPlayReason); +} + +void AFlutterBridge::Tick(float DeltaTime) +{ + Super::Tick(DeltaTime); +} + +// ============================================================ +// MARK: - Singleton Access +// ============================================================ + +AFlutterBridge* AFlutterBridge::GetInstance(const UObject* WorldContextObject) +{ + if (Instance) + { + return Instance; + } + + // Try to find in world + if (WorldContextObject) + { + UWorld* World = WorldContextObject->GetWorld(); + if (World) + { + TArray FoundActors; + UGameplayStatics::GetAllActorsOfClass(World, AFlutterBridge::StaticClass(), FoundActors); + + if (FoundActors.Num() > 0) + { + Instance = Cast(FoundActors[0]); + return Instance; + } + } + } + + return nullptr; +} + +// ============================================================ +// MARK: - Message Communication +// ============================================================ + +void AFlutterBridge::SendToFlutter(const FString& Target, const FString& Method, const FString& Data) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Sending to Flutter: Target=%s, Method=%s"), *Target, *Method); + + // This will be implemented in platform-specific code + // See FlutterBridge_Android.cpp and FlutterBridge_iOS.mm +#if PLATFORM_ANDROID + // JNI call to Java: UnrealEngineController.onMessageFromUnreal() + extern void FlutterBridge_SendToFlutter_Android(const FString& Target, const FString& Method, const FString& Data); + FlutterBridge_SendToFlutter_Android(Target, Method, Data); +#elif PLATFORM_IOS + // Objective-C++ call to Swift: UnrealBridge.notifyMessage() + extern void FlutterBridge_SendToFlutter_iOS(const FString& Target, const FString& Method, const FString& Data); + FlutterBridge_SendToFlutter_iOS(Target, Method, Data); +#elif PLATFORM_MAC + // Objective-C++ call to Swift: UnrealBridge.notifyMessage() + extern void FlutterBridge_SendToFlutter_Mac(const FString& Target, const FString& Method, const FString& Data); + FlutterBridge_SendToFlutter_Mac(Target, Method, Data); +#else + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] SendToFlutter not implemented for this platform")); +#endif +} + +void AFlutterBridge::ReceiveFromFlutter(const FString& Target, const FString& Method, const FString& Data) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Received from Flutter: Target=%s, Method=%s"), *Target, *Method); + + // Hand it to the router, which is the half that reaches C++ actors. + // + // Every AFlutterActor registers itself with the router by name and expects + // messages to arrive that way. Firing only the Blueprint event below means a + // project without Blueprints receives nothing at all: the message crosses + // the channel, reaches the bridge, and stops here, with every log along the + // way reporting success. + bool bRouted = false; + if (UFlutterMessageRouter* Router = UFlutterMessageRouter::Get(this)) + { + bRouted = Router->RouteMessage(Target, Method, Data); + } + + if (!bRouted) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge] Nothing is registered for %s, so %s went nowhere. " + "Check GetFlutterTargetName on the actor you meant to reach."), + *Target, *Method); + } + + // Fire Blueprint event + OnMessageFromFlutter(Target, Method, Data); + + // And the unified one, which fires for everything regardless of target. + // Deliberately after routing, so a named handler still sees the message + // first and binding this takes delivery away from nothing. + OnAnyMessageFromFlutter.Broadcast(Target, Method, Data); +} + +// ============================================================ +// MARK: - Binary Message Communication +// ============================================================ + +void AFlutterBridge::SendBinaryToFlutter(const FString& Target, const FString& Method, const TArray& Data) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Sending binary to Flutter: Target=%s, Method=%s, Size=%d"), *Target, *Method, Data.Num()); + + int32 Checksum = CalculateCRC32(Data); + +#if PLATFORM_ANDROID + extern void FlutterBridge_SendBinaryToFlutter_Android(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum); + FlutterBridge_SendBinaryToFlutter_Android(Target, Method, Data, Checksum); +#elif PLATFORM_IOS + extern void FlutterBridge_SendBinaryToFlutter_iOS(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum); + FlutterBridge_SendBinaryToFlutter_iOS(Target, Method, Data, Checksum); +#elif PLATFORM_MAC + extern void FlutterBridge_SendBinaryToFlutter_Mac(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum); + FlutterBridge_SendBinaryToFlutter_Mac(Target, Method, Data, Checksum); +#else + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] SendBinaryToFlutter not implemented for this platform")); +#endif +} + +void AFlutterBridge::ReceiveBinaryFromFlutter(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Received binary from Flutter: Target=%s, Method=%s, Size=%d"), *Target, *Method, Data.Num()); + + // Verify checksum + if (!VerifyChecksum(Data, Checksum)) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Binary message checksum mismatch!")); + } + + // Fire Blueprint event + OnBinaryMessageFromFlutter(Target, Method, Data); +} + +void AFlutterBridge::ReceiveBinaryChunkHeader( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 TotalSize, + int32 TotalChunks, + int32 Checksum) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Binary chunk header: TransferId=%s, TotalSize=%d, TotalChunks=%d"), *TransferId, TotalSize, TotalChunks); + + FChunkedTransfer Transfer; + Transfer.Target = Target; + Transfer.Method = Method; + Transfer.TotalSize = TotalSize; + Transfer.TotalChunks = TotalChunks; + Transfer.ExpectedChecksum = Checksum; + Transfer.ReceivedChunks = 0; + + ActiveTransfers.Add(TransferId, Transfer); +} + +void AFlutterBridge::ReceiveBinaryChunkData( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 ChunkIndex, + const TArray& Data) +{ + FChunkedTransfer* Transfer = ActiveTransfers.Find(TransferId); + if (!Transfer) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Unknown transfer ID: %s"), *TransferId); + return; + } + + Transfer->Chunks.Add(ChunkIndex, Data); + Transfer->ReceivedChunks++; + + // Report progress + float Progress = (float)Transfer->ReceivedChunks / (float)Transfer->TotalChunks; + OnBinaryTransferProgress(TransferId, Transfer->ReceivedChunks, Transfer->TotalChunks, Progress); + + UE_LOG(LogTemp, Verbose, TEXT("[FlutterBridge] Binary chunk data: TransferId=%s, ChunkIndex=%d, Progress=%.1f%%"), *TransferId, ChunkIndex, Progress * 100.0f); +} + +void AFlutterBridge::ReceiveBinaryChunkFooter( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 TotalChunks, + int32 Checksum) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Binary chunk footer: TransferId=%s"), *TransferId); + + FChunkedTransfer* Transfer = ActiveTransfers.Find(TransferId); + if (!Transfer) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Unknown transfer ID: %s"), *TransferId); + return; + } + + // Verify all chunks received + if (Transfer->ReceivedChunks != Transfer->TotalChunks) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Incomplete transfer: received %d/%d chunks"), Transfer->ReceivedChunks, Transfer->TotalChunks); + } + + // Assemble the complete data + AssembleChunkedTransfer(TransferId); +} + +void AFlutterBridge::AssembleChunkedTransfer(const FString& TransferId) +{ + FChunkedTransfer* Transfer = ActiveTransfers.Find(TransferId); + if (!Transfer) + { + return; + } + + // Assemble chunks in order + TArray CompleteData; + CompleteData.Reserve(Transfer->TotalSize); + + for (int32 i = 0; i < Transfer->TotalChunks; ++i) + { + TArray* ChunkData = Transfer->Chunks.Find(i); + if (ChunkData) + { + CompleteData.Append(*ChunkData); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Missing chunk %d in transfer %s"), i, *TransferId); + } + } + + // Verify checksum + if (!VerifyChecksum(CompleteData, Transfer->ExpectedChecksum)) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Chunked transfer checksum mismatch!")); + } + + // Fire Blueprint event + OnChunkedTransferComplete(TransferId, CompleteData); + OnBinaryMessageFromFlutter(Transfer->Target, Transfer->Method, CompleteData); + + // Cleanup + ActiveTransfers.Remove(TransferId); +} + +void AFlutterBridge::SetBinaryChunkSize(int32 Size) +{ + BinaryChunkSize = FMath::Clamp(Size, 1024, 1024 * 1024); // 1KB to 1MB + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Binary chunk size set to %d"), BinaryChunkSize); +} + +int32 AFlutterBridge::GetBinaryChunkSize() const +{ + return BinaryChunkSize; +} + +int32 AFlutterBridge::CalculateCRC32(const TArray& Data) const +{ + uint32 CRC = 0xFFFFFFFF; + + for (int32 i = 0; i < Data.Num(); ++i) + { + CRC = CRC32Table[(CRC ^ Data[i]) & 0xFF] ^ (CRC >> 8); + } + + return static_cast(CRC ^ 0xFFFFFFFF); +} + +bool AFlutterBridge::VerifyChecksum(const TArray& Data, int32 ExpectedChecksum) const +{ + return CalculateCRC32(Data) == ExpectedChecksum; +} + +TArray AFlutterBridge::CompressData(const TArray& Data) const +{ + // TODO: Implement GZip compression using zlib + // For now, return uncompressed data + return Data; +} + +TArray AFlutterBridge::DecompressData(const TArray& Data) const +{ + // Check for GZip magic number (0x1F 0x8B) + if (Data.Num() >= 2 && Data[0] == 0x1F && Data[1] == 0x8B) + { + // TODO: Implement GZip decompression using zlib + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] GZip decompression not implemented")); + } + + // Return as-is if not compressed + return Data; +} + +// ============================================================ +// MARK: - Console Commands +// ============================================================ + +void AFlutterBridge::ExecuteConsoleCommand(const FString& Command) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Executing console command: %s"), *Command); + + if (GEngine && GEngine->GameViewport) + { + GEngine->GameViewport->ConsoleCommand(Command); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterBridge] Cannot execute console command: GameViewport not available")); + } +} + +void AFlutterBridge::ExecuteConsoleCommandBP(const FString& Command) +{ + ExecuteConsoleCommand(Command); +} + +// ============================================================ +// MARK: - Quality Settings +// ============================================================ + +void AFlutterBridge::ApplyQualitySettings( + int32 QualityLevel, + int32 AntiAliasing, + int32 Shadow, + int32 PostProcess, + int32 Texture, + int32 Effects, + int32 Foliage, + int32 ViewDistance) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Applying quality settings: Level=%d"), QualityLevel); + + // Apply overall quality level if specified + if (QualityLevel >= 0) + { + SetScalabilityQuality(QualityLevel); + } + + // Apply individual settings if specified + if (AntiAliasing >= 0) SetAntiAliasingQuality(AntiAliasing); + if (Shadow >= 0) SetShadowQuality(Shadow); + if (PostProcess >= 0) SetPostProcessQuality(PostProcess); + if (Texture >= 0) SetTextureQuality(Texture); + if (Effects >= 0) SetEffectsQuality(Effects); + if (Foliage >= 0) SetFoliageQuality(Foliage); + if (ViewDistance >= 0) SetViewDistanceQuality(ViewDistance); + + // Save settings + if (UGameUserSettings* Settings = GEngine->GetGameUserSettings()) + { + Settings->ApplySettings(false); + } +} + +void AFlutterBridge::ApplyQualitySettingsBP(int32 QualityLevel) +{ + ApplyQualitySettings(QualityLevel, -1, -1, -1, -1, -1, -1, -1); +} + +TMap AFlutterBridge::GetQualitySettings() +{ + TMap Settings; + + Settings.Add(TEXT("antiAliasing"), GetAntiAliasingQuality()); + Settings.Add(TEXT("shadow"), GetShadowQuality()); + Settings.Add(TEXT("postProcess"), GetPostProcessQuality()); + Settings.Add(TEXT("texture"), GetTextureQuality()); + Settings.Add(TEXT("effects"), GetEffectsQuality()); + Settings.Add(TEXT("foliage"), GetFoliageQuality()); + Settings.Add(TEXT("viewDistance"), GetViewDistanceQuality()); + + return Settings; +} + +TMap AFlutterBridge::GetQualitySettingsBP() +{ + return GetQualitySettings(); +} + +// ============================================================ +// MARK: - Level Loading +// ============================================================ + +void AFlutterBridge::LoadLevel(const FString& LevelName) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Loading level: %s"), *LevelName); + + CurrentLevelName = LevelName; + + if (UWorld* World = GetWorld()) + { + UGameplayStatics::OpenLevel(World, FName(*LevelName)); + } +} + +void AFlutterBridge::LoadLevelBP(const FString& LevelName) +{ + LoadLevel(LevelName); +} + +void AFlutterBridge::OnLevelLoaded() +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Level loaded: %s"), *CurrentLevelName); + + // Notify Flutter + SendToFlutter(TEXT("FlutterBridge"), TEXT("onLevelLoaded"), CurrentLevelName); + + // Fire Blueprint event + OnLevelLoadedBP(CurrentLevelName); +} + +// ============================================================ +// MARK: - Lifecycle Events +// ============================================================ + +void AFlutterBridge::OnEnginePause() +{ + // Pausing twice is not harmless. The sleep counter is matched, and + // AllowSleep asserts when it is released without a KeepAwake to match, so a + // second pause aborts the process rather than doing nothing. + if (bIsPaused) + { + return; + } + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine paused")); + bIsPaused = true; + + // Actually pause the game, rather than only recording that somebody asked. + // Setting a flag and firing a Blueprint event stops nothing: actors keep + // ticking, time keeps advancing, and the only things that appear to pause + // are the ones that happened to check the flag themselves. + if (UWorld* World = GetWorld()) + { + UGameplayStatics::SetGamePaused(World, true); + } + + // And stop driving the engine. A paused game that still renders every frame + // costs the same battery as a running one, which rather defeats the point + // on a phone. + FEmbeddedCommunication::AllowSleep(TEXT("flutter")); + + OnEnginePausedBP(); +} + +void AFlutterBridge::OnEngineResume() +{ + if (!bIsPaused) + { + return; + } + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine resumed")); + bIsPaused = false; + + FEmbeddedCommunication::KeepAwake(TEXT("flutter"), true); + + if (UWorld* World = GetWorld()) + { + UGameplayStatics::SetGamePaused(World, false); + } + + OnEngineResumedBP(); +} + +void AFlutterBridge::OnEngineQuit() +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Engine quitting")); + OnEngineQuitBP(); +} + +// ============================================================ +// MARK: - Surface Events (Android) +// ============================================================ + +void AFlutterBridge::OnSurfaceReady(int32 Width, int32 Height) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Surface ready: %dx%d"), Width, Height); + + bSurfaceReady = true; + SurfaceWidth = Width; + SurfaceHeight = Height; + + // TODO: Configure Unreal rendering to use this surface + // This is where you would: + // 1. Create a custom viewport + // 2. Set up render target + // 3. Configure the rendering pipeline + + // Notify Flutter that surface is ready + SendToFlutter(TEXT("FlutterBridge"), TEXT("onSurfaceReady"), FString::Printf(TEXT("{\"width\":%d,\"height\":%d}"), Width, Height)); +} + +void AFlutterBridge::OnSurfaceSizeChanged(int32 Width, int32 Height) +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Surface size changed: %dx%d"), Width, Height); + + SurfaceWidth = Width; + SurfaceHeight = Height; + + // TODO: Update viewport/render target dimensions + + // Notify Flutter of size change + SendToFlutter(TEXT("FlutterBridge"), TEXT("onSurfaceSizeChanged"), FString::Printf(TEXT("{\"width\":%d,\"height\":%d}"), Width, Height)); +} + +void AFlutterBridge::OnSurfaceDestroyed() +{ + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Surface destroyed")); + + bSurfaceReady = false; + SurfaceWidth = 0; + SurfaceHeight = 0; + + // TODO: Clean up viewport/render target + + // Notify Flutter + SendToFlutter(TEXT("FlutterBridge"), TEXT("onSurfaceDestroyed"), TEXT("{}")); +} + +void AFlutterBridge::GetSurfaceSize(int32& OutWidth, int32& OutHeight) const +{ + OutWidth = SurfaceWidth; + OutHeight = SurfaceHeight; +} + +bool AFlutterBridge::IsSurfaceReady() const +{ + return bSurfaceReady; +} + +// ============================================================ +// MARK: - Platform Bridge Initialization +// ============================================================ + +void AFlutterBridge::InitializePlatformBridge() +{ +#if PLATFORM_ANDROID + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Initializing Android bridge")); + extern void FlutterBridge_SetInstance_Android(AFlutterBridge* Instance); + FlutterBridge_SetInstance_Android(this); +#elif PLATFORM_IOS + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Initializing iOS bridge")); + extern void FlutterBridge_SetInstance_iOS(AFlutterBridge* Instance); + FlutterBridge_SetInstance_iOS(this); +#elif PLATFORM_MAC + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] Initializing macOS bridge")); + extern void FlutterBridge_SetInstance_Mac(AFlutterBridge* Instance); + FlutterBridge_SetInstance_Mac(this); +#else + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge] No platform bridge available")); +#endif +} + +// ============================================================ +// MARK: - Quality Settings Helpers +// ============================================================ + +void AFlutterBridge::SetScalabilityQuality(int32 Level) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.SetFromSingleQualityLevel(Level); + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetAntiAliasingQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.AntiAliasingQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetShadowQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.ShadowQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetPostProcessQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.PostProcessQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetTextureQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.TextureQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetEffectsQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.EffectsQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetFoliageQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.FoliageQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +void AFlutterBridge::SetViewDistanceQuality(int32 Quality) +{ + Scalability::FQualityLevels QualityLevels = Scalability::GetQualityLevels(); + QualityLevels.ViewDistanceQuality = Quality; + Scalability::SetQualityLevels(QualityLevels); +} + +int32 AFlutterBridge::GetAntiAliasingQuality() const +{ + return Scalability::GetQualityLevels().AntiAliasingQuality; +} + +int32 AFlutterBridge::GetShadowQuality() const +{ + return Scalability::GetQualityLevels().ShadowQuality; +} + +int32 AFlutterBridge::GetPostProcessQuality() const +{ + return Scalability::GetQualityLevels().PostProcessQuality; +} + +int32 AFlutterBridge::GetTextureQuality() const +{ + return Scalability::GetQualityLevels().TextureQuality; +} + +int32 AFlutterBridge::GetEffectsQuality() const +{ + return Scalability::GetQualityLevels().EffectsQuality; +} + +int32 AFlutterBridge::GetFoliageQuality() const +{ + return Scalability::GetQualityLevels().FoliageQuality; +} + +int32 AFlutterBridge::GetViewDistanceQuality() const +{ + return Scalability::GetQualityLevels().ViewDistanceQuality; +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp new file mode 100644 index 0000000..244d4e6 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterBridge_Apple.cpp @@ -0,0 +1,635 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" + +#if PLATFORM_IOS || PLATFORM_MAC + +#include "UnrealBridge.h" +#include "Async/Async.h" +#include "Misc/EmbeddedCommunication.h" +#include "Misc/CoreDelegates.h" +#include "HAL/IConsoleManager.h" +#include "Misc/ConfigCacheIni.h" + +#include + +// ============================================================ +// MARK: - Bridge State +// ============================================================ +// +// Shared by iOS and Mac. The two platforms differ only in the names +// AFlutterBridge dispatches to, so the implementation lives here once and the +// platform entry points at the bottom are shims. +// +// The host app links this framework and registers C callbacks. Nothing here +// touches Objective-C, UIKit or AppKit: the boundary is a flat C ABI so the app +// never needs Unreal headers, include paths or symbols of its own. +// +// Two threads are in play. Unreal calls into SendToFlutter from the game +// thread; the app calls the UnrealBridge_* entry points from its main thread. +// Callback pointers are therefore read and written atomically, and every call +// that touches UObjects is marshalled onto the game thread before it runs. + +static std::atomic GFlutterBridgeInstance{nullptr}; + +static std::atomic GMessageCallback{nullptr}; + +/// Trace every message from Flutter back to Flutter, as Trace.queued and +/// Trace.drained. +/// +/// Off by default, because it doubles the message traffic. Worth turning on +/// when a control appears to do nothing: the engine's log file is buffered and +/// mostly shows startup, so a message that disappears between the bridge and an +/// actor otherwise leaves nothing to read. Enable it from the host with +/// executeConsoleCommand("flutter.TraceMessages 1"). +static TAutoConsoleVariable CVarTraceMessages( + TEXT("flutter.TraceMessages"), + 0, + TEXT("Echo each message from Flutter back as Trace.queued and Trace.drained."), + ECVF_Default); + +static bool ShouldTraceMessages() +{ + return CVarTraceMessages.GetValueOnAnyThread() != 0; +} +static std::atomic GBinaryCallback{nullptr}; + +/// Cached quality settings, refreshed on the game thread. +/// +/// UnrealBridge_GetQualitySettings is synchronous and can be called from the +/// app's main thread, where blocking on the game thread risks deadlock against +/// a game thread already waiting on the main thread. So the getter serves this +/// cache and schedules a refresh for next time. The lock is only ever held for +/// a memcpy-sized copy, so neither thread stalls on it. +static FCriticalSection GQualityCacheLock; +static int32 GCachedQuality[UNREALBRIDGE_QUALITY_VALUE_COUNT] = {0}; +static bool GQualityCacheValid = false; + +/// Order must match the documented layout in UnrealBridge.h. +static const TCHAR* const GQualityKeys[UNREALBRIDGE_QUALITY_VALUE_COUNT] = { + TEXT("antiAliasing"), + TEXT("shadow"), + TEXT("postProcess"), + TEXT("texture"), + TEXT("effects"), + TEXT("foliage"), + TEXT("viewDistance") +}; + +// ============================================================ +// MARK: - Waiting for the engine to be ready for a view +// ============================================================ +// +// FAppEntry broadcasts "inisareready" on the embedded-to-native channel once +// the config is loaded, with a comment stating that this is when the view can +// be made. Building the view earlier is a race, so the host is told when +// instead of guessing. +// +// The signal and the host's registration can arrive in either order, so both +// are recorded and whichever comes second does the work. + +static std::atomic GEngineReadyForView{false}; +static std::atomic GEngineReadyCallback{nullptr}; + +static void HandleEmbeddedToNative(const FEmbeddedCallParamsHelper& Params) +{ + if (Params.Command != TEXT("inisareready")) + { + return; + } + + GEngineReadyForView.store(true, std::memory_order_release); + UE_LOG(LogTemp, Log, + TEXT("[FlutterBridge_Apple] Engine reports config is ready; a render view can be made")); + + if (UnrealEngineReadyCallback Callback = + GEngineReadyCallback.load(std::memory_order_acquire)) + { + Callback(); + } +} + +/// Subscribe once, as early as the module loads. +/// +/// The plugin is a PreDefault-phase module, so this runs before FAppEntry gets +/// far enough to broadcast. Registering late would mean missing it entirely, +/// which is why this does not wait for the host to call in. +void FlutterBridge_ListenForEngineReady() +{ + static bool bSubscribed = false; + if (bSubscribed) + { + return; + } + bSubscribed = true; + + FEmbeddedDelegates::GetEmbeddedToNativeParamsDelegateForSubsystem(TEXT("native")) + .AddStatic(&HandleEmbeddedToNative); + + UE_LOG(LogTemp, Log, + TEXT("[FlutterBridge_Apple] Listening for the engine's readiness signal")); +} + +/// Whether a render view can be built yet. Used by the iOS view code. +bool FlutterBridge_IsEngineReadyForView() +{ + return GEngineReadyForView.load(std::memory_order_acquire); +} + +// ============================================================ +// MARK: - Helpers +// ============================================================ + +/// Convert an incoming C string to FString, tolerating null. +static FString CStringToFString(const char* String) +{ + return String ? FString(UTF8_TO_TCHAR(String)) : FString(); +} + +/// Run work on the game thread, immediately if already there. +/// +/// Everything below reaches into UObjects, which is only legal on the game +/// thread. Calls arriving from the app's main thread get queued. +/// Priority for work queued through FEmbeddedCommunication. Zero is the normal +/// band; higher numbers run first. +static constexpr int GBridgeWorkPriority = 0; + +static void RunOnGameThread(TFunction Work) +{ + if (IsInGameThread()) + { + Work(); + return; + } + + // FEmbeddedCommunication::RunOnGameThread is explicitly safe before Init, + // so a host that calls in during startup gets its work queued rather than + // dropped. That matters here: the task graph is not safe that early, and an + // earlier version of this reached straight for AsyncTask and crashed when + // the engine had not been initialised. + FEmbeddedCommunication::RunOnGameThread(GBridgeWorkPriority, MoveTemp(Work)); + FEmbeddedCommunication::WakeGameThread(); +} + +/// Refresh the quality cache. Game thread only. +static void RefreshQualityCache() +{ + check(IsInGameThread()); + + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + const TMap Settings = Bridge->GetQualitySettings(); + + FScopeLock Lock(&GQualityCacheLock); + for (int32 Index = 0; Index < UNREALBRIDGE_QUALITY_VALUE_COUNT; ++Index) + { + const int32* Value = Settings.Find(GQualityKeys[Index]); + GCachedQuality[Index] = Value ? *Value : -1; + } + + GQualityCacheValid = true; +} + +// ============================================================ +// MARK: - Unreal to Flutter +// ============================================================ + +/** + * Send a message to Flutter. + * Called from AFlutterBridge::SendToFlutter() on the game thread. + * + * The callback receives pointers into a temporary UTF-8 conversion, so the + * host must copy anything it intends to keep. This is documented on the + * typedef in UnrealBridge.h. + */ +static void SendToFlutter_Apple(const FString& Target, const FString& Method, const FString& Data) +{ + const UnrealMessageCallback Callback = GMessageCallback.load(std::memory_order_acquire); + if (!Callback) + { + UE_LOG(LogTemp, Verbose, + TEXT("[FlutterBridge_Apple] Dropping message, no callback registered: Target=%s, Method=%s"), + *Target, *Method); + return; + } + + const FTCHARToUTF8 TargetUtf8(*Target); + const FTCHARToUTF8 MethodUtf8(*Method); + const FTCHARToUTF8 DataUtf8(*Data); + + Callback(TargetUtf8.Get(), MethodUtf8.Get(), DataUtf8.Get()); +} + +/** + * Send binary data to Flutter. + * Called from AFlutterBridge::SendBinaryToFlutter() on the game thread. + */ +static void SendBinaryToFlutter_Apple(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + const UnrealBinaryCallback Callback = GBinaryCallback.load(std::memory_order_acquire); + if (!Callback) + { + UE_LOG(LogTemp, Verbose, + TEXT("[FlutterBridge_Apple] Dropping binary payload, no callback registered: Target=%s, Method=%s, Size=%d"), + *Target, *Method, Data.Num()); + return; + } + + const FTCHARToUTF8 TargetUtf8(*Target); + const FTCHARToUTF8 MethodUtf8(*Method); + + Callback(TargetUtf8.Get(), MethodUtf8.Get(), Data.GetData(), Data.Num(), Checksum); +} + +// ============================================================ +// MARK: - Instance Registration +// ============================================================ + +/** + * Set the FlutterBridge instance. + * Called from AFlutterBridge::BeginPlay() on the game thread. + */ +static void SetInstance_Apple(AFlutterBridge* Instance) +{ + GFlutterBridgeInstance.store(Instance, std::memory_order_release); + + if (Instance) + { + RefreshQualityCache(); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] FlutterBridge instance set")); + } + else + { + FScopeLock Lock(&GQualityCacheLock); + GQualityCacheValid = false; + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] FlutterBridge instance cleared")); + } +} + +// ============================================================ +// MARK: - Platform Entry Points +// ============================================================ +// +// AFlutterBridge dispatches to a differently named function per platform. iOS +// and Mac share everything above, so these are shims. UnrealBuildTool compiles +// exactly one branch. + +#if PLATFORM_IOS + +void FlutterBridge_SendToFlutter_iOS(const FString& Target, const FString& Method, const FString& Data) +{ + SendToFlutter_Apple(Target, Method, Data); +} + +void FlutterBridge_SendBinaryToFlutter_iOS(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + SendBinaryToFlutter_Apple(Target, Method, Data, Checksum); +} + +void FlutterBridge_SetInstance_iOS(AFlutterBridge* Instance) +{ + SetInstance_Apple(Instance); +} + +AFlutterBridge* FlutterBridge_GetInstance_iOS() +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire); +} + +#elif PLATFORM_MAC + +void FlutterBridge_SendToFlutter_Mac(const FString& Target, const FString& Method, const FString& Data) +{ + SendToFlutter_Apple(Target, Method, Data); +} + +void FlutterBridge_SendBinaryToFlutter_Mac(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum) +{ + SendBinaryToFlutter_Apple(Target, Method, Data, Checksum); +} + +void FlutterBridge_SetInstance_Mac(AFlutterBridge* Instance) +{ + SetInstance_Apple(Instance); +} + +AFlutterBridge* FlutterBridge_GetInstance_Mac() +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire); +} + +#endif + +// ============================================================ +// MARK: - C ABI (called by the host app) +// ============================================================ + +extern "C" { + +void UnrealBridge_SetMessageCallback(UnrealMessageCallback Callback) +{ + GMessageCallback.store(Callback, std::memory_order_release); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Message callback %s"), + Callback ? TEXT("registered") : TEXT("cleared")); +} + +void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback Callback) +{ + GBinaryCallback.store(Callback, std::memory_order_release); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Binary callback %s"), + Callback ? TEXT("registered") : TEXT("cleared")); +} + +void UnrealBridge_SendToUnreal(const char* Target, const char* Method, const char* Data) +{ + const FString TargetString = CStringToFString(Target); + const FString MethodString = CStringToFString(Method); + const FString DataString = CStringToFString(Data); + + // Traced through the message callback rather than the bridge actor, so it + // still reports when the thing being diagnosed is the bridge actor itself. + if (ShouldTraceMessages()) + { + SendToFlutter_Apple(TEXT("Trace"), TEXT("queued"), + FString::Printf(TEXT("%s.%s"), *TargetString, *MethodString)); + } + + RunOnGameThread([TargetString, MethodString, DataString]() + { + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + + if (ShouldTraceMessages()) + { + SendToFlutter_Apple(TEXT("Trace"), TEXT("drained"), + FString::Printf(TEXT("%s.%s bridge=%s"), *TargetString, *MethodString, + Bridge != nullptr ? TEXT("yes") : TEXT("null"))); + } + + if (Bridge != nullptr) + { + Bridge->ReceiveFromFlutter(TargetString, MethodString, DataString); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Dropping message from Flutter, no bridge actor: Target=%s, Method=%s"), + *TargetString, *MethodString); + } + }); +} + +void UnrealBridge_SendBinaryToUnreal(const char* Target, const char* Method, const void* Data, int32_t Length, int32_t Checksum) +{ + const FString TargetString = CStringToFString(Target); + const FString MethodString = CStringToFString(Method); + + // Copy now. The caller owns its buffer and is free to release it as soon as + // this returns, but the work below runs later on the game thread. + TArray Payload; + if (Data && Length > 0) + { + Payload.Append(static_cast(Data), Length); + } + + RunOnGameThread([TargetString, MethodString, Payload = MoveTemp(Payload), Checksum]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->ReceiveBinaryFromFlutter(TargetString, MethodString, Payload, Checksum); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterBridge_Apple] Dropping binary from Flutter, no bridge actor: Target=%s, Method=%s, Size=%d"), + *TargetString, *MethodString, Payload.Num()); + } + }); +} + +void UnrealBridge_ExecuteConsoleCommand(const char* Command) +{ + const FString CommandString = CStringToFString(Command); + + RunOnGameThread([CommandString]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->ExecuteConsoleCommand(CommandString); + } + }); +} + +void UnrealBridge_LoadLevel(const char* LevelName) +{ + const FString LevelString = CStringToFString(LevelName); + + RunOnGameThread([LevelString]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->LoadLevel(LevelString); + } + }); +} + +void UnrealBridge_ApplyQualitySettings( + int32_t QualityLevel, + int32_t AntiAliasing, + int32_t Shadow, + int32_t PostProcess, + int32_t Texture, + int32_t Effects, + int32_t Foliage, + int32_t ViewDistance) +{ + RunOnGameThread([=]() + { + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + Bridge->ApplyQualitySettings( + QualityLevel, AntiAliasing, Shadow, PostProcess, + Texture, Effects, Foliage, ViewDistance); + + RefreshQualityCache(); + }); +} + +int32_t UnrealBridge_GetQualitySettings(int32_t* OutValues, int32_t Capacity) +{ + // Schedule a refresh regardless, so a later call sees current values. + RunOnGameThread([]() + { + RefreshQualityCache(); + }); + + if (!OutValues || Capacity < UNREALBRIDGE_QUALITY_VALUE_COUNT) + { + return 0; + } + + FScopeLock Lock(&GQualityCacheLock); + if (!GQualityCacheValid) + { + return 0; + } + + for (int32 Index = 0; Index < UNREALBRIDGE_QUALITY_VALUE_COUNT; ++Index) + { + OutValues[Index] = GCachedQuality[Index]; + } + + return UNREALBRIDGE_QUALITY_VALUE_COUNT; +} + +// The macOS view and engine startup live in Mac/FlutterView_Mac.mm, the same +// way the iOS ones live in IOS/FlutterView_IOS.mm. + +void UnrealBridge_SetEngineReadyCallback(UnrealEngineReadyCallback Callback) +{ + GEngineReadyCallback.store(Callback, std::memory_order_release); + + // Already announced, so tell the host now rather than leaving it waiting on + // a broadcast that has been and gone. + if (Callback != nullptr && + GEngineReadyForView.load(std::memory_order_acquire)) + { + Callback(); + } +} + +int32_t UnrealBridge_IsReadyForView(void) +{ + return GEngineReadyForView.load(std::memory_order_acquire) ? 1 : 0; +} + +void UnrealBridge_Init(void) +{ + static std::atomic bInitialised{false}; + bool bExpected = false; + if (!bInitialised.compare_exchange_strong(bExpected, true)) + { + return; + } + + FEmbeddedCommunication::Init(); + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Embedded communication initialised")); +} + +int32_t UnrealBridge_Tick(float DeltaSeconds) +{ + // GConfig is null until the engine has loaded its inis, and + // FEmbeddedCommunication::TickGameThread reads a setting through it without + // checking. A host that starts ticking as soon as the engine is asked to + // start gets there first and dereferences null, which it must, because the + // engine blocks during startup waiting to be handed a view and the tick is + // what offers one. + if (GConfig == nullptr) + { + return 0; + } + + + // Only drain the queue when this really is the game thread. + // + // TickGameThread runs queued work on whoever calls it, and the host drives + // this from a display link on the main thread. Draining there means every + // message from Flutter runs on the wrong thread, which mostly appears to + // work: setting a float on an actor is harmless. Touching anything the + // renderer owns is not, and a material parameter aborts the process inside + // a check that the caller is the game thread. + // + // The engine already drains this queue from its own core ticker, on the + // real game thread, so the right answer here is to leave it alone. The call + // stays for a host that genuinely drives the engine from its own thread, + // which is the other embedded arrangement this ABI supports. + if (!IsInGameThread()) + { + return 0; + } + + return FEmbeddedCommunication::TickGameThread(DeltaSeconds) ? 1 : 0; +} + +void UnrealBridge_WakeGameThread(void) +{ + FEmbeddedCommunication::WakeGameThread(); +} + +void UnrealBridge_KeepAwake(const char* Requester, int32_t bNeedsRendering) +{ + FEmbeddedCommunication::KeepAwake(FName(CStringToFString(Requester)), + bNeedsRendering != 0); +} + +void UnrealBridge_AllowSleep(const char* Requester) +{ + FEmbeddedCommunication::AllowSleep(FName(CStringToFString(Requester))); +} + +int32_t UnrealBridge_IsAwakeForTicking(void) +{ + return FEmbeddedCommunication::IsAwakeForTicking() ? 1 : 0; +} + +int32_t UnrealBridge_IsAwakeForRendering(void) +{ + return FEmbeddedCommunication::IsAwakeForRendering() ? 1 : 0; +} + +void UnrealBridge_Pause(int32_t Paused) +{ + const bool bPaused = Paused != 0; + + RunOnGameThread([bPaused]() + { + AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire); + if (!Bridge) + { + return; + } + + if (bPaused) + { + Bridge->OnEnginePause(); + } + else + { + Bridge->OnEngineResume(); + } + }); +} + +void UnrealBridge_Stop(void) +{ + // Clear callbacks first. The host is going away, and the quit path below + // can still produce messages we would otherwise hand to a dead callback. + GMessageCallback.store(nullptr, std::memory_order_release); + GBinaryCallback.store(nullptr, std::memory_order_release); + + RunOnGameThread([]() + { + if (AFlutterBridge* Bridge = GFlutterBridgeInstance.load(std::memory_order_acquire)) + { + Bridge->OnEngineQuit(); + } + }); + + UE_LOG(LogTemp, Log, TEXT("[FlutterBridge_Apple] Bridge stopped")); +} + +int32_t UnrealBridge_IsReady(void) +{ + return GFlutterBridgeInstance.load(std::memory_order_acquire) != nullptr ? 1 : 0; +} + +} // extern "C" + +#endif // PLATFORM_IOS || PLATFORM_MAC diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp new file mode 100644 index 0000000..cacd7d5 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterMessageRouter.cpp @@ -0,0 +1,421 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterMessageRouter.h" +#include "Engine/World.h" +#include "Engine/Engine.h" + +// Initialize static instance +UFlutterMessageRouter* UFlutterMessageRouter::Instance = nullptr; + +UFlutterMessageRouter::UFlutterMessageRouter() + : bQueueUnknownTargets(true) + , MaxQueueSize(1000) +{ +} + +// ============================================================ +// MARK: - Singleton Access +// ============================================================ + +UFlutterMessageRouter* UFlutterMessageRouter::Get(const UObject* WorldContextObject) +{ + if (!Instance) + { + Instance = NewObject(); + Instance->AddToRoot(); // Prevent garbage collection + } + + return Instance; +} + +// ============================================================ +// MARK: - Target Registration +// ============================================================ + +void UFlutterMessageRouter::RegisterTarget(const FString& Name, UObject* Target, bool bIsSingleton) +{ + if (!Target) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Cannot register null target: %s"), *Name); + return; + } + + // Check if singleton already registered + if (bIsSingleton && Targets.Contains(Name)) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Singleton target already registered: %s"), *Name); + return; + } + + Targets.Add(Name, Target); + SingletonFlags.Add(Name, bIsSingleton); + + UE_LOG(LogTemp, Log, TEXT("[FlutterRouter] Registered target: %s (Singleton=%d)"), *Name, bIsSingleton); + + // Update statistics + Statistics.RegisteredTargets = Targets.Num(); + + // Flush any queued messages for this target + FlushQueue(); +} + +void UFlutterMessageRouter::UnregisterTarget(const FString& Name) +{ + if (Targets.Remove(Name) > 0) + { + SingletonFlags.Remove(Name); + + // Remove cached delegates for this target + TArray KeysToRemove; + for (const auto& Pair : CachedDelegates) + { + if (Pair.Key.StartsWith(Name + TEXT(":"))) + { + KeysToRemove.Add(Pair.Key); + } + } + + for (const FString& Key : KeysToRemove) + { + CachedDelegates.Remove(Key); + } + + // Same for binary delegates + KeysToRemove.Empty(); + for (const auto& Pair : CachedBinaryDelegates) + { + if (Pair.Key.StartsWith(Name + TEXT(":"))) + { + KeysToRemove.Add(Pair.Key); + } + } + + for (const FString& Key : KeysToRemove) + { + CachedBinaryDelegates.Remove(Key); + } + + UE_LOG(LogTemp, Log, TEXT("[FlutterRouter] Unregistered target: %s"), *Name); + + // Update statistics + Statistics.RegisteredTargets = Targets.Num(); + Statistics.CachedDelegates = CachedDelegates.Num() + CachedBinaryDelegates.Num(); + } +} + +bool UFlutterMessageRouter::IsTargetRegistered(const FString& Name) const +{ + return Targets.Contains(Name); +} + +TArray UFlutterMessageRouter::GetRegisteredTargets() const +{ + TArray Result; + + for (const auto& Pair : Targets) + { + FFlutterTargetInfo Info; + Info.TargetName = Pair.Key; + Info.TargetObject = Pair.Value; + Info.bIsSingleton = SingletonFlags.Contains(Pair.Key) ? SingletonFlags[Pair.Key] : false; + + // Count registered methods + int32 MethodCount = 0; + for (const auto& DelegatePair : CachedDelegates) + { + if (DelegatePair.Key.StartsWith(Pair.Key + TEXT(":"))) + { + MethodCount++; + } + } + Info.RegisteredMethods = MethodCount; + + Result.Add(Info); + } + + return Result; +} + +// ============================================================ +// MARK: - Method Registration +// ============================================================ + +void UFlutterMessageRouter::RegisterMethod(const FString& TargetName, const FString& MethodName, FFlutterMethodDelegate Delegate) +{ + FString CacheKey = GetCacheKey(TargetName, MethodName); + CachedDelegates.Add(CacheKey, Delegate); + + UE_LOG(LogTemp, Log, TEXT("[FlutterRouter] Registered method: %s"), *CacheKey); + + Statistics.CachedDelegates = CachedDelegates.Num() + CachedBinaryDelegates.Num(); +} + +void UFlutterMessageRouter::RegisterBinaryMethod(const FString& TargetName, const FString& MethodName, FFlutterBinaryMethodDelegate Delegate) +{ + FString CacheKey = GetCacheKey(TargetName, MethodName); + CachedBinaryDelegates.Add(CacheKey, Delegate); + + UE_LOG(LogTemp, Log, TEXT("[FlutterRouter] Registered binary method: %s"), *CacheKey); + + Statistics.CachedDelegates = CachedDelegates.Num() + CachedBinaryDelegates.Num(); +} + +void UFlutterMessageRouter::UnregisterMethod(const FString& TargetName, const FString& MethodName) +{ + FString CacheKey = GetCacheKey(TargetName, MethodName); + CachedDelegates.Remove(CacheKey); + CachedBinaryDelegates.Remove(CacheKey); + + Statistics.CachedDelegates = CachedDelegates.Num() + CachedBinaryDelegates.Num(); +} + +// ============================================================ +// MARK: - Message Routing +// ============================================================ + +bool UFlutterMessageRouter::RouteMessage(const FString& Target, const FString& Method, const FString& Data) +{ + FString CacheKey = GetCacheKey(Target, Method); + + // Try cached delegate first (zero-reflection fast path) + if (TryRouteCached(CacheKey, Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + + // Then the wildcard, which is what every AFlutterActor registers under. + // RegisterMethod(Target, "*", ...) is how an actor says "send me everything", + // and looking up only the exact method name means that handler can never be + // found. The actor still gets the real method name, so it can dispatch. + if (TryRouteCached(GetCacheKey(Target, TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + + // Then a catch-all target, for an actor that registered under "*" to take + // everything rather than being given a name of its own. + if (TryRouteCached(GetCacheKey(TEXT("*"), Method), Method, Data) || + TryRouteCached(GetCacheKey(TEXT("*"), TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + + // Check if target is registered but method is not + if (Targets.Contains(Target)) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] No handler for method: %s on target: %s"), *Method, *Target); + Statistics.MessagesDropped++; + return false; + } + + // Target not registered - queue if enabled + if (bQueueUnknownTargets) + { + QueueMessage(Target, Method, Data); + return true; + } + + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Unknown target: %s"), *Target); + Statistics.MessagesDropped++; + return false; +} + +bool UFlutterMessageRouter::RouteBinaryMessage(const FString& Target, const FString& Method, const TArray& Data) +{ + FString CacheKey = GetCacheKey(Target, Method); + + // Try cached delegate first + if (TryRouteBinaryCached(CacheKey, Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + + // Then the wildcard, for the same reason as the text path above. + if (TryRouteBinaryCached(GetCacheKey(Target, TEXT("*")), Method, Data)) + { + Statistics.MessagesRouted++; + return true; + } + + // Check if target is registered but method is not + if (Targets.Contains(Target)) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] No binary handler for method: %s on target: %s"), *Method, *Target); + Statistics.MessagesDropped++; + return false; + } + + // Target not registered - queue if enabled + if (bQueueUnknownTargets) + { + FQueuedFlutterMessage QueuedMsg; + QueuedMsg.Target = Target; + QueuedMsg.Method = Method; + QueuedMsg.bIsBinary = true; + QueuedMsg.BinaryData = Data; + + if (MessageQueue.Num() < MaxQueueSize) + { + MessageQueue.Add(QueuedMsg); + Statistics.QueuedMessages = MessageQueue.Num(); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Message queue full, dropping message")); + Statistics.MessagesDropped++; + } + return true; + } + + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Unknown target: %s"), *Target); + Statistics.MessagesDropped++; + return false; +} + +bool UFlutterMessageRouter::TryRouteCached(const FString& CacheKey, const FString& Method, const FString& Data) +{ + FFlutterMethodDelegate* Delegate = CachedDelegates.Find(CacheKey); + if (Delegate && Delegate->IsBound()) + { + Delegate->Execute(Method, Data); + return true; + } + return false; +} + +bool UFlutterMessageRouter::TryRouteBinaryCached(const FString& CacheKey, const FString& Method, const TArray& Data) +{ + FFlutterBinaryMethodDelegate* Delegate = CachedBinaryDelegates.Find(CacheKey); + if (Delegate && Delegate->IsBound()) + { + Delegate->Execute(Method, Data); + return true; + } + return false; +} + +// ============================================================ +// MARK: - Message Queuing +// ============================================================ + +void UFlutterMessageRouter::QueueMessage(const FString& Target, const FString& Method, const FString& Data) +{ + if (MessageQueue.Num() >= MaxQueueSize) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterRouter] Message queue full, dropping oldest message")); + MessageQueue.RemoveAt(0); + } + + FQueuedFlutterMessage QueuedMsg; + QueuedMsg.Target = Target; + QueuedMsg.Method = Method; + QueuedMsg.Data = Data; + QueuedMsg.bIsBinary = false; + + MessageQueue.Add(QueuedMsg); + Statistics.QueuedMessages = MessageQueue.Num(); + + UE_LOG(LogTemp, Verbose, TEXT("[FlutterRouter] Queued message for target: %s"), *Target); +} + +void UFlutterMessageRouter::FlushQueue() +{ + if (MessageQueue.Num() == 0) + { + return; + } + + TArray MessagesToProcess = MessageQueue; + MessageQueue.Empty(); + + for (const FQueuedFlutterMessage& Msg : MessagesToProcess) + { + if (Msg.bIsBinary) + { + if (!RouteBinaryMessage(Msg.Target, Msg.Method, Msg.BinaryData)) + { + // Re-queue if still no handler + if (bQueueUnknownTargets && !Targets.Contains(Msg.Target)) + { + MessageQueue.Add(Msg); + } + } + } + else + { + if (!RouteMessage(Msg.Target, Msg.Method, Msg.Data)) + { + // Re-queue if still no handler + if (bQueueUnknownTargets && !Targets.Contains(Msg.Target)) + { + MessageQueue.Add(Msg); + } + } + } + } + + Statistics.QueuedMessages = MessageQueue.Num(); +} + +void UFlutterMessageRouter::ClearQueue() +{ + int32 Cleared = MessageQueue.Num(); + MessageQueue.Empty(); + Statistics.QueuedMessages = 0; + + UE_LOG(LogTemp, Log, TEXT("[FlutterRouter] Cleared %d queued messages"), Cleared); +} + +// ============================================================ +// MARK: - Statistics +// ============================================================ + +FFlutterRouterStatistics UFlutterMessageRouter::GetStatistics() const +{ + return Statistics; +} + +void UFlutterMessageRouter::ResetStatistics() +{ + Statistics.MessagesRouted = 0; + Statistics.MessagesDropped = 0; + // Keep registration counts accurate + Statistics.RegisteredTargets = Targets.Num(); + Statistics.CachedDelegates = CachedDelegates.Num() + CachedBinaryDelegates.Num(); + Statistics.QueuedMessages = MessageQueue.Num(); +} + +// ============================================================ +// MARK: - Configuration +// ============================================================ + +void UFlutterMessageRouter::SetQueueUnknownTargets(bool bEnable) +{ + bQueueUnknownTargets = bEnable; +} + +void UFlutterMessageRouter::SetMaxQueueSize(int32 Size) +{ + MaxQueueSize = FMath::Max(1, Size); + + // Trim queue if necessary + while (MessageQueue.Num() > MaxQueueSize) + { + MessageQueue.RemoveAt(0); + } + + Statistics.QueuedMessages = MessageQueue.Num(); +} + +// ============================================================ +// MARK: - Helpers +// ============================================================ + +FString UFlutterMessageRouter::GetCacheKey(const FString& Target, const FString& Method) const +{ + return FString::Printf(TEXT("%s:%s"), *Target, *Method); +} diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp new file mode 100644 index 0000000..6cddda7 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/FlutterPlugin.cpp @@ -0,0 +1,33 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterPlugin.h" + +#define LOCTEXT_NAMESPACE "FFlutterPluginModule" + +#if PLATFORM_IOS || PLATFORM_MAC +// Defined in Private/FlutterBridge_Apple.cpp. +extern void FlutterBridge_ListenForEngineReady(); +#endif + +void FFlutterPluginModule::StartupModule() +{ + // This code will execute after your module is loaded into memory + UE_LOG(LogTemp, Log, TEXT("FlutterPlugin module started")); + +#if PLATFORM_IOS || PLATFORM_MAC + // Subscribe before FAppEntry gets far enough to announce that the config is + // loaded. This module loads in the PreDefault phase, so it is early enough; + // registering any later would miss a one-shot broadcast. + FlutterBridge_ListenForEngineReady(); +#endif +} + +void FFlutterPluginModule::ShutdownModule() +{ + // This function may be called during shutdown to clean up your module + UE_LOG(LogTemp, Log, TEXT("FlutterPlugin module shutdown")); +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FFlutterPluginModule, FlutterPlugin) diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm new file mode 100644 index 0000000..0b3ff67 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/IOS/FlutterView_IOS.mm @@ -0,0 +1,375 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterBridge.h" +#include "UnrealEngine.h" +#include "Engine/Engine.h" +#include "Containers/Ticker.h" +#include "Engine/GameViewportClient.h" +#include "Slate/SceneViewport.h" + +#include + +#if PLATFORM_IOS + +#include "UnrealBridge.h" + +#import + +#include "IOS/IOSAppDelegate.h" +#include "IOS/IOSView.h" + +#if BUILD_EMBEDDED_APP + +// Defined in Private/FlutterBridge_Apple.cpp. +extern bool FlutterBridge_IsEngineReadyForView(); + +// ============================================================ +// MARK: - Embedded render view +// ============================================================ +// +// Unreal's embedded mode does not create its own view. LaunchIOS.cpp says so: +// "For embedded apps, the UEEmbeddedView must have been created and set into +// the AppDelegate as IOSView", and the branch that would have built one is +// compiled out under BUILD_EMBEDDED_APP. +// +// So this does what the non-embedded path in FAppEntry does, minus the part +// that parents the view. The host owns placement, because in a Flutter app the +// view belongs to a platform view inside the widget tree. +// +// This file lives under Private/IOS so UnrealBuildTool leaves it out of every +// other platform's build. + +/// The view handed to the host. The app delegate holds the only owning +/// reference, so this is a plain observing pointer. +/// +/// Not __weak: Unreal compiles Objective-C++ under manual reference counting, +/// where weak references are a compile error rather than a nicety. It is +/// cleared in UnrealBridge_DestroyView so it cannot outlive the view. +static FIOSView* GEmbeddedView = nil; + +/// Whether UnrealBridge_StartEngine has been called. +/// +/// FAppEntry blocks the game thread waiting for AppDelegate.IOSView, and it +/// only announces readiness from the main thread once config is loaded. A host +/// that waits for that announcement before building the view is relying on the +/// two crossing in the right order. Creating the view up front is the other +/// way round, so both are allowed: before the engine is started, or after it +/// says it is ready. +static bool GEngineStartRequested = false; + +/// Apply the size Unreal should render at. +/// +/// The size the host wants, in pixels, and the size the engine was last told +/// about. +static std::atomic GDesiredPixelWidth{0}; +static std::atomic GDesiredPixelHeight{0}; +static int32 GAppliedPixelWidth = 0; +static int32 GAppliedPixelHeight = 0; +static FTSTicker::FDelegateHandle GResolutionTicker; + +/// Make the engine's render target match the view it renders into. +/// +/// The engine creates its viewport before the host's view exists, at a default +/// 1280x720, and nothing in an embedded build ever corrects it. It then renders +/// that 16:9 frame into a correctly sized portrait surface, which looks like the +/// scene has been cropped into a band rather than like a render target that is +/// the wrong shape. +/// +/// Resizing the scene viewport is what actually moves it. Asking for a +/// resolution change instead does not: the console manager refuses the r.SetRes +/// write on priority grounds and says so in the log, and calling it off the game +/// thread aborts the process inside the CVar change. +/// +/// Compares against the viewport's real size every tick rather than remembering +/// what it last asked for, so it corrects itself if the engine resizes back, and +/// a rotation puts itself right. In the steady state it is one comparison. +static bool ApplyPendingResolution(float) +{ + const int32 Width = GDesiredPixelWidth.load(std::memory_order_acquire); + const int32 Height = GDesiredPixelHeight.load(std::memory_order_acquire); + if (Width <= 0 || Height <= 0) + { + return true; + } + + if (GEngine == nullptr || GEngine->GameViewport == nullptr) + { + return true; + } + + FSceneViewport* Viewport = GEngine->GameViewport->GetGameViewport(); + if (Viewport == nullptr) + { + return true; + } + + const FIntPoint Current = Viewport->GetSizeXY(); + if (Current.X == Width && Current.Y == Height) + { + return true; + } + + Viewport->ResizeFrame((uint32)Width, (uint32)Height, EWindowMode::Fullscreen); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Render target was %dx%d, resized to %dx%d"), + Current.X, Current.Y, Width, Height); + + return true; +} + +/// Record the size the host wants. Applied later, on the game thread. +static void RequestPixelSize(int32 PixelWidth, int32 PixelHeight) +{ + if (PixelWidth <= 0 || PixelHeight <= 0) + { + return; + } + + GDesiredPixelWidth.store(PixelWidth, std::memory_order_release); + GDesiredPixelHeight.store(PixelHeight, std::memory_order_release); + + if (!GResolutionTicker.IsValid()) + { + GResolutionTicker = FTSTicker::GetCoreTicker().AddTicker( + FTickerDelegate::CreateStatic(&ApplyPendingResolution), 0.0f); + } +} + +/// The engine works in pixels while the host talks in points, so the scale +/// The engine works in pixels while the host talks in points, so the scale +/// factor has to be applied here or the engine renders at the wrong resolution +/// on every device with a retina display, which is all of them. +static void ApplyViewSize(FIOSView* View, float Width, float Height, float Scale) +{ + if (View == nil) + { + return; + } + + const CGFloat EffectiveScale = (Scale > 0.0f) ? (CGFloat)Scale : [UIScreen mainScreen].scale; + + View.frame = CGRectMake(0, 0, (CGFloat)Width, (CGFloat)Height); + View.contentScaleFactor = EffectiveScale; + View.ViewSize = CGSizeMake((CGFloat)Width * EffectiveScale, + (CGFloat)Height * EffectiveScale); + + const int32 PixelWidth = (int32)(Width * EffectiveScale); + const int32 PixelHeight = (int32)(Height * EffectiveScale); + + [View CalculateContentScaleFactor:PixelWidth ScreenHeight:PixelHeight]; + [View UpdateRenderWidth:(unsigned int)PixelWidth andHeight:(unsigned int)PixelHeight]; + + // Sizing the view is not enough. The engine keeps its own idea of the + // resolution, and in an embedded build nothing tells it ours, so it stays + // on the default 1280x720. That is landscape, and rendering it into a + // portrait view is what crops the scene into a band across the middle. + // Recorded, not applied. Changing the resolution has to happen on the game + // thread, and this runs on the main one. + RequestPixelSize(PixelWidth, PixelHeight); +} + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] UnrealBridge_StartEngine must be called on the main thread")); + return 0; + } + + if (GEngineStartRequested) + { + return 1; + } + + // StartupEmbeddedUnreal is the engine's own "LaunchIOS replacement": it + // seeds the command line and starts the game thread. Without it nothing + // boots, the readiness signal never fires, and a host can tick an engine + // that was never running. + // + // It reaches for [IOSAppDelegate GetDelegate], which is Fatal if the app's + // delegate does not subclass IOSAppDelegate. + GEngineStartRequested = true; + [FIOSView StartupEmbeddedUnreal]; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Engine start requested")); + return 1; +} + +void* UnrealBridge_CreateView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] UnrealBridge_CreateView must be called on the main thread")); + return nullptr; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + if (AppDelegate == nil) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterView_IOS] No IOSAppDelegate yet")); + return nullptr; + } + + // Do not wait for the engine to announce readiness. It cannot arrive in + // time, and relying on it deadlocks. + // + // FEngineLoop::PreInit calls FPlatformMisc::PlatformInit (which on iOS is + // FAppEntry::PlatformInit) at around line 2886. That broadcasts + // "inisareready" and then blocks, spinning until AppDelegate.IOSView + // exists. Plugin modules for the PreDefault phase do not load until around + // line 4675, which execution never reaches. So the broadcast happens before + // anything in this plugin is alive to hear it, and the engine then waits + // for a view that a host listening for that broadcast will never create. + // + // The engine polls for the view, so the host can simply make one once the + // engine has been started, and the wait loop picks it up. Before + // StartEngine is too early: Metal comes up as part of engine startup, and + // building a view without it crashes. + if (!GEngineStartRequested) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] Call UnrealBridge_StartEngine before creating a view; " + "Metal is not up until the engine starts")); + return nullptr; + } + + // Already made one. Resize it rather than stranding the engine on a view + // the host has thrown away. + if (AppDelegate.IOSView != nil) + { + ApplyViewSize(AppDelegate.IOSView, Width, Height, Scale); + GEmbeddedView = AppDelegate.IOSView; + return (void*)AppDelegate.IOSView; + } + + const CGFloat EffectiveScale = (Scale > 0.0f) ? (CGFloat)Scale : [UIScreen mainScreen].scale; + FIOSView* View = [[FIOSView alloc] initWithFrame:CGRectMake(0, 0, Width, Height)]; + if (View == nil) + { + UE_LOG(LogTemp, Error, TEXT("[FlutterView_IOS] Failed to create FIOSView")); + return nullptr; + } + + // Mirrors what FAppEntry does for a normal build. + View.clearsContextBeforeDrawing = NO; +#if !PLATFORM_TVOS + View.multipleTouchEnabled = YES; +#endif + View.contentScaleFactor = EffectiveScale; + + // The delegate holds the strong reference, and the engine finds the view + // through it. Assign before creating the framebuffer, because the RHI + // reaches back through the delegate while initialising. + // + // Under manual reference counting the alloc above is +1 and the retain + // property adds another, so hand our own reference to the pool. That also + // keeps View valid through the failure path below, where the property gets + // cleared. + AppDelegate.IOSView = View; + [View autorelease]; + + ApplyViewSize(View, Width, Height, Scale); + + if (![View CreateFramebuffer]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_IOS] CreateFramebuffer failed, the engine has nothing to render into")); + AppDelegate.IOSView = nil; + return nullptr; + } + + GEmbeddedView = View; + UE_LOG(LogTemp, Log, + TEXT("[FlutterView_IOS] Embedded render view created at %.0fx%.0f @%.1fx"), + Width, Height, (float)EffectiveScale); + + // Returned unretained. The delegate owns it; the host must not release it. + return (void*)View; +} + +void UnrealBridge_ResizeView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] UnrealBridge_ResizeView called off the main thread, ignoring")); + return; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + FIOSView* View = (AppDelegate != nil) ? AppDelegate.IOSView : nil; + if (View == nil) + { + return; + } + + ApplyViewSize(View, Width, Height, Scale); + [View forceLayoutSubviews]; +} + +void UnrealBridge_DestroyView(void) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] UnrealBridge_DestroyView called off the main thread, ignoring")); + return; + } + + IOSAppDelegate* AppDelegate = [IOSAppDelegate GetDelegate]; + FIOSView* View = (AppDelegate != nil) ? AppDelegate.IOSView : nil; + if (View == nil) + { + return; + } + + [View DestroyFramebuffer]; + [View removeFromSuperview]; + AppDelegate.IOSView = nil; + GEmbeddedView = nil; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_IOS] Embedded render view destroyed")); +} + +int32_t UnrealBridge_IsViewReady(void) +{ + FIOSView* View = GEmbeddedView; + // bIsInitialized is what FAppEntry itself waits on before letting the RHI + // start, so it is the honest answer to "can this render yet". + return (View != nil && View->bIsInitialized) ? 1 : 0; +} + +} // extern "C" + +#else // !BUILD_EMBEDDED_APP + +// Not an embedded build, so the engine makes and owns its own view and the +// embedded entry points it would need are compiled out of IOSView.h. Keep the +// ABI present so a host can call it unconditionally and get an honest answer. + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) { return 0; } + +void* UnrealBridge_CreateView(float, float, float) +{ + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_IOS] Not an embedded build. Set bBuildAsFramework=True " + "under [/Script/IOSRuntimeSettings.IOSRuntimeSettings] in " + "DefaultEngine.ini to build a framework with an embeddable view.")); + return nullptr; +} + +void UnrealBridge_ResizeView(float, float, float) {} +void UnrealBridge_DestroyView(void) {} +int32_t UnrealBridge_IsViewReady(void) { return 0; } + +} // extern "C" + +#endif // BUILD_EMBEDDED_APP + +#endif // PLATFORM_IOS diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm new file mode 100644 index 0000000..86337eb --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Private/Mac/FlutterView_Mac.mm @@ -0,0 +1,223 @@ +// Starting Unreal, and getting a view out of it, on macOS. +// +// None of this mirrors the iOS path, because the engine does not offer the same +// thing twice. On iOS Epic wrote an embedded mode: FIOSView, a delegate that +// caches itself, and StartupEmbeddedUnreal to boot the lot. On Mac there is no +// embedded anything. BUILD_EMBEDDED_APP is defined only by UEBuildIOS, and the +// only embedded view code in the engine sits under ApplicationCore/*/IOS. +// +// So this does by hand what the Mac app delegate normally does. It starts the +// game thread the same way LaunchMac does, then waits for the engine to make +// its own window and lends the host that window's view. The engine still +// believes it owns a window; it simply never gets shown. + +#include "CoreMinimal.h" + +#if PLATFORM_MAC + +#include "UnrealBridge.h" + +#include "Mac/CocoaThread.h" +#include "Mac/CocoaWindow.h" +#include "Misc/CommandLine.h" + +#import + +#include + +/// The engine's entry point, as LaunchMac declares it. +extern int32 GuardedMain(const TCHAR* CmdLine); + +namespace +{ + std::atomic GEngineStartRequested{false}; + + /// The view lent to the host, and the window it came from. + NSView* GEmbeddedView = nil; + FCocoaWindow* GEngineWindow = nil; + + /// The command line handed to GuardedMain. + /// + /// Mac keeps its own in LaunchMac.cpp as a file-static, so there is nothing + /// to share, and the engine takes it as an argument anyway. Held here rather + /// than on the stack because the game thread reads it after this returns. + FString GEmbeddedCommandLine; + + /// Find the window the engine made for itself. + /// + /// It arrives some time after the game thread starts, so this returns nil + /// until it does and the host keeps asking, the same as on iOS. + FCocoaWindow* FindEngineWindow() + { + for (NSWindow* Window in [NSApp windows]) + { + if ([Window isKindOfClass:[FCocoaWindow class]]) + { + return (FCocoaWindow*)Window; + } + } + return nil; + } +} + +/// Runs GuardedMain, so the game thread has something to call. +@interface FlutterUnrealLauncher : NSObject +- (void)runGameThread:(id)Argument; +@end + +@implementation FlutterUnrealLauncher +- (void)runGameThread:(id)Argument +{ + GuardedMain(*GEmbeddedCommandLine); +} +@end + +extern "C" { + +int32_t UnrealBridge_StartEngine(void) +{ + if (GEngineStartRequested.exchange(true)) + { + return 1; + } + + // The command line normally comes from argv, and a library has none. Read + // it from uecommandline.txt beside the executable instead, which is the + // convention iOS already uses, so a host places the same file on both + // platforms and this does not become another thing to know. + // + // It matters more here than on iOS: a Mac build running uncooked content + // finds the project only if -project points at it. + GEmbeddedCommandLine = TEXT(""); + + NSString* CommandLinePath = + [[NSBundle mainBundle] pathForResource:@"uecommandline" ofType:@"txt"]; + if (CommandLinePath == nil) + { + // Resources are one place; the bundle root is the other, and that is + // where a staged build puts it. + CommandLinePath = [[[NSBundle mainBundle] bundlePath] + stringByAppendingPathComponent:@"uecommandline.txt"]; + } + + NSString* Contents = [NSString stringWithContentsOfFile:CommandLinePath + encoding:NSUTF8StringEncoding + error:nil]; + if (Contents != nil) + { + GEmbeddedCommandLine = FString( + [[Contents stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]] UTF8String]); + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Command line: %s"), *GEmbeddedCommandLine); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[FlutterView_Mac] No uecommandline.txt beside the app, so the engine " + "has no project to open and will not load a level")); + } + + // Start the game thread the way LaunchMac does. RunGameThread registers the + // calling thread as the main one and puts GuardedMain on a thread of its + // own, which is what every later assumption about game and main threads + // depends on. + static FlutterUnrealLauncher* Launcher = [[FlutterUnrealLauncher alloc] init]; + RunGameThread(Launcher, @selector(runGameThread:)); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Engine start requested")); + return 1; +} + +void* UnrealBridge_CreateView(float Width, float Height, float Scale) +{ + if (![NSThread isMainThread]) + { + UE_LOG(LogTemp, Error, + TEXT("[FlutterView_Mac] UnrealBridge_CreateView must be called on the main thread")); + return nullptr; + } + + if (!GEngineStartRequested.load()) + { + return nullptr; + } + + if (GEmbeddedView != nil) + { + UnrealBridge_ResizeView(Width, Height, Scale); + return (void*)GEmbeddedView; + } + + FCocoaWindow* Window = FindEngineWindow(); + if (Window == nil) + { + // Still starting. The host asks again next frame. + return nullptr; + } + + NSView* Content = [Window contentView]; + if (Content == nil) + { + return nullptr; + } + + // Borrow the view rather than build one. The engine already made a window + // with a Metal layer set up the way it wants, and taking that view is far + // less likely to be wrong than assembling a second one beside it. Reparented + // into the host's hierarchy, it renders where Flutter puts it. + GEngineWindow = Window; + GEmbeddedView = [Content retain]; + + // The window it came from would otherwise sit on screen, empty, next to the + // Flutter one. + [Window orderOut:nil]; + + UnrealBridge_ResizeView(Width, Height, Scale); + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Lent the engine's view at %.0fx%.0f"), Width, Height); + return (void*)GEmbeddedView; +} + +void UnrealBridge_ResizeView(float Width, float Height, float Scale) +{ + if (GEmbeddedView == nil || Width <= 0.0f || Height <= 0.0f) + { + return; + } + + // Points here, unlike iOS. AppKit scales for the backing store itself, and + // multiplying by the scale factor a second time would render at four times + // the area on any Retina display. + [GEmbeddedView setFrame:NSMakeRect(0.0, 0.0, Width, Height)]; +} + +void UnrealBridge_DestroyView(void) +{ + if (GEmbeddedView == nil) + { + return; + } + + // Hand it back to the window that owns it. Releasing it while the engine + // still holds a viewport pointing at it would leave the renderer drawing + // into freed memory. + if (GEngineWindow != nil) + { + [GEngineWindow setContentView:GEmbeddedView]; + } + + [GEmbeddedView release]; + GEmbeddedView = nil; + GEngineWindow = nil; + + UE_LOG(LogTemp, Log, TEXT("[FlutterView_Mac] Returned the engine's view")); +} + +int32_t UnrealBridge_IsViewReady(void) +{ + return GEmbeddedView != nil ? 1 : 0; +} + +} // extern "C" + +#endif // PLATFORM_MAC diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterAssetManager.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterAssetManager.h new file mode 100644 index 0000000..c924230 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterAssetManager.h @@ -0,0 +1,280 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Engine/StreamableManager.h" +#include "FlutterAssetManager.generated.h" + +/** + * Asset loading state enumeration + */ +UENUM(BlueprintType) +enum class EFlutterAssetState : uint8 +{ + NotLoaded, + Loading, + Loaded, + Failed, + Unloading +}; + +/** + * Information about a loaded asset + */ +USTRUCT(BlueprintType) +struct FFlutterLoadedAsset +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + FString AssetPath; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + EFlutterAssetState State; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + UObject* Asset = nullptr; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int64 LoadTimeMs = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 SizeBytes = 0; +}; + +/** + * Asset loading progress information + */ +USTRUCT(BlueprintType) +struct FFlutterAssetProgress +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 TotalAssets = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 LoadedAssets = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 FailedAssets = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + float Progress = 0.0f; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int64 TotalSizeBytes = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int64 LoadedSizeBytes = 0; +}; + +/** + * Asset manager statistics + */ +USTRUCT(BlueprintType) +struct FFlutterAssetStatistics +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 TotalAssetsLoaded = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 TotalAssetsUnloaded = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int64 TotalBytesLoaded = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int64 CurrentMemoryUsage = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 CacheHits = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + int32 CacheMisses = 0; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|Assets") + float AverageLoadTimeMs = 0.0f; +}; + +// Delegate declarations +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnFlutterAssetLoaded, const FString&, AssetPath, UObject*, Asset); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnFlutterAssetFailed, const FString&, AssetPath, const FString&, ErrorMessage); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnFlutterAssetProgress, const FFlutterAssetProgress&, Progress); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnFlutterAssetUnloaded, const FString&, AssetPath); + +/** + * Asset manager for Flutter-Unreal integration. + * Provides async asset loading with progress tracking and caching. + */ +UCLASS(BlueprintType, Blueprintable) +class FLUTTERPLUGIN_API UFlutterAssetManager : public UObject +{ + GENERATED_BODY() + +public: + UFlutterAssetManager(); + + /** Get the singleton instance */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets", meta = (WorldContext = "WorldContextObject")) + static UFlutterAssetManager* Get(UObject* WorldContextObject); + + // ==================== ASSET LOADING ==================== + + /** Load a single asset asynchronously */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void LoadAsset(const FString& AssetPath); + + /** Load a single asset and return it (blocking) */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + UObject* LoadAssetSync(const FString& AssetPath); + + /** Load multiple assets asynchronously */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void LoadAssets(const TArray& AssetPaths); + + /** Load a level by name */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void LoadLevel(const FString& LevelName, bool bAbsolute = true); + + /** Load a level asynchronously with streaming */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void LoadLevelAsync(const FString& LevelName); + + // ==================== ASSET UNLOADING ==================== + + /** Unload a single asset */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void UnloadAsset(const FString& AssetPath); + + /** Unload multiple assets */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void UnloadAssets(const TArray& AssetPaths); + + /** Unload all loaded assets */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void UnloadAllAssets(); + + /** Unload a streaming level */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void UnloadLevel(const FString& LevelName); + + // ==================== ASSET QUERIES ==================== + + /** Check if an asset is loaded */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + bool IsAssetLoaded(const FString& AssetPath) const; + + /** Get the state of an asset */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + EFlutterAssetState GetAssetState(const FString& AssetPath) const; + + /** Get a loaded asset */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + UObject* GetLoadedAsset(const FString& AssetPath) const; + + /** Get information about a loaded asset */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + FFlutterLoadedAsset GetAssetInfo(const FString& AssetPath) const; + + /** Get all loaded asset paths */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + TArray GetLoadedAssetPaths() const; + + // ==================== CACHE MANAGEMENT ==================== + + /** Set the maximum cache size in bytes */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void SetCacheMaxSize(int64 MaxSizeBytes); + + /** Get the current cache size */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + int64 GetCacheSize() const; + + /** Clear the asset cache */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void ClearCache(); + + /** Trim cache to fit within max size */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void TrimCache(); + + // ==================== STATISTICS ==================== + + /** Get asset manager statistics */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + FFlutterAssetStatistics GetStatistics() const; + + /** Reset statistics */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void ResetStatistics(); + + // ==================== FLUTTER COMMUNICATION ==================== + + /** Notify Flutter of asset load progress */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void NotifyFlutterProgress(const FFlutterAssetProgress& Progress); + + /** Notify Flutter of asset loaded */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void NotifyFlutterAssetLoaded(const FString& AssetPath); + + /** Notify Flutter of asset load failure */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Assets") + void NotifyFlutterAssetFailed(const FString& AssetPath, const FString& ErrorMessage); + + // ==================== EVENTS ==================== + + /** Called when an asset is loaded successfully */ + UPROPERTY(BlueprintAssignable, Category = "Flutter|Assets") + FOnFlutterAssetLoaded OnAssetLoaded; + + /** Called when an asset fails to load */ + UPROPERTY(BlueprintAssignable, Category = "Flutter|Assets") + FOnFlutterAssetFailed OnAssetFailed; + + /** Called when loading progress updates */ + UPROPERTY(BlueprintAssignable, Category = "Flutter|Assets") + FOnFlutterAssetProgress OnProgress; + + /** Called when an asset is unloaded */ + UPROPERTY(BlueprintAssignable, Category = "Flutter|Assets") + FOnFlutterAssetUnloaded OnAssetUnloaded; + +protected: + /** Handle async load completion */ + void HandleAssetLoaded(const FString& AssetPath, UObject* Asset); + + /** Update progress and notify listeners */ + void UpdateProgress(); + + /** Calculate asset size estimate */ + int32 EstimateAssetSize(UObject* Asset) const; + +private: + /** Singleton instance */ + static UFlutterAssetManager* Instance; + + /** Streamable manager for async loading */ + FStreamableManager StreamableManager; + + /** Currently loaded assets */ + UPROPERTY() + TMap LoadedAssets; + + /** Assets currently being loaded */ + TMap> PendingLoads; + + /** Cache settings */ + int64 CacheMaxSizeBytes = 256 * 1024 * 1024; // 256 MB default + + /** Statistics */ + FFlutterAssetStatistics Statistics; + + /** Current batch loading progress */ + FFlutterAssetProgress CurrentProgress; + + /** Batch load asset paths */ + TArray BatchLoadPaths; +}; diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBlueprintLibrary.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBlueprintLibrary.h new file mode 100644 index 0000000..98e3bd0 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBlueprintLibrary.h @@ -0,0 +1,195 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "FlutterMessageRouter.h" +#include "FlutterBlueprintLibrary.generated.h" + +/** + * Flutter Blueprint Function Library + * + * Provides Blueprint-accessible functions for Flutter integration. + * Includes utilities for messaging, quality settings, and router configuration. + * + * Usage in Blueprints: + * - Send Flutter Message: Send a message to Flutter + * - Send Flutter Binary: Send binary data to Flutter + * - Register Flutter Target: Register an object to receive Flutter messages + * - Get Flutter Bridge: Get the FlutterBridge actor instance + */ +UCLASS() +class FLUTTERPLUGIN_API UFlutterBlueprintLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + // ============================================================ + // MARK: - Messaging + // ============================================================ + + /** + * Send a message to Flutter + * @param Target - The target object in Flutter (e.g., "GameManager") + * @param Method - The method name to call (e.g., "onGameStateChanged") + * @param Data - The data to send (JSON string) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Messaging", meta = (WorldContext = "WorldContextObject")) + static void SendFlutterMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const FString& Data); + + /** + * Send a JSON object to Flutter + * @param Target - The target object in Flutter + * @param Method - The method name to call + * @param JsonObject - Map of key-value pairs to send as JSON + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Messaging", meta = (WorldContext = "WorldContextObject")) + static void SendFlutterJsonMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const TMap& JsonObject); + + /** + * Send binary data to Flutter + * @param Target - The target object in Flutter + * @param Method - The method name to call + * @param Data - The binary data to send + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Messaging", meta = (WorldContext = "WorldContextObject")) + static void SendFlutterBinaryMessage(const UObject* WorldContextObject, const FString& Target, const FString& Method, const TArray& Data); + + // ============================================================ + // MARK: - Router Registration + // ============================================================ + + /** + * Register a target to receive Flutter messages + * @param TargetName - The name to register (e.g., "GameManager") + * @param Target - The object to receive messages + * @param bIsSingleton - If true, only one instance can be registered + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static void RegisterFlutterTarget(const UObject* WorldContextObject, const FString& TargetName, UObject* Target, bool bIsSingleton = true); + + /** + * Unregister a target from receiving Flutter messages + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static void UnregisterFlutterTarget(const UObject* WorldContextObject, const FString& TargetName); + + /** + * Check if a target is registered + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static bool IsFlutterTargetRegistered(const UObject* WorldContextObject, const FString& TargetName); + + /** + * Get all registered Flutter targets + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static TArray GetRegisteredFlutterTargets(const UObject* WorldContextObject); + + /** + * Get router statistics + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static FFlutterRouterStatistics GetFlutterRouterStatistics(const UObject* WorldContextObject); + + // ============================================================ + // MARK: - Quality Settings + // ============================================================ + + /** + * Apply a quality preset + * @param QualityLevel - 0=Low, 1=Medium, 2=High, 3=Epic, 4=Cinematic + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Quality", meta = (WorldContext = "WorldContextObject")) + static void ApplyFlutterQualityPreset(const UObject* WorldContextObject, int32 QualityLevel); + + /** + * Apply custom quality settings + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Quality", meta = (WorldContext = "WorldContextObject")) + static void ApplyFlutterQualitySettings( + const UObject* WorldContextObject, + int32 AntiAliasing, + int32 Shadows, + int32 PostProcess, + int32 Textures, + int32 Effects, + int32 Foliage, + int32 ViewDistance + ); + + /** + * Get current quality settings + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Quality", meta = (WorldContext = "WorldContextObject")) + static TMap GetFlutterQualitySettings(const UObject* WorldContextObject); + + // ============================================================ + // MARK: - Lifecycle + // ============================================================ + + /** + * Request to load a level + * This will notify Flutter and load the level + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level", meta = (WorldContext = "WorldContextObject")) + static void LoadFlutterLevel(const UObject* WorldContextObject, const FString& LevelName); + + /** + * Execute a console command via Flutter bridge + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Console", meta = (WorldContext = "WorldContextObject")) + static void ExecuteFlutterConsoleCommand(const UObject* WorldContextObject, const FString& Command); + + // ============================================================ + // MARK: - Bridge Access + // ============================================================ + + /** + * Get the Flutter Bridge actor instance + * Returns null if not found + */ + UFUNCTION(BlueprintCallable, Category = "Flutter", meta = (WorldContext = "WorldContextObject")) + static class AFlutterBridge* GetFlutterBridge(const UObject* WorldContextObject); + + /** + * Get the Flutter Message Router instance + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static UFlutterMessageRouter* GetFlutterRouter(const UObject* WorldContextObject); + + /** + * Check if Flutter bridge is available + */ + UFUNCTION(BlueprintCallable, Category = "Flutter", meta = (WorldContext = "WorldContextObject")) + static bool IsFlutterBridgeAvailable(const UObject* WorldContextObject); + + // ============================================================ + // MARK: - Utilities + // ============================================================ + + /** + * Convert a map to JSON string + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Utilities") + static FString MapToJsonString(const TMap& Map); + + /** + * Parse JSON string to map + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Utilities") + static TMap JsonStringToMap(const FString& JsonString); + + /** + * Encode bytes to Base64 string + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Utilities") + static FString EncodeBase64(const TArray& Data); + + /** + * Decode Base64 string to bytes + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Utilities") + static TArray DecodeBase64(const FString& Base64String); +}; diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBridge.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBridge.h new file mode 100644 index 0000000..3f27010 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterBridge.h @@ -0,0 +1,401 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "FlutterBridge.generated.h" + +/** + * Flutter Bridge Actor + * + * Main bridge between Flutter and Unreal Engine. + * Handles bidirectional communication, console commands, quality settings, + * and level loading. + */ +/** + * Every message from Flutter, whatever it was addressed to. + */ +DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FFlutterAnyMessage, + const FString&, Target, const FString&, Method, const FString&, Data); + +UCLASS(Blueprintable, BlueprintType) +class FLUTTERPLUGIN_API AFlutterBridge : public AActor +{ + GENERATED_BODY() + +public: + AFlutterBridge(); + +protected: + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + +public: + virtual void Tick(float DeltaTime) override; + + // ============================================================ + // MARK: - Message Communication + // ============================================================ + + /** + * Send a message to Flutter + * @param Target - The target object in Flutter (e.g., "GameManager") + * @param Method - The method name to call (e.g., "onGameStateChanged") + * @param Data - The data to send (JSON string) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SendToFlutter(const FString& Target, const FString& Method, const FString& Data); + + /** + * Called when a message is received from Flutter + * This is called from native code (JNI/Objective-C++) + * @param Target - The target object in Unreal (e.g., "PlayerController") + * @param Method - The method name to call + * @param Data - The data received (JSON string) + */ + void ReceiveFromFlutter(const FString& Target, const FString& Method, const FString& Data); + + /** + * Blueprint event fired when a message is received from Flutter + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter") + void OnMessageFromFlutter(const FString& Target, const FString& Method, const FString& Data); + + /** + * Every message from Flutter, whatever it was addressed to. + * + * The same shape as the Flutter side, where GameWidget's onMessage receives + * everything and decides what to do with it. Bind this when you would + * rather switch on the target yourself than give an actor a name and + * register it, which is most of the time for a single-scene app. + * + * Fires for every message, including ones that a named target also handled, + * so binding it does not take delivery away from anything else. + * + * Assignable from Blueprint, and from C++ with AddDynamic. + */ + UPROPERTY(BlueprintAssignable, Category = "Flutter") + FFlutterAnyMessage OnAnyMessageFromFlutter; + + // ============================================================ + // MARK: - Binary Message Communication + // ============================================================ + + /** + * Send binary data to Flutter + * @param Target - The target object in Flutter + * @param Method - The method name to call + * @param Data - The binary data to send + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Binary") + void SendBinaryToFlutter(const FString& Target, const FString& Method, const TArray& Data); + + /** + * Called when binary data is received from Flutter + * @param Target - The target object in Unreal + * @param Method - The method name to call + * @param Data - The binary data received + * @param Checksum - CRC32 checksum for verification + */ + void ReceiveBinaryFromFlutter(const FString& Target, const FString& Method, const TArray& Data, int32 Checksum); + + /** + * Blueprint event fired when binary data is received from Flutter + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Binary") + void OnBinaryMessageFromFlutter(const FString& Target, const FString& Method, const TArray& Data); + + /** + * Called when a binary chunk header is received (start of chunked transfer) + */ + void ReceiveBinaryChunkHeader( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 TotalSize, + int32 TotalChunks, + int32 Checksum + ); + + /** + * Called when a binary chunk data is received + */ + void ReceiveBinaryChunkData( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 ChunkIndex, + const TArray& Data + ); + + /** + * Called when a binary chunk footer is received (end of chunked transfer) + */ + void ReceiveBinaryChunkFooter( + const FString& Target, + const FString& Method, + const FString& TransferId, + int32 TotalChunks, + int32 Checksum + ); + + /** + * Set the chunk size for binary transfers + */ + void SetBinaryChunkSize(int32 Size); + + /** + * Get the current chunk size for binary transfers + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Binary") + int32 GetBinaryChunkSize() const; + + /** + * Blueprint event fired when a chunked transfer completes + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Binary") + void OnChunkedTransferComplete(const FString& TransferId, const TArray& Data); + + /** + * Blueprint event fired to report binary transfer progress + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Binary") + void OnBinaryTransferProgress(const FString& TransferId, int32 CurrentChunk, int32 TotalChunks, float Progress); + + // ============================================================ + // MARK: - Console Commands + // ============================================================ + + /** + * Execute a console command + * Called from Flutter via native bridge + * @param Command - The console command to execute (e.g., "stat fps") + */ + void ExecuteConsoleCommand(const FString& Command); + + /** + * Execute a console command (Blueprint callable) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Console") + void ExecuteConsoleCommandBP(const FString& Command); + + // ============================================================ + // MARK: - Quality Settings + // ============================================================ + + /** + * Apply quality settings from Flutter + * @param QualityLevel - Overall quality level (0-4: Low, Medium, High, Epic, Cinematic) + * @param AntiAliasing - Anti-aliasing quality (0-4) + * @param Shadow - Shadow quality (0-4) + * @param PostProcess - Post-processing quality (0-4) + * @param Texture - Texture quality (0-4) + * @param Effects - Effects quality (0-4) + * @param Foliage - Foliage quality (0-4) + * @param ViewDistance - View distance quality (0-4) + */ + void ApplyQualitySettings( + int32 QualityLevel, + int32 AntiAliasing, + int32 Shadow, + int32 PostProcess, + int32 Texture, + int32 Effects, + int32 Foliage, + int32 ViewDistance + ); + + /** + * Apply quality settings (Blueprint callable) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Quality") + void ApplyQualitySettingsBP(int32 QualityLevel); + + /** + * Get current quality settings + * @return Map of quality setting names to values + */ + TMap GetQualitySettings(); + + /** + * Get current quality settings (Blueprint callable) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Quality") + TMap GetQualitySettingsBP(); + + // ============================================================ + // MARK: - Level Loading + // ============================================================ + + /** + * Load a level/map + * Called from Flutter via native bridge + * @param LevelName - Name of the level to load + */ + void LoadLevel(const FString& LevelName); + + /** + * Load a level (Blueprint callable) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level") + void LoadLevelBP(const FString& LevelName); + + /** + * Called when a level has finished loading + */ + UFUNCTION() + void OnLevelLoaded(); + + /** + * Blueprint event fired when a level is loaded + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Level") + void OnLevelLoadedBP(const FString& LevelName); + + // ============================================================ + // MARK: - Lifecycle Events + // ============================================================ + + /** + * Called when the engine is paused by Flutter + */ + void OnEnginePause(); + + /** + * Called when the engine is resumed by Flutter + */ + void OnEngineResume(); + + /** + * Called when the engine is being quit by Flutter + */ + void OnEngineQuit(); + + // ============================================================ + // MARK: - Surface Events (Android) + // ============================================================ + + /** + * Called when the rendering surface is ready + * The surface is created by Flutter's SurfaceView and passed to native code + * @param Width - Surface width in pixels + * @param Height - Surface height in pixels + */ + void OnSurfaceReady(int32 Width, int32 Height); + + /** + * Called when the surface dimensions change + * @param Width - New surface width + * @param Height - New surface height + */ + void OnSurfaceSizeChanged(int32 Width, int32 Height); + + /** + * Called when the surface is destroyed + */ + void OnSurfaceDestroyed(); + + /** + * Get the current surface dimensions + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Surface") + void GetSurfaceSize(int32& OutWidth, int32& OutHeight) const; + + /** + * Check if surface is ready for rendering + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Surface") + bool IsSurfaceReady() const; + + /** + * Blueprint events for lifecycle + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Lifecycle") + void OnEnginePausedBP(); + + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Lifecycle") + void OnEngineResumedBP(); + + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Lifecycle") + void OnEngineQuitBP(); + + // ============================================================ + // MARK: - Singleton Access + // ============================================================ + + /** + * Get the global FlutterBridge instance + * Only one instance should exist in the world + */ + UFUNCTION(BlueprintCallable, Category = "Flutter", meta = (WorldContext = "WorldContextObject")) + static AFlutterBridge* GetInstance(const UObject* WorldContextObject); + +private: + // Singleton instance + static AFlutterBridge* Instance; + + // Current level being loaded + FString CurrentLevelName; + + // Is engine paused? + bool bIsPaused; + + // Binary transfer chunk size (default 64KB) + int32 BinaryChunkSize; + + // Surface state (Android) + bool bSurfaceReady; + int32 SurfaceWidth; + int32 SurfaceHeight; + + // Active chunked transfers + struct FChunkedTransfer + { + FString Target; + FString Method; + int32 TotalSize; + int32 TotalChunks; + int32 ExpectedChecksum; + TMap> Chunks; + int32 ReceivedChunks; + + FChunkedTransfer() + : TotalSize(0) + , TotalChunks(0) + , ExpectedChecksum(0) + , ReceivedChunks(0) + {} + }; + + TMap ActiveTransfers; + + // Binary helpers + int32 CalculateCRC32(const TArray& Data) const; + bool VerifyChecksum(const TArray& Data, int32 ExpectedChecksum) const; + TArray CompressData(const TArray& Data) const; + TArray DecompressData(const TArray& Data) const; + void AssembleChunkedTransfer(const FString& TransferId); + + // Platform-specific bridge initialization + void InitializePlatformBridge(); + + // Quality setting helpers + void SetScalabilityQuality(int32 Level); + void SetAntiAliasingQuality(int32 Quality); + void SetShadowQuality(int32 Quality); + void SetPostProcessQuality(int32 Quality); + void SetTextureQuality(int32 Quality); + void SetEffectsQuality(int32 Quality); + void SetFoliageQuality(int32 Quality); + void SetViewDistanceQuality(int32 Quality); + + // Get individual quality settings + int32 GetAntiAliasingQuality() const; + int32 GetShadowQuality() const; + int32 GetPostProcessQuality() const; + int32 GetTextureQuality() const; + int32 GetEffectsQuality() const; + int32 GetFoliageQuality() const; + int32 GetViewDistanceQuality() const; +}; diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterMessageRouter.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterMessageRouter.h new file mode 100644 index 0000000..dc42957 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterMessageRouter.h @@ -0,0 +1,303 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "FlutterMessageRouter.generated.h" + +// Forward declarations +class AFlutterBridge; + +/** + * Delegate for handling Flutter messages + */ +DECLARE_DYNAMIC_DELEGATE_TwoParams(FFlutterMethodDelegate, const FString&, Method, const FString&, Data); + +/** + * Delegate for handling Flutter binary messages + */ +DECLARE_DYNAMIC_DELEGATE_TwoParams(FFlutterBinaryMethodDelegate, const FString&, Method, const TArray&, Data); + +/** + * Registration info for a Flutter target + */ +USTRUCT(BlueprintType) +struct FFlutterTargetInfo +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + FString TargetName; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + UObject* TargetObject; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + bool bIsSingleton; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 RegisteredMethods; + + FFlutterTargetInfo() + : TargetObject(nullptr) + , bIsSingleton(false) + , RegisteredMethods(0) + {} +}; + +/** + * Statistics for the message router + */ +USTRUCT(BlueprintType) +struct FFlutterRouterStatistics +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 MessagesRouted; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 MessagesDropped; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 RegisteredTargets; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 CachedDelegates; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter") + int32 QueuedMessages; + + FFlutterRouterStatistics() + : MessagesRouted(0) + , MessagesDropped(0) + , RegisteredTargets(0) + , CachedDelegates(0) + , QueuedMessages(0) + {} +}; + +/** + * Queued message for pre-ready delivery + */ +struct FQueuedFlutterMessage +{ + FString Target; + FString Method; + FString Data; + bool bIsBinary; + TArray BinaryData; + + FQueuedFlutterMessage() + : bIsBinary(false) + {} +}; + +/** + * Flutter Message Router + * + * High-performance message router with cached delegates for zero-reflection dispatch. + * Supports singleton and multi-instance targets, attribute-based method registration, + * and pre-ready message queuing. + * + * Usage: + * ```cpp + * // Register a target + * UFlutterMessageRouter* Router = UFlutterMessageRouter::Get(this); + * Router->RegisterTarget("GameManager", this, true); + * + * // Register a method handler + * FFlutterMethodDelegate Delegate; + * Delegate.BindDynamic(this, &AMyActor::OnPlayerAction); + * Router->RegisterMethod("GameManager", "onPlayerAction", Delegate); + * ``` + */ +UCLASS(BlueprintType) +class FLUTTERPLUGIN_API UFlutterMessageRouter : public UObject +{ + GENERATED_BODY() + +public: + UFlutterMessageRouter(); + + // ============================================================ + // MARK: - Singleton Access + // ============================================================ + + /** + * Get the global message router instance + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router", meta = (WorldContext = "WorldContextObject")) + static UFlutterMessageRouter* Get(const UObject* WorldContextObject); + + // ============================================================ + // MARK: - Target Registration + // ============================================================ + + /** + * Register a target object that can receive Flutter messages + * @param Name - The target name (e.g., "GameManager") + * @param Target - The object to receive messages + * @param bIsSingleton - If true, only one instance can be registered with this name + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void RegisterTarget(const FString& Name, UObject* Target, bool bIsSingleton = true); + + /** + * Unregister a target + * @param Name - The target name to unregister + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void UnregisterTarget(const FString& Name); + + /** + * Check if a target is registered + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + bool IsTargetRegistered(const FString& Name) const; + + /** + * Get all registered targets + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + TArray GetRegisteredTargets() const; + + // ============================================================ + // MARK: - Method Registration + // ============================================================ + + /** + * Register a method handler for a target + * @param TargetName - The target that receives the method call + * @param MethodName - The method name to handle + * @param Delegate - The delegate to call when the method is received + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void RegisterMethod(const FString& TargetName, const FString& MethodName, FFlutterMethodDelegate Delegate); + + /** + * Register a binary method handler for a target + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void RegisterBinaryMethod(const FString& TargetName, const FString& MethodName, FFlutterBinaryMethodDelegate Delegate); + + /** + * Unregister a method handler + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void UnregisterMethod(const FString& TargetName, const FString& MethodName); + + // ============================================================ + // MARK: - Message Routing + // ============================================================ + + /** + * Route a message to the appropriate target + * @param Target - The target name + * @param Method - The method name + * @param Data - The message data (JSON string) + * @return True if the message was routed successfully + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + bool RouteMessage(const FString& Target, const FString& Method, const FString& Data); + + /** + * Route a binary message to the appropriate target + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + bool RouteBinaryMessage(const FString& Target, const FString& Method, const TArray& Data); + + // ============================================================ + // MARK: - Message Queuing + // ============================================================ + + /** + * Queue a message for later delivery (e.g., before targets are registered) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void QueueMessage(const FString& Target, const FString& Method, const FString& Data); + + /** + * Flush all queued messages to their targets + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void FlushQueue(); + + /** + * Clear all queued messages without delivering them + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void ClearQueue(); + + // ============================================================ + // MARK: - Statistics + // ============================================================ + + /** + * Get routing statistics + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + FFlutterRouterStatistics GetStatistics() const; + + /** + * Reset statistics + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void ResetStatistics(); + + // ============================================================ + // MARK: - Configuration + // ============================================================ + + /** + * Enable or disable message queuing for unknown targets + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void SetQueueUnknownTargets(bool bEnable); + + /** + * Set maximum queue size + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Router") + void SetMaxQueueSize(int32 Size); + +private: + // Singleton instance + static UFlutterMessageRouter* Instance; + + // Registered targets + TMap Targets; + TMap SingletonFlags; + + // Cached delegates for fast lookup + TMap CachedDelegates; + TMap CachedBinaryDelegates; + + // Message queue for pre-ready messages + TArray MessageQueue; + + // Configuration + bool bQueueUnknownTargets; + int32 MaxQueueSize; + + // Statistics + mutable FFlutterRouterStatistics Statistics; + + // Helper to generate cache key + FString GetCacheKey(const FString& Target, const FString& Method) const; + + // Try to route via cached delegate + bool TryRouteCached(const FString& CacheKey, const FString& Method, const FString& Data); + bool TryRouteBinaryCached(const FString& CacheKey, const FString& Method, const TArray& Data); +}; + +/** + * Macro for easy method registration in constructors + */ +#define FLUTTER_REGISTER_METHOD(Router, Target, MethodName, Function) \ + { \ + FFlutterMethodDelegate Delegate; \ + Delegate.BindDynamic(this, &Function); \ + Router->RegisterMethod(Target, MethodName, Delegate); \ + } diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterPlugin.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterPlugin.h new file mode 100644 index 0000000..c19749b --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/FlutterPlugin.h @@ -0,0 +1,14 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + +class FFlutterPluginModule : public IModuleInterface +{ +public: + /** IModuleInterface implementation */ + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h new file mode 100644 index 0000000..bec1757 --- /dev/null +++ b/example/unreal/demo/Plugins/FlutterPlugin/Source/FlutterPlugin/Public/UnrealBridge.h @@ -0,0 +1,244 @@ +// +// UnrealBridge.h +// FlutterPlugin +// +// Flat C ABI across the UnrealFramework boundary. +// +// This header is deliberately free of Unreal types. The Flutter side compiles +// it inside the host app, which must not need CoreMinimal.h, UBT include paths +// or any engine symbols. Everything crossing the boundary is a C primitive. +// +// Direction of travel: +// Flutter -> Unreal UnrealBridge_SendToUnreal and friends +// Unreal -> Flutter callbacks registered with UnrealBridge_Set*Callback +// +// Threading: every UnrealBridge_* entry point is safe to call from any thread +// and hops to the game thread internally. Callbacks fire on the GAME thread, +// so the Flutter side must marshal to the main thread before touching UIKit. +// + +#ifndef UnrealBridge_h +#define UnrealBridge_h + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// Exported from UnrealFramework so the host app can link against it. +#define UNREALBRIDGE_API __attribute__((visibility("default"))) + +// ============================================================ +// MARK: - Unreal to Flutter +// ============================================================ + +/// A string message from Unreal. Pointers are valid only for the duration of +/// the call; copy anything you need to keep. +typedef void (*UnrealMessageCallback)(const char* target, + const char* method, + const char* data); + +/// Binary payload from Unreal. `data` is valid only for the duration of the +/// call. `checksum` is the CRC32 Unreal computed over the payload. +typedef void (*UnrealBinaryCallback)(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +/// Register callbacks. Pass NULL to unregister. Registering replaces any +/// previous callback rather than chaining. +/// +/// There is no separate level-loaded callback: Unreal reports level loads +/// through the message callback with target "FlutterBridge" and method +/// "onLevelLoaded", carrying the level name as data. +UNREALBRIDGE_API void UnrealBridge_SetMessageCallback(UnrealMessageCallback callback); +UNREALBRIDGE_API void UnrealBridge_SetBinaryCallback(UnrealBinaryCallback callback); + +// ============================================================ +// MARK: - Flutter to Unreal +// ============================================================ + +UNREALBRIDGE_API void UnrealBridge_SendToUnreal(const char* target, + const char* method, + const char* data); + +UNREALBRIDGE_API void UnrealBridge_SendBinaryToUnreal(const char* target, + const char* method, + const void* data, + int32_t length, + int32_t checksum); + +UNREALBRIDGE_API void UnrealBridge_ExecuteConsoleCommand(const char* command); + +UNREALBRIDGE_API void UnrealBridge_LoadLevel(const char* levelName); + +/// Quality levels are 0 to 4 (Low, Medium, High, Epic, Cinematic). Pass -1 for +/// any value that should be left alone. +UNREALBRIDGE_API void UnrealBridge_ApplyQualitySettings(int32_t qualityLevel, + int32_t antiAliasing, + int32_t shadow, + int32_t postProcess, + int32_t texture, + int32_t effects, + int32_t foliage, + int32_t viewDistance); + +/// Read the current quality settings into a caller-owned array, in this order: +/// +/// 0 antiAliasing, 1 shadow, 2 postProcess, 3 texture, +/// 4 effects, 5 foliage, 6 viewDistance +/// +/// Note this is the Apply order minus the leading overall quality level, which +/// Unreal exposes no getter for. Returns the number of values written, or 0 if +/// the bridge is not ready or `capacity` is too small. +UNREALBRIDGE_API int32_t UnrealBridge_GetQualitySettings(int32_t* outValues, + int32_t capacity); + +/// Number of values UnrealBridge_GetQualitySettings writes when it succeeds. +#define UNREALBRIDGE_QUALITY_VALUE_COUNT 7 + +// ============================================================ +// MARK: - Engine lifecycle +// ============================================================ +// +// In an embedded build Unreal does not own main() or the run loop, so the host +// has to drive the engine. Unreal exposes this as FEmbeddedCommunication, which +// building as a framework switches on via BUILD_EMBEDDED_APP. +// +// The sequence is: call UnrealBridge_Init once, early, then UnrealBridge_Tick +// every frame from the thread that owns the engine. Between ticks the engine +// sleeps unless something asks it to stay awake. + +/// Bring up the embedded engine plumbing. Safe to call more than once; only +/// the first call does anything. +/// +/// This sets up messaging only. It does not start the engine. +UNREALBRIDGE_API void UnrealBridge_Init(void); + +/// Start Unreal. Call once, from the main thread, after UnrealBridge_Init. +/// +/// In an embedded build Unreal's own launch path never runs, because the host +/// owns main() and the app delegate. This is the replacement: it starts the +/// game thread, after which the engine boots and eventually announces that a +/// render view can be made. +/// +/// Two hard requirements, both enforced by the engine rather than by us: +/// +/// - The application's delegate must be, or subclass, IOSAppDelegate. Unreal +/// logs this Fatal: "Currently, a native app embedding Unreal must have the +/// AppDelegate subclass from IOSAppDelegate." +/// - It must be called on the main thread, before any view is requested. +/// +/// Returns non-zero if the engine was started. +UNREALBRIDGE_API int32_t UnrealBridge_StartEngine(void); + +/// Advance the engine by [deltaSeconds]. Call from the thread that owns the +/// engine, once per frame. Returns non-zero if the engine did work and wants to +/// be ticked again promptly. +/// +/// A host with a display link should pass the real frame delta rather than a +/// fixed step, so the engine's own timing matches the display it renders to. +UNREALBRIDGE_API int32_t UnrealBridge_Tick(float deltaSeconds); + +/// Nudge the game thread when something has been queued for it. +UNREALBRIDGE_API void UnrealBridge_WakeGameThread(void); + +/// Hold the engine awake, or let it sleep again. Calls pair by `requester`, and +/// repeated calls with the same requester must agree on `needsRendering`. +/// Without at least one requester the engine idles between ticks, which is the +/// point: an embedded engine on a mostly static screen should not burn a core. +UNREALBRIDGE_API void UnrealBridge_KeepAwake(const char* requester, + int32_t needsRendering); +UNREALBRIDGE_API void UnrealBridge_AllowSleep(const char* requester); + +/// Whether the engine currently wants ticking, and whether it wants rendering. +/// A host can skip work when both are false. +UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForTicking(void); +UNREALBRIDGE_API int32_t UnrealBridge_IsAwakeForRendering(void); + +// ============================================================ +// MARK: - Rendering surface +// ============================================================ +// +// iOS only. Unreal's embedded mode expects the host to create the view the +// engine renders into and hand it over, which LaunchIOS.cpp states directly: +// "For embedded apps, the UEEmbeddedView must have been created and set into +// the AppDelegate as IOSView". +// +// So the framework builds an FIOSView, registers it with the app delegate, and +// returns it here as an opaque pointer. The host casts it to UIView* and puts +// it in its own hierarchy, which is how it ends up inside a Flutter widget. +// Unreal renders into the view's CAMetalLayer directly, so nothing is copied +// per frame. +// +// macOS has no equivalent. bShouldCompileAsDLL does not define +// BUILD_EMBEDDED_APP there and no Mac runtime code honours it, so these return +// NULL and do nothing. + +/// Fired once the engine has loaded its config and can build a render view. +/// +/// Unreal announces this itself: FAppEntry broadcasts an "inisareready" command +/// on the embedded-to-native channel, with a comment saying it means "the View +/// can be made if it was waiting to create the view". Creating the view before +/// that point is the timing bug this exists to avoid. +/// +/// Fires on the game thread, so marshal before touching UIKit. +typedef void (*UnrealEngineReadyCallback)(void); + +/// Register interest in that signal. If the engine has already announced it, +/// the callback fires immediately rather than never, so a host that registers +/// late is not left waiting. Pass NULL to unregister. +UNREALBRIDGE_API void UnrealBridge_SetEngineReadyCallback( + UnrealEngineReadyCallback callback); + +/// Whether the engine has announced it. Polling alternative to the callback. +UNREALBRIDGE_API int32_t UnrealBridge_IsReadyForView(void); + +/// Create the engine's render view, or return the existing one. +/// +/// Returns NULL until UnrealBridge_IsReadyForView reports non-zero, because the +/// engine has not read the config the view depends on yet. +/// +/// Must be called from the main thread. Returns a UIView* as an opaque +/// pointer, owned by the engine's app delegate: retain it if you need to, but +/// do not release it. Returns NULL on macOS, or if the engine could not make +/// the view. +/// +/// Sizes are in points; [scale] is the display scale, normally +/// UIScreen.main.scale. +UNREALBRIDGE_API void* UnrealBridge_CreateView(float width, float height, + float scale); + +/// Tell the engine the view's size changed. Main thread. +UNREALBRIDGE_API void UnrealBridge_ResizeView(float width, float height, + float scale); + +/// Tear the view down. Main thread. The pointer from UnrealBridge_CreateView is +/// dead after this. +UNREALBRIDGE_API void UnrealBridge_DestroyView(void); + +/// Whether the view exists and its framebuffer is ready for the RHI. Rendering +/// only actually happens once this returns non-zero. +UNREALBRIDGE_API int32_t UnrealBridge_IsViewReady(void); + +// ============================================================ +// MARK: - Host lifecycle +// ============================================================ + +UNREALBRIDGE_API void UnrealBridge_Pause(int32_t paused); + +/// Tear the bridge down. Clears the registered callbacks and tells Unreal the +/// host is going away. +UNREALBRIDGE_API void UnrealBridge_Stop(void); + +/// Whether an AFlutterBridge actor has registered itself. Everything above is +/// safe to call when this returns 0, it just does nothing. +UNREALBRIDGE_API int32_t UnrealBridge_IsReady(void); + +#ifdef __cplusplus +} +#endif + +#endif /* UnrealBridge_h */ diff --git a/example/unreal/demo/Source/GameFrameworkProject.Target.cs b/example/unreal/demo/Source/GameFrameworkProject.Target.cs new file mode 100644 index 0000000..810ec8f --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject.Target.cs @@ -0,0 +1,122 @@ +using UnrealBuildTool; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +public class GameFrameworkProjectTarget : TargetRules +{ + public GameFrameworkProjectTarget(TargetInfo Target) : base(Target) + { + Type = TargetType.Game; + DefaultBuildSettings = BuildSettingsVersion.V6; + IncludeOrderVersion = EngineIncludeOrderVersion.Latest; + ExtraModuleNames.Add("GameFrameworkProject"); + + // Build as a dynamic library so a Flutter host can link the result. + // + // The two platforms ask for it differently. iOS reads bBuildAsFramework + // from Config/DefaultEngine.ini and UEBuildIOS sets bShouldCompileAsDLL + // itself; Mac has no such switch, so the target sets it here. + // + // Either way UBT objects, because the target changes a setting shared + // with the engine's own build products, and it does not care whether + // the project or the ini asked for it. bOverrideBuildEnvironment gets + // past that; TargetBuildEnvironment.Unique is refused outright by an + // installed engine. + // + // Read that as permission, not as a fix. An installed engine ships its + // modules prebuilt, so on iOS BUILD_EMBEDDED_APP reaches this project + // and not the engine, and the resulting framework links, launches, and + // never boots an engine. Embedding needs the engine built from source. + // Building a linkable dylib does not. + if (Target.Platform == UnrealTargetPlatform.Mac || + Target.Platform == UnrealTargetPlatform.IOS) + { + bOverrideBuildEnvironment = true; + } + + if (Target.Platform == UnrealTargetPlatform.Mac) + { + LinkType = TargetLinkType.Monolithic; + bShouldCompileAsDLL = true; + + // Turn on the embedded path for Mac, which the engine never does + // itself. UEBuildIOS adds BUILD_EMBEDDED_APP for iOS and nothing in + // UnrealBuildTool adds it anywhere else, so on Mac every body in + // EmbeddedCommunication.cpp compiles away: RunOnGameThread drops + // the lambda, TickGameThread does nothing, and a host talking to + // the engine is talking to no-ops. + // + // It has to reach the engine's own modules, not just this project. + // FEmbeddedCommunication lives in Core, so a definition that stops + // at the project changes nothing at all, and a unique build + // environment is what makes the engine rebuild with it. A source + // engine allows that; an installed one refuses, which is the same + // constraint embedding has on iOS. + BuildEnvironment = TargetBuildEnvironment.Unique; + GlobalDefinitions.Add("BUILD_EMBEDDED_APP=1"); + } + + if (Target.Platform == UnrealTargetPlatform.IOS) + { + AddSwiftCompatibilityLibraries(Target); + } + } + + /// Link the Swift back-deployment libraries by hand for a framework build. + /// + /// Engine and plugin Swift objects reference __swift_FORCE_LOAD_$_swiftCompatibility56, + /// which lives in a static library shipped inside the Xcode toolchain. For a + /// normal app Xcode drives the final link and adds that path itself. A + /// framework build is linked by UnrealBuildTool directly, and + /// AppleToolChain only adds the system /usr/lib/swift, not the toolchain's + /// static compatibility libraries, so the link fails with undefined symbols. + /// + /// Nothing here is needed once UBT adds the path itself. + private void AddSwiftCompatibilityLibraries(TargetInfo Target) + { + string ToolchainRoot = GetXcodeDeveloperDir(); + if (string.IsNullOrEmpty(ToolchainRoot)) + { + return; + } + + // The simulator has its own copy of these, and linking the device + // ones into a simulator build fails on architecture. + string SwiftPlatformDir = + Target.Architectures.Contains(UnrealArch.IOSSimulator) ? "iphonesimulator" : "iphoneos"; + + string SwiftLibDir = Path.Combine(ToolchainRoot, + "Toolchains", "XcodeDefault.xctoolchain", "usr", "lib", "swift", SwiftPlatformDir); + + if (!Directory.Exists(SwiftLibDir)) + { + return; + } + + AdditionalLinkerArguments = + (AdditionalLinkerArguments ?? "") + + String.Format(" -L\"{0}\" -lswiftCompatibility56 -lswiftCompatibilityConcurrency", SwiftLibDir); + } + + private string GetXcodeDeveloperDir() + { + try + { + ProcessStartInfo Info = new ProcessStartInfo("/usr/bin/xcode-select", "-p"); + Info.RedirectStandardOutput = true; + Info.UseShellExecute = false; + using (Process Proc = Process.Start(Info)) + { + string Output = Proc.StandardOutput.ReadToEnd().Trim(); + Proc.WaitForExit(); + return Proc.ExitCode == 0 ? Output : ""; + } + } + catch (Exception) + { + return ""; + } + } +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.cpp new file mode 100644 index 0000000..6b1a2af --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.cpp @@ -0,0 +1,181 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterActor.h" +#include "FlutterBridge.h" +#include "FlutterMessageRouter.h" +#include "FlutterBlueprintLibrary.h" + +AFlutterActor::AFlutterActor() +{ + PrimaryActorTick.bCanEverTick = false; + bAutoRegister = true; + bIsSingleton = true; + bIsRegistered = false; + CachedBridge = nullptr; + CachedRouter = nullptr; +} + +void AFlutterActor::BeginPlay() +{ + Super::BeginPlay(); + + if (bAutoRegister) + { + RegisterWithFlutter(); + } +} + +void AFlutterActor::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + if (bIsRegistered) + { + UnregisterFromFlutter(); + } + + Super::EndPlay(EndPlayReason); +} + +// ============================================================ +// MARK: - Flutter Configuration +// ============================================================ + +FString AFlutterActor::GetFlutterTargetName_Implementation() const +{ + // Default to class name + return GetClass()->GetName(); +} + +// ============================================================ +// MARK: - Message Handling +// ============================================================ + +void AFlutterActor::HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) +{ + // Default implementation - log message + UE_LOG(LogTemp, Log, TEXT("[FlutterActor] %s received: Method=%s"), *GetFlutterTargetName(), *Method); +} + +void AFlutterActor::HandleFlutterBinaryMessage_Implementation(const FString& Method, const TArray& Data) +{ + // Default implementation - log message + UE_LOG(LogTemp, Log, TEXT("[FlutterActor] %s received binary: Method=%s, Size=%d"), *GetFlutterTargetName(), *Method, Data.Num()); +} + +// ============================================================ +// MARK: - Sending Messages +// ============================================================ + +void AFlutterActor::SendToFlutter(const FString& Method, const FString& Data) +{ + AFlutterBridge* Bridge = GetFlutterBridge(); + if (Bridge) + { + Bridge->SendToFlutter(GetFlutterTargetName(), Method, Data); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterActor] Cannot send message - Flutter bridge not available")); + } +} + +void AFlutterActor::SendJsonToFlutter(const FString& Method, const TMap& JsonData) +{ + FString JsonString = UFlutterBlueprintLibrary::MapToJsonString(JsonData); + SendToFlutter(Method, JsonString); +} + +void AFlutterActor::SendBinaryToFlutter(const FString& Method, const TArray& Data) +{ + AFlutterBridge* Bridge = GetFlutterBridge(); + if (Bridge) + { + Bridge->SendBinaryToFlutter(GetFlutterTargetName(), Method, Data); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterActor] Cannot send binary - Flutter bridge not available")); + } +} + +// ============================================================ +// MARK: - Utilities +// ============================================================ + +bool AFlutterActor::IsFlutterAvailable() const +{ + return GetFlutterBridge() != nullptr; +} + +AFlutterBridge* AFlutterActor::GetFlutterBridge() const +{ + if (CachedBridge) + { + return CachedBridge; + } + + // Cast away const for caching + AFlutterActor* MutableThis = const_cast(this); + MutableThis->CachedBridge = AFlutterBridge::GetInstance(this); + return CachedBridge; +} + +UFlutterMessageRouter* AFlutterActor::GetFlutterRouter() const +{ + if (CachedRouter) + { + return CachedRouter; + } + + // Cast away const for caching + AFlutterActor* MutableThis = const_cast(this); + MutableThis->CachedRouter = UFlutterMessageRouter::Get(this); + return CachedRouter; +} + +// ============================================================ +// MARK: - Registration +// ============================================================ + +void AFlutterActor::RegisterWithFlutter() +{ + UFlutterMessageRouter* Router = GetFlutterRouter(); + if (Router) + { + FString TargetName = GetFlutterTargetName(); + + // Register target + Router->RegisterTarget(TargetName, this, bIsSingleton); + + // Register message handler + FFlutterMethodDelegate MessageDelegate; + MessageDelegate.BindDynamic(this, &AFlutterActor::OnFlutterMessageInternal); + Router->RegisterMethod(TargetName, TEXT("*"), MessageDelegate); // Wildcard registration + + bIsRegistered = true; + UE_LOG(LogTemp, Log, TEXT("[FlutterActor] Registered: %s"), *TargetName); + } +} + +void AFlutterActor::UnregisterFromFlutter() +{ + UFlutterMessageRouter* Router = GetFlutterRouter(); + if (Router && bIsRegistered) + { + FString TargetName = GetFlutterTargetName(); + Router->UnregisterTarget(TargetName); + bIsRegistered = false; + UE_LOG(LogTemp, Log, TEXT("[FlutterActor] Unregistered: %s"), *TargetName); + } +} + +void AFlutterActor::OnFlutterMessageInternal(const FString& Method, const FString& Data) +{ + // Call the overridable handler + HandleFlutterMessage(Method, Data); +} + +void AFlutterActor::OnFlutterBinaryMessageInternal(const FString& Method, const TArray& Data) +{ + // Call the overridable handler + HandleFlutterBinaryMessage(Method, Data); +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.h b/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.h new file mode 100644 index 0000000..b1aaed3 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterActor.h @@ -0,0 +1,183 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "FlutterActor.generated.h" + +// Forward declarations +class AFlutterBridge; +class UFlutterMessageRouter; + +/** + * Flutter Actor - Base class for Actors that integrate with Flutter + * + * Provides automatic registration with the Flutter message router + * and convenient methods for Flutter communication. + * + * Usage: + * 1. Create a subclass of AFlutterActor + * 2. Override GetFlutterTargetName() to set your target name + * 3. Implement HandleFlutterMessage() for custom message handling + * 4. Use SendToFlutter() to send messages back + * + * Example: + * ```cpp + * UCLASS() + * class AMyGameActor : public AFlutterActor + * { + * GENERATED_BODY() + * + * protected: + * virtual FString GetFlutterTargetName() const override { return TEXT("MyGameActor"); } + * + * virtual void HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) override + * { + * if (Method == TEXT("doAction")) + * { + * // Handle action + * SendToFlutter(TEXT("actionComplete"), TEXT("{}")); + * } + * } + * }; + * ``` + */ +UCLASS(Abstract, Blueprintable, BlueprintType) +class AFlutterActor : public AActor +{ + GENERATED_BODY() + +public: + AFlutterActor(); + +protected: + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + +public: + // ============================================================ + // MARK: - Flutter Configuration + // ============================================================ + + /** + * Get the target name this actor registers as + * Override in subclasses to set custom target name + */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Flutter") + FString GetFlutterTargetName() const; + virtual FString GetFlutterTargetName_Implementation() const; + + /** + * Whether this actor should auto-register with the Flutter router + */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Flutter") + bool bAutoRegister; + + /** + * Whether this is a singleton (only one instance can be registered) + */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Flutter") + bool bIsSingleton; + + // ============================================================ + // MARK: - Message Handling + // ============================================================ + + /** + * Called when a message is received from Flutter + * Override in subclasses or Blueprint to handle messages + */ + UFUNCTION(BlueprintNativeEvent, Category = "Flutter") + void HandleFlutterMessage(const FString& Method, const FString& Data); + virtual void HandleFlutterMessage_Implementation(const FString& Method, const FString& Data); + + /** + * Called when binary data is received from Flutter + */ + UFUNCTION(BlueprintNativeEvent, Category = "Flutter") + void HandleFlutterBinaryMessage(const FString& Method, const TArray& Data); + virtual void HandleFlutterBinaryMessage_Implementation(const FString& Method, const TArray& Data); + + // ============================================================ + // MARK: - Sending Messages + // ============================================================ + + /** + * Send a message to Flutter + * @param Method - The method name (e.g., "onStateChanged") + * @param Data - The data to send (JSON string) + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SendToFlutter(const FString& Method, const FString& Data); + + /** + * Send JSON data to Flutter + * @param Method - The method name + * @param JsonData - Key-value pairs to send as JSON + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SendJsonToFlutter(const FString& Method, const TMap& JsonData); + + /** + * Send binary data to Flutter + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SendBinaryToFlutter(const FString& Method, const TArray& Data); + + // ============================================================ + // MARK: - Utilities + // ============================================================ + + /** + * Check if Flutter bridge is available + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + bool IsFlutterAvailable() const; + + /** + * Get the Flutter bridge instance + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + AFlutterBridge* GetFlutterBridge() const; + + /** + * Get the Flutter message router + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + UFlutterMessageRouter* GetFlutterRouter() const; + +protected: + /** + * Register this actor with the Flutter router + */ + virtual void RegisterWithFlutter(); + + /** + * Unregister this actor from the Flutter router + */ + virtual void UnregisterFromFlutter(); + + /** + * Internal callback for message routing + */ + UFUNCTION() + void OnFlutterMessageInternal(const FString& Method, const FString& Data); + + /** + * Internal callback for binary message routing + */ + UFUNCTION() + void OnFlutterBinaryMessageInternal(const FString& Method, const TArray& Data); + +private: + // Cached references + UPROPERTY() + AFlutterBridge* CachedBridge; + + UPROPERTY() + UFlutterMessageRouter* CachedRouter; + + // Registration state + bool bIsRegistered; +}; diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp new file mode 100644 index 0000000..3754d8a --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.cpp @@ -0,0 +1,740 @@ +#include "FlutterDemoScene.h" + +#include "RotatingCube.h" +#include "FlutterBridge.h" + +#include "Camera/CameraActor.h" +#include "Camera/CameraComponent.h" +#include "Engine/GameViewportClient.h" +#include "Slate/SceneViewport.h" +#include "Components/DirectionalLightComponent.h" +#include "Components/SkyAtmosphereComponent.h" +#include "Components/SkyLightComponent.h" +#include "Components/ExponentialHeightFogComponent.h" +#include "Components/StaticMeshComponent.h" +#include "Engine/DirectionalLight.h" +#include "Engine/Engine.h" +#include "HAL/IConsoleManager.h" +#include "Engine/ExponentialHeightFog.h" +#include "Engine/SkyLight.h" +#include "Engine/StaticMesh.h" +#include "Engine/StaticMeshActor.h" +#include "Engine/GameViewportClient.h" +#include "Engine/World.h" +#include "GameFramework/PlayerController.h" +#include "Materials/Material.h" +#include "Materials/MaterialInstanceDynamic.h" +#include "Materials/MaterialInterface.h" + +namespace +{ + /// The ring of shapes around the cube. + constexpr float OrbitRadius = 320.0f; + constexpr float OrbitDegreesPerSecond = 18.0f; + constexpr float OrbitBobHeight = 35.0f; + + /// How far a drag across the screen swings the camera. + constexpr float DegreesPerPixelYaw = 0.32f; + constexpr float DegreesPerPixelPitch = 0.22f; + + /// Until someone touches the screen, drift slowly so the scene reads as + /// alive rather than as a still frame. + constexpr float IdleDriftDegreesPerSecond = 4.0f; + + /// Keep the camera out of the floor and off the top of the sky. + constexpr float MinPitch = -70.0f; + constexpr float MaxPitch = 12.0f; + + /// How close and how far a pinch can take the camera. + constexpr float MinDistance = 260.0f; + constexpr float MaxDistance = 1800.0f; + + constexpr float FocusHeight = 60.0f; +} + +AFlutterDemoScene::AFlutterDemoScene() +{ + PrimaryActorTick.bCanEverTick = true; + RootComponent = CreateDefaultSubobject(TEXT("Root")); +} + +UMaterialInterface* AFlutterDemoScene::FindTintableMaterial() +{ + static UMaterialInterface* Cached = nullptr; + static bool bSearched = false; + if (bSearched) + { + return Cached; + } + bSearched = true; + + // The shape meshes do not arrive with a tintable material. Their own + // material is not cooked into this build, so they fall back to + // WorldGridMaterial and DefaultMaterial, neither of which exposes a colour, + // which is how you end up with a scene of identical grey checkerboards. + // + // So find one that does. Ask each candidate what it exposes rather than + // trusting a parameter name, because setting a name a material does not + // have fails silently and looks exactly like this bug. + TArray Candidates; + if (GEngine != nullptr) + { + // These are TObjectPtr, so unwrap before the upcast. + Candidates.Add(GEngine->LevelColorationLitMaterial.Get()); + Candidates.Add(GEngine->VertexColorMaterial.Get()); + Candidates.Add(GEngine->DebugMeshMaterial.Get()); + } + Candidates.Add(LoadObject( + nullptr, TEXT("/Engine/BasicShapes/BasicShapeMaterial"))); + + for (UMaterialInterface* Candidate : Candidates) + { + if (Candidate == nullptr) + { + continue; + } + + TArray Infos; + TArray Guids; + Candidate->GetAllVectorParameterInfo(Infos, Guids); + + FString Names; + for (const FMaterialParameterInfo& Info : Infos) + { + Names += Info.Name.ToString() + TEXT(" "); + } + UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] %s vector params: [%s]"), + *GetNameSafe(Candidate), *Names); + + if (Infos.Num() > 0) + { + Cached = Candidate; + return Cached; + } + } + + UE_LOG(LogTemp, Warning, + TEXT("[FlutterDemoScene] No tintable material in this build, so the shapes stay grey")); + return nullptr; +} + +void AFlutterDemoScene::TintMesh(UStaticMeshComponent* MeshComponent, const FLinearColor& Color) +{ + if (MeshComponent == nullptr) + { + return; + } + + UMaterialInterface* Parent = FindTintableMaterial(); + if (Parent == nullptr) + { + return; + } + + UMaterialInstanceDynamic* Material = UMaterialInstanceDynamic::Create(Parent, this); + if (Material == nullptr) + { + return; + } + + TArray Infos; + TArray Guids; + Material->GetAllVectorParameterInfo(Infos, Guids); + if (Infos.Num() == 0) + { + return; + } + + // Prefer an obviously colour-shaped name, otherwise take the first one. + static const FName Preferred[] = { + FName(TEXT("Color")), FName(TEXT("Colour")), + FName(TEXT("BaseColor")), FName(TEXT("Base Color")), FName(TEXT("Tint")) + }; + + FMaterialParameterInfo Chosen = Infos[0]; + for (const FName& Name : Preferred) + { + const FMaterialParameterInfo* Match = Infos.FindByPredicate( + [&Name](const FMaterialParameterInfo& Info) { return Info.Name == Name; }); + if (Match != nullptr) + { + Chosen = *Match; + break; + } + } + + Material->SetVectorParameterValue(Chosen.Name, Color); + MeshComponent->SetMaterial(0, Material); +} + +AStaticMeshActor* AFlutterDemoScene::SpawnShape(const TCHAR* MeshPath, + const FVector& Location, const FVector& Scale, const FLinearColor& Color) +{ + UWorld* World = GetWorld(); + if (World == nullptr) + { + return nullptr; + } + + UStaticMesh* Mesh = LoadObject(nullptr, MeshPath); + if (Mesh == nullptr) + { + UE_LOG(LogTemp, Warning, TEXT("[FlutterDemoScene] %s is not cooked into this build"), MeshPath); + return nullptr; + } + + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + AStaticMeshActor* Actor = World->SpawnActor( + AStaticMeshActor::StaticClass(), FTransform(Location), Params); + if (Actor == nullptr) + { + return nullptr; + } + + UStaticMeshComponent* MeshComponent = Actor->GetStaticMeshComponent(); + + // Anything spawned at runtime has to be Movable, the floor included. A + // Static actor expects baked lighting, and a level built in code has none, + // so it would render unlit. + MeshComponent->SetMobility(EComponentMobility::Movable); + MeshComponent->SetStaticMesh(Mesh); + Actor->SetActorScale3D(Scale); + TintMesh(MeshComponent, Color); + + return Actor; +} + +void AFlutterDemoScene::BuildSky() +{ + UWorld* World = GetWorld(); + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + // A computed sky rather than a cubemap. SkyAtmosphere scatters light from + // the sun direction, so there is no texture to author and nothing extra to + // cook, and turning the sun changes the sky with it. + World->SpawnActor(ASkyAtmosphere::StaticClass(), FTransform::Identity, Params); + + // Let the sky light the scene. Real-time capture means it picks up the + // atmosphere above instead of needing a cubemap of its own. + if (ASkyLight* Sky = World->SpawnActor( + ASkyLight::StaticClass(), FTransform(FVector(0.0f, 0.0f, 200.0f)), Params)) + { + if (USkyLightComponent* Component = Sky->GetLightComponent()) + { + Component->SetMobility(EComponentMobility::Movable); + Component->SetRealTimeCaptureEnabled(true); + Component->SetIntensity(1.0f); + } + } + + // A little haze, so the floor fades out instead of ending on a hard line. + if (AExponentialHeightFog* Fog = World->SpawnActor( + AExponentialHeightFog::StaticClass(), + FTransform(FVector(0.0f, 0.0f, -200.0f)), Params)) + { + if (UExponentialHeightFogComponent* Component = Fog->GetComponent()) + { + Component->SetFogDensity(0.015f); + Component->SetFogInscatteringColor(FLinearColor(0.42f, 0.55f, 0.78f)); + Component->SetStartDistance(600.0f); + } + } +} + +void AFlutterDemoScene::BuildLights() +{ + UWorld* World = GetWorld(); + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + // The sun. Low and warm, which gives the shapes long shadows and stops the + // scene reading as a flat product render. + SunLight = World->SpawnActor( + ADirectionalLight::StaticClass(), + FTransform(FRotator(-32.0f, -40.0f, 0.0f)), Params); + if (SunLight != nullptr) + { + SunLight->SetMobility(EComponentMobility::Movable); + if (UDirectionalLightComponent* Light = Cast(SunLight->GetLightComponent())) + { + Light->SetIntensity(5.0f); + Light->SetLightColor(FLinearColor(1.0f, 0.93f, 0.82f)); + Light->SetCastShadows(true); + + // This is what makes SkyAtmosphere treat it as the sun, so the sky + // brightens around the direction the light points from. + Light->SetAtmosphereSunLight(true); + } + } + + // Cool fill from behind, so the shadowed faces read as shape rather than + // as black. + if (ADirectionalLight* Fill = World->SpawnActor( + ADirectionalLight::StaticClass(), + FTransform(FRotator(-18.0f, 145.0f, 0.0f)), Params)) + { + Fill->SetMobility(EComponentMobility::Movable); + if (UDirectionalLightComponent* Light = Cast(Fill->GetLightComponent())) + { + Light->SetIntensity(1.2f); + Light->SetLightColor(FLinearColor(0.42f, 0.56f, 1.0f)); + Light->SetCastShadows(false); + } + } +} + +void AFlutterDemoScene::BuildShapes() +{ + UWorld* World = GetWorld(); + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + // Floor, wide enough to catch the shadows and to run out into the fog. + SpawnShape(TEXT("/Engine/BasicShapes/Plane"), FVector(0.0f, 0.0f, -110.0f), + FVector(40.0f, 40.0f, 1.0f), FLinearColor(0.10f, 0.12f, 0.16f)); + + // The hero cube. This is the actor Flutter sends setSpeed, setAxis and + // setColor to, so it is spawned rather than dropped in a level. + HeroCube = World->SpawnActor(ARotatingCube::StaticClass(), + FTransform(FVector(0.0f, 0.0f, 40.0f)), Params); + if (HeroCube != nullptr) + { + HeroCube->SetActorScale3D(FVector(1.6f)); + } + + struct FOrbiter + { + const TCHAR* Mesh; + FLinearColor Color; + float Scale; + }; + static const FOrbiter Ring[] = { + {TEXT("/Engine/BasicShapes/Sphere"), FLinearColor(0.92f, 0.28f, 0.32f), 0.7f}, + {TEXT("/Engine/BasicShapes/Cone"), FLinearColor(0.98f, 0.72f, 0.20f), 0.8f}, + {TEXT("/Engine/BasicShapes/Cylinder"), FLinearColor(0.24f, 0.82f, 0.60f), 0.7f}, + {TEXT("/Engine/BasicShapes/Cube"), FLinearColor(0.34f, 0.52f, 0.96f), 0.7f}, + {TEXT("/Engine/BasicShapes/Sphere"), FLinearColor(0.72f, 0.38f, 0.95f), 0.6f}, + }; + + const int32 Count = UE_ARRAY_COUNT(Ring); + for (int32 Index = 0; Index < Count; ++Index) + { + const float Angle = (360.0f / Count) * Index; + const FVector Location( + OrbitRadius * FMath::Cos(FMath::DegreesToRadians(Angle)), + OrbitRadius * FMath::Sin(FMath::DegreesToRadians(Angle)), + 0.0f); + + if (AStaticMeshActor* Shape = SpawnShape(Ring[Index].Mesh, Location, + FVector(Ring[Index].Scale), Ring[Index].Color)) + { + Orbiters.Add(Shape); + } + } +} + +void AFlutterDemoScene::BuildCamera() +{ + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + Camera = GetWorld()->SpawnActor( + ACameraActor::StaticClass(), FTransform::Identity, Params); + + if (Camera != nullptr) + { + // ACameraActor's constructor pins the camera to 16:9 and turns on the + // aspect ratio constraint, and the engine then adds black bars to fill + // whatever it is rendering into. On a phone held upright that is most + // of the screen, and it looks exactly like the scene has been cropped + // into a landscape band, which sends you hunting through resolutions + // and viewports instead. Let it fill the view it is given. + UCameraComponent* Component = Camera->GetCameraComponent(); + Component->SetConstraintAspectRatio(false); + + // With the constraint off, which axis the FOV is held on decides what a + // tall screen shows. The component's own setting is ignored unless this + // override is on, and the player's default holds the vertical FOV, + // which squeezes the horizontal one on a portrait screen and looks like + // a zoomed-in landscape crop. Hold the horizontal FOV instead, so a + // taller screen shows more of the scene rather than less. + Component->bOverrideAspectRatioAxisConstraint = true; + Component->SetAspectRatioAxisConstraint(EAspectRatioAxisConstraint::AspectRatio_MaintainXFOV); + } + + PositionCamera(); +} + +void AFlutterDemoScene::PositionCamera() +{ + if (Camera == nullptr) + { + return; + } + + // A phone held upright has a tall, narrow viewport, and a framing chosen + // for a wide one puts the ring of shapes off both edges. So widen the lens + // and back off as the viewport gets narrower, which keeps the same subject + // in frame in either orientation. + float Aspect = 1.0f; + if (GEngine != nullptr && GEngine->GameViewport != nullptr) + { + FVector2D Size; + GEngine->GameViewport->GetViewportSize(Size); + if (Size.X > 0.0f && Size.Y > 0.0f) + { + Aspect = Size.X / Size.Y; + } + } + + const bool bPortrait = Aspect < 1.0f; + const float FieldOfView = bPortrait ? 88.0f : 70.0f; + const float Distance = OrbitDistance * (bPortrait ? 1.28f : 1.0f); + + const float YawRadians = FMath::DegreesToRadians(OrbitYaw); + const float PitchRadians = FMath::DegreesToRadians(OrbitPitch); + + const float Horizontal = Distance * FMath::Cos(PitchRadians); + const FVector Focus(0.0f, 0.0f, FocusHeight); + const FVector Location = Focus + FVector( + -Horizontal * FMath::Cos(YawRadians), + -Horizontal * FMath::Sin(YawRadians), + -Distance * FMath::Sin(PitchRadians)); + + Camera->SetActorLocation(Location); + Camera->SetActorRotation((Focus - Location).Rotation()); + Camera->GetCameraComponent()->SetFieldOfView(FieldOfView); +} + +void AFlutterDemoScene::BeginPlay() +{ + Super::BeginPlay(); + + if (GetWorld() == nullptr) + { + return; + } + + BuildSky(); + BuildLights(); + BuildShapes(); + BuildCamera(); + TakeOverTheView(); + + // Take everything from Flutter, without registering a name for this actor. + // The cube is reached through the router because it has a name; this is the + // other way, and the two do not compete: the router runs first and this + // still sees the message afterwards. + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) + { + Bridge->OnAnyMessageFromFlutter.AddDynamic(this, &AFlutterDemoScene::OnAnyFlutterMessage); + } + + UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] Scene built: %d orbiters, cube %s"), + Orbiters.Num(), HeroCube ? TEXT("yes") : TEXT("no")); + +} + +void AFlutterDemoScene::EndPlay(const EEndPlayReason::Type Reason) +{ + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) + { + Bridge->OnAnyMessageFromFlutter.RemoveDynamic(this, &AFlutterDemoScene::OnAnyFlutterMessage); + } + + Super::EndPlay(Reason); +} + +void AFlutterDemoScene::OnAnyFlutterMessage(const FString& Target, const FString& Method, + const FString& Data) +{ + // Answer so the host can see this fired, and for which message. Behind the + // same switch as the bridge's own trace, because echoing every message is + // what you want while proving the path works and noise once it does. + static IConsoleVariable* TraceVar = + IConsoleManager::Get().FindConsoleVariable(TEXT("flutter.TraceMessages")); + if (TraceVar != nullptr && TraceVar->GetInt() != 0) + { + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) + { + Bridge->SendToFlutter(TEXT("Scene"), TEXT("saw"), + FString::Printf(TEXT("%s.%s"), *Target, *Method)); + } + } + + // A scene-level command, addressed to whatever you like. No actor is + // registered under any of it, so the router drops it and this still runs. + if (Method == TEXT("resetCamera")) + { + OrbitYaw = 0.0f; + OrbitPitch = -14.0f; + OrbitDistance = 640.0f; + bViewerHasTakenOver = true; + PositionCamera(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] Camera reset, asked for by %s"), *Target); + } +} + +void AFlutterDemoScene::TakeOverTheView() +{ + if (bViewClaimed || Camera == nullptr) + { + return; + } + + APlayerController* Controller = GetWorld()->GetFirstPlayerController(); + if (Controller == nullptr) + { + return; + } + + Controller->SetViewTarget(Camera); + + // Touch has to be switched on explicitly, and the engine's own on-screen + // stick is off, so the whole surface is free for dragging. + Controller->bShowMouseCursor = false; + Controller->bEnableTouchEvents = true; + Controller->bEnableTouchOverEvents = true; + + // Remove the engine's on-screen sticks. Setting DefaultTouchInterface in + // the ini would need a re-cook, and this is the same thing at runtime. + // They are right for a game you drive by hand and wrong for a view sitting + // under Flutter controls, where they only steal touches from the orbit. + Controller->ActivateTouchInterface(nullptr); + + bViewClaimed = true; + UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] Camera is the view target")); +} + +void AFlutterDemoScene::UpdateOrbitFromTouch(float DeltaSeconds) +{ + APlayerController* Controller = GetWorld()->GetFirstPlayerController(); + if (Controller == nullptr) + { + return; + } + + float X1 = 0.0f, Y1 = 0.0f, X2 = 0.0f, Y2 = 0.0f; + bool bFirst = false, bSecond = false; + Controller->GetInputTouchState(ETouchIndex::Touch1, X1, Y1, bFirst); + Controller->GetInputTouchState(ETouchIndex::Touch2, X2, Y2, bSecond); + + // Two fingers pinch to zoom. Checked first, because during a pinch the + // first finger is also moving and would otherwise swing the camera at the + // same time. + if (bFirst && bSecond) + { + const float Spread = FVector2D::Distance(FVector2D(X1, Y1), FVector2D(X2, Y2)); + + if (bWasPinching && LastPinchSpread > KINDA_SMALL_NUMBER) + { + // Move the camera by the ratio the fingers moved, so the zoom feels + // the same whether they start close together or far apart. + OrbitDistance = FMath::Clamp( + OrbitDistance * (LastPinchSpread / Spread), MinDistance, MaxDistance); + PositionCamera(); + } + + LastPinchSpread = Spread; + bWasPinching = true; + bWasTouching = false; + bViewerHasTakenOver = true; + return; + } + + bWasPinching = false; + + if (bFirst) + { + const FVector2D Touch(X1, Y1); + + // Only orbit on the second and later frames of a drag. Using the first + // one would treat wherever the finger landed as a delta and snap the + // camera across the scene. + if (bWasTouching) + { + const FVector2D Delta = Touch - LastTouch; + OrbitYaw += Delta.X * DegreesPerPixelYaw; + OrbitPitch = FMath::Clamp( + OrbitPitch + Delta.Y * DegreesPerPixelPitch, MinPitch, MaxPitch); + PositionCamera(); + } + + LastTouch = Touch; + bWasTouching = true; + bViewerHasTakenOver = true; + return; + } + + bWasTouching = false; + + // Drift until someone takes over, then leave the camera where they put it. + if (!bViewerHasTakenOver) + { + OrbitYaw += IdleDriftDegreesPerSecond * DeltaSeconds; + PositionCamera(); + } +} + +bool AFlutterDemoScene::HasSizedViewport() const +{ + if (GEngine == nullptr || GEngine->GameViewport == nullptr) + { + return false; + } + + FVector2D Size = FVector2D::ZeroVector; + GEngine->GameViewport->GetViewportSize(Size); + return Size.X > 0.0f && Size.Y > 0.0f; +} + +void AFlutterDemoScene::ReportCameraIfMoved() +{ + // Only once the viewer has taken the camera over. Until then it drifts on + // its own, and drift crosses any sensible threshold several times a second, + // so reporting it means a steady stream of messages about a camera nobody + // is touching. + if (!bViewerHasTakenOver) + { + return; + } + + // Then only when it actually moved, and no faster than the throttle. A + // message every frame of a drag would flood the channel and the HUD with + // values nobody can read. + const bool bMoved = + !FMath::IsNearlyEqual(OrbitDistance, LastSentDistance, 1.0f) || + !FMath::IsNearlyEqual(OrbitYaw, LastSentYaw, 0.5f) || + !FMath::IsNearlyEqual(OrbitPitch, LastSentPitch, 0.5f); + + if (!bMoved || CameraReportCooldown > 0.0f) + { + return; + } + + LastSentDistance = OrbitDistance; + LastSentYaw = OrbitYaw; + LastSentPitch = OrbitPitch; + CameraReportCooldown = 0.1f; + + // Zoom as a fraction of the range, which is what a host actually wants: + // 0 is as close as the camera goes, 1 as far. The raw distance goes too, + // for anything that needs the real units. + const float Zoom = FMath::GetRangePct(MinDistance, MaxDistance, OrbitDistance); + + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) + { + Bridge->SendToFlutter(TEXT("Camera"), TEXT("moved"), + FString::Printf( + TEXT("{\"zoom\":%.3f,\"distance\":%.0f,\"yaw\":%.1f,\"pitch\":%.1f}"), + Zoom, OrbitDistance, OrbitYaw, OrbitPitch)); + } +} + +void AFlutterDemoScene::ReportRenderState() const +{ + FString CameraState = TEXT("no camera"); + if (Camera != nullptr) + { + if (const UCameraComponent* Component = Camera->GetCameraComponent()) + { + CameraState = FString::Printf(TEXT("constrain=%d fov=%.1f axisOverride=%d axis=%d"), + Component->bConstrainAspectRatio ? 1 : 0, + Component->FieldOfView, + Component->bOverrideAspectRatioAxisConstraint ? 1 : 0, + (int32)Component->AspectRatioAxisConstraint.GetValue()); + } + } + + FString ViewportState = TEXT("no game viewport"); + if (GEngine != nullptr && GEngine->GameViewport != nullptr) + { + FVector2D Size = FVector2D::ZeroVector; + GEngine->GameViewport->GetViewportSize(Size); + ViewportState = FString::Printf(TEXT("client=%.0fx%.0f"), Size.X, Size.Y); + + if (const FSceneViewport* Scene = GEngine->GameViewport->GetGameViewport()) + { + const FIntPoint SceneSize = Scene->GetSizeXY(); + ViewportState += FString::Printf(TEXT(" scene=%dx%d"), SceneSize.X, SceneSize.Y); + } + else + { + ViewportState += TEXT(" scene=none"); + } + } + + UE_LOG(LogTemp, Log, TEXT("[FlutterDemoScene] render: camera[%s] viewport[%s]"), + *CameraState, *ViewportState); + + // Also send it to Flutter. The engine's log file is buffered and a reinstall + // wipes the container, so the HUD panel is the one readout that is reliably + // there while the thing is actually running. + if (AFlutterBridge* Bridge = AFlutterBridge::GetInstance(this)) + { + Bridge->SendToFlutter(TEXT("Scene"), TEXT("renderState"), + FString::Printf(TEXT("%s | %s"), *CameraState, *ViewportState)); + } +} + +void AFlutterDemoScene::Tick(float DeltaSeconds) +{ + Super::Tick(DeltaSeconds); + + // The player controller may not exist yet when the scene is built, so keep + // asking until it does rather than assuming the order. + if (!bViewClaimed) + { + TakeOverTheView(); + return; + } + + // Report once, and only once there is something real to report. Claiming + // the view happens during BeginPlay, well before the viewport exists, so + // keying off that still prints zeroes, which is worse than not reporting at + // all. Wait for a viewport with a size. Useful for telling a letterbox from + // a camera framing problem when someone says the scene looks wrong. + if (!bReportedRenderState && HasSizedViewport()) + { + bReportedRenderState = true; + + // Frame it again now the viewport has a size. The first attempt runs + // during BeginPlay, when asking for the aspect gives nothing, so the + // camera settles on the landscape field of view and the scene starts + // tighter than intended. It corrects itself the moment you drag, which + // makes it easy to miss. + PositionCamera(); + ReportRenderState(); + } + + UpdateOrbitFromTouch(DeltaSeconds); + + CameraReportCooldown = FMath::Max(0.0f, CameraReportCooldown - DeltaSeconds); + ReportCameraIfMoved(); + + SceneTime += DeltaSeconds; + + + const int32 Count = Orbiters.Num(); + for (int32 Index = 0; Index < Count; ++Index) + { + AStaticMeshActor* Shape = Orbiters[Index]; + if (Shape == nullptr) + { + continue; + } + + const float Angle = (360.0f / Count) * Index + SceneTime * OrbitDegreesPerSecond; + const float Radians = FMath::DegreesToRadians(Angle); + const float Bob = OrbitBobHeight * FMath::Sin(SceneTime * 1.3f + Index); + + Shape->SetActorLocation(FVector( + OrbitRadius * FMath::Cos(Radians), + OrbitRadius * FMath::Sin(Radians), + Bob)); + Shape->AddActorLocalRotation(FRotator(0.0f, 40.0f * DeltaSeconds, 0.0f)); + } +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h new file mode 100644 index 0000000..e0c72d4 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterDemoScene.h @@ -0,0 +1,124 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "FlutterDemoScene.generated.h" + +class ACameraActor; +class ADirectionalLight; +class ARotatingCube; +class AStaticMeshActor; + +/** + * Builds the demo scene in code, at runtime. + * + * There is no .umap here on purpose. A scaffolded project has no level of its + * own and boots an engine map, which is empty, so anything you want to see has + * to be spawned. That also keeps the demo working without opening the editor, + * which matters when the whole point is to run it from Flutter. + * + * Nothing here needs an asset you have to make first. The shapes come from + * /Engine/BasicShapes, and the sky is SkyAtmosphere, which is computed rather + * than textured, so there is no cubemap to cook. + */ +UCLASS() +class AFlutterDemoScene : public AActor +{ + GENERATED_BODY() + +public: + AFlutterDemoScene(); + + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type Reason) override; + virtual void Tick(float DeltaSeconds) override; + + /** + * Every message from Flutter, whatever it was addressed to. + * + * Bound to the bridge rather than registered with the router under a name, + * which is the point: this scene never tells Flutter what it is called and + * still sees everything. Handles messages for targets no actor registered, + * which the router alone would drop. + */ + UFUNCTION() + void OnAnyFlutterMessage(const FString& Target, const FString& Method, const FString& Data); + + /** The hero cube, which is what Flutter talks to. */ + UPROPERTY(Transient) + ARotatingCube* HeroCube = nullptr; + +private: + /** Spawn one tinted shape. Always Movable: see the note in the .cpp. */ + AStaticMeshActor* SpawnShape(const TCHAR* MeshPath, const FVector& Location, + const FVector& Scale, const FLinearColor& Color); + + /** A material that actually exposes a colour, or null if this build has none. */ + static class UMaterialInterface* FindTintableMaterial(); + + /** Tint a mesh, whatever its material happens to call the parameter. */ + void TintMesh(class UStaticMeshComponent* MeshComponent, const FLinearColor& Color); + + void BuildSky(); + void BuildLights(); + void BuildShapes(); + void BuildCamera(); + + /** Point the player at our camera. */ + void TakeOverTheView(); + + /** Read touch and turn a drag into a camera orbit. */ + void UpdateOrbitFromTouch(float DeltaSeconds); + + /** Place the camera from the current orbit angles and viewport shape. */ + void PositionCamera(); + + /** Log what the renderer is doing, so a letterbox can be traced to a cause. */ + void ReportRenderState() const; + + /** Tell Flutter where the camera is, when the viewer has moved it. */ + void ReportCameraIfMoved(); + + /** Whether the viewport exists and has a size worth reporting. */ + bool HasSizedViewport() const; + + UPROPERTY(Transient) + ACameraActor* Camera = nullptr; + + UPROPERTY(Transient) + ADirectionalLight* SunLight = nullptr; + + UPROPERTY(Transient) + TArray Orbiters; + + /** Seconds since BeginPlay, which drives the orbit of the shapes. */ + float SceneTime = 0.0f; + + /** Whether the one-off render state report has gone out. */ + bool bReportedRenderState = false; + + /** Throttles camera updates, and what was last sent, so a still camera is silent. */ + float CameraReportCooldown = 0.0f; + float LastSentDistance = -1.0f; + float LastSentYaw = 0.0f; + float LastSentPitch = 0.0f; + + /** Where the camera sits, in orbit terms. Yaw and pitch are degrees. */ + float OrbitYaw = 0.0f; + float OrbitPitch = -14.0f; + float OrbitDistance = 640.0f; + + /** Touch tracking, so a drag becomes a delta rather than a jump. */ + bool bWasTouching = false; + FVector2D LastTouch = FVector2D::ZeroVector; + + /** Pinch tracking. Same reason: a ratio between frames, not an absolute. */ + bool bWasPinching = false; + float LastPinchSpread = 0.0f; + + /** Idle drift, which stops the first time the viewer touches the screen. */ + bool bViewerHasTakenOver = false; + + /** The view target has to be claimed after the player controller exists. */ + bool bViewClaimed = false; +}; diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.cpp b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.cpp new file mode 100644 index 0000000..345b730 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.cpp @@ -0,0 +1,332 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "FlutterGameMode.h" +#include "FlutterBridge.h" +#include "FlutterMessageRouter.h" +#include "FlutterDemoScene.h" +#include "Engine/World.h" +#include "TimerManager.h" +#include "Dom/JsonObject.h" +#include "Serialization/JsonWriter.h" +#include "Serialization/JsonSerializer.h" + +AFlutterGameMode::AFlutterGameMode() +{ + bIsGameRunning = false; + bIsGamePaused = false; + CurrentScore = 0; + CurrentLevel = 1; + FlutterTargetName = TEXT("GameMode"); + // Off by default. A heartbeat once a second is useful when you are + // debugging the channel and pure noise once it works, and it buries the + // messages you actually want to read in any log panel on the Flutter side. + bAutoSyncState = false; + StateSyncInterval = 1.0f; + FlutterBridge = nullptr; + MessageRouter = nullptr; +} + +void AFlutterGameMode::BeginPlay() +{ + Super::BeginPlay(); + + // The bridge is an actor, and AFlutterBridge::GetInstance only ever looks + // for one. With no level of our own there is nothing to have placed it in, + // so spawn it here, before anything asks for it. Without this the engine + // runs, the scene renders, and every message from Flutter is quietly + // dropped. + if (UWorld* World = GetWorld()) + { + if (AFlutterBridge::GetInstance(this) == nullptr) + { + FActorSpawnParameters BridgeParams; + BridgeParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + World->SpawnActor( + AFlutterBridge::StaticClass(), FTransform::Identity, BridgeParams); + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Spawned the Flutter bridge actor")); + } + } + + InitializeFlutter(); + + // Build the scene in code. A scaffolded project has no level of its own and + // boots an engine map, which is empty, so without this you get a correctly + // running engine rendering nothing and no way to tell the two apart. + if (UWorld* World = GetWorld()) + { + FActorSpawnParameters Params; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + DemoScene = World->SpawnActor( + AFlutterDemoScene::StaticClass(), FTransform::Identity, Params); + } +} + +void AFlutterGameMode::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + // Clear timer + if (GetWorld()) + { + GetWorld()->GetTimerManager().ClearTimer(StateSyncTimerHandle); + } + + // Unregister from router + if (MessageRouter) + { + MessageRouter->UnregisterTarget(FlutterTargetName); + } + + Super::EndPlay(EndPlayReason); +} + +void AFlutterGameMode::InitializeFlutter() +{ + // Get Flutter bridge + FlutterBridge = AFlutterBridge::GetInstance(this); + MessageRouter = UFlutterMessageRouter::Get(this); + + if (MessageRouter) + { + // Register as target + MessageRouter->RegisterTarget(FlutterTargetName, this, true); + + // Register message handlers + FFlutterMethodDelegate Delegate; + Delegate.BindDynamic(this, &AFlutterGameMode::HandleFlutterMessage); + MessageRouter->RegisterMethod(FlutterTargetName, TEXT("playerAction"), Delegate); + MessageRouter->RegisterMethod(FlutterTargetName, TEXT("requestState"), Delegate); + MessageRouter->RegisterMethod(FlutterTargetName, TEXT("setLevel"), Delegate); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Registered with Flutter router")); + } + + // Start state sync timer if enabled + if (bAutoSyncState && StateSyncInterval > 0.0f && GetWorld()) + { + GetWorld()->GetTimerManager().SetTimer( + StateSyncTimerHandle, + this, + &AFlutterGameMode::SyncGameState, + StateSyncInterval, + true + ); + } +} + +// ============================================================ +// MARK: - Game State +// ============================================================ + +void AFlutterGameMode::StartGame() +{ + if (!bIsGameRunning) + { + bIsGameRunning = true; + bIsGamePaused = false; + + NotifyFlutter(TEXT("gameStarted"), TEXT("{}")); + OnGameStarted(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Game started")); + } +} + +void AFlutterGameMode::PauseGame() +{ + if (bIsGameRunning && !bIsGamePaused) + { + bIsGamePaused = true; + + NotifyFlutter(TEXT("gamePaused"), TEXT("{}")); + OnGamePaused(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Game paused")); + } +} + +void AFlutterGameMode::ResumeGame() +{ + if (bIsGameRunning && bIsGamePaused) + { + bIsGamePaused = false; + + NotifyFlutter(TEXT("gameResumed"), TEXT("{}")); + OnGameResumed(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Game resumed")); + } +} + +void AFlutterGameMode::StopGame() +{ + if (bIsGameRunning) + { + bIsGameRunning = false; + bIsGamePaused = false; + + NotifyFlutter(TEXT("gameStopped"), TEXT("{}")); + OnGameStopped(); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Game stopped")); + } +} + +void AFlutterGameMode::GameOver(const FString& Reason) +{ + bIsGameRunning = false; + bIsGamePaused = false; + + // Build JSON + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + JsonObject->SetStringField(TEXT("reason"), Reason); + JsonObject->SetNumberField(TEXT("finalScore"), CurrentScore); + JsonObject->SetNumberField(TEXT("finalLevel"), CurrentLevel); + + FString JsonString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + NotifyFlutter(TEXT("gameOver"), JsonString); + OnGameOver(Reason); + + UE_LOG(LogTemp, Log, TEXT("[FlutterGameMode] Game over: %s"), *Reason); +} + +void AFlutterGameMode::RestartGame() +{ + StopGame(); + ResetScore(); + CurrentLevel = 1; + StartGame(); +} + +// ============================================================ +// MARK: - Score Management +// ============================================================ + +void AFlutterGameMode::SetScore(int32 NewScore) +{ + int32 Delta = NewScore - CurrentScore; + CurrentScore = NewScore; + + // Build JSON + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + JsonObject->SetNumberField(TEXT("score"), CurrentScore); + JsonObject->SetNumberField(TEXT("delta"), Delta); + + FString JsonString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + NotifyFlutter(TEXT("scoreChanged"), JsonString); + OnScoreChanged(CurrentScore, Delta); +} + +void AFlutterGameMode::AddScore(int32 Points) +{ + SetScore(CurrentScore + Points); +} + +void AFlutterGameMode::ResetScore() +{ + SetScore(0); +} + +// ============================================================ +// MARK: - Level Management +// ============================================================ + +void AFlutterGameMode::SetLevel(int32 NewLevel) +{ + CurrentLevel = FMath::Max(1, NewLevel); + + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + JsonObject->SetNumberField(TEXT("level"), CurrentLevel); + + FString JsonString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + NotifyFlutter(TEXT("levelChanged"), JsonString); + OnLevelChanged(CurrentLevel); +} + +void AFlutterGameMode::NextLevel() +{ + SetLevel(CurrentLevel + 1); +} + +void AFlutterGameMode::LoadGameLevel(const FString& LevelName) +{ + if (FlutterBridge) + { + FlutterBridge->LoadLevel(LevelName); + } +} + +// ============================================================ +// MARK: - Flutter Communication +// ============================================================ + +void AFlutterGameMode::SendGameEvent(const FString& EventName, const FString& EventData) +{ + NotifyFlutter(EventName, EventData); +} + +void AFlutterGameMode::SyncGameState() +{ + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + JsonObject->SetBoolField(TEXT("isRunning"), bIsGameRunning); + JsonObject->SetBoolField(TEXT("isPaused"), bIsGamePaused); + JsonObject->SetNumberField(TEXT("score"), CurrentScore); + JsonObject->SetNumberField(TEXT("level"), CurrentLevel); + + FString JsonString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + NotifyFlutter(TEXT("stateSync"), JsonString); +} + +void AFlutterGameMode::NotifyFlutter(const FString& Event, const FString& Data) +{ + if (FlutterBridge) + { + FlutterBridge->SendToFlutter(FlutterTargetName, Event, Data); + } +} + +// ============================================================ +// MARK: - Flutter Message Handlers +// ============================================================ + +void AFlutterGameMode::HandleFlutterMessage(const FString& Method, const FString& Data) +{ + if (Method == TEXT("playerAction")) + { + // Parse action from Data + TSharedPtr JsonObject; + TSharedRef> Reader = TJsonReaderFactory<>::Create(Data); + + if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid()) + { + FString Action = JsonObject->GetStringField(TEXT("action")); + FString ActionData = JsonObject->GetStringField(TEXT("data")); + OnPlayerAction(Action, ActionData); + } + } + else if (Method == TEXT("requestState")) + { + SyncGameState(); + } + else if (Method == TEXT("setLevel")) + { + TSharedPtr JsonObject; + TSharedRef> Reader = TJsonReaderFactory<>::Create(Data); + + if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid()) + { + int32 Level = JsonObject->GetIntegerField(TEXT("level")); + SetLevel(Level); + } + } +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.h b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.h new file mode 100644 index 0000000..5fc1431 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/FlutterGameMode.h @@ -0,0 +1,270 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/GameModeBase.h" +#include "FlutterGameMode.generated.h" + +// Forward declarations +class AFlutterBridge; +class UFlutterMessageRouter; + +class AFlutterDemoScene; + +/** + * Flutter Game Mode - Base GameMode with Flutter integration + * + * Provides a ready-to-use GameMode that integrates with Flutter + * for game state management, scoring, and level control. + * + * Features: + * - Automatic Flutter bridge setup + * - Game state synchronization (start, pause, resume, stop) + * - Score tracking and updates + * - Level management + * - Player action handling + * + * Usage: + * 1. Create a subclass or use directly as your GameMode + * 2. Call game state methods from Blueprint or C++ + * 3. Flutter receives state updates automatically + * + * Example Blueprint: + * - On game start: Call StartGame() + * - On player death: Call GameOver("Player died") + * - On score: Call AddScore(100) + */ +UCLASS(Blueprintable, BlueprintType) +class AFlutterGameMode : public AGameModeBase +{ + GENERATED_BODY() + +public: + AFlutterGameMode(); + +protected: + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + +public: + // ============================================================ + // MARK: - Game State + // ============================================================ + + /** + * Start the game + * Notifies Flutter and fires OnGameStarted + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void StartGame(); + + /** + * Pause the game + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void PauseGame(); + + /** + * Resume the game + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void ResumeGame(); + + /** + * Stop/end the game + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void StopGame(); + + /** + * Game over with reason + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void GameOver(const FString& Reason); + + /** + * Restart the game + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Game") + void RestartGame(); + + // ============================================================ + // MARK: - Score Management + // ============================================================ + + /** + * Set the current score + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Score") + void SetScore(int32 NewScore); + + /** + * Add to the current score + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Score") + void AddScore(int32 Points); + + /** + * Get the current score + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Score") + int32 GetScore() const { return CurrentScore; } + + /** + * Reset the score to zero + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Score") + void ResetScore(); + + // ============================================================ + // MARK: - Level Management + // ============================================================ + + /** + * Set the current level + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level") + void SetLevel(int32 NewLevel); + + /** + * Advance to next level + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level") + void NextLevel(); + + /** + * Get the current level + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level") + int32 GetCurrentLevel() const { return CurrentLevel; } + + /** + * Load a level by name + */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Level") + void LoadGameLevel(const FString& LevelName); + + // ============================================================ + // MARK: - Flutter Communication + // ============================================================ + + /** + * Send a custom event to Flutter + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SendGameEvent(const FString& EventName, const FString& EventData); + + /** + * Send the current game state to Flutter + */ + UFUNCTION(BlueprintCallable, Category = "Flutter") + void SyncGameState(); + + // ============================================================ + // MARK: - Blueprint Events + // ============================================================ + + /** + * Called when the game starts + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnGameStarted(); + + /** + * Called when the game is paused + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnGamePaused(); + + /** + * Called when the game is resumed + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnGameResumed(); + + /** + * Called when the game stops + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnGameStopped(); + + /** + * Called on game over + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnGameOver(const FString& Reason); + + /** + * Called when score changes + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnScoreChanged(int32 NewScore, int32 Delta); + + /** + * Called when level changes + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnLevelChanged(int32 NewLevel); + + /** + * Called when a player action is received from Flutter + */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnPlayerAction(const FString& Action, const FString& Data); + + // ============================================================ + // MARK: - Flutter Message Handlers + // ============================================================ + + /** + * Handle messages from Flutter + */ + UFUNCTION() + void HandleFlutterMessage(const FString& Method, const FString& Data); + +protected: + // Game state + UPROPERTY(BlueprintReadOnly, Category = "Flutter|State") + bool bIsGameRunning; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|State") + bool bIsGamePaused; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|State") + int32 CurrentScore; + + UPROPERTY(BlueprintReadOnly, Category = "Flutter|State") + int32 CurrentLevel; + + // Configuration + UPROPERTY(EditDefaultsOnly, Category = "Flutter") + FString FlutterTargetName; + + UPROPERTY(EditDefaultsOnly, Category = "Flutter") + bool bAutoSyncState; + + UPROPERTY(EditDefaultsOnly, Category = "Flutter") + float StateSyncInterval; + +private: + // Cached references + UPROPERTY() + AFlutterBridge* FlutterBridge; + + UPROPERTY() + UFlutterMessageRouter* MessageRouter; + + /** The demo scene, spawned in BeginPlay because there is no level to hold it. */ + UPROPERTY() + AFlutterDemoScene* DemoScene = nullptr; + + // State sync timer + FTimerHandle StateSyncTimerHandle; + + // Initialization + void InitializeFlutter(); + + // Send state to Flutter + void NotifyFlutter(const FString& Event, const FString& Data); +}; diff --git a/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.Build.cs b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.Build.cs new file mode 100644 index 0000000..939a4cf --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.Build.cs @@ -0,0 +1,24 @@ +using UnrealBuildTool; + +public class GameFrameworkProject : ModuleRules +{ + public GameFrameworkProject(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange(new string[] { + "Core", + "CoreUObject", + "Engine", + "InputCore", + "FlutterPlugin", + // FlutterGameMode parses and builds JSON directly. A monolithic + // game target pulls these in through the plugin, so the omission + // only shows up when the modular editor target tries to link. + "Json", + "JsonUtilities" + }); + + PrivateDependencyModuleNames.AddRange(new string[] { }); + } +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.cpp b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.cpp new file mode 100644 index 0000000..f8d025d --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.cpp @@ -0,0 +1,6 @@ +#include "GameFrameworkProject.h" +#include "Modules/ModuleManager.h" + +IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, GameFrameworkProject, "GameFrameworkProject"); + +DEFINE_LOG_CATEGORY(LogGameFramework); diff --git a/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.h b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.h new file mode 100644 index 0000000..5f7f9f3 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/GameFrameworkProject.h @@ -0,0 +1,5 @@ +#pragma once + +#include "CoreMinimal.h" + +DECLARE_LOG_CATEGORY_EXTERN(LogGameFramework, Log, All); diff --git a/example/unreal/demo/Source/GameFrameworkProject/README.md b/example/unreal/demo/Source/GameFrameworkProject/README.md new file mode 100644 index 0000000..1550bcc --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/README.md @@ -0,0 +1,222 @@ +# Unreal Engine Flutter Integration Templates + +This directory contains ready-to-use C++ classes and Blueprints for integrating Unreal Engine with Flutter. + +## Quick Start + +1. Copy the template files to your Unreal project's Source folder +2. Add the FlutterPlugin module to your project's Build.cs +3. Create subclasses or use directly in Blueprints + +## Template Files + +### FlutterActor.h/.cpp + +Base class for any Actor that needs to communicate with Flutter. + +**Features:** +- Auto-registration with Flutter message router +- Convenient SendToFlutter() methods +- Overridable message handlers +- Singleton support + +**Usage:** +```cpp +UCLASS() +class AMyGameActor : public AFlutterActor +{ + GENERATED_BODY() + +protected: + virtual FString GetFlutterTargetName() const override + { + return TEXT("MyGameActor"); + } + + virtual void HandleFlutterMessage_Implementation( + const FString& Method, + const FString& Data) override + { + if (Method == TEXT("doSomething")) + { + // Handle the message + DoSomething(); + + // Send response + SendToFlutter(TEXT("somethingDone"), TEXT("{\"success\": true}")); + } + } +}; +``` + +### FlutterGameMode.h/.cpp + +Full-featured GameMode with Flutter integration for game state management. + +**Features:** +- Game lifecycle (start, pause, resume, stop, game over) +- Score tracking and updates +- Level management +- Automatic state synchronization +- Blueprint events for all state changes + +**Usage (Blueprint):** +1. Create a Blueprint subclass of FlutterGameMode +2. Set it as your project's default GameMode +3. Use the provided functions: + - `StartGame()` - Begin the game + - `PauseGame()` / `ResumeGame()` - Control game state + - `AddScore(Points)` - Update score + - `NextLevel()` - Advance level + - `GameOver(Reason)` - End the game + +**Usage (C++):** +```cpp +// Get the GameMode +AFlutterGameMode* GameMode = Cast(GetWorld()->GetAuthGameMode()); +if (GameMode) +{ + GameMode->AddScore(100); + GameMode->SendGameEvent(TEXT("powerUp"), TEXT("{\"type\": \"speed\"}")); +} +``` + +## Flutter Side Integration + +### Receiving Messages from Unreal + +```dart +// Listen for messages from Unreal +controller.messageStream.listen((message) { + final metadata = message.metadata; + final target = metadata['target'] as String?; + final method = metadata['method'] as String?; + + if (target == 'GameMode') { + switch (method) { + case 'scoreChanged': + final data = jsonDecode(message.data); + updateScore(data['score']); + break; + case 'gameOver': + final data = jsonDecode(message.data); + showGameOverScreen(data['reason']); + break; + } + } +}); +``` + +### Sending Messages to Unreal + +```dart +// Send player action +await controller.sendJsonMessage('GameMode', 'playerAction', { + 'action': 'jump', + 'data': '{}', +}); + +// Request current game state +await controller.sendMessage('GameMode', 'requestState', '{}'); +``` + +## Message Protocol + +### Standard Messages + +All messages follow this format: +- **Target**: The registered target name (e.g., "GameMode", "Player") +- **Method**: The action to perform (e.g., "playerAction", "scoreChanged") +- **Data**: JSON string with parameters + +### Game State Messages (from Unreal) + +| Method | Data | Description | +|--------|------|-------------| +| gameStarted | {} | Game has started | +| gamePaused | {} | Game is paused | +| gameResumed | {} | Game resumed | +| gameStopped | {} | Game stopped | +| gameOver | {reason, finalScore, finalLevel} | Game over | +| scoreChanged | {score, delta} | Score updated | +| levelChanged | {level} | Level changed | +| stateSync | {isRunning, isPaused, score, level} | Full state sync | + +### Player Actions (to Unreal) + +| Method | Data | Description | +|--------|------|-------------| +| playerAction | {action, data} | Player performed action | +| requestState | {} | Request state sync | +| setLevel | {level} | Set current level | + +## Blueprint Setup + +### Creating a Flutter-Enabled GameMode + +1. Create new Blueprint Class based on FlutterGameMode +2. Configure settings: + - **Flutter Target Name**: Name to register with (default: "GameMode") + - **Auto Sync State**: Enable periodic state sync + - **State Sync Interval**: Seconds between syncs +3. Override events as needed: + - **OnGameStarted** - Called when game starts + - **OnScoreChanged** - Called when score updates + - **OnPlayerAction** - Handle player input from Flutter + +### Creating a Flutter-Enabled Actor + +1. Create new Blueprint Class based on FlutterActor +2. Override `Get Flutter Target Name` to return your target name +3. Override `Handle Flutter Message` to process incoming messages +4. Use `Send To Flutter` to send responses + +## Best Practices + +1. **Use JSON for complex data**: Always send structured data as JSON strings +2. **Keep messages small**: Send only changed data, use delta compression for large states +3. **Handle missing data gracefully**: Check for null/missing fields in JSON +4. **Use meaningful target names**: Make them descriptive and unique +5. **Register early**: Targets should register in BeginPlay +6. **Unregister on destroy**: Clean up in EndPlay to avoid memory leaks + +## Troubleshooting + +### Messages not received + +1. Check that FlutterBridge is in the level +2. Verify target name matches between Unreal and Flutter +3. Check the message router is initialized +4. Look for registration errors in the output log + +### Score not updating + +1. Verify the GameMode is correctly set +2. Check that the Flutter listener is subscribed to messageStream +3. Ensure JSON parsing handles the correct field names + +### Performance issues + +1. Reduce state sync frequency if not needed +2. Use binary messaging for large data transfers +3. Consider message batching for high-frequency updates + +## Example Project Structure + +``` +Source/ + MyGame/ + MyGame.Build.cs + MyGameMode.h # Subclass of FlutterGameMode + MyGameMode.cpp + Player/ + MyPlayerActor.h # Subclass of FlutterActor + MyPlayerActor.cpp + UI/ + ScoreActor.h + ScoreActor.cpp +``` + +## License + +These templates are part of the GameFramework and are provided under the same license. diff --git a/example/unreal/demo/Source/GameFrameworkProject/ROTATING_CUBE_DEMO.md b/example/unreal/demo/Source/GameFrameworkProject/ROTATING_CUBE_DEMO.md new file mode 100644 index 0000000..9894120 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/ROTATING_CUBE_DEMO.md @@ -0,0 +1,482 @@ +# Rotating Cube Demo - Unreal Engine + +This guide demonstrates how to create a rotating cube in Unreal Engine that communicates with Flutter, matching the Unity rotating cube demo. + +## Overview + +The demo creates a simple 3D cube that: +- Rotates continuously in Unreal Engine +- Receives rotation speed commands from Flutter +- Sends rotation state updates to Flutter +- Can be paused/resumed from Flutter + +## Quick Setup + +### 1. Create the Rotating Cube Actor + +Create a new C++ class inheriting from `AFlutterActor`: + +**RotatingCubeActor.h** +```cpp +#pragma once + +#include "CoreMinimal.h" +#include "FlutterActor.h" +#include "RotatingCubeActor.generated.h" + +UCLASS(Blueprintable) +class ARotatingCubeActor : public AFlutterActor +{ + GENERATED_BODY() + +public: + ARotatingCubeActor(); + +protected: + virtual void BeginPlay() override; + virtual void Tick(float DeltaTime) override; + + virtual FString GetFlutterTargetName() const override; + virtual void HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) override; + +public: + // Configuration + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation") + float RotationSpeed; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation") + FVector RotationAxis; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation") + bool bIsRotating; + + // Control methods + UFUNCTION(BlueprintCallable, Category = "Rotation") + void SetRotationSpeed(float Speed); + + UFUNCTION(BlueprintCallable, Category = "Rotation") + void SetRotationAxis(const FVector& Axis); + + UFUNCTION(BlueprintCallable, Category = "Rotation") + void StartRotation(); + + UFUNCTION(BlueprintCallable, Category = "Rotation") + void StopRotation(); + + UFUNCTION(BlueprintCallable, Category = "Rotation") + void ToggleRotation(); + +private: + // Visual component + UPROPERTY(VisibleAnywhere) + UStaticMeshComponent* CubeMesh; + + // State reporting + float TimeSinceLastUpdate; + float UpdateInterval; + void SendStateUpdate(); +}; +``` + +**RotatingCubeActor.cpp** +```cpp +#include "RotatingCubeActor.h" +#include "Components/StaticMeshComponent.h" +#include "Dom/JsonObject.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" + +ARotatingCubeActor::ARotatingCubeActor() +{ + PrimaryActorTick.bCanEverTick = true; + + RotationSpeed = 45.0f; // degrees per second + RotationAxis = FVector(0, 0, 1); // Z-axis + bIsRotating = true; + TimeSinceLastUpdate = 0.0f; + UpdateInterval = 0.1f; // 10 updates per second + + // Create cube mesh + CubeMesh = CreateDefaultSubobject(TEXT("CubeMesh")); + RootComponent = CubeMesh; + + // Load default cube mesh + static ConstructorHelpers::FObjectFinder CubeMeshAsset( + TEXT("/Engine/BasicShapes/Cube")); + if (CubeMeshAsset.Succeeded()) + { + CubeMesh->SetStaticMesh(CubeMeshAsset.Object); + } + + // Load default material + static ConstructorHelpers::FObjectFinder CubeMaterial( + TEXT("/Engine/BasicShapes/BasicShapeMaterial")); + if (CubeMaterial.Succeeded()) + { + CubeMesh->SetMaterial(0, CubeMaterial.Object); + } + + CubeMesh->SetWorldScale3D(FVector(0.5f, 0.5f, 0.5f)); +} + +void ARotatingCubeActor::BeginPlay() +{ + Super::BeginPlay(); + + // Send initial state + SendStateUpdate(); +} + +void ARotatingCubeActor::Tick(float DeltaTime) +{ + Super::Tick(DeltaTime); + + // Rotate cube + if (bIsRotating) + { + FRotator DeltaRotation = FRotator( + RotationAxis.X * RotationSpeed * DeltaTime, + RotationAxis.Y * RotationSpeed * DeltaTime, + RotationAxis.Z * RotationSpeed * DeltaTime + ); + AddActorLocalRotation(DeltaRotation); + } + + // Send periodic updates + TimeSinceLastUpdate += DeltaTime; + if (TimeSinceLastUpdate >= UpdateInterval) + { + TimeSinceLastUpdate = 0.0f; + SendStateUpdate(); + } +} + +FString ARotatingCubeActor::GetFlutterTargetName() const +{ + return TEXT("RotatingCube"); +} + +void ARotatingCubeActor::HandleFlutterMessage_Implementation( + const FString& Method, const FString& Data) +{ + // Parse JSON data + TSharedPtr JsonObject; + TSharedRef> Reader = TJsonReaderFactory<>::Create(Data); + + if (Method == TEXT("setSpeed")) + { + if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid()) + { + float Speed = JsonObject->GetNumberField(TEXT("speed")); + SetRotationSpeed(Speed); + } + } + else if (Method == TEXT("setAxis")) + { + if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid()) + { + float X = JsonObject->GetNumberField(TEXT("x")); + float Y = JsonObject->GetNumberField(TEXT("y")); + float Z = JsonObject->GetNumberField(TEXT("z")); + SetRotationAxis(FVector(X, Y, Z)); + } + } + else if (Method == TEXT("start")) + { + StartRotation(); + } + else if (Method == TEXT("stop")) + { + StopRotation(); + } + else if (Method == TEXT("toggle")) + { + ToggleRotation(); + } + else if (Method == TEXT("getState")) + { + SendStateUpdate(); + } +} + +void ARotatingCubeActor::SetRotationSpeed(float Speed) +{ + RotationSpeed = Speed; + SendStateUpdate(); +} + +void ARotatingCubeActor::SetRotationAxis(const FVector& Axis) +{ + RotationAxis = Axis.GetSafeNormal(); + SendStateUpdate(); +} + +void ARotatingCubeActor::StartRotation() +{ + bIsRotating = true; + SendToFlutter(TEXT("started"), TEXT("{}")); + SendStateUpdate(); +} + +void ARotatingCubeActor::StopRotation() +{ + bIsRotating = false; + SendToFlutter(TEXT("stopped"), TEXT("{}")); + SendStateUpdate(); +} + +void ARotatingCubeActor::ToggleRotation() +{ + if (bIsRotating) + StopRotation(); + else + StartRotation(); +} + +void ARotatingCubeActor::SendStateUpdate() +{ + FRotator CurrentRotation = GetActorRotation(); + + TSharedPtr JsonObject = MakeShareable(new FJsonObject); + JsonObject->SetBoolField(TEXT("isRotating"), bIsRotating); + JsonObject->SetNumberField(TEXT("speed"), RotationSpeed); + + // Rotation axis + TSharedPtr AxisObject = MakeShareable(new FJsonObject); + AxisObject->SetNumberField(TEXT("x"), RotationAxis.X); + AxisObject->SetNumberField(TEXT("y"), RotationAxis.Y); + AxisObject->SetNumberField(TEXT("z"), RotationAxis.Z); + JsonObject->SetObjectField(TEXT("axis"), AxisObject); + + // Current rotation + TSharedPtr RotationObject = MakeShareable(new FJsonObject); + RotationObject->SetNumberField(TEXT("pitch"), CurrentRotation.Pitch); + RotationObject->SetNumberField(TEXT("yaw"), CurrentRotation.Yaw); + RotationObject->SetNumberField(TEXT("roll"), CurrentRotation.Roll); + JsonObject->SetObjectField(TEXT("rotation"), RotationObject); + + FString JsonString; + TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonString); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + + SendToFlutter(TEXT("stateUpdate"), JsonString); +} +``` + +### 2. Flutter Integration + +**Dart Code:** +```dart +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:gameframework_unreal/gameframework_unreal.dart'; + +class RotatingCubeController extends StatefulWidget { + final UnrealController controller; + + const RotatingCubeController({required this.controller}); + + @override + _RotatingCubeControllerState createState() => _RotatingCubeControllerState(); +} + +class _RotatingCubeControllerState extends State { + bool isRotating = true; + double rotationSpeed = 45.0; + Map rotationAxis = {'x': 0, 'y': 0, 'z': 1}; + Map currentRotation = {'pitch': 0, 'yaw': 0, 'roll': 0}; + + @override + void initState() { + super.initState(); + _listenToUpdates(); + } + + void _listenToUpdates() { + widget.controller.messageStream.listen((message) { + final metadata = message.metadata; + if (metadata['target'] == 'RotatingCube') { + final method = metadata['method'] as String?; + + if (method == 'stateUpdate') { + final data = jsonDecode(message.data); + setState(() { + isRotating = data['isRotating'] ?? false; + rotationSpeed = (data['speed'] ?? 45.0).toDouble(); + rotationAxis = { + 'x': (data['axis']?['x'] ?? 0).toDouble(), + 'y': (data['axis']?['y'] ?? 0).toDouble(), + 'z': (data['axis']?['z'] ?? 1).toDouble(), + }; + currentRotation = { + 'pitch': (data['rotation']?['pitch'] ?? 0).toDouble(), + 'yaw': (data['rotation']?['yaw'] ?? 0).toDouble(), + 'roll': (data['rotation']?['roll'] ?? 0).toDouble(), + }; + }); + } + } + }); + } + + Future _setSpeed(double speed) async { + await widget.controller.sendJsonMessage( + 'RotatingCube', + 'setSpeed', + {'speed': speed}, + ); + } + + Future _toggle() async { + await widget.controller.sendMessage('RotatingCube', 'toggle', '{}'); + } + + Future _setAxis(double x, double y, double z) async { + await widget.controller.sendJsonMessage( + 'RotatingCube', + 'setAxis', + {'x': x, 'y': y, 'z': z}, + ); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Rotating Cube', style: Theme.of(context).textTheme.headlineSmall), + SizedBox(height: 16), + + // Status + Row( + children: [ + Icon( + isRotating ? Icons.play_arrow : Icons.pause, + color: isRotating ? Colors.green : Colors.orange, + ), + SizedBox(width: 8), + Text(isRotating ? 'Rotating' : 'Paused'), + ], + ), + SizedBox(height: 16), + + // Speed slider + Text('Speed: ${rotationSpeed.toStringAsFixed(1)}°/s'), + Slider( + value: rotationSpeed, + min: 0, + max: 180, + onChanged: (value) { + setState(() => rotationSpeed = value); + _setSpeed(value); + }, + ), + + // Toggle button + ElevatedButton.icon( + onPressed: _toggle, + icon: Icon(isRotating ? Icons.pause : Icons.play_arrow), + label: Text(isRotating ? 'Pause' : 'Start'), + ), + + SizedBox(height: 16), + + // Current rotation display + Text('Current Rotation:'), + Text(' Pitch: ${currentRotation['pitch']?.toStringAsFixed(1)}°'), + Text(' Yaw: ${currentRotation['yaw']?.toStringAsFixed(1)}°'), + Text(' Roll: ${currentRotation['roll']?.toStringAsFixed(1)}°'), + + SizedBox(height: 16), + + // Axis presets + Text('Rotation Axis:'), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: () => _setAxis(1, 0, 0), + child: Text('X'), + ), + ElevatedButton( + onPressed: () => _setAxis(0, 1, 0), + child: Text('Y'), + ), + ElevatedButton( + onPressed: () => _setAxis(0, 0, 1), + child: Text('Z'), + ), + ElevatedButton( + onPressed: () => _setAxis(1, 1, 1), + child: Text('XYZ'), + ), + ], + ), + ], + ), + ), + ); + } +} +``` + +### 3. Blueprint Version + +For a Blueprint-only implementation: + +1. Create a new Blueprint Actor +2. Add a Static Mesh Component with a cube mesh +3. Add the following variables: + - `RotationSpeed` (Float, default 45.0) + - `RotationAxis` (Vector, default 0,0,1) + - `bIsRotating` (Boolean, default true) +4. In Event Tick: + - Add Delta Rotation using speed * delta time * axis +5. Implement Flutter message handling via the message router + +## Message Protocol + +### From Flutter to Unreal + +| Method | Data | Description | +|--------|------|-------------| +| setSpeed | {speed: float} | Set rotation speed in degrees/second | +| setAxis | {x, y, z: float} | Set rotation axis | +| start | {} | Start rotation | +| stop | {} | Stop rotation | +| toggle | {} | Toggle rotation | +| getState | {} | Request current state | + +### From Unreal to Flutter + +| Method | Data | Description | +|--------|------|-------------| +| stateUpdate | {isRotating, speed, axis, rotation} | Full state update | +| started | {} | Rotation started | +| stopped | {} | Rotation stopped | + +## Testing + +1. Add the RotatingCubeActor to your level +2. Run the Flutter app with the Unreal widget +3. Use the Flutter controls to: + - Adjust rotation speed + - Change rotation axis + - Start/stop rotation +4. Observe real-time updates in the Flutter UI + +## Comparison with Unity Demo + +| Feature | Unity | Unreal | +|---------|-------|--------| +| Base Class | FlutterMonoBehaviour | FlutterActor | +| Message Attribute | [FlutterMethod] | HandleFlutterMessage override | +| Rotation | transform.Rotate() | AddActorLocalRotation() | +| JSON Parsing | JsonUtility | FJsonSerializer | +| Update Rate | Configurable | Configurable | + +The API and message protocol are identical, ensuring consistent Flutter integration across both engines. diff --git a/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.cpp b/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.cpp new file mode 100644 index 0000000..5c89a5d --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.cpp @@ -0,0 +1,317 @@ +#include "RotatingCube.h" +#include "FlutterBridge.h" +#include "Components/StaticMeshComponent.h" +#include "Materials/MaterialInstanceDynamic.h" +#include "Engine/StaticMesh.h" +#include "UObject/ConstructorHelpers.h" +#include "TimerManager.h" + +ARotatingCube::ARotatingCube() +{ + PrimaryActorTick.bCanEverTick = true; + + // Create cube mesh component + CubeMesh = CreateDefaultSubobject(TEXT("CubeMesh")); + RootComponent = CubeMesh; + + // Set default cube mesh + static ConstructorHelpers::FObjectFinder CubeMeshAsset(TEXT("/Engine/BasicShapes/Cube")); + if (CubeMeshAsset.Succeeded()) + { + CubeMesh->SetStaticMesh(CubeMeshAsset.Object); + } + + // Set default values + bAutoRegister = true; +} + +void ARotatingCube::BeginPlay() +{ + Super::BeginPlay(); + + // Create dynamic material + if (CubeMesh && CubeMesh->GetMaterial(0)) + { + DynamicMaterial = CubeMesh->CreateAndSetMaterialInstanceDynamic(0); + UpdateMaterialColor(); + } + + // Notify Flutter that we're ready + SendToFlutter(TEXT("onReady"), TEXT("true")); + + // Set up auto-sync timer if enabled + if (SyncIntervalSeconds > 0.0f) + { + GetWorldTimerManager().SetTimer( + SyncTimerHandle, + this, + &ARotatingCube::SyncStateToFlutter, + SyncIntervalSeconds, + true + ); + } + + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] BeginPlay - Speed: %.1f, Axis: %s"), + RotationSpeed, *RotationAxis.ToString()); +} + +void ARotatingCube::Tick(float DeltaTime) +{ + Super::Tick(DeltaTime); + + if (bIsRotating && RotationSpeed != 0.0f) + { + // Calculate rotation delta + float DeltaRotation = RotationSpeed * DeltaTime; + CurrentRotationAngle += DeltaRotation; + + // Wrap angle + if (CurrentRotationAngle > 360.0f) CurrentRotationAngle -= 360.0f; + if (CurrentRotationAngle < -360.0f) CurrentRotationAngle += 360.0f; + + // Apply rotation + FRotator DeltaRotator = FRotator::ZeroRotator; + if (RotationAxis.X != 0.0f) DeltaRotator.Roll = DeltaRotation * RotationAxis.X; + if (RotationAxis.Y != 0.0f) DeltaRotator.Pitch = DeltaRotation * RotationAxis.Y; + if (RotationAxis.Z != 0.0f) DeltaRotator.Yaw = DeltaRotation * RotationAxis.Z; + + AddActorLocalRotation(DeltaRotator); + } +} + +FString ARotatingCube::GetFlutterTargetName_Implementation() const +{ + // Use a specific name for the rotating cube demo + return TEXT("GameFrameworkDemo"); +} + +void ARotatingCube::HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) +{ + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Message: %s(%s)"), *Method, *Data); + + // Acknowledge every message straight back to Flutter. The engine's log file + // is buffered and only reliably shows startup, so an ack in the HUD is the + // one signal that says whether a control reached the actor at all, as + // opposed to being dropped somewhere along the route. + SendToFlutter(TEXT("gotMessage"), Method); + + if (Method == TEXT("setSpeed")) + { + float NewSpeed = FCString::Atof(*Data); + SetSpeed(NewSpeed); + } + else if (Method == TEXT("setAxis")) + { + FVector NewAxis = ParseAxisFromJson(Data); + SetAxis(NewAxis); + } + else if (Method == TEXT("setColor")) + { + FLinearColor NewColor = ParseColorFromJson(Data); + SetColor(NewColor); + } + else if (Method == TEXT("reset")) + { + Reset(); + } + else if (Method == TEXT("getState")) + { + SyncStateToFlutter(); + } + else if (Method == TEXT("setRotating")) + { + bool bRotate = Data.ToBool(); + SetRotating(bRotate); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[RotatingCube] Unknown method: %s"), *Method); + } +} + +void ARotatingCube::SetSpeed(float NewSpeed) +{ + RotationSpeed = FMath::Clamp(NewSpeed, -360.0f, 360.0f); + + // Notify Blueprint + OnSpeedChanged_Blueprint(RotationSpeed); + + // Notify Flutter + FString JsonData = FString::Printf(TEXT("{\"speed\":%.1f,\"rpm\":%.2f}"), RotationSpeed, GetRPM()); + SendToFlutter(TEXT("onSpeedChanged"), JsonData); + + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Speed set to: %.1f"), RotationSpeed); +} + +void ARotatingCube::SetAxis(FVector NewAxis) +{ + RotationAxis = NewAxis.GetSafeNormal(); + + // Notify Blueprint + OnAxisChanged_Blueprint(RotationAxis); + + // Notify Flutter + FString JsonData = FString::Printf(TEXT("{\"x\":%.2f,\"y\":%.2f,\"z\":%.2f}"), + RotationAxis.X, RotationAxis.Y, RotationAxis.Z); + SendToFlutter(TEXT("onAxisChanged"), JsonData); + + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Axis set to: %s"), *RotationAxis.ToString()); +} + +void ARotatingCube::SetColor(FLinearColor NewColor) +{ + CubeColor = NewColor; + UpdateMaterialColor(); + + // Notify Blueprint + OnColorChanged_Blueprint(CubeColor); + + // Notify Flutter + FString JsonData = FString::Printf(TEXT("{\"r\":%.2f,\"g\":%.2f,\"b\":%.2f,\"a\":%.2f}"), + CubeColor.R, CubeColor.G, CubeColor.B, CubeColor.A); + SendToFlutter(TEXT("onColorChanged"), JsonData); + + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Color set to: %s"), *CubeColor.ToString()); +} + +void ARotatingCube::Reset() +{ + RotationSpeed = DefaultSpeed; + RotationAxis = DefaultAxis; + CubeColor = DefaultColor; + CurrentRotationAngle = 0.0f; + bIsRotating = true; + + // Reset rotation + SetActorRotation(FRotator::ZeroRotator); + + // Update material + UpdateMaterialColor(); + + // Notify Blueprint + OnReset_Blueprint(); + + // Notify Flutter + SendToFlutter(TEXT("onReset"), GetStateJson()); + + UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Reset to defaults")); +} + +FString ARotatingCube::GetStateJson() const +{ + return FString::Printf( + TEXT("{\"speed\":%.1f,\"rpm\":%.2f,\"axis\":{\"x\":%.2f,\"y\":%.2f,\"z\":%.2f},") + TEXT("\"color\":{\"r\":%.2f,\"g\":%.2f,\"b\":%.2f,\"a\":%.2f},") + TEXT("\"rotation\":%.1f,\"isRotating\":%s}"), + RotationSpeed, + GetRPM(), + RotationAxis.X, RotationAxis.Y, RotationAxis.Z, + CubeColor.R, CubeColor.G, CubeColor.B, CubeColor.A, + CurrentRotationAngle, + bIsRotating ? TEXT("true") : TEXT("false") + ); +} + +void ARotatingCube::SetRotating(bool bShouldRotate) +{ + bIsRotating = bShouldRotate; + + FString JsonData = FString::Printf(TEXT("{\"isRotating\":%s}"), bShouldRotate ? TEXT("true") : TEXT("false")); + SendToFlutter(TEXT("onRotatingChanged"), JsonData); +} + +float ARotatingCube::GetRPM() const +{ + // Degrees per second to RPM + return RotationSpeed / 6.0f; // 360 degrees = 60 seconds for 1 RPM +} + +void ARotatingCube::SyncStateToFlutter() +{ + SendToFlutter(TEXT("onState"), GetStateJson()); +} + +void ARotatingCube::UpdateMaterialColor() +{ + if (DynamicMaterial) + { + DynamicMaterial->SetVectorParameterValue(TEXT("BaseColor"), CubeColor); + } +} + +FVector ARotatingCube::ParseAxisFromJson(const FString& JsonData) +{ + FVector Result = FVector(0.0f, 1.0f, 0.0f); + + // Simple JSON parsing (for production, use FJsonSerializer) + float X = 0.0f, Y = 1.0f, Z = 0.0f; + + // Find x value + int32 XStart = JsonData.Find(TEXT("\"x\":")); + if (XStart != INDEX_NONE) + { + FString XStr = JsonData.Mid(XStart + 4, 10); + X = FCString::Atof(*XStr); + } + + // Find y value + int32 YStart = JsonData.Find(TEXT("\"y\":")); + if (YStart != INDEX_NONE) + { + FString YStr = JsonData.Mid(YStart + 4, 10); + Y = FCString::Atof(*YStr); + } + + // Find z value + int32 ZStart = JsonData.Find(TEXT("\"z\":")); + if (ZStart != INDEX_NONE) + { + FString ZStr = JsonData.Mid(ZStart + 4, 10); + Z = FCString::Atof(*ZStr); + } + + Result = FVector(X, Y, Z); + return Result; +} + +FLinearColor ARotatingCube::ParseColorFromJson(const FString& JsonData) +{ + FLinearColor Result = FLinearColor::White; + + float R = 1.0f, G = 1.0f, B = 1.0f, A = 1.0f; + + // Find r value + int32 RStart = JsonData.Find(TEXT("\"r\":")); + if (RStart != INDEX_NONE) + { + FString RStr = JsonData.Mid(RStart + 4, 10); + R = FCString::Atof(*RStr); + } + + // Find g value + int32 GStart = JsonData.Find(TEXT("\"g\":")); + if (GStart != INDEX_NONE) + { + FString GStr = JsonData.Mid(GStart + 4, 10); + G = FCString::Atof(*GStr); + } + + // Find b value + int32 BStart = JsonData.Find(TEXT("\"b\":")); + if (BStart != INDEX_NONE) + { + FString BStr = JsonData.Mid(BStart + 4, 10); + B = FCString::Atof(*BStr); + } + + // Find a value + int32 AStart = JsonData.Find(TEXT("\"a\":")); + if (AStart != INDEX_NONE) + { + FString AStr = JsonData.Mid(AStart + 4, 10); + A = FCString::Atof(*AStr); + } + + Result = FLinearColor(R, G, B, A); + return Result; +} diff --git a/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.h b/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.h new file mode 100644 index 0000000..0d6cc51 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProject/RotatingCube.h @@ -0,0 +1,139 @@ +#pragma once + +#include "CoreMinimal.h" +#include "FlutterActor.h" +#include "Components/StaticMeshComponent.h" +#include "Materials/MaterialInstanceDynamic.h" +#include "RotatingCube.generated.h" + +/** + * Rotating cube demo actor for Flutter-Unreal integration. + * Demonstrates bidirectional communication between Flutter and Unreal. + * + * Features: + * - Responds to Flutter commands (setSpeed, setAxis, setColor, reset, getState) + * - Sends state updates back to Flutter (onSpeedChanged, onState, onReset) + * - Configurable rotation speed and axis + * - Blueprint-friendly with exposed properties + */ +UCLASS(Blueprintable, ClassGroup=(Flutter), meta=(BlueprintSpawnableComponent)) +class ARotatingCube : public AFlutterActor +{ + GENERATED_BODY() + +public: + ARotatingCube(); + + // ==================== EXPOSED PROPERTIES ==================== + + /** Current rotation speed in degrees per second */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation", meta = (ClampMin = "-360", ClampMax = "360")) + float RotationSpeed = 50.0f; + + /** Rotation axis (normalized) */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation") + FVector RotationAxis = FVector(0.0f, 1.0f, 0.0f); + + /** Cube color */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Appearance") + FLinearColor CubeColor = FLinearColor(0.5f, 0.5f, 1.0f, 1.0f); + + /** Whether the cube is currently rotating */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rotation") + bool bIsRotating = true; + + /** Auto-sync state to Flutter at this interval (0 = disabled) */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Flutter") + float SyncIntervalSeconds = 0.0f; + + // ==================== BLUEPRINT EVENTS ==================== + + /** Called when speed changes from Flutter */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnSpeedChanged_Blueprint(float NewSpeed); + + /** Called when axis changes from Flutter */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnAxisChanged_Blueprint(FVector NewAxis); + + /** Called when color changes from Flutter */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnColorChanged_Blueprint(FLinearColor NewColor); + + /** Called when reset is requested from Flutter */ + UFUNCTION(BlueprintImplementableEvent, Category = "Flutter|Events") + void OnReset_Blueprint(); + + // ==================== BLUEPRINT CALLABLE FUNCTIONS ==================== + + /** Set the rotation speed */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void SetSpeed(float NewSpeed); + + /** Set the rotation axis */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void SetAxis(FVector NewAxis); + + /** Set the cube color */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void SetColor(FLinearColor NewColor); + + /** Reset cube to default state */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void Reset(); + + /** Get the current state as JSON string */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + FString GetStateJson() const; + + /** Start/stop rotation */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void SetRotating(bool bShouldRotate); + + /** Get current RPM (rotations per minute) */ + UFUNCTION(BlueprintPure, Category = "Flutter|Cube") + float GetRPM() const; + + /** Send current state to Flutter */ + UFUNCTION(BlueprintCallable, Category = "Flutter|Cube") + void SyncStateToFlutter(); + +protected: + // ==================== OVERRIDES ==================== + + virtual void BeginPlay() override; + virtual void Tick(float DeltaTime) override; + virtual FString GetFlutterTargetName_Implementation() const override; + virtual void HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) override; + + // ==================== INTERNAL ==================== + + /** The static mesh component for the cube */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components") + UStaticMeshComponent* CubeMesh; + + /** Dynamic material instance for color changes */ + UPROPERTY() + UMaterialInstanceDynamic* DynamicMaterial; + + /** Timer handle for auto-sync */ + FTimerHandle SyncTimerHandle; + +private: + /** Apply current color to material */ + void UpdateMaterialColor(); + + /** Parse axis from JSON */ + FVector ParseAxisFromJson(const FString& JsonData); + + /** Parse color from JSON */ + FLinearColor ParseColorFromJson(const FString& JsonData); + + /** Current rotation angle (for state tracking) */ + float CurrentRotationAngle = 0.0f; + + /** Default values for reset */ + float DefaultSpeed = 50.0f; + FVector DefaultAxis = FVector(0.0f, 1.0f, 0.0f); + FLinearColor DefaultColor = FLinearColor(0.5f, 0.5f, 1.0f, 1.0f); +}; diff --git a/example/unreal/demo/Source/GameFrameworkProjectEditor.Target.cs b/example/unreal/demo/Source/GameFrameworkProjectEditor.Target.cs new file mode 100644 index 0000000..6b11400 --- /dev/null +++ b/example/unreal/demo/Source/GameFrameworkProjectEditor.Target.cs @@ -0,0 +1,13 @@ +using UnrealBuildTool; +using System.Collections.Generic; + +public class GameFrameworkProjectEditorTarget : TargetRules +{ + public GameFrameworkProjectEditorTarget(TargetInfo Target) : base(Target) + { + Type = TargetType.Editor; + DefaultBuildSettings = BuildSettingsVersion.V7; + IncludeOrderVersion = EngineIncludeOrderVersion.Latest; + ExtraModuleNames.Add("GameFrameworkProject"); + } +} diff --git a/packages/gameframework/ios/Classes/Core/GameEngineController.swift b/packages/gameframework/ios/Classes/Core/GameEngineController.swift index db53c45..7cac870 100644 --- a/packages/gameframework/ios/Classes/Core/GameEngineController.swift +++ b/packages/gameframework/ios/Classes/Core/GameEngineController.swift @@ -6,12 +6,21 @@ import UIKit */ class GameEngineContainerView: UIView { weak var engineView: UIView? - + + /// Fires after the engine view has been stretched to match the container. + /// + /// Engines that render into their own surface have to be told the new size. + /// Resizing the UIView alone leaves them drawing at whatever resolution + /// they started at, which shows up as a blurry or cropped scene after a + /// rotation rather than as an obvious failure. + var onEngineViewResized: ((CGSize) -> Void)? + override func layoutSubviews() { super.layoutSubviews() // Automatically resize engine view to match container bounds if let engineView = engineView, !bounds.isEmpty { engineView.frame = bounds + onEngineViewResized?(bounds.size) } } } @@ -121,8 +130,18 @@ open class GameEngineController: NSObject, GameEnginePlatformView, FlutterStream self.channel.setMethodCallHandler(handleMethodCall) self.eventChannel.setStreamHandler(self) + + // Weakly, because the controller owns the container. + self.containerView.onEngineViewResized = { [weak self] size in + self?.engineViewDidResize(to: size) + } } + /// Called on the main thread whenever the container has resized the engine + /// view. Override it if your engine needs its render surface resized too. + /// The default does nothing. + open func engineViewDidResize(to size: CGSize) {} + // MARK: - Abstract Methods (Override in subclasses) open func createEngine() { @@ -149,6 +168,13 @@ open class GameEngineController: NSObject, GameEnginePlatformView, FlutterStream fatalError("unloadEngine() must be overridden") } + /// Undo unloadEngine. Default does nothing, for an engine that cannot. + /// + /// Not abstract like the rest: unloading is a suggestion an engine may or + /// may not be able to act on, so being unable to come back from it is a + /// legitimate answer rather than a missing implementation. + open func reloadEngine() {} + open func destroyEngine() { fatalError("destroyEngine() must be overridden") } @@ -177,11 +203,21 @@ open class GameEngineController: NSObject, GameEnginePlatformView, FlutterStream // MARK: - Method Channel Handler private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + // Every call from Dart, named. This is the boundary that tells you + // whether a control that "does nothing" never left Dart or was dropped + // on this side, and there is no other way to see it: Dart's own stdout + // does not reach the device console. + NSLog("GameEngineController: <- \(call.method)") + switch call.method { case "engine#create": createEngine() result(true) + case "engine#reload": + reloadEngine() + result(true) + case "engine#isReady": result(isReady) diff --git a/packages/gameframework/macos/Classes/Core/GameEngineController.swift b/packages/gameframework/macos/Classes/Core/GameEngineController.swift new file mode 100644 index 0000000..287cd02 --- /dev/null +++ b/packages/gameframework/macos/Classes/Core/GameEngineController.swift @@ -0,0 +1,283 @@ +import FlutterMacOS +import Foundation + +/** + * Container view that resizes the engine's view to match its own bounds. + * + * AppKit does not resize subviews for you the way autoresizing hints imply, so + * this does it in layout, and tells whoever is interested that the size + * changed. An engine rendering into its own surface has to be told: stretching + * the view alone leaves it drawing at whatever size it started at. + */ +class GameEngineContainerView: NSView { + weak var engineView: NSView? + + /// Fires after the engine view has been stretched to match the container. + var onEngineViewResized: ((CGSize) -> Void)? + + override var isFlipped: Bool { true } + + override func layout() { + super.layout() + + if let engineView = engineView, !bounds.isEmpty { + engineView.frame = bounds + onEngineViewResized?(bounds.size) + } + } +} + +/** + * Protocol defining the interface for game engine platform views on macOS. + */ +public protocol GameEnginePlatformView: AnyObject { + func createEngine() + func attachEngine() + func detachEngine() + func pauseEngine() + func resumeEngine() + func unloadEngine() + func destroyEngine() + func sendMessage(target: String, method: String, data: String) + + var engineType: String { get } + var engineVersion: String { get } + + func view() -> NSView +} + +/** + * Base controller for an embedded engine on macOS. + * + * The same shape as the iOS one, and deliberately so: it answers the same + * method channel and sends the same events, so the Dart side does not need to + * know which platform it is talking to. What differs is only what AppKit + * forces, which is the container's layout and the platform view protocol. + */ +open class GameEngineController: NSObject, GameEnginePlatformView, FlutterStreamHandler { + + public let viewId: Int64 + public let messenger: FlutterBinaryMessenger + public let channel: FlutterMethodChannel + public let eventChannel: FlutterEventChannel + + private var eventSink: FlutterEventSink? + + /// Events raised before Flutter subscribed. + /// + /// The engine is created and reports itself ready well before the Dart side + /// has listened, and an event sent to nobody is simply lost. Queued here and + /// flushed on subscribe, because the one that goes missing is onCreated, + /// without which the controller never believes the engine exists. + private var pendingEvents: [[String: Any]] = [] + private let eventQueueLock = NSLock() + + private let containerView: GameEngineContainerView + private var engineView: NSView? + + open var _isReady = false + open var _isPaused = false + + private let config: [String: Any] + + public init( + frame: CGRect, + viewId: Int64, + messenger: FlutterBinaryMessenger, + config: [String: Any] + ) { + self.viewId = viewId + self.messenger = messenger + self.config = config + self.containerView = GameEngineContainerView(frame: frame) + + self.channel = FlutterMethodChannel( + name: "com.xraph.gameframework/engine_\(viewId)", + binaryMessenger: messenger + ) + + self.eventChannel = FlutterEventChannel( + name: "com.xraph.gameframework/events_\(viewId)", + binaryMessenger: messenger + ) + + super.init() + + self.channel.setMethodCallHandler(handleMethodCall) + self.eventChannel.setStreamHandler(self) + + // Weakly, because the controller owns the container. + self.containerView.onEngineViewResized = { [weak self] size in + self?.engineViewDidResize(to: size) + } + } + + // MARK: - Abstract + + open func createEngine() { fatalError("createEngine() must be overridden") } + open func attachEngine() { fatalError("attachEngine() must be overridden") } + open func detachEngine() { fatalError("detachEngine() must be overridden") } + open func pauseEngine() { fatalError("pauseEngine() must be overridden") } + open func resumeEngine() { fatalError("resumeEngine() must be overridden") } + open func unloadEngine() { fatalError("unloadEngine() must be overridden") } + open func destroyEngine() { fatalError("destroyEngine() must be overridden") } + open func sendMessage(target: String, method: String, data: String) { + fatalError("sendMessage() must be overridden") + } + + open var engineType: String { fatalError("engineType must be overridden") } + open var engineVersion: String { fatalError("engineVersion must be overridden") } + + /// Undo unloadEngine. Default does nothing, for an engine that cannot. + open func reloadEngine() {} + + /// Called when the container resized the engine view. Override to tell the + /// engine its new surface size. + open func engineViewDidResize(to size: CGSize) {} + + public var isReady: Bool { _isReady } + public var isPaused: Bool { _isPaused } + + // MARK: - Platform view + + public func view() -> NSView { + return containerView + } + + /// Put the engine's view inside the container, where Flutter composites it. + public func addEngineView(_ view: NSView) { + engineView = view + containerView.engineView = view + + view.frame = containerView.bounds + view.autoresizingMask = [.width, .height] + containerView.addSubview(view) + containerView.needsLayout = true + } + + /// Take it back out, without destroying it. + public func removeEngineView() { + engineView?.removeFromSuperview() + containerView.engineView = nil + engineView = nil + } + + // MARK: - Method channel + + private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + // Every call from Dart, named. This is the boundary that tells you + // whether a control that "does nothing" ever left Dart. + NSLog("GameEngineController: <- \(call.method)") + + switch call.method { + case "engine#create": + createEngine() + result(true) + + case "engine#reload": + reloadEngine() + result(true) + + case "engine#pause": + pauseEngine() + result(nil) + + case "engine#resume": + resumeEngine() + result(nil) + + case "engine#unload": + unloadEngine() + result(nil) + + case "engine#quit": + destroyEngine() + result(nil) + + case "engine#isReady": + result(isReady) + + case "engine#isPaused": + result(isPaused) + + case "engine#isLoaded": + result(isReady) + + case "engine#isInBackground": + result(isPaused) + + case "engine#sendMessage": + guard let args = call.arguments as? [String: Any], + let target = args["target"] as? String, + let method = args["method"] as? String, + let data = args["data"] as? String else { + result(FlutterError(code: "INVALID_ARGS", + message: "Invalid arguments", + details: nil)) + return + } + sendMessage(target: target, method: method, data: data) + result(nil) + + case "events#setup": + // Answered so the Dart side knows the handler is live before it + // subscribes. Without this it can listen too early and miss the + // events already queued. + result(true) + + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - Events + + public func sendEvent(name: String, data: Any?) { + let event: [String: Any] = [ + "event": name, + "data": data ?? NSNull() + ] + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + if let sink = self.eventSink { + sink(event) + } else { + self.eventQueueLock.lock() + self.pendingEvents.append(event) + self.eventQueueLock.unlock() + NSLog("GameEngineController: Queued event '\(name)' (Flutter not subscribed yet)") + } + } + } + + public func onListen(withArguments arguments: Any?, + eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + + eventQueueLock.lock() + let queued = pendingEvents + pendingEvents.removeAll() + eventQueueLock.unlock() + + if !queued.isEmpty { + NSLog("GameEngineController: Flushing \(queued.count) pending events to Flutter") + for event in queued { + events(event) + } + } + + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + /// Read a value from the config Flutter passed when creating the view. + public func getConfigValue(_ key: String) -> T? { + return config[key] as? T + } +} diff --git a/packages/gameframework/macos/Classes/Core/GameEngineRegistry.swift b/packages/gameframework/macos/Classes/Core/GameEngineRegistry.swift new file mode 100644 index 0000000..490d8c4 --- /dev/null +++ b/packages/gameframework/macos/Classes/Core/GameEngineRegistry.swift @@ -0,0 +1,116 @@ +import FlutterMacOS +import Foundation + +/** + * Protocol for engine factories + * + * Engine plugins must provide a factory that creates their specific controller. + */ +public protocol GameEngineFactory { + /// Create an engine controller + func createController( + frame: CGRect, + viewId: Int64, + messenger: FlutterBinaryMessenger, + config: [String: Any] + ) -> GameEnginePlatformView +} + +/** + * Singleton registry for game engine implementations + * + * Manages the registration and lifecycle of engine controllers and factories. + */ +public class GameEngineRegistry { + + public static let shared = GameEngineRegistry() + + private init() {} + + private var factories: [String: GameEngineFactory] = [:] + private var controllers: [GameEnginePlatformView] = [] + + /// Register an engine factory + public func registerFactory(engineType: String, factory: GameEngineFactory) { + factories[engineType] = factory + } + + /// Unregister an engine factory + public func unregisterFactory(engineType: String) { + factories.removeValue(forKey: engineType) + } + + /// Check if an engine is registered + public func isEngineRegistered(_ engineType: String) -> Bool { + return factories[engineType] != nil + } + + /// Get all registered engine types + public func getRegisteredEngines() -> [String] { + return Array(factories.keys) + } + + /// Get factory for a specific engine type + public func getFactory(_ engineType: String) -> GameEngineFactory? { + return factories[engineType] + } + + /// Keep a controller alive for as long as its platform view exists + public func registerController(_ controller: GameEnginePlatformView) { + controllers.append(controller) + } + + /// Drop a controller once its platform view is gone + public func unregisterController(_ controller: GameEnginePlatformView) { + controllers.removeAll { $0 === controller } + } +} + +/** + * Platform view factory for game engines + * + * Wraps the GameEngineFactory protocol for Flutter's platform view system. + * + * The macOS protocol differs from the iOS one: it hands over a view identifier + * and arguments but no frame, because AppKit sizes the view from its container + * afterwards. The controller is built with a zero frame and laid out on the + * first pass, which is what the iOS side ends up doing anyway. + */ +public class GameEnginePlatformViewFactory: NSObject, FlutterPlatformViewFactory { + + private let messenger: FlutterBinaryMessenger + private let engineType: String + + public init(messenger: FlutterBinaryMessenger, engineType: String) { + self.messenger = messenger + self.engineType = engineType + super.init() + } + + public func create(withViewIdentifier viewId: Int64, arguments args: Any?) -> NSView { + let config = args as? [String: Any] ?? [:] + + guard let factory = GameEngineRegistry.shared.getFactory(engineType) else { + // Not fatal, unlike iOS. A missing engine should show an empty view + // and say why, rather than take the whole app down: on desktop this + // is usually a plugin that failed to register, and killing the app + // hides the one message that would tell you so. + NSLog("GameEngineRegistry: no factory for \(engineType); showing an empty view") + return NSView(frame: .zero) + } + + let controller = factory.createController( + frame: .zero, + viewId: viewId, + messenger: messenger, + config: config + ) + + GameEngineRegistry.shared.registerController(controller) + return controller.view() + } + + public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } +} diff --git a/packages/gameframework/macos/Classes/GameframeworkPlugin.swift b/packages/gameframework/macos/Classes/GameframeworkPlugin.swift index 08c126d..78eceb9 100644 --- a/packages/gameframework/macos/Classes/GameframeworkPlugin.swift +++ b/packages/gameframework/macos/Classes/GameframeworkPlugin.swift @@ -2,12 +2,40 @@ import Cocoa import FlutterMacOS public class GameframeworkPlugin: NSObject, FlutterPlugin { + + /// Kept so an engine plugin can register its platform view later. + /// + /// Plugin registration order is not something a plugin can rely on, and an + /// engine cannot register a view for a factory it has not created yet. So the + /// registrar is held here and engine plugins call back once they are ready. + private static var pluginRegistrar: FlutterPluginRegistrar? + public static func register(with registrar: FlutterPluginRegistrar) { + pluginRegistrar = registrar + let channel = FlutterMethodChannel(name: "gameframework", binaryMessenger: registrar.messenger) let instance = GameframeworkPlugin() registrar.addMethodCallDelegate(instance, channel: channel) } + /// Called by engine plugins once they have registered their factory. + public static func registerPlatformView(engineType: String) { + guard let registrar = pluginRegistrar else { + NSLog("GameframeworkPlugin: no registrar yet, cannot register a platform view for \(engineType)") + return + } + + let factory = GameEnginePlatformViewFactory( + messenger: registrar.messenger, + engineType: engineType + ) + + // register, not registerViewFactory. The Objective-C header declares + // registerViewFactory:withId:, and Swift imports it under the shorter name. + registrar.register(factory, withId: "com.xraph.gameframework/\(engineType)") + NSLog("GameframeworkPlugin: registered platform view com.xraph.gameframework/\(engineType)") + } + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "getPlatformVersion": diff --git a/templates/unreal/Scripts/FlutterGameMode.cpp b/templates/unreal/Scripts/FlutterGameMode.cpp index 4b2d6f1..f80f533 100644 --- a/templates/unreal/Scripts/FlutterGameMode.cpp +++ b/templates/unreal/Scripts/FlutterGameMode.cpp @@ -3,6 +3,7 @@ #include "FlutterGameMode.h" #include "FlutterBridge.h" #include "FlutterMessageRouter.h" +#include "Engine/World.h" #include "TimerManager.h" #include "Dom/JsonObject.h" #include "Serialization/JsonWriter.h" diff --git a/templates/unreal/Scripts/FlutterGameMode.h b/templates/unreal/Scripts/FlutterGameMode.h index 4e48414..de064ff 100644 --- a/templates/unreal/Scripts/FlutterGameMode.h +++ b/templates/unreal/Scripts/FlutterGameMode.h @@ -135,7 +135,7 @@ class AFlutterGameMode : public AGameModeBase * Get the current level */ UFUNCTION(BlueprintCallable, Category = "Flutter|Level") - int32 GetLevel() const { return CurrentLevel; } + int32 GetCurrentLevel() const { return CurrentLevel; } /** * Load a level by name diff --git a/templates/unreal/Scripts/RotatingCube.cpp b/templates/unreal/Scripts/RotatingCube.cpp index 6d1a9c5..53bf01c 100644 --- a/templates/unreal/Scripts/RotatingCube.cpp +++ b/templates/unreal/Scripts/RotatingCube.cpp @@ -22,7 +22,6 @@ ARotatingCube::ARotatingCube() } // Set default values - FlutterTargetName = TEXT("RotatingCube"); bAutoRegister = true; } @@ -80,13 +79,13 @@ void ARotatingCube::Tick(float DeltaTime) } } -FString ARotatingCube::GetFlutterTargetName() const +FString ARotatingCube::GetFlutterTargetName_Implementation() const { // Use a specific name for the rotating cube demo return TEXT("GameFrameworkDemo"); } -void ARotatingCube::OnFlutterMessage_Implementation(const FString& Method, const FString& Data) +void ARotatingCube::HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) { UE_LOG(LogTemp, Log, TEXT("[RotatingCube] Message: %s(%s)"), *Method, *Data); diff --git a/templates/unreal/Scripts/RotatingCube.h b/templates/unreal/Scripts/RotatingCube.h index 29bd843..0d6cc51 100644 --- a/templates/unreal/Scripts/RotatingCube.h +++ b/templates/unreal/Scripts/RotatingCube.h @@ -2,6 +2,8 @@ #include "CoreMinimal.h" #include "FlutterActor.h" +#include "Components/StaticMeshComponent.h" +#include "Materials/MaterialInstanceDynamic.h" #include "RotatingCube.generated.h" /** @@ -101,8 +103,8 @@ class ARotatingCube : public AFlutterActor virtual void BeginPlay() override; virtual void Tick(float DeltaTime) override; - virtual FString GetFlutterTargetName() const override; - virtual void OnFlutterMessage_Implementation(const FString& Method, const FString& Data) override; + virtual FString GetFlutterTargetName_Implementation() const override; + virtual void HandleFlutterMessage_Implementation(const FString& Method, const FString& Data) override; // ==================== INTERNAL ====================