diff --git a/AppleCommon/GBAudioClient.m b/AppleCommon/GBAudioClient.m index 8f57e1f..e650aaf 100644 --- a/AppleCommon/GBAudioClient.m +++ b/AppleCommon/GBAudioClient.m @@ -46,11 +46,17 @@ static OSStatus render( // Get the default playback output unit AudioComponent defaultOutput = AudioComponentFindNext(NULL, &defaultOutputDescription); - NSAssert(defaultOutput, @"Can't find default output"); + if (!defaultOutput) { + NSLog(@"Can't find default output"); + return nil; + } // Create a new unit based on this that we'll use for output OSErr err = AudioComponentInstanceNew(defaultOutput, &audioUnit); - NSAssert1(audioUnit, @"Error creating unit: %hd", err); + if (!audioUnit) { + NSLog(@"Error creating unit: %hd", err); + return nil; + } // Set our tone rendering function on the unit AURenderCallbackStruct input; @@ -62,7 +68,10 @@ static OSStatus render( 0, &input, sizeof(input)); - NSAssert1(err == noErr, @"Error setting callback: %hd", err); + if (err) { + NSLog(@"Error setting callback: %hd", err); + return nil; + } AudioStreamBasicDescription streamFormat; streamFormat.mSampleRate = rate; @@ -80,9 +89,16 @@ static OSStatus render( 0, &streamFormat, sizeof(AudioStreamBasicDescription)); - NSAssert1(err == noErr, @"Error setting stream format: %hd", err); + + if (err) { + NSLog(@"Error setting stream format: %hd", err); + return nil; + } err = AudioUnitInitialize(audioUnit); - NSAssert1(err == noErr, @"Error initializing unit: %hd", err); + if (err) { + NSLog(@"Error initializing unit: %hd", err); + return nil; + } self.renderBlock = block; _rate = rate; @@ -93,7 +109,10 @@ static OSStatus render( -(void) start { OSErr err = AudioOutputUnitStart(audioUnit); - NSAssert1(err == noErr, @"Error starting unit: %hd", err); + if (err) { + NSLog(@"Error starting unit: %hd", err); + return; + } _playing = true; } diff --git a/Cocoa/GBApp.m b/Cocoa/GBApp.m index 01082c0..47b742b 100644 --- a/Cocoa/GBApp.m +++ b/Cocoa/GBApp.m @@ -66,7 +66,7 @@ static uint32_t color_to_int(NSColor *color) @"GBFilter": @"NearestNeighbor", @"GBColorCorrection": @(GB_COLOR_CORRECTION_MODERN_BALANCED), @"GBHighpassFilter": @(GB_HIGHPASS_ACCURATE), - @"GBRewindLength": @(10), + @"GBRewindLength": @(120), @"GBFrameBlendingMode": @([defaults boolForKey:@"DisableFrameBlending"]? GB_FRAME_BLENDING_MODE_DISABLED : GB_FRAME_BLENDING_MODE_ACCURATE), @"GBDMGModel": @(GB_MODEL_DMG_B), diff --git a/Cocoa/GBButtons.h b/Cocoa/GBButtons.h index 3f33cfd..54a2f09 100644 --- a/Cocoa/GBButtons.h +++ b/Cocoa/GBButtons.h @@ -1,4 +1,4 @@ -typedef enum : NSUInteger { +typedef enum { GBRight, GBLeft, GBUp, diff --git a/Cocoa/Info.plist b/Cocoa/Info.plist index 8ebf8cd..c437485 100644 --- a/Cocoa/Info.plist +++ b/Cocoa/Info.plist @@ -123,7 +123,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - Version @VERSION + @VERSION CFBundleSignature ???? CFBundleSupportedPlatforms diff --git a/Core/save_state.c b/Core/save_state.c index 147a861..ed39776 100644 --- a/Core/save_state.c +++ b/Core/save_state.c @@ -1347,10 +1347,12 @@ static int load_state_internal(GB_gameboy_t *gb, virtual_file_t *file) return errno ?: EIO; } - size_t orig_ram_size = gb->ram_size; + uint32_t ram_size = gb->ram_size; + uint32_t mbc_ram_size = gb->mbc_ram_size; memcpy(gb, &save, sizeof(save)); - gb->ram_size = orig_ram_size; - + gb->ram_size = ram_size; + gb->mbc_ram_size = mbc_ram_size; + sanitize_state(gb); GB_rewind_invalidate_for_backstepping(gb); return 0; diff --git a/Makefile b/Makefile index 578fd66..7c31fce 100644 --- a/Makefile +++ b/Makefile @@ -143,6 +143,9 @@ override CONF := release FAT_FLAGS += -arch x86_64 -arch arm64 endif +IOS_MIN := 11.0 + +IOS_PNGS := $(shell ls iOS/*.png) # Support out-of-PATH RGBDS RGBASM := $(RGBDS)rgbasm RGBLINK := $(RGBDS)rgblink @@ -239,13 +242,13 @@ SYSROOT := $(shell xcodebuild -sdk iphoneos -version Path 2> $(NULL)) ifeq ($(SYSROOT),) $(error Could not find an iOS SDK) endif -CFLAGS += -arch arm64 -miphoneos-version-min=11.0 -isysroot $(SYSROOT) -IAppleCommon -DGB_DISABLE_DEBUGGER +CFLAGS += -arch arm64 -miphoneos-version-min=$(IOS_MIN) -isysroot $(SYSROOT) -IAppleCommon -DGB_DISABLE_DEBUGGER CORE_FILTER += Core/debugger.c Core/sm83_disassembler.c Core/symbol_hash.c LDFLAGS += -arch arm64 OCFLAGS += -x objective-c -fobjc-arc -Wno-deprecated-declarations -isysroot $(SYSROOT) -LDFLAGS += -miphoneos-version-min=11.0 -isysroot $(SYSROOT) +LDFLAGS += -miphoneos-version-min=$(IOS_MIN) -isysroot $(SYSROOT) IOS_INSTALLER_LDFLAGS := $(LDFLAGS) -lobjc -framework CoreServices -framework Foundation -LDFLAGS += -lobjc -framework UIKit -framework Foundation -framework CoreGraphics -framework Metal -framework MetalKit -framework AudioToolbox -framework AVFoundation -framework QuartzCore -framework CoreMotion -framework CoreVideo -framework CoreMedia -framework CoreImage -framework UserNotifications -weak_framework CoreHaptics +LDFLAGS += -lobjc -framework UIKit -framework Foundation -framework CoreGraphics -framework Metal -framework MetalKit -framework AudioToolbox -framework AVFoundation -framework QuartzCore -framework CoreMotion -framework CoreVideo -framework CoreMedia -framework CoreImage -framework UserNotifications -framework GameController -weak_framework CoreHaptics -framework MobileCoreServices CODESIGN := codesign -fs - else ifeq ($(PLATFORM),Darwin) @@ -288,6 +291,7 @@ endif # Don't use function outlining. I breaks Obj-C ARC optimizations and Apple never bothered to fix it. It also hardly has any effect on file size. ifeq ($(shell $(CC) -x c -c $(NULL) -o $(NULL) -Werror -mno-outline 2> $(NULL); echo $$?),0) FRONTEND_CFLAGS += -mno-outline +LDFLAGS += -mno-outline endif STRIP := strip @@ -423,7 +427,7 @@ $(OBJ)/%.m.o: %.m # iOS Port $(BIN)/SameBoy-iOS.app: $(BIN)/SameBoy-iOS.app/SameBoy \ - $(shell ls iOS/*.png) \ + $(IOS_PNGS) \ iOS/License.html \ iOS/Info.plist \ $(BIN)/SameBoy-iOS.app/dmg_boot.bin \ @@ -433,9 +437,10 @@ $(BIN)/SameBoy-iOS.app: $(BIN)/SameBoy-iOS.app/SameBoy \ $(BIN)/SameBoy-iOS.app/agb_boot.bin \ $(BIN)/SameBoy-iOS.app/sgb_boot.bin \ $(BIN)/SameBoy-iOS.app/sgb2_boot.bin \ + $(BIN)/SameBoy-iOS.app/LaunchScreen.storyboardc \ Shaders $(MKDIR) -p $(BIN)/SameBoy-iOS.app - cp iOS/*.png $(BIN)/SameBoy-iOS.app + cp $(IOS_PNGS) $(BIN)/SameBoy-iOS.app sed "s/@VERSION/$(VERSION)/;s/@COPYRIGHT_YEAR/$(COPYRIGHT_YEAR)/" < iOS/Info.plist > $(BIN)/SameBoy-iOS.app/Info.plist sed "s/@COPYRIGHT_YEAR/$(COPYRIGHT_YEAR)/" < iOS/License.html > $(BIN)/SameBoy-iOS.app/License.html $(MKDIR) -p $(BIN)/SameBoy-iOS.app/Shaders @@ -491,6 +496,9 @@ endif $(BIN)/SameBoy.app/Contents/Resources/%.nib: Cocoa/%.xib ibtool --target-device mac --minimum-deployment-target 10.9 --compile $@ $^ 2>&1 | cat - +$(BIN)/SameBoy-iOS.app/%.storyboardc: iOS/%.storyboard + ibtool --target-device iphone --target-device ipad --minimum-deployment-target $(IOS_MIN) --compile $@ $^ 2>&1 | cat - + # Quick Look generator $(BIN)/SameBoy.qlgenerator: $(BIN)/SameBoy.qlgenerator/Contents/MacOS/SameBoyQL \ diff --git a/Shaders/MonoLCD.fsh b/Shaders/MonoLCD.fsh index 61d878d..00b63c2 100644 --- a/Shaders/MonoLCD.fsh +++ b/Shaders/MonoLCD.fsh @@ -35,6 +35,7 @@ STATIC vec4 scale(sampler2D image, vec2 position, vec2 input_resolution, vec2 ou } vec4 pre_shadow = mix(texture(image, position) * multiplier, mix(r1, r2, s.y), BLOOM); + pre_shadow.a = 1.0; pixel += vec2(-0.6, -0.8); q11 = texture(image, (floor(pixel) + 0.5) / input_resolution); diff --git a/iOS/GBAboutController.m b/iOS/GBAboutController.m index 49ca64b..3941a4e 100644 --- a/iOS/GBAboutController.m +++ b/iOS/GBAboutController.m @@ -237,4 +237,9 @@ { return UIModalPresentationFormSheet; } + +- (void)dismissViewController +{ + [self dismissViewControllerAnimated:true completion:nil]; +} @end diff --git a/iOS/GBBackgroundView.h b/iOS/GBBackgroundView.h index 1c34798..c36260f 100644 --- a/iOS/GBBackgroundView.h +++ b/iOS/GBBackgroundView.h @@ -3,7 +3,12 @@ #import "GBView.h" @interface GBBackgroundView : UIImageView +- (instancetype)initWithLayout:(GBLayout *)layout; + @property (readonly) GBView *gbView; @property (nonatomic) GBLayout *layout; @property (nonatomic) bool usesSwipePad; + +- (void)enterPreviewMode:(bool)showLabel; +- (void)reloadThemeImages; @end diff --git a/iOS/GBBackgroundView.m b/iOS/GBBackgroundView.m index 05cdc22..ca6e128 100644 --- a/iOS/GBBackgroundView.m +++ b/iOS/GBBackgroundView.m @@ -85,9 +85,11 @@ static GB_key_mask_t angleToKeyMask(double angle) UITouch *_swipePadTouch; CGPoint _padSwipeOrigin; UITouch *_screenTouch; + UITouch *_logoTouch; CGPoint _screenSwipeOrigin; bool _screenSwiped; bool _inDynamicSpeedMode; + bool _previewMode; UIImageView *_dpadView; UIImageView *_dpadShadowView; @@ -104,10 +106,21 @@ static GB_key_mask_t angleToKeyMask(double angle) GB_key_mask_t _lastMask; } -- (instancetype)init +- (void)reloadThemeImages +{ + _aButtonView.image = [_layout.theme imageNamed:@"buttonA"]; + _bButtonView.image = [_layout.theme imageNamed:@"buttonB"]; + _startButtonView.image = [_layout.theme imageNamed:@"button2"]; + _selectButtonView.image = [_layout.theme imageNamed:@"button2"]; + self.usesSwipePad = self.usesSwipePad; +} + +- (instancetype)initWithLayout:(GBLayout *)layout; { self = [super initWithImage:nil]; if (!self) return nil; + + _layout = layout; _touches = [NSMutableSet set]; _screenLabel = [[UILabel alloc] initWithFrame:CGRectZero]; @@ -119,13 +132,14 @@ static GB_key_mask_t angleToKeyMask(double angle) _screenLabel.numberOfLines = 0; [self addSubview:_screenLabel]; - _dpadView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"dpad"]]; + _dpadView = [[UIImageView alloc] initWithImage:[_layout.theme imageNamed:@"dpad"]]; + _aButtonView = [[UIImageView alloc] initWithImage:[_layout.theme imageNamed:@"buttonA"]]; + _bButtonView = [[UIImageView alloc] initWithImage:[_layout.theme imageNamed:@"buttonB"]]; + _startButtonView = [[UIImageView alloc] initWithImage:[_layout.theme imageNamed:@"button2"]]; + _selectButtonView = [[UIImageView alloc] initWithImage:[_layout.theme imageNamed:@"button2"]]; + _dpadShadowView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"dpadShadow"]]; _dpadShadowView.hidden = true; - _aButtonView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"button"]]; - _bButtonView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"button"]]; - _startButtonView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"button2"]]; - _selectButtonView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"button2"]]; _gbView = [[GBViewMetal alloc] initWithFrame:CGRectZero]; [self addSubview:_dpadView]; @@ -168,11 +182,19 @@ static GB_key_mask_t angleToKeyMask(double angle) - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + if (_previewMode) return; static const double dpadRadius = 75; CGPoint dpadLocation = _layout.dpadLocation; double factor = [UIScreen mainScreen].scale; dpadLocation.x /= factor; dpadLocation.y /= factor; + CGRect logoRect = _layout.logoRect; + + logoRect.origin.x /= factor; + logoRect.origin.y /= factor; + logoRect.size.width /= factor; + logoRect.size.height /= factor; + for (UITouch *touch in touches) { CGPoint point = [touch locationInView:self]; if (CGRectContainsPoint(self.gbView.frame, point) && !_screenTouch) { @@ -194,8 +216,10 @@ static GB_key_mask_t angleToKeyMask(double angle) } } } - - if (_usesSwipePad && !_swipePadTouch) { + else if (CGRectContainsPoint(logoRect, point) && !_logoTouch) { + _logoTouch = touch; + } + else if (_usesSwipePad && !_swipePadTouch) { if (fabs(point.x - dpadLocation.x) <= dpadRadius && fabs(point.y - dpadLocation.y) <= dpadRadius) { _swipePadTouch = touch; @@ -209,17 +233,18 @@ static GB_key_mask_t angleToKeyMask(double angle) - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { - if ([touches containsObject:_swipePadTouch]) { + if (_swipePadTouch && [touches containsObject:_swipePadTouch]) { _swipePadTouch = nil; } - if ([touches containsObject:_screenTouch]) { + if (_screenTouch && [touches containsObject:_screenTouch]) { _screenTouch = nil; if (self.viewController.runMode == GBRunModePaused) { self.viewController.runMode = GBRunModeNormal; [self fadeOverlayOut]; } if (!_screenSwiped) { + self.window.backgroundColor = nil; [self.window.rootViewController presentViewController:[GBMenuViewController menu] animated:true completion:nil]; } if (![[NSUserDefaults standardUserDefaults] boolForKey:@"GBSwipeLock"]) { @@ -229,6 +254,24 @@ static GB_key_mask_t angleToKeyMask(double angle) } } } + + if (_logoTouch && [touches containsObject:_logoTouch]) { + + double factor = [UIScreen mainScreen].scale; + CGRect logoRect = _layout.logoRect; + + logoRect.origin.x /= factor; + logoRect.origin.y /= factor; + logoRect.size.width /= factor; + logoRect.size.height /= factor; + + CGPoint point = [_logoTouch locationInView:self]; + if (CGRectContainsPoint(logoRect, point)) { + self.window.backgroundColor = nil; + [self.window.rootViewController presentViewController:[GBMenuViewController menu] animated:true completion:nil]; + } + _logoTouch = nil; + } [_touches minusSet:touches]; [self touchesChanged]; @@ -246,6 +289,7 @@ static GB_key_mask_t angleToKeyMask(double angle) - (void)touchesChanged { + if (_previewMode) return; if (!GB_is_inited(_gbView.gb)) return; GB_key_mask_t mask = 0; double factor = [UIScreen mainScreen].scale; @@ -345,10 +389,10 @@ static GB_key_mask_t angleToKeyMask(double angle) } } if (mask != _lastMask) { - _aButtonView.image = [UIImage imageNamed:(mask & GB_KEY_A_MASK)? @"buttonPressed" : @"button"]; - _bButtonView.image = [UIImage imageNamed:(mask & GB_KEY_B_MASK)? @"buttonPressed" : @"button"]; - _startButtonView.image = [UIImage imageNamed:(mask & GB_KEY_START_MASK) ? @"button2Pressed" : @"button2"]; - _selectButtonView.image = [UIImage imageNamed:(mask & GB_KEY_SELECT_MASK)? @"button2Pressed" : @"button2"]; + _aButtonView.image = [_layout.theme imageNamed:(mask & GB_KEY_A_MASK)? @"buttonAPressed" : @"buttonA"]; + _bButtonView.image = [_layout.theme imageNamed:(mask & GB_KEY_B_MASK)? @"buttonBPressed" : @"buttonB"]; + _startButtonView.image = [_layout.theme imageNamed:(mask & GB_KEY_START_MASK) ? @"button2Pressed" : @"button2"]; + _selectButtonView.image = [_layout.theme imageNamed:(mask & GB_KEY_SELECT_MASK)? @"button2Pressed" : @"button2"]; bool hidden = false; bool diagonal = false; @@ -425,7 +469,8 @@ static GB_key_mask_t angleToKeyMask(double angle) - (void)setUsesSwipePad:(bool)usesSwipePad { _usesSwipePad = usesSwipePad; - _dpadView.image = [UIImage imageNamed:usesSwipePad? @"swipepad" : @"dpad"]; + _dpadView.image = nil; // Some bug in UIImage seems to trigger without this? + _dpadView.image = [_layout.theme imageNamed:usesSwipePad? @"swipepad" : @"dpad"]; } - (void)displayOverlayWithImage:(NSString *)imageName orTitle:(NSString *)title @@ -519,4 +564,16 @@ static GB_key_mask_t angleToKeyMask(double angle) [self.viewController start]; } +- (void)enterPreviewMode:(bool)showLabel +{ + if (showLabel) { + _screenLabel.text = [NSString stringWithFormat:@"Previewing Theme “%@”", _layout.theme.name]; + } + else { + [_screenLabel removeFromSuperview]; + _screenLabel = nil; + } + _previewMode = true; +} + @end diff --git a/iOS/GBHapticManager.h b/iOS/GBHapticManager.h index e2c543c..7f2e2d9 100644 --- a/iOS/GBHapticManager.h +++ b/iOS/GBHapticManager.h @@ -1,7 +1,9 @@ #import +#import @interface GBHapticManager : NSObject + (instancetype)sharedManager; - (void)doTapHaptic; - (void)setRumbleStrength:(double)rumble; +@property (weak) GCController *controller; @end diff --git a/iOS/GBHapticManager.m b/iOS/GBHapticManager.m index 5cc09d3..b82abeb 100644 --- a/iOS/GBHapticManager.m +++ b/iOS/GBHapticManager.m @@ -7,8 +7,10 @@ #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wpartial-availability" CHHapticEngine *_engine; + CHHapticEngine *_externalEngine; id _rumblePlayer; #pragma clang diagnostic pop + __weak GCController *_controller; double _rumble; } @@ -58,18 +60,30 @@ if (_rumble) return; CHHapticPattern *pattern = [[CHHapticPattern alloc] initWithEvents:@[[self eventWithType:CHHapticEventTypeHapticTransient - sharpness:0.25 - intensity:0.75 - duration:1.0]] + sharpness:0.25 + intensity:[[NSUserDefaults standardUserDefaults] doubleForKey:@"GBHapticsStrength"] + duration:1.0]] parameters:nil error:nil]; - id player = [_engine createPlayerWithPattern:pattern error:nil]; - - [player startAtTime:0 error:nil]; + @try { + id player = [_engine createPlayerWithPattern:pattern error:nil]; + + [player startAtTime:0 error:nil]; + } + @catch (NSException *exception) {} } - (void)setRumbleStrength:(double)rumble { + if (!_controller) { // Controller disconnected + _externalEngine = nil; + } + if (!_externalEngine && _controller && !_controller.isAttachedToDevice) { + /* We have a controller with no rumble support which is not attached to the device, + don't rumble since the user is holding neither the device nor a haptic-enabled + controller. */ + rumble = 0; + } if (rumble == 0) { [_rumblePlayer stopAtTime:0 error:nil]; _rumblePlayer = nil; @@ -83,11 +97,38 @@ duration:1.0]] parameters:nil error:nil]; - id newPlayer = [_engine createPlayerWithPattern:pattern error:nil]; - - [newPlayer startAtTime:0 error:nil]; - [_rumblePlayer stopAtTime:0 error:nil]; - _rumblePlayer = newPlayer; + @try { + id newPlayer = [_externalEngine ?: _engine createPlayerWithPattern:pattern error:nil]; + + [newPlayer startAtTime:0 error:nil]; + [_rumblePlayer stopAtTime:0 error:nil]; + _rumblePlayer = newPlayer; + } + @catch (NSException *exception) { + if (_externalEngine) { + // Something might have happened with our controller? Delete and try again + _externalEngine = nil; + [self setRumbleStrength: rumble]; + } + } +} + +- (void)setController:(GCController *)controller +{ + if (_controller != controller) { + if (@available(iOS 14.0, *)) { + _externalEngine = [controller.haptics createEngineWithLocality:GCHapticsLocalityDefault]; + _externalEngine.playsHapticsOnly = true; + _externalEngine.autoShutdownEnabled = true; + + } + _controller = controller; + } +} + +- (GCController *)controller +{ + return _controller; } @end diff --git a/iOS/GBHorizontalLayout.m b/iOS/GBHorizontalLayout.m index 94891eb..66f7cb3 100644 --- a/iOS/GBHorizontalLayout.m +++ b/iOS/GBHorizontalLayout.m @@ -3,9 +3,9 @@ @implementation GBHorizontalLayout -- (instancetype)init +- (instancetype)initWithTheme:(GBTheme *)theme { - self = [super init]; + self = [super initWithTheme:theme]; if (!self) return nil; CGSize resolution = {self.resolution.height - self.cutout, self.resolution.width}; @@ -89,16 +89,25 @@ (self.aLocation.y + self.bLocation.y) / 2}; - UIGraphicsBeginImageContextWithOptions(resolution, true, 1); + if (theme.renderingPreview) { + UIGraphicsBeginImageContextWithOptions((CGSize){resolution.width / 8, resolution.height / 8}, true, 1); + CGContextScaleCTM(UIGraphicsGetCurrentContext(), 1 / 8.0, 1 / 8.0); + } + else { + UIGraphicsBeginImageContextWithOptions(resolution, true, 1); + } [self drawBackground]; [self drawScreenBezels]; - if (drawSameBoyLogo) { - double bezelBottom = screenRect.origin.y + screenRect.size.height + screenBorderWidth; - double freeSpace = resolution.height - bezelBottom; - [self drawLogoInVerticalRange:(NSRange){bezelBottom + screenBorderWidth * 2, freeSpace - screenBorderWidth * 4}]; - } - [self drawLabels]; + [self drawThemedLabelsWithBlock:^{ + if (drawSameBoyLogo) { + double bezelBottom = screenRect.origin.y + screenRect.size.height + screenBorderWidth; + double freeSpace = resolution.height - bezelBottom; + [self drawLogoInVerticalRange:(NSRange){bezelBottom + screenBorderWidth * 2, freeSpace - screenBorderWidth * 4}]; + } + + [self drawLabels]; + }]; self.background = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); @@ -112,4 +121,10 @@ } return CGRectMake(0, 0, self.background.size.width / self.factor, self.background.size.height / self.factor); } + +- (CGSize)size +{ + return (CGSize){self.resolution.height, self.resolution.width}; +} + @end diff --git a/iOS/GBLayout.h b/iOS/GBLayout.h index acba9b2..cda5741 100644 --- a/iOS/GBLayout.h +++ b/iOS/GBLayout.h @@ -1,14 +1,20 @@ #import +#import "GBTheme.h" @interface GBLayout : NSObject +- (instancetype)initWithTheme:(GBTheme *)theme; +@property (readonly) GBTheme *theme; + @property (readonly) UIImage *background; @property (readonly) CGRect screenRect; +@property (readonly) CGRect logoRect; @property (readonly) CGPoint dpadLocation; @property (readonly) CGPoint aLocation; @property (readonly) CGPoint bLocation; @property (readonly) CGPoint abComboLocation; @property (readonly) CGPoint startLocation; @property (readonly) CGPoint selectLocation; + - (CGRect)viewRectForOrientation:(UIInterfaceOrientation)orientation; @end @@ -23,19 +29,19 @@ @property CGPoint abComboLocation; @property CGPoint startLocation; @property CGPoint selectLocation; -@property (readonly) CGSize resolution; +@property (readonly) CGSize resolution; // Always vertical +@property (readonly) CGSize size; // Size in pixels, override to make horizontal @property (readonly) unsigned factor; @property (readonly) unsigned minY; @property (readonly) unsigned homeBar; @property (readonly) unsigned cutout; @property (readonly) bool hasFractionalPixels; -@property (readonly) UIColor *brandColor; - - (void)drawBackground; - (void)drawScreenBezels; - (void)drawLogoInVerticalRange:(NSRange)range; - (void)drawLabels; +- (void)drawThemedLabelsWithBlock:(void (^)(void))block; - (CGSize)buttonDeltaForMaxHorizontalDistance:(double)distance; @end diff --git a/iOS/GBLayout.m b/iOS/GBLayout.m index 6850015..9331c51 100644 --- a/iOS/GBLayout.m +++ b/iOS/GBLayout.m @@ -1,15 +1,43 @@ #define GBLayoutInternal #import "GBLayout.h" -@interface UIApplication() -- (double)statusBarHeightForOrientation:(UIInterfaceOrientation)orientation ignoreHidden:(bool)ignoreHidden; -@end +static double StatusBarHeight(void) +{ + static double ret = 0; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + @autoreleasepool { + UIWindow *window = [[UIWindow alloc] init]; + [window makeKeyAndVisible]; + UIEdgeInsets insets = window.safeAreaInsets; + ret = MAX(MAX(insets.left, insets.right), MAX(insets.top, insets.bottom)) ?: 20; + [window setHidden:true]; + } + }); + return ret; +} + +static bool HasHomeBar(void) +{ + static bool ret = false; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + ret = [UIApplication sharedApplication].windows[0].safeAreaInsets.bottom; + }); + return ret; +} @implementation GBLayout -- (instancetype)init +{ + bool _isRenderingMask; +} + +- (instancetype)initWithTheme:(GBTheme *)theme { self = [super init]; if (!self) return nil; + + _theme = theme; _factor = [UIScreen mainScreen].scale; _resolution = [UIScreen mainScreen].bounds.size; _resolution.width *= _factor; @@ -17,11 +45,11 @@ if (_resolution.width > _resolution.height) { _resolution = (CGSize){_resolution.height, _resolution.width}; } - _minY = [[UIApplication sharedApplication] statusBarHeightForOrientation:UIInterfaceOrientationPortrait - ignoreHidden:true] * _factor; + + _minY = StatusBarHeight() * _factor; _cutout = _minY <= 24 * _factor? 0 : _minY; - if ([UIApplication sharedApplication].windows[0].safeAreaInsets.bottom) { + if (HasHomeBar()) { _homeBar = 21 * _factor; } @@ -36,21 +64,11 @@ return CGRectMake(0, 0, self.background.size.width / self.factor, self.background.size.height / self.factor); } -- (UIColor *)brandColor -{ - static dispatch_once_t onceToken; - static UIColor *ret = nil; - dispatch_once(&onceToken, ^{ - ret = [UIColor colorWithRed:0 / 255.0 green:70 / 255.0 blue:141 / 255.0 alpha:1.0]; - }); - return ret; -} - - (void)drawBackground { CGContextRef context = UIGraphicsGetCurrentContext(); - CGColorRef top = [UIColor colorWithRed:192 / 255.0 green:195 / 255.0 blue:199 / 255.0 alpha:1.0].CGColor; - CGColorRef bottom = [UIColor colorWithRed:174 / 255.0 green:176 / 255.0 blue:180 / 255.0 alpha:1.0].CGColor; + CGColorRef top = _theme.backgroundGradientTop.CGColor; + CGColorRef bottom = _theme.backgroundGradientBottom.CGColor; CGColorRef colors[] = {top, bottom}; CFArrayRef colorsArray = CFArrayCreate(NULL, (const void **)colors, 2, &kCFTypeArrayCallBacks); @@ -59,7 +77,7 @@ CGContextDrawLinearGradient(context, gradient, (CGPoint){0, 0}, - (CGPoint){0, CGBitmapContextGetHeight(context)}, + (CGPoint){0, self.size.height}, 0); CFRelease(gradient); @@ -70,8 +88,8 @@ - (void)drawScreenBezels { CGContextRef context = UIGraphicsGetCurrentContext(); - CGColorRef top = [UIColor colorWithWhite:53 / 255.0 alpha:1.0].CGColor; - CGColorRef bottom = [UIColor colorWithWhite:45 / 255.0 alpha:1.0].CGColor; + CGColorRef top = _theme.bezelsGradientTop.CGColor; + CGColorRef bottom = _theme.bezelsGradientBottom.CGColor; CGColorRef colors[] = {top, bottom}; CFArrayRef colorsArray = CFArrayCreate(NULL, (const void **)colors, 2, &kCFTypeArrayCallBacks); @@ -83,8 +101,8 @@ bezelRect.size.height += borderWidth * 2; UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:bezelRect cornerRadius:borderWidth]; CGContextSaveGState(context); - CGContextSetShadowWithColor(context, (CGSize){0,}, borderWidth / 2, [UIColor colorWithWhite:0 alpha:1.0].CGColor); - [[UIColor colorWithWhite:0 alpha:0.25] setFill]; + CGContextSetShadowWithColor(context, (CGSize){0, _factor}, _factor, [UIColor colorWithWhite:1 alpha:0.25].CGColor); + [_theme.backgroundGradientBottom setFill]; [path fill]; [path addClip]; @@ -96,10 +114,17 @@ (CGPoint){bezelRect.origin.x, bezelRect.origin.y + bezelRect.size.height}, 0); + CGContextSetShadowWithColor(context, (CGSize){0, _factor}, _factor, [UIColor colorWithWhite:0 alpha:0.25].CGColor); + + path.usesEvenOddFillRule = true; + [path appendPath:[UIBezierPath bezierPathWithRect:(CGRect){{0, 0}, self.size}]]; + [path fill]; + + CGContextRestoreGState(context); CGContextSaveGState(context); - CGContextSetShadowWithColor(context, (CGSize){0,}, borderWidth / 2, [UIColor colorWithWhite:0 alpha:0.25].CGColor); + CGContextSetShadowWithColor(context, (CGSize){0, 0}, borderWidth / 4, [UIColor colorWithWhite:0 alpha:0.125].CGColor); [[UIColor blackColor] setFill]; UIRectFill(self.screenRect); @@ -116,15 +141,26 @@ CGRect rect = CGRectMake(0, range.location - range.length / 3, - CGBitmapContextGetWidth(UIGraphicsGetCurrentContext()), range.length * 2); + self.size.width, range.length * 2); NSMutableParagraphStyle *style = [NSParagraphStyle defaultParagraphStyle].mutableCopy; style.alignment = NSTextAlignmentCenter; [@"SAMEBOY" drawInRect:rect withAttributes:@{ NSFontAttributeName: font, - NSForegroundColorAttributeName:self.brandColor, + NSForegroundColorAttributeName:_isRenderingMask? [UIColor whiteColor] : _theme.brandColor, NSParagraphStyleAttributeName: style, }]; + + _logoRect = (CGRect){ + {(self.size.width - _screenRect.size.width) / 2, rect.origin.y}, + {_screenRect.size.width, rect.size.height} + }; +} + +- (void)drawThemedLabelsWithBlock:(void (^)(void))block +{ + // Start with a normal normal pass + block(); } - (void)drawRotatedLabel:(NSString *)label withFont:(UIFont *)font origin:(CGPoint)origin distance:(double)distance @@ -141,7 +177,7 @@ [label drawInRect:CGRectMake(-256, distance, 512, 256) withAttributes:@{ NSFontAttributeName: font, - NSForegroundColorAttributeName:self.brandColor, + NSForegroundColorAttributeName:_isRenderingMask? [UIColor whiteColor] : _theme.brandColor, NSParagraphStyleAttributeName: style, }]; CGContextRestoreGState(context); @@ -167,4 +203,9 @@ } return (CGSize){maxDistance, floor(sqrt(100 * 100 * self.factor * self.factor - maxDistance * maxDistance))}; } + +- (CGSize)size +{ + return _resolution; +} @end diff --git a/iOS/GBLoadROMTableViewController.m b/iOS/GBLoadROMTableViewController.m index a27d3d4..828dd74 100644 --- a/iOS/GBLoadROMTableViewController.m +++ b/iOS/GBLoadROMTableViewController.m @@ -1,5 +1,11 @@ #import "GBLoadROMTableViewController.h" #import "GBROMManager.h" +#import "GBViewController.h" +#import +#import + +@interface GBLoadROMTableViewController() +@end @implementation GBLoadROMTableViewController { @@ -15,16 +21,22 @@ - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { - return 1; + return 2; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + if (section == 1) return 1; return [GBROMManager sharedManager].allROMs.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.section == 1) { + UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; + cell.textLabel.text = @"Import ROM files"; + return cell; + } UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; NSString *rom = [GBROMManager sharedManager].allROMs[[indexPath indexAtPosition:1]]; cell.textLabel.text = rom; @@ -51,6 +63,8 @@ - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.section == 1) return [super tableView:tableView heightForRowAtIndexPath:indexPath]; + return 60; } @@ -61,17 +75,123 @@ - (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section { - return @"Import ROMs by opening them in SameBoy using the Files app or a web browser, or by sending them over with AirDrop"; + if (section == 0) return nil; + + return @"You can also import ROM files by opening them in SameBoy using the Files app or a web browser, or by sending them over with AirDrop."; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.section == 1) { + UIViewController *parent = self.presentingViewController; + NSString *gbUTI = (__bridge_transfer NSString *)UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)@"gb", NULL); + NSString *gbcUTI = (__bridge_transfer NSString *)UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)@"gbc", NULL); + NSString *isxUTI = (__bridge_transfer NSString *)UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)@"isx", NULL); + + NSMutableSet *extensions = [NSMutableSet set]; + [extensions addObjectsFromArray:(__bridge NSArray *)UTTypeCopyAllTagsWithClass((__bridge CFStringRef)gbUTI, kUTTagClassFilenameExtension)]; + [extensions addObjectsFromArray:(__bridge NSArray *)UTTypeCopyAllTagsWithClass((__bridge CFStringRef)gbcUTI, kUTTagClassFilenameExtension)]; + [extensions addObjectsFromArray:(__bridge NSArray *)UTTypeCopyAllTagsWithClass((__bridge CFStringRef)isxUTI, kUTTagClassFilenameExtension)]; + + if (extensions.count != 3) { + if (![[NSUserDefaults standardUserDefaults] boolForKey:@"GBShownUTIWarning"]) { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"File Association Conflict" + message:@"Due to a limitation in iOS, the file picker will allow you to select files not supported by SameBoy. SameBoy will only import GB, GBC and ISX files.\n\nIf you have a multi-system emulator installed, updating it could fix this problem." + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Close" + style:UIAlertActionStyleCancel + handler:^(UIAlertAction *action) { + [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"GBShownUTIWarning"]; + [self tableView:tableView didSelectRowAtIndexPath:indexPath]; + }]]; + [self presentViewController:alert animated:true completion:nil]; + return; + } + } + + [self.presentingViewController dismissViewControllerAnimated:true completion:^{ + UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:@[@"com.github.liji32.sameboy.gb", + @"com.github.liji32.sameboy.gbc", + @"com.github.liji32.sameboy.isx", + gbUTI ?: @"", + gbcUTI ?: @"", + isxUTI ?: @""] + inMode:UIDocumentPickerModeImport]; + picker.allowsMultipleSelection = true; + if (@available(iOS 13.0, *)) { + picker.shouldShowFileExtensions = true; + } + picker.delegate = self; + objc_setAssociatedObject(picker, @selector(delegate), self, OBJC_ASSOCIATION_RETAIN); + [parent presentViewController:picker animated:true completion:nil]; + }]; + return; + } [GBROMManager sharedManager].currentROM = [GBROMManager sharedManager].allROMs[[indexPath indexAtPosition:1]]; [self.presentingViewController dismissViewControllerAnimated:true completion:^{ [[NSNotificationCenter defaultCenter] postNotificationName:@"GBROMChanged" object:nil]; }]; } +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls +{ + NSMutableArray *validURLs = [NSMutableArray array]; + NSMutableArray *skippedBasenames = [NSMutableArray array]; + + for (NSURL *url in urls) { + if ([@[@"gb", @"gbc", @"isx"] containsObject:url.pathExtension.lowercaseString]) { + [validURLs addObject:url]; + } + else { + [skippedBasenames addObject:url.lastPathComponent]; + } + } + + if (skippedBasenames.count) { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Unsupported Files" + message:[NSString stringWithFormat:@"Could not import the following files because they're not supported:\n%@", + [skippedBasenames componentsJoinedByString:@"\n"]] + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Close" + style:UIAlertActionStyleCancel + handler:^(UIAlertAction *action) { + [[NSUserDefaults standardUserDefaults] setBool:false forKey:@"GBShownUTIWarning"]; // Somebody might need a reminder + }]]; + [[UIApplication sharedApplication].keyWindow.rootViewController presentViewController:alert animated:true completion:nil]; + urls = validURLs; + } + + if (urls.count == 1) { + NSURL *url = urls.firstObject; + NSString *potentialROM = [[url.path stringByDeletingLastPathComponent] lastPathComponent]; + if ([[[GBROMManager sharedManager] romFileForROM:potentialROM].stringByStandardizingPath isEqualToString:url.path.stringByStandardizingPath]) { + [GBROMManager sharedManager].currentROM = potentialROM; + } + else { + [url startAccessingSecurityScopedResource]; + [GBROMManager sharedManager].currentROM = + [[GBROMManager sharedManager] importROM:url.path + keepOriginal:true]; + [url stopAccessingSecurityScopedResource]; + } + [[NSNotificationCenter defaultCenter] postNotificationName:@"GBROMChanged" object:nil]; + } + else { + for (NSURL *url in urls) { + NSString *potentialROM = [[url.path stringByDeletingLastPathComponent] lastPathComponent]; + if ([[[GBROMManager sharedManager] romFileForROM:potentialROM].stringByStandardizingPath isEqualToString:url.path.stringByStandardizingPath]) { + // That's an already imported ROM + continue; + } + [url startAccessingSecurityScopedResource]; + [[GBROMManager sharedManager] importROM:url.path + keepOriginal:true]; + [url stopAccessingSecurityScopedResource]; + } + [(GBViewController *)[UIApplication sharedApplication].keyWindow.rootViewController openLibrary]; + } +} + - (UIModalPresentationStyle)modalPresentationStyle { return UIModalPresentationOverFullScreen; @@ -79,6 +199,8 @@ - (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.section == 1) return; + if (editingStyle != UITableViewCellEditingStyleDelete) return; NSString *rom = [GBROMManager sharedManager].allROMs[[indexPath indexAtPosition:1]]; UIAlertController *alert = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:@"Delete ROM “%@”?", rom] @@ -102,6 +224,8 @@ - (void)renameRow:(NSIndexPath *)indexPath { + if (indexPath.section == 1) return; + UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; UITextField *field = [[UITextField alloc] initWithFrame:cell.textLabel.frame]; field.font = cell.textLabel.font; @@ -140,11 +264,18 @@ _renamingPath = nil; } +- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath +{ + return indexPath.section == 0; +} + // Leave these ROM management to iOS 13.0 and up for now - (UIContextMenuConfiguration *)tableView:(UITableView *)tableView contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point API_AVAILABLE(ios(13.0)) { + if (indexPath.section == 1) return nil; + return [UIContextMenuConfiguration configurationWithIdentifier:nil previewProvider:nil actionProvider:^UIMenu *(NSArray *suggestedActions) { diff --git a/iOS/GBMenuViewController.m b/iOS/GBMenuViewController.m index 72a55a3..1a47011 100644 --- a/iOS/GBMenuViewController.m +++ b/iOS/GBMenuViewController.m @@ -4,7 +4,31 @@ #import "GBViewController.h" #import "GBROMManager.h" +static NSString *const tips[] = { + @"Tip: AirDrop ROM files from a Mac or another device to play them.", + @"Tip: Swipe right on the Game Boy screen to fast forward emulation.", + @"Tip: The D-pad can be replaced with a Swipe-pad in Control Settings. Give it a try!", + @"Tip: Swipe left on the Game Boy screen to rewind.", + @"Tip: Enable Quick Save and Load in Control Settings to use save state gestures.", + @"Tip: The turbo and rewind speeds can be changed in Emulation Settings.", + @"Tip: Change turbo and rewind behavior to locking in Controls Settings.", + @"Tip: Single Touch A+B combo can be enabled in Controls Settings.", + @"Tip: Try different scaling filters in Display Settings.", + @"Tip: Dynamically control turbo and rewind speed by enabling Dynamic Control in Control Settings.", + @"Tip: Rumble can be enabled even for games without rumble support in Control Settings.", + @"Tip: Try different color palettes for monochrome models in Display Settings.", + @"Did you know? The Game Boy uses a Sharp SM83 CPU.", + @"Did you know? The Game Boy Color has 6 different hardware revisions.", + @"Did you know? The Game Boy's frame rate is approximately 59.73 frames per second.", + @"Did you know? The original Super Game Boy runs slightly faster than other Game Boys.", + @"Did you know? The Game Boy generates audio at a sample rate of over 2MHz!", +}; + @implementation GBMenuViewController +{ + UILabel *_tipLabel; + UIVisualEffectView *_effectView; +} + (instancetype)menu { @@ -73,9 +97,53 @@ }; objc_setAssociatedObject(button, "RetainedBlock", block, OBJC_ASSOCIATION_RETAIN); [button addTarget:block action:@selector(invoke) forControlEvents:UIControlEventTouchUpInside]; - - } + + _effectView = [[UIVisualEffectView alloc] initWithEffect:[UIBlurEffect effectWithStyle:UIBlurEffectStyleProminent]]; + _effectView.layer.cornerRadius = 8; + _effectView.layer.masksToBounds = true; + [self.view.superview addSubview:_effectView]; + _tipLabel = [[UILabel alloc] init]; + unsigned tipIndex = [[NSUserDefaults standardUserDefaults] integerForKey:@"GBTipIndex"]; + _tipLabel.text = tips[tipIndex % (sizeof(tips) / sizeof(tips[0]))]; + if (@available(iOS 13.0, *)) { + _tipLabel.textColor = [UIColor labelColor]; + } + _tipLabel.font = [UIFont systemFontOfSize:14]; + _tipLabel.alpha = 0.8; + [[NSUserDefaults standardUserDefaults] setInteger:tipIndex + 1 forKey:@"GBTipIndex"]; + _tipLabel.lineBreakMode = NSLineBreakByWordWrapping; + _tipLabel.numberOfLines = 3; + [_effectView.contentView addSubview:_tipLabel]; + [self layoutTip]; + _effectView.alpha = 0; + [UIView animateWithDuration:0.25 animations:^{ + _effectView.alpha = 1.0; + }]; +} + +- (void)layoutTip +{ + UIView *view = self.view.superview; + CGSize outerSize = view.frame.size; + CGSize size = [_tipLabel textRectForBounds:(CGRect){{0, 0}, + {outerSize.width - 32, + outerSize.height - 32}} + limitedToNumberOfLines:3].size; + size.width = ceil(size.width); + _tipLabel.frame = (CGRect){{8, 8}, size}; + _effectView.frame = (CGRect) { + {round((outerSize.width - size.width - 16) / 2), view.window.safeAreaInsets.top + 12}, + {size.width + 16, size.height + 16} + }; +} + + +- (void)viewWillDisappear:(BOOL)animated +{ + [UIView animateWithDuration:0.25 animations:^{ + _effectView.alpha = 0; + }]; } - (void)viewDidLayoutSubviews @@ -83,6 +151,7 @@ if (self.view.bounds.size.height < 88 * 2) { [self.view.heightAnchor constraintEqualToConstant:self.view.bounds.size.height + 88 * 2].active = true; } + [self layoutTip]; [super viewDidLayoutSubviews]; } diff --git a/iOS/GBOptionViewController.m b/iOS/GBOptionViewController.m index 36b9f0e..85f3910 100644 --- a/iOS/GBOptionViewController.m +++ b/iOS/GBOptionViewController.m @@ -9,7 +9,11 @@ - (instancetype)initWithHeader:(NSString *)header { - self = [super initWithStyle:UITableViewStyleGrouped]; + UITableViewStyle style = UITableViewStyleGrouped; + if (@available(iOS 13.0, *)) { + style = UITableViewStyleInsetGrouped; + } + self = [super initWithStyle:style]; self.header = header; _options = [NSMutableArray array]; _actions = [NSMutableArray array]; diff --git a/iOS/GBSettingsViewController.h b/iOS/GBSettingsViewController.h index c580dea..5583cd6 100644 --- a/iOS/GBSettingsViewController.h +++ b/iOS/GBSettingsViewController.h @@ -1,7 +1,31 @@ #import #import +#import "GCExtendedGamepad+AllElements.h" +#import "GBTheme.h" + +typedef enum { + GBRight, + GBLeft, + GBUp, + GBDown, + GBA, + GBB, + GBSelect, + GBStart, + GBTurbo, + GBRewind, + GBUnderclock, + // GBHotkey1, // Todo + // GBHotkey2, // Todo + GBJoypadButtonCount, + GBButtonCount = GBUnderclock + 1, + GBGameBoyButtonCount = GBStart + 1, + GBUnusedButton = 0xFF, +} GBButton; @interface GBSettingsViewController : UITableViewController + (UIViewController *)settingsViewControllerWithLeftButton:(UIBarButtonItem *)button; + (const GB_palette_t *)paletteForTheme:(NSString *)theme; ++ (GBButton)controller:(GCController *)controller convertUsageToButton:(GBControllerUsage)usage; ++ (GBTheme *)themeNamed:(NSString *)name; @end diff --git a/iOS/GBSettingsViewController.m b/iOS/GBSettingsViewController.m index dd120c1..1581df1 100644 --- a/iOS/GBSettingsViewController.m +++ b/iOS/GBSettingsViewController.m @@ -1,10 +1,14 @@ #import "GBSettingsViewController.h" #import "GBTemperatureSlider.h" #import "GBViewBase.h" +#import "GBThemesViewController.h" +#import "GBHapticManager.h" +#import "GCExtendedGamepad+AllElements.h" #import static NSString const *typeSubmenu = @"submenu"; static NSString const *typeOptionSubmenu = @"optionSubmenu"; +static NSString const *typeBlock = @"block"; static NSString const *typeRadio = @"radio"; static NSString const *typeCheck = @"check"; static NSString const *typeDisabled = @"disabled"; @@ -16,6 +20,7 @@ static NSString const *typeLightTemp = @"typeLightTemp"; { NSArray *_structure; UINavigationController *_detailsNavigation; + NSArray *> *_themes; // For prewarming } + (const GB_palette_t *)paletteForTheme:(NSString *)theme @@ -316,10 +321,16 @@ static NSString const *typeLightTemp = @"typeLightTemp"; ]; NSArray *controlsMenu = @[ + @{ + @"items": @[ + @{@"type": typeBlock, @"title": @"Configure Game Controllers", @"block": ^bool(GBSettingsViewController *controller){ + return [controller configureGameControllers]; + }}, + ], + }, @{ @"header": @"D-pad Style", @"items": @[ - // TODO: Convert to enum when implemented @{@"type": typeRadio, @"pref": @"GBSwipeDpad", @"title": @"Standard", @"value": @NO,}, @{@"type": typeRadio, @"pref": @"GBSwipeDpad", @"title": @"Swipe", @"value": @YES,}, ], @@ -379,6 +390,11 @@ static NSString const *typeLightTemp = @"typeLightTemp"; @{ @"items": @[ @{@"type": typeCheck, @"pref": @"GBButtonHaptics", @"title": @"Enable Button Haptics"}, + @{@"type": typeSlider, @"pref": @"GBHapticsStrength", @"min": @0.25, @"max": @1, @"minImage": @"waveform.weak", @"maxImage": @"waveform", + @"previewBlock": ^void(void){ + [[GBHapticManager sharedManager] doTapHaptic]; + } + } ], }, ]; @@ -410,6 +426,12 @@ static NSString const *typeLightTemp = @"typeLightTemp"; @"submenu": controlsMenu, @"image": [UIImage imageNamed:@"controlsSettings"], }, + @{ + @"title": @"Themes", + @"type": typeSubmenu, + @"class": [GBThemesViewController class], + @"image": [UIImage imageNamed:@"themeSettings"], + }, ] } ]; @@ -418,7 +440,12 @@ static NSString const *typeLightTemp = @"typeLightTemp"; + (UIViewController *)settingsViewControllerWithLeftButton:(UIBarButtonItem *)button { - GBSettingsViewController *root = [[self alloc] initWithStructure:[self rootStructure] title:@"Settings" style:UITableViewStyleGrouped]; + UITableViewStyle style = UITableViewStyleGrouped; + if (@available(iOS 13.0, *)) { + style = UITableViewStyleInsetGrouped; + } + GBSettingsViewController *root = [[self alloc] initWithStructure:[self rootStructure] title:@"Settings" style:style]; + [root preloadThemePreviews]; UINavigationController *controller = [[UINavigationController alloc] initWithRootViewController:root]; [controller.visibleViewController.navigationItem setLeftBarButtonItem:button]; if ([UIDevice currentDevice].userInterfaceIdiom != UIUserInterfaceIdiomPad) { @@ -434,6 +461,220 @@ static NSString const *typeLightTemp = @"typeLightTemp"; return split; } +static UIImage *ImageForController(GCController *controller) +{ + if (@available(iOS 13.0, *)) { + + NSString *symbolName = @"gamecontroller.fill"; + UIColor *color = [UIColor grayColor]; + + if (@available(iOS 14.5, *)) { + if ([controller.extendedGamepad isKindOfClass:[GCDualSenseGamepad class]]) { + symbolName = @"logo.playstation"; + color = [UIColor colorWithRed:0 green:0x30 / 255.0 blue:0x87 / 255.0 alpha:1.0]; + } + } + if (@available(iOS 14.0, *)) { + if ([controller.extendedGamepad isKindOfClass:[GCDualShockGamepad class]]) { + symbolName = @"logo.playstation"; + color = [UIColor colorWithRed:0 green:0x30 / 255.0 blue:0x87 / 255.0 alpha:1.0]; + } + if ([controller.extendedGamepad isKindOfClass:[GCXboxGamepad class]]) { + symbolName = @"logo.xbox"; + color = [UIColor colorWithRed:0xe / 255.0 green:0x7a / 255.0 blue:0xd / 255.0 alpha:1.0]; + } + } + + UIImage *glyph = [[UIImage systemImageNamed:symbolName] imageWithTintColor:[UIColor whiteColor]]; + if (!glyph) { + glyph = [[UIImage systemImageNamed:@"gamecontroller.fill"] imageWithTintColor:[UIColor whiteColor]]; + } + + UIGraphicsBeginImageContextWithOptions((CGSize){29, 29}, false, [UIScreen mainScreen].scale); + [color setFill]; + [[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, 29, 29) cornerRadius:7] fill]; + double height = 25 / glyph.size.width * glyph.size.height; + [glyph drawInRect:CGRectMake(2, (29 - height) / 2, 25, height)]; + + UIImage *ret = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return ret; + } + return nil; + +} + ++ (GBButton)controller:(GCController *)controller convertUsageToButton:(GBControllerUsage)usage +{ + bool isSony = false; + if (@available(iOS 14.5, *)) { + if ([controller.extendedGamepad isKindOfClass:[GCDualSenseGamepad class]]) { + isSony = true; + } + } + if (@available(iOS 14.0, *)) { + if ([controller.extendedGamepad isKindOfClass:[GCDualShockGamepad class]]) { + isSony = true; + } + } + + NSNumber *mapping = [[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBControllerMappings"][controller.vendorName][[NSString stringWithFormat:@"%u", usage]]; + if (mapping) { + return mapping.intValue; + } + + switch (usage) { + case GBUsageButtonA: return isSony? GBB : GBA; + case GBUsageButtonB: return isSony? GBA : GBB; + case GBUsageButtonX: return isSony? GBSelect : GBStart; + case GBUsageButtonY: return isSony? GBStart : GBSelect; + case GBUsageButtonMenu: return GBStart; + case GBUsageButtonOptions: return GBSelect; + case GBUsageButtonHome: return GBStart; + case GBUsageLeftShoulder: return GBRewind; + case GBUsageRightShoulder: return GBTurbo; + case GBUsageLeftTrigger: return GBUnderclock; + case GBUsageRightTrigger: return GBTurbo; + default: return GBUnusedButton; + } +} + +static NSString *LocalizedNameForElement(GCControllerElement *element, GBControllerUsage usage) +{ + if (@available(iOS 14.0, *)) { + return element.localizedName; + } + switch (usage) { + case GBUsageDpad: return @"D-Pad"; + case GBUsageButtonA: return @"A"; + case GBUsageButtonB: return @"B"; + case GBUsageButtonX: return @"X"; + case GBUsageButtonY: return @"Y"; + case GBUsageButtonMenu: return @"Menu"; + case GBUsageButtonOptions: return @"Options"; + case GBUsageButtonHome: return @"Home"; + case GBUsageLeftThumbstick: return @"Left Thumbstick"; + case GBUsageRightThumbstick: return @"Right Thumbstick"; + case GBUsageLeftShoulder: return @"Left Shoulder"; + case GBUsageRightShoulder: return @"Right Shoulder"; + case GBUsageLeftTrigger: return @"Left Trigger"; + case GBUsageRightTrigger: return @"Right Trigger"; + case GBUsageLeftThumbstickButton: return @"Left Thumbstick Button"; + case GBUsageRightThumbstickButton: return @"Right Thumbstick Button"; + case GBUsageTouchpadButton: return @"Touchpad Button"; + } + + return @"Button"; +} + +- (void)configureGameController:(GCController *)controller +{ + NSMutableArray *items = [NSMutableArray array]; + NSDictionary *elementsDict = controller.extendedGamepad.elementsDictionary; + for (NSNumber *usage in [[elementsDict allKeys] sortedArrayUsingSelector:@selector(compare:)]) { + GCControllerElement *element = elementsDict[usage]; + if (![element isKindOfClass:[GCControllerButtonInput class]]) continue; + + id (^getter)(void) = ^id(void) { + return @([GBSettingsViewController controller:controller convertUsageToButton:usage.intValue]); + }; + + void (^setter)(id) = ^void(id value) { + NSMutableDictionary *mapping = ([[NSUserDefaults standardUserDefaults] dictionaryForKey:@"GBControllerMappings"] ?: @{}).mutableCopy; + + NSMutableDictionary *vendorMapping = ((NSDictionary *)mapping[controller.vendorName] ?: @{}).mutableCopy; + vendorMapping[usage.stringValue] = value; + mapping[controller.vendorName] = vendorMapping; + [[NSUserDefaults standardUserDefaults] setObject:mapping forKey:@"GBControllerMappings"]; + }; + + + NSDictionary *item = @{ + @"title": LocalizedNameForElement(element, usage.unsignedIntValue), + @"type": typeOptionSubmenu, + @"submenu": @[@{@"items": @[ + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"None", @"value": @(GBUnusedButton)}, + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"Right", @"value": @(GBRight)}, + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"Left", @"value": @(GBLeft)}, + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"Up", @"value": @(GBUp)}, + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"Down", @"value": @(GBDown)}, + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"A", @"value": @(GBA)}, + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"B", @"value": @(GBB)}, + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"Select", @"value": @(GBSelect)}, + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"Start", @"value": @(GBStart)}, + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"Turbo", @"value": @(GBTurbo)}, + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"Rewind", @"value": @(GBRewind)}, + @{@"type": typeRadio, @"getter": getter, @"setter": setter, @"title": @"Slow-motion", @"value": @(GBUnderclock)}, + ]}], + }; + if (@available(iOS 14.0, *)) { + UIImage *image = [[UIImage systemImageNamed:element.sfSymbolsName] imageWithTintColor:UIColor.labelColor renderingMode:UIImageRenderingModeAlwaysOriginal]; + if (image) { + item = [item mutableCopy]; + ((NSMutableDictionary *)item)[@"image"] = image; + } + } + [items addObject:item]; + } + + UITableViewStyle style = UITableViewStyleGrouped; + if (@available(iOS 13.0, *)) { + style = UITableViewStyleInsetGrouped; + } + + GBSettingsViewController *submenu = [[GBSettingsViewController alloc] initWithStructure:@[@{@"items": items}] + title:controller.vendorName + style:style]; + [self.navigationController pushViewController:submenu animated:true]; +} + +- (bool)configureGameControllers +{ + + NSMutableArray *items = [NSMutableArray array]; + for (GCController *controller in [GCController controllers]) { + if (!controller.extendedGamepad) continue; + NSDictionary *item = @{ + @"title": controller.vendorName, + @"type": typeBlock, + @"block": ^bool(void) { + [self configureGameController:controller]; + return true; + } + }; + UIImage *image = ImageForController(controller); + if (image) { + item = [item mutableCopy]; + ((NSMutableDictionary *)item)[@"image"] = image; + } + + [items addObject:item]; + } + if (items.count) { + UITableViewStyle style = UITableViewStyleGrouped; + if (@available(iOS 13.0, *)) { + style = UITableViewStyleInsetGrouped; + } + + GBSettingsViewController *submenu = [[GBSettingsViewController alloc] initWithStructure:@[@{@"items": items}] + title:@"Configure Game Controllers" + style:style]; + [self.navigationController pushViewController:submenu animated:true]; + } + else { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"No Controllers Connected" + message:@"There are no connected game controllers to configure" + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Close" + style:UIAlertActionStyleCancel + handler:nil]]; + [self presentViewController:alert animated:true completion:nil]; + return false; + + } + return true; +} + - (instancetype)initWithStructure:(NSArray *)structure title:(NSString *)title style:(UITableViewStyle)style { self = [super initWithStyle:style]; @@ -490,6 +731,14 @@ static NSString const *typeLightTemp = @"typeLightTemp"; } } +static id ValueForItem(NSDictionary *item) +{ + if (item[@"getter"]) { + return ((id(^)(void))item[@"getter"])(); + } + return [[NSUserDefaults standardUserDefaults] objectForKey:item[@"pref"]] ?: @0; +} + - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { NSDictionary *item = [self itemForIndexPath:indexPath]; @@ -497,13 +746,13 @@ static NSString const *typeLightTemp = @"typeLightTemp"; UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:nil]; cell.textLabel.text = item[@"title"]; - if (item[@"type"] == typeSubmenu || item[@"type"] == typeOptionSubmenu) { + if (item[@"type"] == typeSubmenu || item[@"type"] == typeOptionSubmenu || item[@"type"] == typeBlock) { cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; cell.selectionStyle = UITableViewCellSelectionStyleBlue; if (item[@"type"] == typeOptionSubmenu) { for (NSDictionary *section in item[@"submenu"]) { for (NSDictionary *item in section[@"items"]) { - if (item[@"value"] && [([[NSUserDefaults standardUserDefaults] objectForKey:item[@"pref"]] ?: @0) isEqual:item[@"value"]]) { + if (item[@"value"] && [ValueForItem(item) isEqual:item[@"value"]]) { cell.detailTextLabel.text = item[@"title"]; break; } @@ -512,7 +761,7 @@ static NSString const *typeLightTemp = @"typeLightTemp"; } } else if (item[@"type"] == typeRadio) { - if ([([[NSUserDefaults standardUserDefaults] objectForKey:item[@"pref"]] ?: @0) isEqual:item[@"value"]]) { + if ([ValueForItem(item) isEqual:item[@"value"]]) { cell.accessoryType = UITableViewCellAccessoryCheckmark; } } @@ -569,21 +818,12 @@ static NSString const *typeLightTemp = @"typeLightTemp"; slider.value = [[NSUserDefaults standardUserDefaults] floatForKey:item[@"pref"]]; cell.selectionStyle = UITableViewCellSelectionStyleNone; - if (item[@"minImage"] && item[@"maxImage"]) { - if ([item[@"minImage"] isKindOfClass:[UIImage class]]) { - slider.minimumValueImage = item[@"minImage"]; + if (@available(iOS 13.0, *)) { + if (item[@"minImage"] && item[@"maxImage"]) { + slider.minimumValueImage = [UIImage systemImageNamed:item[@"minImage"]] ?: [[UIImage imageNamed:item[@"minImage"]] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + slider.maximumValueImage = [UIImage systemImageNamed:item[@"maxImage"]] ?: [[UIImage imageNamed:item[@"maxImage"]] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + [GBSettingsViewController fixSliderTint:slider]; } - else if (@available(iOS 13.0, *)) { - slider.minimumValueImage = [UIImage systemImageNamed:item[@"minImage"]]; - } - - if ([item[@"maxImage"] isKindOfClass:[UIImage class]]) { - slider.maximumValueImage = item[@"maxImage"]; - } - else if (@available(iOS 13.0, *)) { - slider.maximumValueImage = [UIImage systemImageNamed:item[@"maxImage"]]; - } - [GBSettingsViewController fixSliderTint:slider]; } id block = ^(){ @@ -592,6 +832,9 @@ static NSString const *typeLightTemp = @"typeLightTemp"; objc_setAssociatedObject(cell, "RetainedBlock", block, OBJC_ASSOCIATION_RETAIN); [slider addTarget:block action:@selector(invoke) forControlEvents:UIControlEventValueChanged]; + if (item[@"previewBlock"]) { + [slider addTarget:item[@"previewBlock"] action:@selector(invoke) forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchDown]; + } } if ([self followingItemForIndexPath:indexPath][@"type"] == typeSeparator) { @@ -606,14 +849,20 @@ static NSString const *typeLightTemp = @"typeLightTemp"; NSDictionary *item = [self itemForIndexPath:indexPath]; if (item[@"type"] == typeSubmenu || item[@"type"] == typeOptionSubmenu) { UITableViewStyle style = UITableViewStyleGrouped; - if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad) { - if (@available(iOS 13.0, *)) { - style = UITableViewStyleInsetGrouped; - } + if (@available(iOS 13.0, *)) { + style = UITableViewStyleInsetGrouped; + } + UITableViewController *submenu = nil; + + if (item[@"class"]) { + submenu = [(UITableViewController *)[item[@"class"] alloc] initWithStyle:style]; + submenu.title = item[@"title"]; + } + else { + submenu = [[GBSettingsViewController alloc] initWithStructure:item[@"submenu"] + title:item[@"title"] + style:style]; } - GBSettingsViewController *submenu = [[GBSettingsViewController alloc] initWithStructure:item[@"submenu"] - title:item[@"title"] - style:style]; if (_detailsNavigation) { [_detailsNavigation setViewControllers:@[submenu] animated:false]; } @@ -623,9 +872,19 @@ static NSString const *typeLightTemp = @"typeLightTemp"; return indexPath; } else if (item[@"type"] == typeRadio) { - [[NSUserDefaults standardUserDefaults] setObject:item[@"value"] forKey:item[@"pref"]]; + if (item[@"setter"]) { + ((void(^)(id))item[@"setter"])(item[@"value"]); + } + else { + [[NSUserDefaults standardUserDefaults] setObject:item[@"value"] forKey:item[@"pref"]]; + } [self.tableView reloadData]; } + else if (item[@"type"] == typeBlock) { + if (((bool(^)(GBSettingsViewController *))item[@"block"])(self)) { + return indexPath; + } + } return nil; } @@ -649,4 +908,36 @@ static NSString const *typeLightTemp = @"typeLightTemp"; [self.tableView reloadData]; } +- (void)preloadThemePreviews +{ + /* These take some time to render, preload them when loading the root controller */ + _themes = [GBThemesViewController themes]; + double time = 0; + for (NSArray *section in _themes) { + for (GBTheme *theme in section) { + /* Sadly they can't be safely rendered outside the main thread, but we can + queue each of them individually to not block the main quote for too long. */ + time += 0.1; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, time * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + [theme verticalPreview]; + [theme horizontalPreview]; + }); + } + } +} + ++ (GBTheme *)themeNamed:(NSString *)name +{ + NSArray *themes = [GBThemesViewController themes]; + for (NSArray *section in themes) { + for (GBTheme *theme in section) { + if ([theme.name isEqualToString:name]) { + return theme; + } + } + } + + return [themes.firstObject firstObject]; +} + @end diff --git a/iOS/GBTheme.h b/iOS/GBTheme.h new file mode 100644 index 0000000..e35ad59 --- /dev/null +++ b/iOS/GBTheme.h @@ -0,0 +1,26 @@ +#import + +@interface GBTheme : NSObject +@property (readonly, direct) UIColor *brandColor; +@property (readonly, direct) UIColor *backgroundGradientTop; +@property (readonly, direct) UIColor *backgroundGradientBottom; +@property (readonly, direct) UIColor *bezelsGradientTop; +@property (readonly, direct) UIColor *bezelsGradientBottom; + + +@property (readonly, direct) NSString *name; + +@property (readonly, direct) bool renderingPreview; // Kind of a hack + +@property (readonly, direct) UIImage *horizontalPreview; +@property (readonly, direct) UIImage *verticalPreview; + +@property (readonly, direct) bool isDark; + +- (instancetype)initDefaultTheme __attribute__((objc_direct)); +- (instancetype)initDarkTheme __attribute__((objc_direct)); + + +- (UIImage *)imageNamed:(NSString *)name __attribute__((objc_direct)); + +@end diff --git a/iOS/GBTheme.m b/iOS/GBTheme.m new file mode 100644 index 0000000..b015454 --- /dev/null +++ b/iOS/GBTheme.m @@ -0,0 +1,226 @@ +#import "GBTheme.h" +#import "GBVerticalLayout.h" +#import "GBHorizontalLayout.h" +#import "GBBackgroundView.h" + +@interface GBLazyObject : NSProxy +@end + +@implementation GBLazyObject +{ + id _target; + id (^_constructor)(void); +} + + +- (instancetype)initWithConstructor:(id (^)(void))constructor +{ + _constructor = constructor; + return self; +} + +- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel +{ + if (GB_likely(!_target)) { + _target = _constructor(); + _constructor = nil; + } + return [_target methodSignatureForSelector:sel]; +} + +- (void)forwardInvocation:(NSInvocation *)invocation +{ + if (GB_likely(!_target)) { + _target = _constructor(); + _constructor = nil; + } + invocation.target = _target; + [invocation invoke]; +} + +- (instancetype)self +{ + if (GB_likely(!_target)) { + _target = _constructor(); + _constructor = nil; + } + return _target; +} + +@end + +#define MakeColor(r, g, b) [UIColor colorWithRed:(r) / 255.0 green:(g) / 255.0 blue:(b) / 255.0 alpha:1.0] + +__attribute__((objc_direct_members)) +@implementation GBTheme +{ + NSDictionary *_imageOverrides; + UIImage *_horizontalPreview; + UIImage *_verticalPreview; +} + + +// Assumes the image has a purple hue ++ (UIImage *)_recolorImage:(UIImage *)image withColor:(UIColor *)color +{ + double scale = image.scale; + + image = [UIImage imageWithCGImage:image.CGImage scale:1.0 orientation:UIImageOrientationUp]; + + CIImage *ciImage = [CIImage imageWithCGImage:image.CGImage]; + CIFilter *filter = [CIFilter filterWithName:@"CIColorMatrix"]; + double r, g, b; + [color getRed:&r green:&g blue:&b alpha:NULL]; + + [filter setDefaults]; + [filter setValue:ciImage forKey:@"inputImage"]; + + [filter setValue:[CIVector vectorWithX:r * 1.34 Y:1 - r Z:0 W:0] forKey:@"inputRVector"]; + [filter setValue:[CIVector vectorWithX:g * 1.34 Y:1 - g Z:0 W:0] forKey:@"inputGVector"]; + [filter setValue:[CIVector vectorWithX:b * 1.34 Y:1 - b Z:0 W:0] forKey:@"inputBVector"]; + + [filter setValue:[CIVector vectorWithX:0 Y:0 Z:0 W:1] forKey:@"inputAVector"]; + + + CIContext *context = [CIContext context]; + CGImageRef cgImage = [context createCGImage:filter.outputImage fromRect:filter.outputImage.extent]; + image = [UIImage imageWithCGImage:cgImage scale:scale orientation:0]; + CGImageRelease(cgImage); + + return image; +} + ++ (UIImage *)recolorImage:(UIImage *)image withColor:(UIColor *)color +{ + return (id)[[GBLazyObject alloc] initWithConstructor:^id{ + return [self _recolorImage:image withColor:color]; + }]; +} + +- (instancetype)initDefaultTheme +{ + self = [super init]; + + _brandColor = [UIColor colorWithRed:0 / 255.0 green:70 / 255.0 blue:141 / 255.0 alpha:1.0]; + _backgroundGradientTop = [UIColor colorWithRed:192 / 255.0 green:195 / 255.0 blue:199 / 255.0 alpha:1.0]; + _backgroundGradientBottom = [UIColor colorWithRed:174 / 255.0 green:176 / 255.0 blue:180 / 255.0 alpha:1.0]; + + _bezelsGradientTop = [UIColor colorWithWhite:53 / 255.0 alpha:1.0]; + _bezelsGradientBottom = [UIColor colorWithWhite:45 / 255.0 alpha:1.0]; + + _name = @"SameBoy"; + + return self; +} + +- (void)setupBackgroundWithColor:(uint32_t)color +{ + uint8_t r = color >> 16; + uint8_t g = color >> 8; + uint8_t b = color; + + _backgroundGradientTop = MakeColor(r, g, b); + _backgroundGradientBottom = [UIColor colorWithRed:pow(r / 255.0, 1.125) green:pow(g / 255.0, 1.125) blue:pow(b / 255.0, 1.125) alpha:1.0]; +} + +- (void)setupButtonsWithColor:(UIColor *)color +{ + _imageOverrides = @{ + @"button": [GBTheme recolorImage:[UIImage imageNamed:@"button"] withColor:color], + @"buttonPressed": [GBTheme recolorImage:[UIImage imageNamed:@"buttonPressed"] withColor:color], + + @"dpad": [GBTheme recolorImage:[UIImage imageNamed:@"dpad-tint"] withColor:color], + @"swipepad": [GBTheme recolorImage:[UIImage imageNamed:@"swipepad-tint"] withColor:color], + + @"button2": [GBTheme recolorImage:[UIImage imageNamed:@"button2-tint"] withColor:color], + @"button2Pressed": [GBTheme recolorImage:[UIImage imageNamed:@"button2Pressed-tint"] withColor:color], + }; +} + +- (instancetype)initDarkTheme +{ + self = [super init]; + + [self setupBackgroundWithColor:0x181c23]; + + _brandColor = [UIColor colorWithRed:0 / 255.0 green:70 / 255.0 blue:141 / 255.0 alpha:1.0]; + _bezelsGradientTop = [UIColor colorWithWhite:53 / 255.0 alpha:1.0]; + _bezelsGradientBottom = [UIColor colorWithWhite:45 / 255.0 alpha:1.0]; + + [self setupButtonsWithColor:MakeColor(0x08, 0x0c, 0x12)]; + + _name = @"SameBoy Dark"; + + return self; +} + + +- (bool)isDark +{ + double r, g, b; + [_backgroundGradientTop getRed:&r green:&g blue:&b alpha:NULL]; + if (r > 0.25) return false; + if (g > 0.25) return false; + if (b > 0.25) return false; + return true; +} + +- (UIImage *)imageNamed:(NSString *)name +{ + UIImage *ret = _imageOverrides[name].self ?: [UIImage imageNamed:name]; + if (!ret) { + if ([name isEqual:@"buttonA"] || [name isEqual:@"buttonB"]) { + return [self imageNamed:@"button"]; + } + if ([name isEqual:@"buttonAPressed"] || [name isEqual:@"buttonBPressed"]) { + return [self imageNamed:@"buttonPressed"]; + } + } + return ret; +} + +- (UIImage *)horizontalPreview +{ + if (_horizontalPreview) return _horizontalPreview; + _renderingPreview = true; + GBLayout *layout = [[GBHorizontalLayout alloc] initWithTheme:self]; + _renderingPreview = false; + GBBackgroundView *view = [[GBBackgroundView alloc] initWithLayout:layout]; + [view enterPreviewMode:false]; + view.usesSwipePad = [[NSUserDefaults standardUserDefaults] boolForKey:@"GBSwipePad"]; + view.layout = layout; + view.bounds = CGRectMake(0, 0, + MAX(UIScreen.mainScreen.bounds.size.width, UIScreen.mainScreen.bounds.size.height), + MIN(UIScreen.mainScreen.bounds.size.width, UIScreen.mainScreen.bounds.size.height)); + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:(CGSize){view.bounds.size.width / 8, + view.bounds.size.height / 8, + }]; + return _horizontalPreview = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + CGContextScaleCTM(UIGraphicsGetCurrentContext(), 1 / 8.0, 1 / 8.0); + [view.layer renderInContext:rendererContext.CGContext]; + }]; +} + +- (UIImage *)verticalPreview +{ + if (_verticalPreview) return _verticalPreview; + _renderingPreview = true; + GBLayout *layout = [[GBVerticalLayout alloc] initWithTheme:self]; + _renderingPreview = false; + GBBackgroundView *view = [[GBBackgroundView alloc] initWithLayout:layout]; + [view enterPreviewMode:false]; + view.usesSwipePad = [[NSUserDefaults standardUserDefaults] boolForKey:@"GBSwipePad"]; + view.layout = layout; + view.bounds = CGRectMake(0, 0, + MIN(UIScreen.mainScreen.bounds.size.width, UIScreen.mainScreen.bounds.size.height), + MAX(UIScreen.mainScreen.bounds.size.width, UIScreen.mainScreen.bounds.size.height)); + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:(CGSize){view.bounds.size.width / 8, + view.bounds.size.height / 8, + }]; + return _verticalPreview = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + CGContextScaleCTM(UIGraphicsGetCurrentContext(), 1 / 8.0, 1 / 8.0); + [view.layer renderInContext:rendererContext.CGContext]; + }]; +} + +@end diff --git a/iOS/GBThemePreviewController.h b/iOS/GBThemePreviewController.h new file mode 100644 index 0000000..bac864e --- /dev/null +++ b/iOS/GBThemePreviewController.h @@ -0,0 +1,7 @@ +#import +#import "GBTheme.h" + +@interface GBThemePreviewController : UIViewController +- (instancetype)initWithTheme:(GBTheme *)theme; +@end + diff --git a/iOS/GBThemePreviewController.m b/iOS/GBThemePreviewController.m new file mode 100644 index 0000000..d1d99f4 --- /dev/null +++ b/iOS/GBThemePreviewController.m @@ -0,0 +1,116 @@ +#import "GBThemePreviewController.h" +#import "GBVerticalLayout.h" +#import "GBHorizontalLayout.h" +#import "GBBackgroundView.h" + +@implementation GBThemePreviewController +{ + GBHorizontalLayout *_horizontalLayout; + GBVerticalLayout *_verticalLayout; + GBBackgroundView *_backgroundView; +} + +- (instancetype)initWithTheme:(GBTheme *)theme +{ + self = [super init]; + _horizontalLayout = [[GBHorizontalLayout alloc] initWithTheme:theme]; + _verticalLayout = [[GBVerticalLayout alloc] initWithTheme:theme]; + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + _backgroundView = [[GBBackgroundView alloc] initWithLayout:_verticalLayout]; + self.view.backgroundColor = _verticalLayout.theme.backgroundGradientBottom; + [self.view addSubview:_backgroundView]; + [_backgroundView enterPreviewMode:true]; + _backgroundView.autoresizingMask = UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleBottomMargin; + + [self willRotateToInterfaceOrientation:self.interfaceOrientation duration:0]; + [self.view addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(showPopup)]]; +} + +- (UIModalPresentationStyle)modalPresentationStyle +{ + return UIModalPresentationFullScreen; +} + +- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)orientation duration:(NSTimeInterval)duration +{ + GBLayout *layout = _horizontalLayout; + if (orientation == UIInterfaceOrientationPortrait || orientation == UIInterfaceOrientationPortraitUpsideDown) { + layout = _verticalLayout; + } + _backgroundView.frame = [layout viewRectForOrientation:orientation]; + _backgroundView.layout = layout; +} + +- (void)showPopup +{ + UIAlertController *alert = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:@"Apply “%@” as the current theme?", _verticalLayout.theme.name] + message:nil + preferredStyle:[UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad? + UIAlertControllerStyleAlert : UIAlertControllerStyleActionSheet]; + if (false) { + // No supporter-only themes outside the App Store release + } + else { + [alert addAction:[UIAlertAction actionWithTitle:@"Apply Theme" + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *action) { + [[NSUserDefaults standardUserDefaults] setObject:_verticalLayout.theme.name forKey:@"GBInterfaceTheme"]; + [[self presentingViewController] dismissViewControllerAnimated:true completion:nil]; + }]]; + } + [alert addAction:[UIAlertAction actionWithTitle:@"Exit Preview" + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *action) { + [[self presentingViewController] dismissViewControllerAnimated:true completion:nil]; + }]]; + [self presentViewController:alert animated:true completion:^{ + alert.view.superview.userInteractionEnabled = true; + [alert.view.superview addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(dismissPopup)]]; + for (UIView *view in alert.view.superview.subviews) { + if (view.backgroundColor) { + view.userInteractionEnabled = true; + [view addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(dismissPopup)]]; + + } + } + }]; +} + +- (void)dismissViewController +{ + [self dismissViewControllerAnimated:true completion:nil]; +} + +- (void)dismissPopup +{ + [self dismissViewControllerAnimated:true completion:nil]; +} + +- (UIStatusBarStyle)preferredStatusBarStyle +{ + if (@available(iOS 13.0, *)) { + return _verticalLayout.theme.isDark? UIStatusBarStyleLightContent : UIStatusBarStyleDarkContent; + } + return _verticalLayout.theme.isDark? UIStatusBarStyleLightContent : UIStatusBarStyleDefault; +} + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations +{ + if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad) { + return UIInterfaceOrientationMaskAll; + } + if (MAX([UIScreen mainScreen].bounds.size.height, [UIScreen mainScreen].bounds.size.width) <= 568) { + return UIInterfaceOrientationMaskLandscape; + } + return UIInterfaceOrientationMaskAllButUpsideDown; +} + +@end diff --git a/iOS/GBThemesViewController.h b/iOS/GBThemesViewController.h new file mode 100644 index 0000000..348597e --- /dev/null +++ b/iOS/GBThemesViewController.h @@ -0,0 +1,7 @@ +#import +#import "GBTheme.h" + +@interface GBThemesViewController : UITableViewController ++ (NSArray *> *)themes; +@end + diff --git a/iOS/GBThemesViewController.m b/iOS/GBThemesViewController.m new file mode 100644 index 0000000..5d0e223 --- /dev/null +++ b/iOS/GBThemesViewController.m @@ -0,0 +1,104 @@ +#import "GBThemesViewController.h" +#import "GBThemePreviewController.h" +#import "GBTheme.h" + +@interface GBThemesViewController () + +@end + +@implementation GBThemesViewController +{ + NSArray *> *_themes; +} + ++ (NSArray *> *)themes +{ + static __weak NSArray *> *cache = nil; + if (cache) return cache; + id ret = @[ + @[ + [[GBTheme alloc] initDefaultTheme], + [[GBTheme alloc] initDarkTheme], + ], + ]; + cache = ret; + return ret; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + _themes = [[self class] themes]; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + return _themes.count; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return _themes[section].count; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return 60; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + + UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; + GBTheme *theme = _themes[indexPath.section][indexPath.row]; + cell.textLabel.text = theme.name; + + cell.accessoryType = [[[NSUserDefaults standardUserDefaults] stringForKey:@"GBInterfaceTheme"] isEqual:theme.name]? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone; + bool horizontal = self.interfaceOrientation >= UIInterfaceOrientationLandscapeRight; + UIImage *preview = horizontal? [theme horizontalPreview] : [theme verticalPreview]; + UIGraphicsBeginImageContextWithOptions((CGSize){60, 60}, false, self.view.window.screen.scale); + unsigned width = 60; + unsigned height = 56; + if (horizontal) { + height = round(preview.size.height / preview.size.width * 60); + } + else { + width = round(preview.size.width / preview.size.height * 56); + } + UIBezierPath *mask = [UIBezierPath bezierPathWithRoundedRect:CGRectMake((60 - width) / 2, (60 - height) / 2, width, height) cornerRadius:4]; + [mask addClip]; + [preview drawInRect:mask.bounds]; + if (@available(iOS 13.0, *)) { + [[UIColor tertiaryLabelColor] set]; + } + else { + [[UIColor colorWithWhite:0 alpha:0.5] set]; + } + [mask stroke]; + cell.imageView.image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + + return cell; +} + +- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration +{ + [self.tableView reloadData]; + [super willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration]; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + GBTheme *theme = _themes[indexPath.section][indexPath.row]; + GBThemePreviewController *preview = [[GBThemePreviewController alloc] initWithTheme:theme]; + [self presentViewController:preview animated:true completion:nil]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [self.tableView reloadData]; +} + +@end diff --git a/iOS/GBVerticalLayout.m b/iOS/GBVerticalLayout.m index 9ee26ae..44797d4 100644 --- a/iOS/GBVerticalLayout.m +++ b/iOS/GBVerticalLayout.m @@ -3,9 +3,9 @@ @implementation GBVerticalLayout -- (instancetype)init +- (instancetype)initWithTheme:(GBTheme *)theme { - self = [super init]; + self = [super initWithTheme:theme]; if (!self) return nil; CGSize resolution = self.resolution; @@ -59,16 +59,24 @@ double controlsTop = self.dpadLocation.y - 80 * self.factor; double middleSpace = self.bLocation.x - buttonRadius - (self.dpadLocation.x + 80 * self.factor); - UIGraphicsBeginImageContextWithOptions(resolution, true, 1); + if (theme.renderingPreview) { + UIGraphicsBeginImageContextWithOptions((CGSize){resolution.width / 8, resolution.height / 8}, true, 1); + CGContextScaleCTM(UIGraphicsGetCurrentContext(), 1 / 8.0, 1 / 8.0); + } + else { + UIGraphicsBeginImageContextWithOptions(resolution, true, 1); + } [self drawBackground]; [self drawScreenBezels]; - if (controlsTop - controlAreaStart > 24 * self.factor + screenBorderWidth * 2 || - middleSpace > 160 * self.factor) { - [self drawLogoInVerticalRange:(NSRange){controlAreaStart + screenBorderWidth, 24 * self.factor}]; - } - - [self drawLabels]; + [self drawThemedLabelsWithBlock:^{ + if (controlsTop - controlAreaStart > 24 * self.factor + screenBorderWidth * 2 || + middleSpace > 160 * self.factor) { + [self drawLogoInVerticalRange:(NSRange){controlAreaStart + screenBorderWidth, 24 * self.factor}]; + } + + [self drawLabels]; + }]; self.background = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); diff --git a/iOS/GBViewController.h b/iOS/GBViewController.h index a846a80..096a6b5 100644 --- a/iOS/GBViewController.h +++ b/iOS/GBViewController.h @@ -22,6 +22,6 @@ typedef enum { - (void)openSettings; - (void)showAbout; - (void)saveStateToFile:(NSString *)file; -- (void)loadStateFromFile:(NSString *)file; +- (bool)loadStateFromFile:(NSString *)file; @property (nonatomic) GBRunMode runMode; @end diff --git a/iOS/GBViewController.m b/iOS/GBViewController.m index ac01e32..c0c8265 100644 --- a/iOS/GBViewController.m +++ b/iOS/GBViewController.m @@ -12,6 +12,7 @@ #import "GBAboutController.h" #import "GBSettingsViewController.h" #import "GBStatesViewController.h" +#import "GCExtendedGamepad+AllElements.h" #import #import @@ -25,6 +26,7 @@ bool _rewind; bool _rewindOver; bool _romLoaded; + bool _swappingROM; UIInterfaceOrientation _orientation; GBHorizontalLayout *_horizontalLayout; @@ -50,6 +52,8 @@ NSTimer *_disableCameraTimer; AVCaptureDevicePosition _cameraPosition; UIButton *_cameraPositionButton; + + __weak GCController *_lastController; } static void loadBootROM(GB_gameboy_t *gb, GB_boot_rom_t type) @@ -177,12 +181,22 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) _window.rootViewController = self; [_window makeKeyAndVisible]; - _window.backgroundColor = [UIColor colorWithRed:174 / 255.0 green:176 / 255.0 blue:180 / 255.0 alpha:1.0]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-retain-cycles" + [self addDefaultObserver:^(id newValue) { + GBTheme *theme = [GBSettingsViewController themeNamed:newValue]; + _horizontalLayout = [[GBHorizontalLayout alloc] initWithTheme:theme]; + _verticalLayout = [[GBVerticalLayout alloc] initWithTheme:theme]; + + [self willRotateToInterfaceOrientation:[UIApplication sharedApplication].statusBarOrientation + duration:0]; + [_backgroundView reloadThemeImages]; + + [self setNeedsStatusBarAppearanceUpdate]; + } forKey:@"GBInterfaceTheme"]; +#pragma clang diagnostic pop - _horizontalLayout = [[GBHorizontalLayout alloc] init]; - _verticalLayout = [[GBVerticalLayout alloc] init]; - - _backgroundView = [[GBBackgroundView alloc] init]; + _backgroundView = [[GBBackgroundView alloc] initWithLayout:_verticalLayout]; [_window addSubview:_backgroundView]; self.view = _backgroundView; @@ -206,11 +220,11 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) [self addDefaultObserver:^(id newValue) { backgroundView.usesSwipePad = [newValue boolValue]; } forKey:@"GBSwipeDpad"]; - + [self willRotateToInterfaceOrientation:[UIApplication sharedApplication].statusBarOrientation duration:0]; - + _audioLock = [[NSCondition alloc] init]; @@ -226,10 +240,10 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) _motionManager = [[CMMotionManager alloc] init]; _cameraPosition = AVCaptureDevicePositionBack; _cameraPositionButton = [[UIButton alloc] initWithFrame:CGRectMake(8, - _backgroundView.bounds.size.height - 8 - 32, + 0, 32, 32)]; - _cameraPositionButton.autoresizingMask = UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin; + [self didRotateFromInterfaceOrientation:[UIApplication sharedApplication].statusBarOrientation]; if (@available(iOS 13.0, *)) { [_cameraPositionButton setImage:[UIImage systemImageNamed:@"camera.rotate" withConfiguration:[UIImageSymbolConfiguration configurationWithScale:UIImageSymbolScaleLarge]] @@ -252,9 +266,138 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) [UNUserNotificationCenter currentNotificationCenter].delegate = self; [self verifyEntitlements]; + [self setControllerHandlers]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(setControllerHandlers) + name:GCControllerDidConnectNotification + object:nil]; + return true; } + +- (void)setControllerHandlers +{ + for (GCController *controller in [GCController controllers]) { + __weak GCController *weakController = controller; + if (controller.extendedGamepad) { + [[controller.extendedGamepad elementsDictionary] enumerateKeysAndObjectsUsingBlock:^(NSNumber *usage, GCControllerElement *element, BOOL *stop) { + if ([element isKindOfClass:[GCControllerButtonInput class]]) { + [(GCControllerButtonInput *)element setValueChangedHandler:^(GCControllerButtonInput *button, float value, BOOL pressed) { + [self controller:weakController buttonChanged:button usage:usage.unsignedIntValue]; + }]; + } + else if ([element isKindOfClass:[GCControllerDirectionPad class]]) { + [(GCControllerDirectionPad *)element setValueChangedHandler:^(GCControllerDirectionPad *dpad, float xValue, float yValue) { + [self controller:weakController axisChanged:dpad usage:usage.unsignedIntValue]; + }]; + } + }]; + + if (controller.motion) { + [controller.motion setValueChangedHandler:^(GCMotion *motion) { + [self controller:weakController motionChanged:motion]; + }]; + } + } + } +} + +- (void)updateLastController:(GCController *)controller +{ + if (_lastController == controller) return; + _lastController = controller; + [GBHapticManager sharedManager].controller = controller; +} + +- (void)controller:(GCController *)controller buttonChanged:(GCControllerButtonInput *)button usage:(GBControllerUsage)usage +{ + [self updateLastController:controller]; + + GBButton gbButton = [GBSettingsViewController controller:controller convertUsageToButton:usage]; + static const double analogThreshold = 0.0625; + switch (gbButton) { + case GBRight: + case GBLeft: + case GBUp: + case GBDown: + case GBA: + case GBB: + case GBSelect: + case GBStart: + GB_set_key_state(&_gb, (GB_key_t)gbButton, button.value > 0.25); + break; + case GBTurbo: + if (button.value > analogThreshold) { + [self setRunMode:GBRunModeTurbo ignoreDynamicSpeed:!button.isAnalog]; + if (button.isAnalog && [[NSUserDefaults standardUserDefaults] boolForKey:@"GBDynamicSpeed"]) { + GB_set_clock_multiplier(&_gb, (button.value - analogThreshold) / (1 - analogThreshold) * 3 + 1); + } + } + else { + [self setRunMode:GBRunModeNormal]; + } + break; + case GBRewind: + if (button.value > analogThreshold) { + [self setRunMode:GBRunModeRewind ignoreDynamicSpeed:!button.isAnalog]; + if (button.isAnalog && [[NSUserDefaults standardUserDefaults] boolForKey:@"GBDynamicSpeed"]) { + GB_set_clock_multiplier(&_gb, (button.value - analogThreshold) / (1 - analogThreshold) * 4); + } + } + else { + [self setRunMode:GBRunModeNormal]; + } + break; + case GBUnderclock: + if (button.value > analogThreshold) { + if (button.isAnalog && [[NSUserDefaults standardUserDefaults] boolForKey:@"GBDynamicSpeed"]) { + GB_set_clock_multiplier(&_gb, 1 - ((button.value - analogThreshold) / (1 - analogThreshold) * 0.75)); + } + else { + GB_set_clock_multiplier(&_gb, 0.5); + } + } + else { + GB_set_clock_multiplier(&_gb, 1.0); + } + break; + default: break; + } +} + +- (void)controller:(GCController *)controller axisChanged:(GCControllerDirectionPad *)axis usage:(GBControllerUsage)usage +{ + [self updateLastController:controller]; + + GB_set_key_state(&_gb, GB_KEY_LEFT, axis.left.value > 0.5); + GB_set_key_state(&_gb, GB_KEY_RIGHT, axis.right.value > 0.5); + GB_set_key_state(&_gb, GB_KEY_UP, axis.up.value > 0.5); + GB_set_key_state(&_gb, GB_KEY_DOWN, axis.down.value > 0.5); +} + +- (void)controller:(GCController *)controller motionChanged:(GCMotion *)motion +{ + if (controller != _lastController) return; + GCAcceleration gravity = {0,}; + GCAcceleration userAccel = {0,}; + if (@available(iOS 14.0, *)) { + if (motion.hasGravityAndUserAcceleration) { + gravity = motion.gravity; + userAccel = motion.userAcceleration; + } + else { + gravity = motion.acceleration; + } + } + else { + gravity = motion.gravity; + userAccel = motion.userAcceleration; + } + GB_set_accelerometer_values(&_gb, -(gravity.x + userAccel.x), gravity.y + userAccel.y); +} + + - (void)verifyEntitlements { /* @@ -265,14 +408,12 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) void *libxpc = dlopen("/usr/lib/system/libxpc.dylib", RTLD_NOW); extern xpc_object_t xpc_copy_entitlements_for_self$(void); - extern void xpc_release$ (xpc_object_t *object); - extern const char *xpc_dictionary_get_string$ (xpc_object_t *object, const char *key); + extern const char *xpc_dictionary_get_string$(xpc_object_t *object, const char *key); typeof(xpc_copy_entitlements_for_self$) *xpc_copy_entitlements_for_self = dlsym(libxpc, "xpc_copy_entitlements_for_self"); - typeof(xpc_release$) *xpc_release = dlsym(libxpc, "xpc_release"); typeof(xpc_dictionary_get_string$) *xpc_dictionary_get_string = dlsym(libxpc, "xpc_dictionary_get_string"); - if (!xpc_copy_entitlements_for_self || !xpc_release || !xpc_dictionary_get_string) return; + if (!xpc_copy_entitlements_for_self || !xpc_dictionary_get_string) return; xpc_object_t entitlements = xpc_copy_entitlements_for_self(); if (!entitlements) return; @@ -282,7 +423,7 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) const char *_teamIdentifier = xpc_dictionary_get_string(entitlements, "com.apple.developer.team-identifier"); NSString *teamIdentifier = _teamIdentifier? @(_teamIdentifier) : nil; - xpc_release(entitlements); + CFRelease(entitlements); if (!entIdentifier) { // No identifier. Installed using a jailbreak, we're fine. return; @@ -317,19 +458,22 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) [UIImagePNGRepresentation(screenshot) writeToFile:[file stringByAppendingPathExtension:@"png"] atomically:false]; } -- (void)loadStateFromFile:(NSString *)file +- (bool)loadStateFromFile:(NSString *)file { + [self stop]; GB_model_t model; if (!GB_get_state_model(file.fileSystemRepresentation, &model)) { if (GB_get_model(&_gb) != model) { GB_switch_model_and_reset(&_gb, model); } - GB_load_state(&_gb, file.fileSystemRepresentation); + return GB_load_state(&_gb, file.fileSystemRepresentation) == 0; } + return false; } - (void)loadROM { + _swappingROM = true; [self stop]; GBROMManager *romManager = [GBROMManager sharedManager]; if (romManager.romFile) { @@ -340,17 +484,28 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) else { _romLoaded = GB_load_rom(&_gb, romManager.romFile.fileSystemRepresentation) == 0; } - GB_rewind_reset(&_gb); if (_romLoaded) { GB_reset(&_gb); GB_load_battery(&_gb, [GBROMManager sharedManager].batterySaveFile.fileSystemRepresentation); - [self loadStateFromFile:[GBROMManager sharedManager].autosaveStateFile]; + if (![self loadStateFromFile:[GBROMManager sharedManager].autosaveStateFile]) { + // Newly played ROM, pick the best model + uint8_t *rom = GB_get_direct_access(&_gb, GB_DIRECT_ACCESS_ROM, NULL, NULL); + + if ((rom[0x143] & 0x80) && !GB_is_cgb(&_gb)) { + GB_switch_model_and_reset(&_gb, [[NSUserDefaults standardUserDefaults] integerForKey:@"GBCGBModel"]); + } + else if ((rom[0x146] == 3) && !GB_is_sgb(&_gb)) { + GB_switch_model_and_reset(&_gb, [[NSUserDefaults standardUserDefaults] integerForKey:@"GBSGBModel"]); + } + } } + GB_rewind_reset(&_gb); } else { _romLoaded = false; } _gbView.hidden = !_romLoaded; + _swappingROM = false; } - (void)applicationDidBecomeActive:(UIApplication *)application @@ -411,8 +566,23 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) model = [[NSUserDefaults standardUserDefaults] integerForKey:items[i].settingKey]; } [controller addOption:items[i].title withCheckmark:items[i].checked action:^{ + [self stop]; GB_switch_model_and_reset(&_gb, model); - [self start]; + if (model > GB_MODEL_CGB_E && ![[NSUserDefaults standardUserDefaults] boolForKey:@"GBShownGBAWarning"]) { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"SameBoy is not a Game Boy Advance Emulator" + message:@"SameBoy cannot play GBA games. Changing the model to Game Boy Advance lets you play Game Boy games as if on a Game Boy Advance in Game Boy Color mode." + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Close" + style:UIAlertActionStyleCancel + handler:^(UIAlertAction *action) { + [self start]; + [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"GBShownGBAWarning"]; + }]]; + [self presentViewController:alert animated:true completion:nil]; + } + else { + [self start]; + } }]; } controller.title = @"Change Model"; @@ -473,6 +643,15 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) }]; } +- (void)setNeedsUpdateOfSupportedInterfaceOrientations +{ + /* Hack. Some view controllers dismiss without calling the method above. */ + [super setNeedsUpdateOfSupportedInterfaceOrientations]; + if (!self.presentedViewController) { + [self start]; + } +} + - (void)dismissViewController { [self dismissViewControllerAnimated:true completion:nil]; @@ -492,6 +671,15 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) } _backgroundView.frame = [layout viewRectForOrientation:orientation]; _backgroundView.layout = layout; + if (!self.presentedViewController) { + _window.backgroundColor = layout.theme.backgroundGradientBottom; + } +} + +- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation +{ + UIEdgeInsets insets = self.window.safeAreaInsets; + _cameraPositionButton.frame = CGRectMake(insets.left + 8, _backgroundView.bounds.size.height - 8 - insets.bottom - 32, 32, 32); } - (UIInterfaceOrientationMask)supportedInterfaceOrientations @@ -529,6 +717,15 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) } } +- (UIStatusBarStyle)preferredStatusBarStyle +{ + if (@available(iOS 13.0, *)) { + return _verticalLayout.theme.isDark? UIStatusBarStyleLightContent : UIStatusBarStyleDarkContent; + } + return _verticalLayout.theme.isDark? UIStatusBarStyleLightContent : UIStatusBarStyleDefault; +} + + - (void)preRun { GB_set_pixels_output(&_gb, _gbView.pixels); @@ -569,8 +766,16 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) [_audioClient start]; if (GB_has_accelerometer(&_gb)) { + if (@available(iOS 14.0, *)) { + for (GCController *controller in [GCController controllers]) { + if (controller.motion.sensorsRequireManualActivation) { + [controller.motion setSensorsActive:true]; + } + } + } [_motionManager startAccelerometerUpdatesToQueue:[NSOperationQueue mainQueue] withHandler:^(CMAccelerometerData *accelerometerData, NSError *error) { + if (_lastController.motion) return; CMAcceleration data = accelerometerData.acceleration; UIInterfaceOrientation orientation = _orientation; switch (orientation) { @@ -683,16 +888,27 @@ didReceiveNotificationResponse:(UNNotificationResponse *)response - (void)postRun { [_audioLock lock]; - memset(_audioBuffer, 0, (_audioBufferSize - _audioBufferPosition) * sizeof(*_audioBuffer)); + if (_audioBuffer) { + memset(_audioBuffer, 0, (_audioBufferSize - _audioBufferPosition) * sizeof(*_audioBuffer)); + } _audioBufferPosition = _audioBufferNeeded; [_audioLock signal]; [_audioLock unlock]; [_audioClient stop]; _audioClient = nil; - GB_save_battery(&_gb, [GBROMManager sharedManager].batterySaveFile.fileSystemRepresentation); - [self saveStateToFile:[GBROMManager sharedManager].autosaveStateFile]; + if (!_swappingROM) { + GB_save_battery(&_gb, [GBROMManager sharedManager].batterySaveFile.fileSystemRepresentation); + [self saveStateToFile:[GBROMManager sharedManager].autosaveStateFile]; + } [[GBHapticManager sharedManager] setRumbleStrength:0]; + if (@available(iOS 14.0, *)) { + for (GCController *controller in [GCController controllers]) { + if (controller.motion.sensorsRequireManualActivation) { + [controller.motion setSensorsActive:false]; + } + } + } [_motionManager stopAccelerometerUpdates]; unsigned timeToAlarm = GB_time_to_alarm(&_gb); @@ -826,7 +1042,7 @@ didReceiveNotificationResponse:(UNNotificationResponse *)response return [GBROMManager sharedManager].currentROM != nil; } -- (void)setRunMode:(GBRunMode)runMode +- (void)setRunMode:(GBRunMode)runMode ignoreDynamicSpeed:(bool)ignoreDynamicSpeed { if (runMode == GBRunModeRewind && _rewindOver) { runMode = GBRunModePaused; @@ -844,7 +1060,7 @@ didReceiveNotificationResponse:(UNNotificationResponse *)response _rewindOver = false; } - if (_runMode == GBRunModeNormal || ![[NSUserDefaults standardUserDefaults] boolForKey:@"GBDynamicSpeed"]) { + if (_runMode == GBRunModeNormal || !([[NSUserDefaults standardUserDefaults] boolForKey:@"GBDynamicSpeed"] && !ignoreDynamicSpeed)) { if (_runMode == GBRunModeTurbo) { double multiplier = [[NSUserDefaults standardUserDefaults] doubleForKey:@"GBTurboSpeed"]; GB_set_turbo_mode(&_gb, multiplier == 1, false); @@ -857,6 +1073,11 @@ didReceiveNotificationResponse:(UNNotificationResponse *)response } } +- (void)setRunMode:(GBRunMode)runMode +{ + [self setRunMode:runMode ignoreDynamicSpeed:false]; +} + - (AVCaptureDevice *)captureDevice { NSArray *devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo]; diff --git a/iOS/GCExtendedGamepad+AllElements.h b/iOS/GCExtendedGamepad+AllElements.h new file mode 100644 index 0000000..427d5eb --- /dev/null +++ b/iOS/GCExtendedGamepad+AllElements.h @@ -0,0 +1,25 @@ +#import + +typedef enum { + GBUsageDpad, + GBUsageButtonA, + GBUsageButtonB, + GBUsageButtonX, + GBUsageButtonY, + GBUsageButtonMenu, + GBUsageButtonOptions, + GBUsageButtonHome, + GBUsageLeftThumbstick, + GBUsageRightThumbstick, + GBUsageLeftShoulder, + GBUsageRightShoulder, + GBUsageLeftTrigger, + GBUsageRightTrigger, + GBUsageLeftThumbstickButton, + GBUsageRightThumbstickButton, + GBUsageTouchpadButton, +} GBControllerUsage; + +@interface GCExtendedGamepad (AllElements) +- (NSDictionary *)elementsDictionary; +@end diff --git a/iOS/GCExtendedGamepad+AllElements.m b/iOS/GCExtendedGamepad+AllElements.m new file mode 100644 index 0000000..006ea3a --- /dev/null +++ b/iOS/GCExtendedGamepad+AllElements.m @@ -0,0 +1,48 @@ +#import "GCExtendedGamepad+AllElements.h" +#import + +@implementation GCExtendedGamepad (AllElements) + +- (NSDictionary *)elementsDictionary; +{ + NSMutableDictionary *ret = [NSMutableDictionary dictionary]; + if (self.dpad) ret[@(GBUsageDpad)] = self.dpad; + if (self.buttonA) ret[@(GBUsageButtonA)] = self.buttonA; + if (self.buttonB) ret[@(GBUsageButtonB)] = self.buttonB; + if (self.buttonX) ret[@(GBUsageButtonX)] = self.buttonX; + if (self.buttonY) ret[@(GBUsageButtonY)] = self.buttonY; + if (@available(iOS 13.0, *)) { + if (self.buttonMenu) ret[@(GBUsageButtonMenu)] = self.buttonMenu; + if (self.buttonOptions) ret[@(GBUsageButtonOptions)] = self.buttonOptions; + } + // Can't be used + /* if (@available(iOS 14.0, *)) { + if (self.buttonHome) ret[@(GBUsageButtonHome)] = self.buttonHome; + } */ + if (self.leftThumbstick) ret[@(GBUsageLeftThumbstick)] = self.leftThumbstick; + if (self.rightThumbstick) ret[@(GBUsageRightThumbstick)] = self.rightThumbstick; + if (self.leftShoulder) ret[@(GBUsageLeftShoulder)] = self.leftShoulder; + if (self.rightShoulder) ret[@(GBUsageRightShoulder)] = self.rightShoulder; + if (self.leftTrigger) ret[@(GBUsageLeftTrigger)] = self.leftTrigger; + if (self.rightTrigger) ret[@(GBUsageRightTrigger)] = self.rightTrigger; + if (@available(iOS 12.1, *)) { + if (self.leftThumbstickButton) ret[@(GBUsageLeftThumbstickButton)] = self.leftThumbstickButton; + if (self.rightThumbstickButton) ret[@(GBUsageRightThumbstickButton)] = self.rightThumbstickButton; + } + + if (@available(iOS 14.0, *)) { + if ([self isKindOfClass:[GCDualShockGamepad class]]) { + GCDualShockGamepad *dualShock = (GCDualShockGamepad *)self; + if (dualShock.touchpadButton) ret[@(GBUsageTouchpadButton)] = dualShock.touchpadButton; + } + } + + if (@available(iOS 14.5, *)) { + if ([self isKindOfClass:[GCDualSenseGamepad class]]) { + GCDualSenseGamepad *dualSense = (GCDualSenseGamepad *)self; + if (dualSense.touchpadButton) ret[@(GBUsageTouchpadButton)] = dualSense.touchpadButton; + } + } + return ret; +} +@end diff --git a/iOS/Info.plist b/iOS/Info.plist index 97c1e76..88e3ef4 100644 --- a/iOS/Info.plist +++ b/iOS/Info.plist @@ -13,7 +13,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - Version @VERSION + @VERSION CFBundleVersion 1 LSRequiresIPhoneOS diff --git a/iOS/LaunchScreen.storyboard b/iOS/LaunchScreen.storyboard new file mode 100644 index 0000000..605cefa --- /dev/null +++ b/iOS/LaunchScreen.storyboard @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/button2-tint@2x.png b/iOS/button2-tint@2x.png new file mode 100644 index 0000000..88469b2 Binary files /dev/null and b/iOS/button2-tint@2x.png differ diff --git a/iOS/button2-tint@3x.png b/iOS/button2-tint@3x.png new file mode 100644 index 0000000..8d8fbfe Binary files /dev/null and b/iOS/button2-tint@3x.png differ diff --git a/iOS/button2Pressed-tint@2x.png b/iOS/button2Pressed-tint@2x.png new file mode 100644 index 0000000..38084ca Binary files /dev/null and b/iOS/button2Pressed-tint@2x.png differ diff --git a/iOS/button2Pressed-tint@3x.png b/iOS/button2Pressed-tint@3x.png new file mode 100644 index 0000000..4bb86a7 Binary files /dev/null and b/iOS/button2Pressed-tint@3x.png differ diff --git a/iOS/dpad-tint@2x.png b/iOS/dpad-tint@2x.png new file mode 100644 index 0000000..f4b5fac Binary files /dev/null and b/iOS/dpad-tint@2x.png differ diff --git a/iOS/dpad-tint@3x.png b/iOS/dpad-tint@3x.png new file mode 100644 index 0000000..512a89e Binary files /dev/null and b/iOS/dpad-tint@3x.png differ diff --git a/iOS/main.m b/iOS/main.m index d76bd77..612a8c7 100644 --- a/iOS/main.m +++ b/iOS/main.m @@ -11,7 +11,7 @@ int main(int argc, char * argv[]) @"GBColorCorrection": @(GB_COLOR_CORRECTION_MODERN_BALANCED), @"GBAudioMode": @"switch", @"GBHighpassFilter": @(GB_HIGHPASS_ACCURATE), - @"GBRewindLength": @(10), + @"GBRewindLength": @(120), @"GBFrameBlendingMode": @(GB_FRAME_BLENDING_MODE_ACCURATE), @"GBDMGModel": @(GB_MODEL_DMG_B), @@ -20,9 +20,12 @@ int main(int argc, char * argv[]) @"GBSGBModel": @(GB_MODEL_SGB2), @"GBRumbleMode": @(GB_RUMBLE_CARTRIDGE_ONLY), @"GBButtonHaptics": @YES, + @"GBHapticsStrength": @0.75, @"GBTurboSpeed": @1, @"GBRewindSpeed": @1, @"GBDynamicSpeed": @NO, + + @"GBInterfaceTheme": @"SameBoy", @"GBCurrentTheme": @"Lime (Game Boy)", // Default themes diff --git a/iOS/swipepad-tint@2x.png b/iOS/swipepad-tint@2x.png new file mode 100644 index 0000000..62a9d99 Binary files /dev/null and b/iOS/swipepad-tint@2x.png differ diff --git a/iOS/swipepad-tint@3x.png b/iOS/swipepad-tint@3x.png new file mode 100644 index 0000000..2017aca Binary files /dev/null and b/iOS/swipepad-tint@3x.png differ diff --git a/iOS/themeSettings@2x.png b/iOS/themeSettings@2x.png new file mode 100644 index 0000000..668b453 Binary files /dev/null and b/iOS/themeSettings@2x.png differ diff --git a/iOS/themeSettings@3x.png b/iOS/themeSettings@3x.png new file mode 100644 index 0000000..3e52ae4 Binary files /dev/null and b/iOS/themeSettings@3x.png differ diff --git a/iOS/waveform.weak@2x.png b/iOS/waveform.weak@2x.png new file mode 100644 index 0000000..e206b66 Binary files /dev/null and b/iOS/waveform.weak@2x.png differ diff --git a/iOS/waveform.weak@3x.png b/iOS/waveform.weak@3x.png new file mode 100644 index 0000000..be46c96 Binary files /dev/null and b/iOS/waveform.weak@3x.png differ