SameBoy | Accurate GB/GBC emulator |
| download: https://git.y1.nz/archives/sameboy.tar.gz | |
| README | Files | Log | Refs | LICENSE |
commit 6ddc3b0f0ab845158c8ad6e472af4cd4075b8133 parent e582e38abc27b0c64d18fba1d9418fba51f3c3ae Author: Lior Halphon <LIJI32@gmail.com> Date: Sat, 21 Jan 2023 19:41:38 +0200 Settings screen Diffstat:
| M | iOS/GBBackgroundView.m | 2 | +- |
| M | iOS/GBLoadROMTableViewController.m | 3 | +-- |
| M | iOS/GBMenuViewController.m | 2 | +- |
| M | iOS/GBROMManager.m | 1 | + |
| A | iOS/GBSettingsViewController.h | 7 | +++++++ |
| A | iOS/GBSettingsViewController.m | 595 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| D | iOS/GBTableViewCell.h | 5 | ----- |
| D | iOS/GBTableViewCell.m | 11 | ----------- |
| A | iOS/GBTemperatureSlider.h | 5 | +++++ |
| A | iOS/GBTemperatureSlider.m | 80 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | iOS/GBViewController.h | 1 | + |
| M | iOS/GBViewController.m | 119 | ++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- |
| A | iOS/audioSettings@2x.png | 0 | |
| A | iOS/audioSettings@3x.png | 0 | |
| A | iOS/controlsSettings@2x.png | 0 | |
| A | iOS/controlsSettings@3x.png | 0 | |
| A | iOS/emulationSettings@2x.png | 0 | |
| A | iOS/emulationSettings@3x.png | 0 | |
| M | iOS/main.m | 8 | ++++---- |
| A | iOS/videoSettings@2x.png | 0 | |
| A | iOS/videoSettings@3x.png | 0 |
21 files changed, 775 insertions(+), 64 deletions(-)
diff --git a/iOS/GBBackgroundView.m b/iOS/GBBackgroundView.m @@ -185,7 +185,7 @@ static void positionView(UIImageView *view, CGPoint position) } GB_set_key_mask(_gbView.gb, mask); - if (mask & ~_lastMask) { + if ((mask & ~_lastMask) && ([[NSUserDefaults standardUserDefaults] boolForKey:@"GBButtonHaptics"])) { [[GBHapticManager sharedManager] doTapHaptic]; } _lastMask = mask; diff --git a/iOS/GBLoadROMTableViewController.m b/iOS/GBLoadROMTableViewController.m @@ -1,5 +1,4 @@ #import "GBLoadROMTableViewController.h" -#import "GBTableViewCell.h" #import "GBROMManager.h" @interface GBLoadROMTableViewController () @@ -27,7 +26,7 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - GBTableViewCell *cell = [[GBTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; + UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; NSString *rom = [GBROMManager sharedManager].allROMs[[indexPath indexAtPosition:1]]; cell.textLabel.text = rom; cell.accessoryType = [rom isEqualToString:[GBROMManager sharedManager].currentROM]? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone; diff --git a/iOS/GBMenuViewController.m b/iOS/GBMenuViewController.m @@ -38,7 +38,7 @@ {@"Library", @"bookmark", SelectorString(openLibrary)}, {@"Model", @"ModelTemplate", SelectorString(changeModel)}, {@"States", @"square.stack", nil}, - {@"Settings", @"gear", nil}, + {@"Settings", @"gear", SelectorString(openSettings)}, {@"About", @"info.circle", SelectorString(showAbout)}, }; diff --git a/iOS/GBROMManager.m b/iOS/GBROMManager.m @@ -58,6 +58,7 @@ - (NSString *)romFileForROM:(NSString *)rom { + if ([rom isEqualToString:@"Inbox"]) return nil; if (rom == _currentROM) { return self.romFile; } diff --git a/iOS/GBSettingsViewController.h b/iOS/GBSettingsViewController.h @@ -0,0 +1,7 @@ +#import <UIKit/UIKit.h> +#include <Core/gb.h> + +@interface GBSettingsViewController : UITableViewController ++ (UIViewController *)settingsViewControllerWithLeftButton:(UIBarButtonItem *)button; ++ (const GB_palette_t *)paletteForTheme:(NSString *)theme; +@end diff --git a/iOS/GBSettingsViewController.m b/iOS/GBSettingsViewController.m @@ -0,0 +1,595 @@ +#import "GBSettingsViewController.h" +#import "GBTemperatureSlider.h" +#import "GBViewBase.h" +#import <objc/runtime.h> + +static NSString const *typeSubmenu = @"submenu"; +static NSString const *typeOptionSubmenu = @"optionSubmenu"; +static NSString const *typeRadio = @"radio"; +static NSString const *typeCheck = @"check"; +static NSString const *typeDisabled = @"disabled"; +static NSString const *typeSeparator = @"separator"; +static NSString const *typeSlider = @"slider"; +static NSString const *typeLightTemp = @"typeLightTemp"; + +@implementation GBSettingsViewController +{ + NSArray<NSDictionary *> *_structure; + UINavigationController *_detailsNavigation; +} + ++ (const GB_palette_t *)paletteForTheme:(NSString *)theme +{ + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + if ([theme isEqualToString:@"Greyscale"]) { + return &GB_PALETTE_GREY; + } + if ([theme isEqualToString:@"Lime (Game Boy)"]) { + return &GB_PALETTE_DMG; + } + if ([theme isEqualToString:@"Olive (Pocket)"]) { + return &GB_PALETTE_MGB; + } + if ([theme isEqualToString:@"Teal (Light)"]) { + return &GB_PALETTE_GBL; + } + static GB_palette_t customPalette; + NSArray *colors = [defaults dictionaryForKey:@"GBThemes"][theme][@"Colors"]; + if (colors.count != 5) return &GB_PALETTE_DMG; + unsigned i = 0; + for (NSNumber *color in colors) { + uint32_t c = [color unsignedIntValue]; + customPalette.colors[i++] = (struct GB_color_s) {c, c >> 8, c >> 16}; + } + return &customPalette; +} + ++ (UIColor *) colorFromGBColor:(const struct GB_color_s *)color +{ + return [UIColor colorWithRed:color->r / 255.0 + green:color->g / 255.0 + blue:color->b / 255.0 + alpha:1.0]; +} + ++ (UIImage *)previewImageForTheme:(NSString *)theme +{ + const GB_palette_t *palette = [self paletteForTheme:theme]; + UIGraphicsBeginImageContextWithOptions((CGSize){29, 29}, false, [UIScreen mainScreen].scale); + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, 29, 29) cornerRadius:7]; + [[self colorFromGBColor:&palette->colors[4]] set]; + [path fill]; + + path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(4, 4, 9, 9) cornerRadius:2]; + [[self colorFromGBColor:&palette->colors[0]] set]; + [path fill]; + + path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(16, 4, 9, 9) cornerRadius:2]; + [[self colorFromGBColor:&palette->colors[1]] set]; + [path fill]; + + path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(4, 16, 9, 9) cornerRadius:2]; + [[self colorFromGBColor:&palette->colors[2]] set]; + [path fill]; + + path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(16, 16, 9, 9) cornerRadius:2]; + [[self colorFromGBColor:&palette->colors[3]] set]; + [path fill]; + + UIImage *ret = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + return ret; +} + ++ (NSArray<NSDictionary *> *)paletteMenu +{ + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + NSArray *themes = [@[ + @"Greyscale", + @"Lime (Game Boy)", + @"Olive (Pocket)", + @"Teal (Light)", + ] arrayByAddingObjectsFromArray:[[defaults dictionaryForKey:@"GBThemes"] allKeys]]; + NSMutableArray<NSDictionary *> *themeItems = [NSMutableArray arrayWithCapacity:themes.count]; + for (NSString *theme in themes) { + [themeItems addObject: @{@"type": typeRadio, @"pref": @"GBCurrentTheme", + @"title": theme, @"value": theme, + @"image": [self previewImageForTheme:theme]}]; + } + return @[ + @{ + @"items": [themeItems subarrayWithRange:(NSRange){0, 4}] + }, + @{ + @"items": [themeItems subarrayWithRange:(NSRange){4, themeItems.count - 4}] + } + ]; +} + ++ (NSArray<NSDictionary *> *)rootStructure +{ +#define QUICK_SUBMENU(title, ...) @{@"type": typeOptionSubmenu, @"title": title, @"submenu": @[@{@"items": __VA_ARGS__}]} + NSArray<NSDictionary *> *emulationMenu = @[ + @{ + @"header": @"Rewind duration", + @"items": @[ + @{@"type": typeRadio, @"pref": @"GBRewindLength", @"title": @"Disabled", @"value": @0,}, + @{@"type": typeRadio, @"pref": @"GBRewindLength", @"title": @"10 Seconds", @"value": @10,}, + @{@"type": typeRadio, @"pref": @"GBRewindLength", @"title": @"30 Seconds", @"value": @30,}, + @{@"type": typeRadio, @"pref": @"GBRewindLength", @"title": @"1 Minute", @"value": @60,}, + @{@"type": typeRadio, @"pref": @"GBRewindLength", @"title": @"2 Minutes", @"value": @120,}, + @{@"type": typeRadio, @"pref": @"GBRewindLength", @"title": @"5 Minutes", @"value": @300,}, + @{@"type": typeRadio, @"pref": @"GBRewindLength", @"title": @"10 Minutes", @"value": @600,}, + ] + }, + @{ + @"header": @"Real Time Clock emulation", + @"items": @[ + @{@"type": typeRadio, @"pref": @"GBRTCMode", @"title": @"Accurate", @"value": @(GB_RTC_MODE_ACCURATE),}, + @{@"type": typeRadio, @"pref": @"GBRTCMode", @"title": @"Sync to system clock", @"value": @(GB_RTC_MODE_SYNC_TO_HOST),}, + ] + }, + @{ + @"header": @"Emulated revisions", + @"items": @[ + QUICK_SUBMENU(@"Game Boy", @[ + @{@"type": typeDisabled, @"title": @"DMG-CPU 0"}, + @{@"type": typeDisabled, @"title": @"DMG-CPU A"}, + @{@"type": typeRadio, @"pref": @"GBDMGModel", @"title": @"DMG-CPU B", @"value": @(GB_MODEL_DMG_B),}, + @{@"type": typeDisabled, @"title": @"DMG-CPU C"}, + ]), + QUICK_SUBMENU(@"Super Game Boy", @[ + @{@"type": typeRadio, @"pref": @"GBSGBModel", @"title": @"Super Game Boy (NTSC)", @"value": @(GB_MODEL_SGB_NTSC),}, + @{@"type": typeRadio, @"pref": @"GBSGBModel", @"title": @"Super Game Boy (PAL)", @"value": @(GB_MODEL_SGB_PAL),}, + @{@"type": typeRadio, @"pref": @"GBSGBModel", @"title": @"Super Game Boy 2", @"value": @(GB_MODEL_SGB2),}, + ]), + QUICK_SUBMENU(@"Game Boy Color", @[ + @{@"type": typeRadio, @"pref": @"GBCGBModel", @"title": @"CPU CGB 0 (Experimental)", @"value": @(GB_MODEL_CGB_0),}, + @{@"type": typeRadio, @"pref": @"GBCGBModel", @"title": @"CPU CGB A (Experimental)", @"value": @(GB_MODEL_CGB_A),}, + @{@"type": typeRadio, @"pref": @"GBCGBModel", @"title": @"CPU CGB B (Experimental)", @"value": @(GB_MODEL_CGB_B),}, + @{@"type": typeRadio, @"pref": @"GBCGBModel", @"title": @"CPU CGB C (Experimental)", @"value": @(GB_MODEL_CGB_C),}, + @{@"type": typeRadio, @"pref": @"GBCGBModel", @"title": @"CPU CGB D", @"value": @(GB_MODEL_CGB_D),}, + @{@"type": typeRadio, @"pref": @"GBCGBModel", @"title": @"CPU CGB E", @"value": @(GB_MODEL_CGB_E),}, + ]), + QUICK_SUBMENU(@"Game Boy Advance", @[ + @{@"type": typeDisabled, @"title": @"CPU AGB 0 (Early GBA)",}, + @{@"type": typeRadio, @"pref": @"GBAGBModel", @"title": @"CPU AGB A (GBA)", @"value": @(GB_MODEL_AGB_A),}, + @{@"type": typeRadio, @"pref": @"GBAGBModel", @"title": @"CPU AGB A (Game Boy Player)", @"value": @(GB_MODEL_GBP_A),}, + @{@"type": typeDisabled, @"title": @"CPU AGB B (GBA SP)",}, + @{@"type": typeDisabled, @"title": @"CPU AGB E (Late GBA SP)",}, + @{@"type": typeDisabled, @"title": @"CPU AGB E (Late Game Boy Player)",}, + ]), + ], + @"footer": @"Changing the emulated revision on active ROMs will take effect after selecting Reset from the menu or changing the emulated model.", + }, + ]; + + NSArray<NSDictionary *> *videoMenu = @[ + @{ + @"items": @[ + @{ + @"title": @"Graphics filter", + @"type": typeOptionSubmenu, + @"submenu": @[ + @{ + @"header": @"Standard filters", + @"items": @[ + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"Nearest Neighbor (Pixelated)", @"value": @"NearestNeighbor",}, + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"Bilinear (Blurry)", @"value": @"Bilinear",}, + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"Smooth Bilinear (Less blurry)", @"value": @"SmoothBilinear",}, + ] + }, + @{ + @"header": @"Screen filters", + @"items": @[ + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"Monochrome LCD Display", @"value": @"MonoLCD",}, + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"LCD Display", @"value": @"LCD",}, + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"CRT Display", @"value": @"CRT",}, + ] + }, + @{ + @"header": @"Upscaling filters", + @"items": @[ + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"Scale2x", @"value": @"Scale2x"}, + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"Scale4x", @"value": @"Scale4x"}, + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"Anti-aliased Scale2x", @"value": @"AAScale2x"}, + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"Anti-aliased Scale4x", @"value": @"AAScale4x"}, + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"HQ2x", @"value": @"HQ2x"}, + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"OmniScale", @"value": @"OmniScale"}, + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"OmniScale Legacy", @"value": @"OmniScaleLegacy"}, + @{@"type": typeRadio, @"pref": @"GBFilter", @"title": @"AA OmniScale Legacy", @"value": @"AAOmniScaleLegacy"}, + ] + }, + ] + }, + ] + }, + @{ + @"header": @"Color Correction", + @"items": @[ + @{@"type": typeRadio, @"pref": @"GBColorCorrection", @"title": @"Disabled", @"value": @(GB_COLOR_CORRECTION_DISABLED),}, + @{@"type": typeRadio, @"pref": @"GBColorCorrection", @"title": @"Correct color curves", @"value": @(GB_COLOR_CORRECTION_CORRECT_CURVES),}, + @{@"type": typeSeparator}, + @{@"type": typeRadio, @"pref": @"GBColorCorrection", @"title": @"Modern – Balanced", @"value": @(GB_COLOR_CORRECTION_MODERN_BALANCED),}, + @{@"type": typeRadio, @"pref": @"GBColorCorrection", @"title": @"Modern – Accurate", @"value": @(GB_COLOR_CORRECTION_MODERN_ACCURATE),}, + @{@"type": typeRadio, @"pref": @"GBColorCorrection", @"title": @"Modern – Boost contrast", @"value": @(GB_COLOR_CORRECTION_MODERN_BOOST_CONTRAST),}, + @{@"type": typeSeparator}, + @{@"type": typeRadio, @"pref": @"GBColorCorrection", @"title": @"Reduce contrast", @"value": @(GB_COLOR_CORRECTION_REDUCE_CONTRAST),}, + @{@"type": typeRadio, @"pref": @"GBColorCorrection", @"title": @"Harsh reality (low contrast)", @"value": @(GB_COLOR_CORRECTION_LOW_CONTRAST),}, + ], + @"footer": ^NSString *(){ + return (NSString * const[]){ + [GB_COLOR_CORRECTION_DISABLED] = @"Colors are directly interpreted as sRGB, resulting in unbalanced colors and inaccurate hues.", + [GB_COLOR_CORRECTION_CORRECT_CURVES] = @"Colors have their brightness corrected, but hues remain unbalanced.", + [GB_COLOR_CORRECTION_MODERN_BALANCED] = @"Emulates a modern display. Blue contrast is moderately enhanced at the cost of slight hue inaccuracy.", + [GB_COLOR_CORRECTION_MODERN_BOOST_CONTRAST] = @"Like Modern – Balanced, but further boosts the contrast of greens and magentas that is lacking on the original hardware.", + [GB_COLOR_CORRECTION_REDUCE_CONTRAST] = @"Slightly reduce the contrast to better represent the tint and contrast of the original display.", + [GB_COLOR_CORRECTION_LOW_CONTRAST] = @"Harshly reduce the contrast to accurately represent the tint low constrast of the original display.", + [GB_COLOR_CORRECTION_MODERN_ACCURATE] = @"Emulates a modern display. Colors have their hues and brightness corrected.", + }[MIN(GB_COLOR_CORRECTION_MODERN_ACCURATE, [[NSUserDefaults standardUserDefaults] integerForKey:@"GBColorCorrection"])]; + }, + }, + @{ + @"header": @"Ambient light temperature", + @"items": @[ + @{@"type": typeLightTemp, @"pref": @"GBLightTemperature", @"min": @-1, @"max": @1} + ], + }, + @{ + @"header": @"Frame blending", + @"items": @[ + @{@"type": typeRadio, @"pref": @"GBFrameBlendingMode", @"title": @"Disabled", @"value": @(GB_FRAME_BLENDING_MODE_DISABLED),}, + @{@"type": typeRadio, @"pref": @"GBFrameBlendingMode", @"title": @"Simple", @"value": @(GB_FRAME_BLENDING_MODE_SIMPLE),}, + @{@"type": typeRadio, @"pref": @"GBFrameBlendingMode", @"title": @"Accurate", @"value": @(GB_FRAME_BLENDING_MODE_ACCURATE),}, + ] + }, + @{ + @"items": @[@{ + @"title": @"Monochrome palette", + @"type": typeOptionSubmenu, + @"submenu": [self paletteMenu] + }], + @"footer": @"This palette will be used when emulating a monochrome model such as the original Game Boy." + } + ]; + + NSArray<NSDictionary *> *audioMenu = @[ + @{ + @"header": @"High-pass filter", + @"items": @[ + @{@"type": typeRadio, @"pref": @"GBHighpassFilter", @"title": @"Disabled (Keep DC offset)", @"value": @(GB_HIGHPASS_OFF),}, + @{@"type": typeRadio, @"pref": @"GBHighpassFilter", @"title": @"Accurate (Emulate hardware)", @"value": @(GB_HIGHPASS_ACCURATE),}, + @{@"type": typeRadio, @"pref": @"GBHighpassFilter", @"title": @"Preserve waveform", @"value": @(GB_HIGHPASS_REMOVE_DC_OFFSET),}, + ], + @"footer": ^NSString *(){ + return (NSString * const[]){ + [GB_HIGHPASS_OFF] = @"No high-pass filter will be applied. DC offset will be kept, pausing and resuming will trigger audio pops.", + [GB_HIGHPASS_ACCURATE] = @"An accurate high-pass filter will be applied, removing the DC offset while somewhat attenuating the bass.", + [GB_HIGHPASS_REMOVE_DC_OFFSET] = @"A high-pass filter will be applied to the DC offset itself, removing the DC offset while preserving the waveform.", + }[MIN(GB_HIGHPASS_REMOVE_DC_OFFSET, [[NSUserDefaults standardUserDefaults] integerForKey:@"GBHighpassFilter"])]; + }, + }, + @{ + @"header": @"Interference volume", + @"items": @[ + @{@"type": typeSlider, @"pref": @"GBInterferenceVolume", @"min": @0, @"max": @1, @"minImage": @"speaker.fill", @"maxImage": @"speaker.3.fill"} + ], + }, + ]; + + NSArray<NSDictionary *> *controlsMenu = @[ + @{ + @"header": @"D-pad style", + @"items": @[ + // TODO: Convert to enum when implemented + @{@"type": typeRadio, @"pref": @"GBDpadMode", @"title": @"Standard", @"value": @(0),}, + @{@"type": typeRadio, @"pref": @"GBDpadMode", @"title": @"Swipe", @"value": @(1),}, + ], + @"footer": ^NSString *(){ + return (NSString * const[]){ + [0] = @"Directional input is determined by the touch position.", + [1] = @"Directional input is determined by the swipe direction.", + }[MIN(1, [[NSUserDefaults standardUserDefaults] integerForKey:@"GBDpadMode"])]; + }, + }, + @{ + @"header": @"Fast-forward and rewind behavior", + @"items": @[ + @{@"type": typeCheck, @"pref": @"GBSwipeLock", @"title": @"Lock after swiping"}, + ], + @"footer": ^NSString *(){ + if ([[NSUserDefaults standardUserDefaults] boolForKey:@"GBSwipeLock"]) { + return @"Swipe right on the Game Boy screen to fast-forward, and swipe left to rewind. Tap on the Game Boy screen to return to normal."; + } + return @"Swipe right on the Game Boy screen to fast-forward, and swipe left to rewind. Raise the touch to return to normal."; + }, + }, + @{ + @"header": @"Quick save and load", + @"items": @[ + @{@"type": typeCheck, @"pref": @"GBSwipeState", @"title": @"Swipe to save and load from slot 1"}, + ], + @"footer": ^NSString *(){ + if ([[NSUserDefaults standardUserDefaults] boolForKey:@"GBSwipeState"]) { + return @"Swipe down on the Game Boy to save the state into state slot 1. Swipe up to load the state from state slot 1"; + } + return @" "; // This space is needed, otherwise UITableView spacing breaks + }, + }, + @{ + @"header": @"Enable rumble", + @"items": @[ + @{@"type": typeRadio, @"pref": @"GBRumbleMode", @"title": @"Never", @"value": @(GB_RUMBLE_DISABLED),}, + @{@"type": typeRadio, @"pref": @"GBRumbleMode", @"title": @"For rumble-enabled Game Paks", @"value": @(GB_RUMBLE_CARTRIDGE_ONLY),}, + @{@"type": typeRadio, @"pref": @"GBRumbleMode", @"title": @"Always", @"value": @(GB_RUMBLE_ALL_GAMES),}, + ], + }, + @{ + @"items": @[ + @{@"type": typeCheck, @"pref": @"GBButtonHaptics", @"title": @"Enable button haptics"}, + ], + }, + ]; + + return @[ + @{ + @"items": @[ + @{ + @"title": @"Emulation", + @"type": typeSubmenu, + @"submenu": emulationMenu, + @"image": [UIImage imageNamed:@"emulationSettings"], + }, + @{ + @"title": @"Video", + @"type": typeSubmenu, + @"submenu": videoMenu, + @"image": [UIImage imageNamed:@"videoSettings"], + }, + @{ + @"title": @"Audio", + @"type": typeSubmenu, + @"submenu": audioMenu, + @"image": [UIImage imageNamed:@"audioSettings"], + }, + @{ + @"title": @"Controls", + @"type": typeSubmenu, + @"submenu": controlsMenu, + @"image": [UIImage imageNamed:@"controlsSettings"], + }, + ] + } + ]; +} + + ++ (UIViewController *)settingsViewControllerWithLeftButton:(UIBarButtonItem *)button +{ + GBSettingsViewController *root = [[self alloc] initWithStructure:[self rootStructure] title:@"Settings" style:UITableViewStyleGrouped]; + UINavigationController *controller = [[UINavigationController alloc] initWithRootViewController:root]; + [controller.visibleViewController.navigationItem setLeftBarButtonItem:button]; + if ([UIDevice currentDevice].userInterfaceIdiom != UIUserInterfaceIdiomPad) { + return controller; + } + + UISplitViewController *split = [[UISplitViewController alloc] init]; + UIViewController *blank = [[UIViewController alloc] init]; + blank.view.backgroundColor = root.view.backgroundColor; + root->_detailsNavigation = [[UINavigationController alloc] initWithRootViewController:blank]; + split.viewControllers = @[controller, root->_detailsNavigation]; + split.preferredDisplayMode = UISplitViewControllerDisplayModeAllVisible; + return split; +} + +- (instancetype)initWithStructure:(NSArray *)structure title:(NSString *)title style:(UITableViewStyle)style +{ + self = [super initWithStyle:style]; + if (!self) return nil; + self.title = title; + _structure = structure; + return self; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + return _structure.count; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return [_structure[section][@"items"] count]; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ + return _structure[section][@"header"]; +} + +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section +{ + if ([_structure[section][@"footer"] respondsToSelector:@selector(invoke)]) { + return ((NSString *(^)(void))_structure[section][@"footer"])(); + } + return _structure[section][@"footer"]; +} + +- (NSDictionary *)itemForIndexPath:(NSIndexPath *)indexPath +{ + return _structure[[indexPath indexAtPosition:0]][@"items"][[indexPath indexAtPosition:1]]; +} + +- (NSDictionary *)followingItemForIndexPath:(NSIndexPath *)indexPath +{ + NSArray *items = _structure[[indexPath indexAtPosition:0]][@"items"]; + if ([indexPath indexAtPosition:1] + 1 >= items.count) { + return nil; + } + return items[[indexPath indexAtPosition:1] + 1]; +} + ++ (void)fixSliderTint:(UIView *)view +{ + if ([view isKindOfClass:[UIImageView class]]) { + view.tintColor = [UIColor systemGrayColor]; + } + for (UIView *subview in view.subviews) { + [self fixSliderTint:subview]; + } +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + NSDictionary *item = [self itemForIndexPath:indexPath]; + + + UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:nil]; + cell.textLabel.text = item[@"title"]; + if (item[@"type"] == typeSubmenu || item[@"type"] == typeOptionSubmenu) { + 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"]]) { + cell.detailTextLabel.text = item[@"title"]; + break; + } + } + } + } + } + else if (item[@"type"] == typeRadio) { + if ([([[NSUserDefaults standardUserDefaults] objectForKey:item[@"pref"]] ?: @0) isEqual:item[@"value"]]) { + cell.accessoryType = UITableViewCellAccessoryCheckmark; + } + } + else if (item[@"type"] == typeCheck) { + UISwitch *button = [[UISwitch alloc] init]; + cell.accessoryView = button; + if ([[NSUserDefaults standardUserDefaults] boolForKey:item[@"pref"]]) { + button.on = true; + } + + __weak typeof(self) weakSelf = self; + id block = ^(){ + [[NSUserDefaults standardUserDefaults] setBool:button.on forKey:item[@"pref"]]; + unsigned section = [indexPath indexAtPosition:0]; + UITableViewHeaderFooterView *view = [weakSelf.tableView footerViewForSection:section]; + view.textLabel.text = [weakSelf tableView:weakSelf.tableView titleForFooterInSection:section]; + [UIView setAnimationsEnabled:false]; + [weakSelf.tableView beginUpdates]; + [view sizeToFit]; + [weakSelf.tableView endUpdates]; + [UIView setAnimationsEnabled:true]; + }; + objc_setAssociatedObject(cell, "RetainedBlock", block, OBJC_ASSOCIATION_RETAIN); + + [button addTarget:block action:@selector(invoke) forControlEvents:UIControlEventValueChanged]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + } + else if (item[@"type"] == typeDisabled) { + cell.selectionStyle = UITableViewCellSelectionStyleNone; + if (@available(iOS 13.0, *)) { + cell.textLabel.textColor = [UIColor separatorColor]; + } + else { + cell.textLabel.textColor = [UIColor colorWithWhite:0 alpha:0.75]; + } + } + else if (item[@"type"] == typeSeparator) { + cell.backgroundColor = [UIColor clearColor]; + cell.separatorInset = UIEdgeInsetsZero; + } + else if (item[@"type"] == typeSlider || + item[@"type"] == typeLightTemp) { + CGRect rect = cell.contentView.bounds; + rect.size.width -= 24; + rect.size.height -= 24; + rect.origin.x += 12; + rect.origin.y += 12; + UISlider *slider = [item[@"type"] == typeLightTemp? [GBTemperatureSlider alloc] : [UISlider alloc] initWithFrame:rect]; + slider.continuous = true; + slider.minimumValue = [item[@"min"] floatValue]; + slider.maximumValue = [item[@"max"] floatValue]; + slider.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [cell.contentView addSubview:slider]; + 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"]; + } + 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 = ^(){ + [[NSUserDefaults standardUserDefaults] setDouble:slider.value forKey:item[@"pref"]]; + }; + objc_setAssociatedObject(cell, "RetainedBlock", block, OBJC_ASSOCIATION_RETAIN); + + [slider addTarget:block action:@selector(invoke) forControlEvents:UIControlEventValueChanged]; + } + + if ([self followingItemForIndexPath:indexPath][@"type"] == typeSeparator) { + cell.separatorInset = UIEdgeInsetsZero; + } + cell.imageView.image = item[@"image"]; + return cell; +} + +- (NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + 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; + } + } + GBSettingsViewController *submenu = [[GBSettingsViewController alloc] initWithStructure:item[@"submenu"] + title:item[@"title"] + style:style]; + if (_detailsNavigation) { + [_detailsNavigation setViewControllers:@[submenu] animated:false]; + } + else { + [self.navigationController pushViewController:submenu animated:true]; + } + return indexPath; + } + else if (item[@"type"] == typeRadio) { + [[NSUserDefaults standardUserDefaults] setObject:item[@"value"] forKey:item[@"pref"]]; + [self.tableView reloadData]; + } + return nil; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + NSDictionary *item = [self itemForIndexPath:indexPath]; + if (item[@"type"] == typeSeparator) { + return 8; + } + if (item[@"type"] == typeSlider || + item[@"type"] == typeLightTemp) { + return 63; + } + return [super tableView:tableView heightForRowAtIndexPath:indexPath]; + +} + +@end diff --git a/iOS/GBTableViewCell.h b/iOS/GBTableViewCell.h @@ -1,5 +0,0 @@ -#import <UIKit/UIKit.h> - -@interface GBTableViewCell : UITableViewCell -@property double padding; -@end diff --git a/iOS/GBTableViewCell.m b/iOS/GBTableViewCell.m @@ -1,11 +0,0 @@ -#import "GBTableViewCell.h" - -@implementation GBTableViewCell - --(void )layoutSubviews -{ - [super layoutSubviews]; - self.imageView.frame = CGRectInset(self.imageView.frame, 0, self.padding); -} - -@end diff --git a/iOS/GBTemperatureSlider.h b/iOS/GBTemperatureSlider.h @@ -0,0 +1,5 @@ +#import <UIKit/UIKit.h> + +@interface GBTemperatureSlider : UISlider + +@end diff --git a/iOS/GBTemperatureSlider.m b/iOS/GBTemperatureSlider.m @@ -0,0 +1,80 @@ +#import "GBTemperatureSlider.h" + +static inline void temperature_tint(double temperature, double *r, double *g, double *b) +{ + if (temperature >= 0) { + *r = 1; + *g = pow(1 - temperature, 0.375); + if (temperature >= 0.75) { + *b = 0; + } + else { + *b = sqrt(0.75 - temperature) / sqrt(0.75); + } + } + else { + *b = 1; + double squared = pow(temperature, 2); + *g = 0.125 * squared + 0.3 * temperature + 1.0; + *r = 0.21875 * squared + 0.5 * temperature + 1.0; + } +} + +@implementation GBTemperatureSlider + +- (instancetype)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + [self addTarget:self action:@selector(valueChanged) forControlEvents:UIControlEventValueChanged]; + return self; +} + +- (void)updateTint +{ + double r, g, b; + temperature_tint(self.value, &r, &g, &b); + self.thumbTintColor = [UIColor colorWithRed:r green:g blue:b alpha:1.0]; +} + +- (void)setValue:(float)value +{ + [super setValue:value]; + [self updateTint]; +} + +- (void)valueChanged +{ + if (fabsf(self.value) < 0.05 && self.value != 0) { + self.value = 0; + } + else { + [self updateTint]; + } +} + +-(UIImage *)maximumTrackImageForState:(UIControlState)state +{ + return [[UIImage alloc] init]; +} + + +-(UIImage *)minimumTrackImageForState:(UIControlState)state +{ + return [[UIImage alloc] init]; +} + +- (void)drawRect:(CGRect)rect +{ + CGSize size = self.bounds.size; + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(2, round(size.height / 2 - 1.5), size.width - 4, 3) cornerRadius:4]; + [path appendPath:[UIBezierPath bezierPathWithRoundedRect:CGRectMake(round(size.width / 2 - 1.5), 12, 3, size.height - 24) cornerRadius:4]]; + [[UIColor colorWithRed:120 / 255.0 + green:120 / 255.0 + blue:130 / 255.0 + alpha:70 / 255.0] set]; + [path fill]; + + [super drawRect:rect]; +} + +@end diff --git a/iOS/GBViewController.h b/iOS/GBViewController.h @@ -7,5 +7,6 @@ - (void)start; - (void)stop; - (void)changeModel; +- (void)openSettings; - (void)showAbout; @end diff --git a/iOS/GBViewController.m b/iOS/GBViewController.m @@ -10,6 +10,7 @@ #import "GBMenuViewController.h" #import "GBOptionViewController.h" #import "GBAboutController.h" +#import "GBSettingsViewController.h" #include <Core/gb.h> @implementation GBViewController @@ -28,6 +29,7 @@ size_t _audioBufferPosition; size_t _audioBufferNeeded; GBAudioClient *_audioClient; + NSMutableSet *_defaultsObservers; } static void loadBootROM(GB_gameboy_t *gb, GB_boot_rom_t type) @@ -71,22 +73,60 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) - (void)initGameBoy { - GB_init(&_gb, [[NSUserDefaults standardUserDefaults] integerForKey:@"GBCGBModel"]); - GB_set_user_data(&_gb, (__bridge void *)(self)); - GB_set_boot_rom_load_callback(&_gb, (GB_boot_rom_load_callback_t)loadBootROM); - GB_set_vblank_callback(&_gb, (GB_vblank_callback_t) vblank); - GB_set_log_callback(&_gb, (GB_log_callback_t) consoleLog); - GB_set_color_correction_mode(&_gb, (GB_color_correction_mode_t) [[NSUserDefaults standardUserDefaults] integerForKey:@"GBColorCorrection"]); - GB_set_light_temperature(&_gb, [[NSUserDefaults standardUserDefaults] doubleForKey:@"GBLightTemperature"]); - GB_set_interference_volume(&_gb, [[NSUserDefaults standardUserDefaults] doubleForKey:@"GBInterferenceVolume"]); - GB_set_border_mode(&_gb, GB_BORDER_NEVER); - [self updatePalette]; - GB_set_rgb_encode_callback(&_gb, rgbEncode); - GB_set_highpass_filter_mode(&_gb, (GB_highpass_mode_t) [[NSUserDefaults standardUserDefaults] integerForKey:@"GBHighpassFilter"]); - GB_set_rtc_mode(&_gb, [[NSUserDefaults standardUserDefaults] integerForKey:@"GBRTCMode"]); - GB_apu_set_sample_callback(&_gb, audioCallback); - GB_set_rumble_callback(&_gb, rumbleCallback); - [self updateRumbleMode]; + GB_gameboy_t *gb = &_gb; + GB_init(gb, [[NSUserDefaults standardUserDefaults] integerForKey:@"GBCGBModel"]); + GB_set_user_data(gb, (__bridge void *)(self)); + GB_set_boot_rom_load_callback(gb, (GB_boot_rom_load_callback_t)loadBootROM); + GB_set_vblank_callback(gb, (GB_vblank_callback_t) vblank); + GB_set_log_callback(gb, (GB_log_callback_t) consoleLog); + [self addDefaultObserver:^(id newValue) { + GB_set_color_correction_mode(gb, (GB_color_correction_mode_t)[newValue integerValue]); + } forKey:@"GBColorCorrection"]; + [self addDefaultObserver:^(id newValue) { + GB_set_light_temperature(gb, [newValue doubleValue]); + } forKey:@"GBLightTemperature"]; + [self addDefaultObserver:^(id newValue) { + GB_set_interference_volume(gb, [newValue doubleValue]); + } forKey:@"GBInterferenceVolume"]; + GB_set_border_mode(gb, GB_BORDER_NEVER); + __weak typeof(self) weakSelf = self; + [self addDefaultObserver:^(id newValue) { + [weakSelf updatePalette]; + } forKey:@"GBCurrentTheme"]; + GB_set_rgb_encode_callback(gb, rgbEncode); + [self addDefaultObserver:^(id newValue) { + GB_set_highpass_filter_mode(gb, (GB_highpass_mode_t)[newValue integerValue]); + } forKey:@"GB_HIGHPASS_ACCURATE"]; + [self addDefaultObserver:^(id newValue) { + GB_set_rtc_mode(gb, [newValue integerValue]); + } forKey:@"GBRTCMode"]; + GB_apu_set_sample_callback(gb, audioCallback); + GB_set_rumble_callback(gb, rumbleCallback); + [self addDefaultObserver:^(id newValue) { + GB_set_rumble_mode(gb, [newValue integerValue]); + } forKey:@"GBRumbleMode"]; + [self addDefaultObserver:^(id newValue) { + GB_set_interference_volume(gb, [newValue doubleValue]); + } forKey:@"GBInterferenceVolume"]; +} + +- (void)addDefaultObserver:(void(^)(id newValue))block forKey:(NSString *)key +{ + if (!_defaultsObservers) { + _defaultsObservers = [NSMutableSet set]; + } + block = [block copy]; + [_defaultsObservers addObject:block]; + [[NSUserDefaults standardUserDefaults] addObserver:self + forKeyPath:key + options:NSKeyValueObservingOptionNew + context:(void *)block]; + block([[NSUserDefaults standardUserDefaults] objectForKey:key]); +} + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context +{ + ((__bridge void(^)(id))context)(change[NSKeyValueChangeNewKey]); } - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions @@ -111,6 +151,16 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) _gbView.gb = &_gb; [_gbView screenSizeChanged]; + [self addDefaultObserver:^(id newValue) { + [[NSNotificationCenter defaultCenter] postNotificationName:@"GBFilterChanged" object:nil]; + } forKey:@"GBFilter"]; + + __weak GBView *weakGBView = _gbView; + [self addDefaultObserver:^(id newValue) { + weakGBView.frameBlendingMode = [newValue integerValue]; + } forKey:@"GBFrameBlendingMode"]; + + [self willRotateToInterfaceOrientation:[UIApplication sharedApplication].statusBarOrientation duration:0]; @@ -221,6 +271,17 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) [self presentViewController:controller animated:true completion:nil]; } +- (void)openSettings +{ + UIBarButtonItem *close = [[UIBarButtonItem alloc] initWithTitle:@"Close" + style:UIBarButtonItemStylePlain + target:self + action:@selector(dismissViewController)]; + [self presentViewController:[GBSettingsViewController settingsViewControllerWithLeftButton:close] + animated:true + completion:nil]; +} + - (void)showAbout { [self presentViewController:[[GBAboutController alloc] init] animated:true completion:nil]; @@ -383,6 +444,7 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) GB_save_battery(&_gb, [GBROMManager sharedManager].batterySaveFile.fileSystemRepresentation); [self saveStateToFile:[GBROMManager sharedManager].autosaveStateFile]; + [[GBHapticManager sharedManager] setRumbleStrength:0]; } - (void)start @@ -463,32 +525,9 @@ static void rumbleCallback(GB_gameboy_t *gb, double amp) [[GBHapticManager sharedManager] setRumbleStrength:amp]; } -- (void)updateRumbleMode -{ - GB_set_rumble_mode(&_gb, [[NSUserDefaults standardUserDefaults] integerForKey:@"GBRumbleMode"]); -} - - (const GB_palette_t *)userPalette { - NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; - switch ([defaults integerForKey:@"GBColorPalette"]) { - case 1: return &GB_PALETTE_DMG; - case 2: return &GB_PALETTE_MGB; - case 3: return &GB_PALETTE_GBL; - default: return &GB_PALETTE_GREY; - case -1: { - static GB_palette_t customPalette; - NSArray *colors = [defaults dictionaryForKey:@"GBThemes"][[defaults stringForKey:@"GBCurrentTheme"]][@"Colors"]; - if (colors.count == 5) { - unsigned i = 0; - for (NSNumber *color in colors) { - uint32_t c = [color unsignedIntValue]; - customPalette.colors[i++] = (struct GB_color_s) {c, c >> 8, c >> 16}; - } - } - return &customPalette; - } - } + return [GBSettingsViewController paletteForTheme:[[NSUserDefaults standardUserDefaults] stringForKey:@"GBCurrentTheme"]]; } - (void)updatePalette diff --git a/iOS/audioSettings@2x.png b/iOS/audioSettings@2x.png Binary files differ. diff --git a/iOS/audioSettings@3x.png b/iOS/audioSettings@3x.png Binary files differ. diff --git a/iOS/controlsSettings@2x.png b/iOS/controlsSettings@2x.png Binary files differ. diff --git a/iOS/controlsSettings@3x.png b/iOS/controlsSettings@3x.png Binary files differ. diff --git a/iOS/emulationSettings@2x.png b/iOS/emulationSettings@2x.png Binary files differ. diff --git a/iOS/emulationSettings@3x.png b/iOS/emulationSettings@3x.png Binary files differ. diff --git a/iOS/main.m b/iOS/main.m @@ -9,7 +9,8 @@ int main(int argc, char * argv[]) [[NSUserDefaults standardUserDefaults] registerDefaults:@{ @"GBFilter": @"NearestNeighbor", @"GBColorCorrection": @(GB_COLOR_CORRECTION_MODERN_BALANCED), - @"GBHighpassFilter": @(GB_HIGHPASS_REMOVE_DC_OFFSET), + @"GBHighpassFilter": @(GB_HIGHPASS_ACCURATE), + @"GBRewindLength": @(10), @"GBFrameBlendingMode": @(GB_FRAME_BLENDING_MODE_ACCURATE), @"GBDMGModel": @(GB_MODEL_DMG_B), @@ -17,10 +18,9 @@ int main(int argc, char * argv[]) @"GBAGBModel": @(GB_MODEL_AGB_A), @"GBSGBModel": @(GB_MODEL_SGB2), @"GBRumbleMode": @(GB_RUMBLE_CARTRIDGE_ONLY), - @"GBColorPalette": @(1), - - @"GBVolume": @(1.0), + @"GBButtonHaptics": @YES, + @"GBCurrentTheme": @"Lime (Game Boy)", // Default themes @"GBThemes": @{ @"Desert": @{ diff --git a/iOS/videoSettings@2x.png b/iOS/videoSettings@2x.png Binary files differ. diff --git a/iOS/videoSettings@3x.png b/iOS/videoSettings@3x.png Binary files differ.
This webpage is intended to be an accessible preview of this repository. To get a fuller picture, clone it and use the git CLI.