diff --git a/ios/GamerEmulator/GamerEmulator.xcodeproj/project.pbxproj b/ios/GamerEmulator/GamerEmulator.xcodeproj/project.pbxproj new file mode 100644 index 0000000..dcea9b0 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator.xcodeproj/project.pbxproj @@ -0,0 +1,450 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + AA000011 /* GamerEmulatorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000010 /* GamerEmulatorApp.swift */; }; + AA000013 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000012 /* ContentView.swift */; }; + AA000016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA000015 /* Assets.xcassets */; }; + AA000022 /* GamerHardware.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000021 /* GamerHardware.swift */; }; + AA000024 /* SoundEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000023 /* SoundEngine.swift */; }; + AA000032 /* GameAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000031 /* GameAssets.swift */; }; + AA000042 /* HighScoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000041 /* HighScoreStore.swift */; }; + AA000052 /* GameProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000051 /* GameProtocol.swift */; }; + AA000054 /* SnakeGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000053 /* SnakeGame.swift */; }; + AA000056 /* TetrisGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000055 /* TetrisGame.swift */; }; + AA000058 /* BreakoutGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000057 /* BreakoutGame.swift */; }; + AA00005A /* SimonGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000059 /* SimonGame.swift */; }; + AA00005C /* FlappyGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00005B /* FlappyGame.swift */; }; + AA00005E /* DinoGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00005D /* DinoGame.swift */; }; + AA000060 /* AlienGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00005F /* AlienGame.swift */; }; + AA000062 /* ConwayGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000061 /* ConwayGame.swift */; }; + AA000064 /* BrightnessGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000063 /* BrightnessGame.swift */; }; + AA000072 /* Launcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000071 /* Launcher.swift */; }; + AA000082 /* MatrixDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000081 /* MatrixDisplayView.swift */; }; + AA000084 /* ButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000083 /* ButtonsView.swift */; }; + AA000086 /* GamerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000085 /* GamerView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AA00000F /* GamerEmulator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GamerEmulator.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AA000010 /* GamerEmulatorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamerEmulatorApp.swift; sourceTree = ""; }; + AA000012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + AA000014 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AA000015 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AA000021 /* GamerHardware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamerHardware.swift; sourceTree = ""; }; + AA000023 /* SoundEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundEngine.swift; sourceTree = ""; }; + AA000031 /* GameAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameAssets.swift; sourceTree = ""; }; + AA000041 /* HighScoreStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighScoreStore.swift; sourceTree = ""; }; + AA000051 /* GameProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameProtocol.swift; sourceTree = ""; }; + AA000053 /* SnakeGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnakeGame.swift; sourceTree = ""; }; + AA000055 /* TetrisGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TetrisGame.swift; sourceTree = ""; }; + AA000057 /* BreakoutGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreakoutGame.swift; sourceTree = ""; }; + AA000059 /* SimonGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimonGame.swift; sourceTree = ""; }; + AA00005B /* FlappyGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlappyGame.swift; sourceTree = ""; }; + AA00005D /* DinoGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DinoGame.swift; sourceTree = ""; }; + AA00005F /* AlienGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlienGame.swift; sourceTree = ""; }; + AA000061 /* ConwayGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConwayGame.swift; sourceTree = ""; }; + AA000063 /* BrightnessGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessGame.swift; sourceTree = ""; }; + AA000071 /* Launcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Launcher.swift; sourceTree = ""; }; + AA000081 /* MatrixDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixDisplayView.swift; sourceTree = ""; }; + AA000083 /* ButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonsView.swift; sourceTree = ""; }; + AA000085 /* GamerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamerView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AA000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AA000003 /* root */ = { + isa = PBXGroup; + children = ( + AA000004 /* GamerEmulator */, + AA000005 /* Products */, + ); + sourceTree = ""; + }; + AA000004 /* GamerEmulator */ = { + isa = PBXGroup; + children = ( + AA000010 /* GamerEmulatorApp.swift */, + AA000012 /* ContentView.swift */, + AA000014 /* Info.plist */, + AA000015 /* Assets.xcassets */, + AA000020 /* Hardware */, + AA000030 /* GameAssets */, + AA000040 /* Persistence */, + AA000050 /* Games */, + AA000070 /* Launcher */, + AA000080 /* Views */, + ); + path = GamerEmulator; + sourceTree = ""; + }; + AA000005 /* Products */ = { + isa = PBXGroup; + children = ( + AA00000F /* GamerEmulator.app */, + ); + name = Products; + sourceTree = ""; + }; + AA000020 /* Hardware */ = { + isa = PBXGroup; + children = ( + AA000021 /* GamerHardware.swift */, + AA000023 /* SoundEngine.swift */, + ); + path = Hardware; + sourceTree = ""; + }; + AA000030 /* GameAssets */ = { + isa = PBXGroup; + children = ( + AA000031 /* GameAssets.swift */, + ); + path = GameAssets; + sourceTree = ""; + }; + AA000040 /* Persistence */ = { + isa = PBXGroup; + children = ( + AA000041 /* HighScoreStore.swift */, + ); + path = Persistence; + sourceTree = ""; + }; + AA000050 /* Games */ = { + isa = PBXGroup; + children = ( + AA000051 /* GameProtocol.swift */, + AA000053 /* SnakeGame.swift */, + AA000055 /* TetrisGame.swift */, + AA000057 /* BreakoutGame.swift */, + AA000059 /* SimonGame.swift */, + AA00005B /* FlappyGame.swift */, + AA00005D /* DinoGame.swift */, + AA00005F /* AlienGame.swift */, + AA000061 /* ConwayGame.swift */, + AA000063 /* BrightnessGame.swift */, + ); + path = Games; + sourceTree = ""; + }; + AA000070 /* Launcher */ = { + isa = PBXGroup; + children = ( + AA000071 /* Launcher.swift */, + ); + path = Launcher; + sourceTree = ""; + }; + AA000080 /* Views */ = { + isa = PBXGroup; + children = ( + AA000081 /* MatrixDisplayView.swift */, + AA000083 /* ButtonsView.swift */, + AA000085 /* GamerView.swift */, + ); + path = Views; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA000002 /* GamerEmulator */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA00000C /* Build configuration list for PBXNativeTarget "GamerEmulator" */; + buildPhases = ( + AA000006 /* Sources */, + AA000007 /* Resources */, + AA000008 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = GamerEmulator; + productName = GamerEmulator; + productReference = AA00000F /* GamerEmulator.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + AA000002 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = AA00000B /* Build configuration list for PBXProject "GamerEmulator" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AA000003 /* root */; + productRefGroup = AA000005 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA000002 /* GamerEmulator */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AA000007 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA000016 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AA000006 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA000011 /* GamerEmulatorApp.swift in Sources */, + AA000013 /* ContentView.swift in Sources */, + AA000022 /* GamerHardware.swift in Sources */, + AA000024 /* SoundEngine.swift in Sources */, + AA000032 /* GameAssets.swift in Sources */, + AA000042 /* HighScoreStore.swift in Sources */, + AA000052 /* GameProtocol.swift in Sources */, + AA000054 /* SnakeGame.swift in Sources */, + AA000056 /* TetrisGame.swift in Sources */, + AA000058 /* BreakoutGame.swift in Sources */, + AA00005A /* SimonGame.swift in Sources */, + AA00005C /* FlappyGame.swift in Sources */, + AA00005E /* DinoGame.swift in Sources */, + AA000060 /* AlienGame.swift in Sources */, + AA000062 /* ConwayGame.swift in Sources */, + AA000064 /* BrightnessGame.swift in Sources */, + AA000072 /* Launcher.swift in Sources */, + AA000082 /* MatrixDisplayView.swift in Sources */, + AA000084 /* ButtonsView.swift in Sources */, + AA000086 /* GamerView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AA000009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_IVAR_ACCESS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AA00000A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_IVAR_ACCESS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AA00000D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = GamerEmulator/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; + INFOPLIST_KEY_UILaunchScreen_Generation = NO; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.28pins.GamerEmulator; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AA00000E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = GamerEmulator/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; + INFOPLIST_KEY_UILaunchScreen_Generation = NO; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.28pins.GamerEmulator; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AA00000B /* Build configuration list for PBXProject "GamerEmulator" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA000009 /* Debug */, + AA00000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA00000C /* Build configuration list for PBXNativeTarget "GamerEmulator" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA00000D /* Debug */, + AA00000E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + + }; + rootObject = AA000001 /* Project object */; +} diff --git a/ios/GamerEmulator/GamerEmulator/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/GamerEmulator/GamerEmulator/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/GamerEmulator/GamerEmulator/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d2e5ff2 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,9 @@ +{ + "images" : [ + { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Assets.xcassets/Contents.json b/ios/GamerEmulator/GamerEmulator/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/GamerEmulator/GamerEmulator/ContentView.swift b/ios/GamerEmulator/GamerEmulator/ContentView.swift new file mode 100644 index 0000000..aaf15f9 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/ContentView.swift @@ -0,0 +1,15 @@ +// ContentView.swift — App root view. +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import SwiftUI + +struct ContentView: View { + var body: some View { + GamerView() + } +} + +#Preview { + ContentView() +} diff --git a/ios/GamerEmulator/GamerEmulator/GameAssets/GameAssets.swift b/ios/GamerEmulator/GamerEmulator/GameAssets/GameAssets.swift new file mode 100644 index 0000000..c66b3be --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/GameAssets/GameAssets.swift @@ -0,0 +1,129 @@ +// GameAssets.swift — All bitmap / melody data ported from progmem_assets.h +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation + +// MARK: - Note constants (OCR2A register values; frequency ≈ 1,000,000 / (n+1) Hz) +let NOTE_B7: Int = 252 // ~3937 Hz +let NOTE_C8: Int = 238 // ~4202 Hz +let NOTE_D8: Int = 212 // ~4717 Hz +let NOTE_E8: Int = 189 // ~5263 Hz +let NOTE_G8: Int = 158 // ~6329 Hz +let NOTE_A8: Int = 140 // ~7042 Hz +let NOTE_B8: Int = 125 // ~7812 Hz + +enum GameAssets { + + // MARK: - Launcher animation frames (8 rows per frame, MSB = column 0) + + static let startup: [[UInt8]] = [ + [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF] + ] + + /// Snake: frame 0 = S-curve + food dot; frame 1 = food dot off + static let snake: [[UInt8]] = [ + [0x00, 0x38, 0x08, 0x0E, 0x00, 0x40, 0x00, 0x00], + [0x00, 0x38, 0x08, 0x0E, 0x00, 0x00, 0x00, 0x00] + ] + + /// Breakout: frame 0 = blocks + ball + paddle; frame 1 = one block missing + static let breakout: [[UInt8]] = [ + [0xFF, 0xFF, 0x00, 0x10, 0x00, 0x00, 0x00, 0x38], + [0xFF, 0xDF, 0x00, 0x00, 0x10, 0x00, 0x00, 0x38] + ] + + /// Simon: frame 0 = TL+BR lit; frame 1 = TR+BL lit + static let simon: [[UInt8]] = [ + [0xF0, 0xF0, 0xF0, 0xF0, 0x0F, 0x0F, 0x0F, 0x0F], + [0x0F, 0x0F, 0x0F, 0x0F, 0xF0, 0xF0, 0xF0, 0xF0] + ] + + /// Flappy: frame 0 = wing up; frame 1 = wing down + static let flappy: [[UInt8]] = [ + [0x01, 0x01, 0x40, 0x60, 0x00, 0x00, 0x01, 0x01], + [0x01, 0x01, 0x00, 0x60, 0x40, 0x00, 0x01, 0x01] + ] + + /// Tetris: O-piece falling + static let tetris: [[UInt8]] = [ + [0x30, 0x30, 0x00, 0x00, 0x00, 0x00, 0xCF, 0xFF], + [0x00, 0x30, 0x30, 0x00, 0x00, 0x00, 0xCF, 0xFF] + ] + + /// Alien: two-frame walk cycle + static let alien: [[UInt8]] = [ + [0x00, 0x00, 0x7E, 0x5A, 0x7E, 0x24, 0x24, 0x66], + [0x00, 0x7E, 0x5A, 0x7E, 0x24, 0x42, 0xC3, 0x00] + ] + + /// Conway: glider two-step + static let conway: [[UInt8]] = [ + [0x40, 0x20, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00], + [0xA0, 0x60, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00] + ] + + /// Dino: standing + cactus / jumping over cactus + static let dino: [[UInt8]] = [ + [0x00, 0x00, 0x00, 0x00, 0x00, 0x44, 0x44, 0xFF], + [0x00, 0x00, 0x00, 0x40, 0x40, 0x04, 0x04, 0xFF] + ] + + /// Brightness: sun outline / sun filled + static let brightness: [[UInt8]] = [ + [0x00, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x00], + [0x00, 0x66, 0x3C, 0x7E, 0x7E, 0x3C, 0x66, 0x00] + ] + + // MARK: - In-game images + + /// Breakout win/lose face + static let breakoutFaces: [[UInt8]] = [ + [0x00, 0x66, 0x66, 0x00, 0x42, 0x3C, 0x00, 0x00], // win + [0x00, 0x66, 0x66, 0x00, 0x00, 0x3C, 0x42, 0x00] // lose + ] + + /// Simon arrows: [0]=up [1]=down [2]=left [3]=right + static let simonArrows: [[UInt8]] = [ + [0x00, 0x18, 0x3C, 0x7E, 0x18, 0x18, 0x18, 0x00], // up + [0x00, 0x18, 0x18, 0x18, 0x7E, 0x3C, 0x18, 0x00], // down + [0x00, 0x10, 0x30, 0x7E, 0x7E, 0x30, 0x10, 0x00], // left + [0x00, 0x08, 0x0C, 0x7E, 0x7E, 0x0C, 0x08, 0x00] // right + ] + + /// Simon "GO" + static let simonGo: [UInt8] = [ + 0x00, 0x6E, 0x8A, 0x8A, 0x8A, 0xAA, 0x6E, 0x20 + ] + + /// Simon "WRONG" (X) + static let simonWrong: [UInt8] = [ + 0xC3, 0x66, 0x3C, 0x18, 0x18, 0x3C, 0x66, 0xC3 + ] + + /// Simon "RIGHT" (checkmark) + static let simonRight: [UInt8] = [ + 0x01, 0x03, 0x07, 0x0E, 0xDC, 0xF8, 0x70, 0x20 + ] + + // MARK: - Score digit bitmaps (3 pixels wide; left-shift 5 for tens position) + static let numbers: [[UInt8]] = [ + [0x07, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x07], // 0 + [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], // 1 + [0x07, 0x01, 0x01, 0x07, 0x04, 0x04, 0x04, 0x07], // 2 + [0x07, 0x01, 0x01, 0x03, 0x01, 0x01, 0x01, 0x07], // 3 + [0x05, 0x05, 0x05, 0x07, 0x01, 0x01, 0x01, 0x01], // 4 + [0x07, 0x04, 0x04, 0x07, 0x01, 0x01, 0x01, 0x07], // 5 + [0x07, 0x04, 0x04, 0x07, 0x05, 0x05, 0x05, 0x07], // 6 + [0x07, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01], // 7 + [0x07, 0x05, 0x05, 0x07, 0x05, 0x05, 0x05, 0x07], // 8 + [0x07, 0x05, 0x05, 0x07, 0x01, 0x01, 0x01, 0x07] // 9 + ] + + // MARK: - Tetris Korobeiniki melody (OCR2A note values, 16 notes) + /// Playback: one note per 200 ms, looping. + static let tetrisMelody: [Int] = [ + 189, 252, 238, 212, 212, 238, 252, 238, + 189, 140, 140, 238, 189, 212, 238, 252 + ] +} diff --git a/ios/GamerEmulator/GamerEmulator/GamerEmulatorApp.swift b/ios/GamerEmulator/GamerEmulator/GamerEmulatorApp.swift new file mode 100644 index 0000000..4b2b31f --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/GamerEmulatorApp.swift @@ -0,0 +1,24 @@ +// GamerEmulatorApp.swift — SwiftUI application entry point. +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import SwiftUI + +@main +struct GamerEmulatorApp: App { + + init() { + // First-launch high-score initialisation (mirrors Arduino EEPROM init) + let key = "highscores_initialised" + if !UserDefaults.standard.bool(forKey: key) { + HighScoreStore.resetAll() + UserDefaults.standard.set(true, forKey: key) + } + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Games/AlienGame.swift b/ios/GamerEmulator/GamerEmulator/Games/AlienGame.swift new file mode 100644 index 0000000..5f0d0fa --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Games/AlienGame.swift @@ -0,0 +1,124 @@ +// AlienGame.swift — Faithful port of src/games/alien.h +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation + +@MainActor +final class AlienGame: Game { + + let name = "ALIEN" + let gameIndex = 5 + let animFrames = GameAssets.alien + + // ── State ────────────────────────────────────────────────────────────────── + private var gameGoing: Bool = false + private var lastMove: Double = 0 + private var moveDelay: Int = 800 + private var alienLineCount: Int = 0 + private var score: Int = 0 + private var playerX: Int = 3 + + // ── Game protocol ────────────────────────────────────────────────────────── + + func reset(fromHighScore: Bool, startingScore: UInt8) { + if fromHighScore && startingScore > 0 { + score = Int(startingScore) / 2 + alienLineCount = score * 10 - 1 + moveDelay = max(330, 800 - score * 5) + } else { + score = 0 + alienLineCount = 0 + moveDelay = 800 + } + playerX = 3 + lastMove = 0 + gameGoing = true + } + + func loop(gamer: GamerHardware) async { + if gameGoing { + await moveAliens(gamer: gamer) + + // Inner wait loop: handle input during move delay + while gamer.millis() - lastMove < Double(moveDelay) { + await gamer.delay(10) + gamer.updateLEDFlash() + if gamer.soundEnabled { gamer.stopTone() } + + if gamer.isPressed(.up) { + // Shoot upward + for i in stride(from: 7, through: 0, by: -1) { + gamer.display[playerX][i] = 1 + await gamer.delay(20) + renderPlayer(gamer: gamer) + gamer.updateDisplay() + } + for i in stride(from: 7, through: 0, by: -1) { + gamer.display[playerX][i] = 0 + await gamer.delay(20) + renderPlayer(gamer: gamer) + gamer.updateDisplay() + } + lastMove += 200 + } + if gamer.isPressed(.left) && playerX > 0 { + playerX -= 1; renderPlayer(gamer: gamer); gamer.updateDisplay() + } + if gamer.isPressed(.right) && playerX < 7 { + playerX += 1; renderPlayer(gamer: gamer); gamer.updateDisplay() + } + } + } else { + // Game over state + if gamer.isPressed(.up) || gamer.isPressed(.left) || gamer.isPressed(.right) { + reset(fromHighScore: false, startingScore: 0) + } else { + gamer.showScore(score / 10, score % 10) + await gamer.delay(1000) + reset(fromHighScore: false, startingScore: 0) + } + } + } + + // MARK: - Helpers + + private func renderPlayer(gamer: GamerHardware) { + for i in 0..<8 { gamer.display[i][7] = 0; gamer.display[i][6] = 0 } + gamer.display[playerX][7] = 1 + gamer.display[playerX][6] = 1 + if playerX > 0 { gamer.display[playerX - 1][7] = 1 } + if playerX < 7 { gamer.display[playerX + 1][7] = 1 } + } + + private func generateAlienRow(gamer: GamerHardware) { + for i in 0..<8 { + gamer.display[i][0] = Int.random(in: 0..<(moveDelay > 330 ? 3 : 4)) > 1 ? 1 : 0 + } + } + + private func moveAliens(gamer: GamerHardware) async { + // Check for loss: alien in row 5 + for i in 0..<8 { + if gamer.display[i][5] == 1 { + gameGoing = false + HighScoreStore.saveHighScore(score, gameIndex) + return + } + } + // Shift rows down + for i in 0..<8 { + for j in stride(from: 5, through: 1, by: -1) { + gamer.display[i][j] = gamer.display[i][j - 1] + } + } + generateAlienRow(gamer: gamer) + renderPlayer(gamer: gamer) + gamer.updateDisplay() + lastMove = gamer.millis() + + alienLineCount += 1 + if alienLineCount % 10 == 0 { score += 1 } + if moveDelay > 300 { moveDelay -= 5 } + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Games/BreakoutGame.swift b/ios/GamerEmulator/GamerEmulator/Games/BreakoutGame.swift new file mode 100644 index 0000000..0ffe272 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Games/BreakoutGame.swift @@ -0,0 +1,214 @@ +// BreakoutGame.swift — Faithful port of src/games/breakout.h +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation + +@MainActor +final class BreakoutGame: Game { + + let name = "BRKOUT" + let gameIndex = 1 + let animFrames = GameAssets.breakout + + // ── State ────────────────────────────────────────────────────────────────── + private var ballX: Int = 5 + private var ballY: Int = 5 + private var velX: Int = -1 + private var velY: Int = -1 + private var origVX: Int = -1 + private var origVY: Int = -1 + private var blocks: [[Int]] = Array(repeating: Array(repeating: 0, count: 8), count: 8) + private var paddleX: Int = 2 + private var counter: Int = 0 + private var score: Int = 0 + + // ── Game protocol ────────────────────────────────────────────────────────── + + func reset(fromHighScore: Bool, startingScore: UInt8) { + resetBlocks() + if fromHighScore && startingScore > 0 { + score = Int(startingScore) / 4 + } else { + score = 0 + } + ballX = Int.random(in: 4..<8) + ballY = 5 + velX = -1 + velY = -1 + paddleX = 2 + counter = 0 + } + + func loop(gamer: GamerHardware) async { + gamer.updateLEDFlash() + if gamer.soundEnabled { gamer.stopTone() } + + if counter > 2 { + // Clear ball region + for x in 0..<8 { for y in 0..<8 { gamer.display[x][y] = 0 } } + } + + // Clear bottom row for paddle redraw + for x in 0..<8 { gamer.display[x][7] = 0 } + + // Paddle input + if gamer.isHeld(.left) && paddleX > -3 { paddleX -= 1 } + else if gamer.isHeld(.right) && paddleX < 7 { paddleX += 1 } + + // Draw paddle (4 wide) + for a in 0..<4 { + let px = paddleX + a + if px >= 0 && px < 8 { gamer.display[px][7] = 1 } + } + + if counter > 2 { + origVX = velX; origVY = velY + // Draw blocks + for x in 0..<8 { for y in 0..<4 { if blocks[x][y] == 1 { gamer.display[x][y] = 1 } } } + + physics(gamer: gamer) + + // Propagate block destruction + for x in 0..<8 { + for y in 0..<8 { + if blocks[x][y] == 0 { + let adjX = ((x % 2) == (y % 2)) ? ((x < 7) ? x + 1 : 0) : ((x > 0) ? x - 1 : 7) + blocks[adjX][y] = 0 + } + } + } + for x in 0..<8 { for y in 0..<4 { if blocks[x][y] == 0 { gamer.display[x][y] = 0 } } } + + // Secondary boundary logic (mirrors original) + let newX = ballX + velX + let newY = ballY + velY + if newX > -1 && newX < 8 { + if !(newY > -1 && newY < 8) { + let checkY = ballY - velY + if checkY > -1 && checkY < 8 { + if gamer.display[newX][checkY] == 0 { + if newX > -1 && newX < 8 && (ballY + velY) > -1 && (ballY + velY) < 8 { + blocks[newX][ballY + velY] = 0 + } + velY *= -1 + } else { + if (ballX + velX) > -1 && (ballX + velX) < 8 && (ballY + velY) > -1 && (ballY + velY) < 8 { + blocks[ballX + velX][ballY + velY] = 0 + } + velY *= -1; velX *= -1 + } + } + } + } else { + // Handle side-wall collision safely: only use newY when it is within bounds. + if newY > -1 && newY < 8 && + (ballX - velX) > -1 && (ballX - velX) < 8 { + if gamer.display[ballX - velX][newY] == 0 { + if (ballX + velX) > -1 && (ballX + velX) < 8 { + blocks[ballX + velX][newY] = 0 + } + velX *= -1 + } else { + // Explosion / multi-block destruction, guarded by bounds on bx/by. + for dx in -1...1 { for dy in -1...1 { + let bx = ballX + dx, by = ballY + dy + if bx >= 0 && bx < 8 && by >= 0 && by < 8 { blocks[bx][by] = 0 } + }} + velX *= -1; velY *= -1 + } + } else { + // Corner/top-bottom collision when newY is out of vertical bounds. + let bounceY = ballY - velY + if bounceY > -1 && bounceY < 8 && + (ballX + velX) > -1 && (ballX + velX) < 8 && + (ballX - velX) > -1 && (ballX - velX) < 8 { + if gamer.display[ballX + velX][bounceY] == 0 { + blocks[ballX - velX][bounceY] = 0 + velY *= -1 + } + } + } + } + + ballX += velX; ballY += velY + if ballX >= 0 && ballX < 8 && ballY >= 0 && ballY < 8 { + gamer.display[ballX][ballY] = 1 + } + counter = 0 + } else { + counter += 1 + } + + gamer.updateDisplay() + + // Ball lost + if ballY == 7 { + for _ in 0..<4 { + gamer.clear() + await gamer.delay(150) + if ballX >= 0 && ballX < 8 { gamer.display[ballX][ballY] = 1 } + gamer.updateDisplay() + await gamer.delay(150) + } + gamer.clear() + HighScoreStore.saveHighScore(score, gameIndex) + if score > 0 { gamer.showScore(score / 10, score % 10) } + await gamer.delay(500) + reset(fromHighScore: false, startingScore: 0) + return + } + + // Win – all blocks cleared + var finished = true + outer: for x in 0..<8 { for y in 0..<4 { if blocks[x][y] == 1 { finished = false; break outer } } } + if finished { + score += 1 + gamer.clear() + HighScoreStore.saveHighScore(score, gameIndex) + await gamer.delay(500) + resetBlocks() + ballX = Int.random(in: 4..<8); ballY = 5; velX = -1; velY = -1 + } + + await gamer.delay(50) + } + + // MARK: - Helpers + + private func resetBlocks() { + for x in 0..<8 { for y in 0..<8 { blocks[x][y] = 0 } } + for x in 0..<8 { for y in 0..<4 { blocks[x][y] = 1 } } + } + + private func outOfBounds(_ x: Int, _ y: Int) -> Bool { + x >= 8 || x < 0 || y >= 8 || y < 0 + } + + private func isFree(_ x: Int, _ y: Int, gamer: GamerHardware) -> Bool { + !outOfBounds(x, y) && gamer.display[x][y] == 0 + } + + private func physics(gamer: GamerHardware) { + let nextX = ballX + velX + let nextY = ballY + velY + + guard !outOfBounds(nextX, nextY) && gamer.display[nextX][nextY] == 0 else { + // Collision + gamer.startLEDFlash() + if gamer.soundEnabled { + gamer.playTone(ballY == 6 ? NOTE_C8 : NOTE_E8) + } + let canBounceY = isFree(nextX, ballY - velY, gamer: gamer) + let canBounceX = isFree(ballX - velX, nextY, gamer: gamer) + if canBounceY { velY *= -1 } + else if canBounceX { velX *= -1 } + else { velX *= -1; velY *= -1 } + + if !outOfBounds(ballX + origVX, ballY + origVY) { + blocks[ballX + origVX][ballY + origVY] = 0 + } + return + } + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Games/BrightnessGame.swift b/ios/GamerEmulator/GamerEmulator/Games/BrightnessGame.swift new file mode 100644 index 0000000..fd8f036 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Games/BrightnessGame.swift @@ -0,0 +1,61 @@ +// BrightnessGame.swift — Faithful port of src/games/brightness.h +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation + +@MainActor +final class BrightnessGame: Game { + + let name = "BRIGHT" + let gameIndex = 8 + let animFrames = GameAssets.brightness + + private var brightSetting: Int = 8 + private var ledCompSetting: Bool = true + private var needsInitialDraw: Bool = true + + // ── Game protocol ────────────────────────────────────────────────────────── + + func reset(fromHighScore: Bool, startingScore: UInt8) { + // Mark that we need to render the initial brightness screen on next loop + needsInitialDraw = true + } + + func loop(gamer: GamerHardware) async { + // On first loop after reset, sync from gamer and draw once + if needsInitialDraw { + brightSetting = gamer.getBrightness() + ledCompSetting = gamer.getLEDCompensation() + drawScreen(gamer: gamer) + needsInitialDraw = false + } else { + brightSetting = gamer.getBrightness() + ledCompSetting = gamer.getLEDCompensation() + } + + var changed = false + + if gamer.isPressed(.up) || gamer.isPressed(.right) { + if brightSetting < 8 { brightSetting += 1; gamer.setBrightness(brightSetting); changed = true } + } else if gamer.isPressed(.down) { + if brightSetting > 1 { brightSetting -= 1; gamer.setBrightness(brightSetting); changed = true } + } else if gamer.isPressed(.left) { + ledCompSetting.toggle(); gamer.setLEDCompensation(ledCompSetting); changed = true + } + + if changed { drawScreen(gamer: gamer) } + + await gamer.delay(150) + } + + // MARK: - Private + + private func drawScreen(gamer: GamerHardware) { + gamer.clear() + let bval = gamer.getBrightness() + for c in 0.. Self.stagnationLimit { randomize() } + } else { + stuck = 0 + } + + if gamer.isPressed(.right) { randomize() } + + for x in 0..<8 { for y in 0..<8 { gamer.display[x][y] = (curr[y] >> x) & 1 } } + gamer.updateDisplay() + await gamer.delay(180) + } + + // MARK: - Conway logic + + func randomize() { + for i in 0..<8 { curr[i] = UInt8.random(in: 0...255) } + stuck = 0 + } + + @discardableResult + func step() -> Bool { + var anyChange = false + for y in 0..<8 { + next_[y] = 0 + for x in 0..<8 { + var alive: Int = 0 + for dy in -1...1 { + for dx in -1...1 { + if dx == 0 && dy == 0 { continue } + let nx = (x + dx + 8) & 7 + let ny = (y + dy + 8) & 7 + if (curr[ny] >> nx) & 1 == 1 { alive += 1 } + } + } + let isCurr = (curr[y] >> x) & 1 == 1 + let isNext = alive == 3 || (isCurr && alive == 2) + if isNext { next_[y] |= (1 << x) } + if isNext != isCurr { anyChange = true } + } + } + for i in 0..<8 { curr[i] = next_[i] } + return anyChange + } + + /// Restricted 4×4 step used by the launcher animation preview. + @discardableResult + func stepSmall() -> Bool { + var anyChange = false + for y in 2..<6 { + next_[y] = curr[y] + for x in 2..<6 { + var alive = 0 + for dy in -1...1 { + for dx in -1...1 { + if dx == 0 && dy == 0 { continue } + var nx = (x - 2 + dx); if nx < 0 { nx += 4 }; if nx >= 4 { nx -= 4 }; nx += 2 + var ny = (y - 2 + dy); if ny < 0 { ny += 4 }; if ny >= 4 { ny -= 4 }; ny += 2 + if (curr[ny] >> nx) & 1 == 1 { alive += 1 } + } + } + let isCurr = (curr[y] >> x) & 1 == 1 + let isNext = alive == 3 || (isCurr && alive == 2) + if isNext { next_[y] |= (1 << x) } + else { next_[y] &= ~(1 << x) } + if isNext != isCurr { anyChange = true } + } + } + for i in 2..<6 { curr[i] = next_[i] } + return anyChange + } + + /// Access the current grid for launcher preview rendering. + func getCurr() -> [UInt8] { curr } +} diff --git a/ios/GamerEmulator/GamerEmulator/Games/DinoGame.swift b/ios/GamerEmulator/GamerEmulator/Games/DinoGame.swift new file mode 100644 index 0000000..702d1a5 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Games/DinoGame.swift @@ -0,0 +1,175 @@ +// DinoGame.swift — Faithful port of src/games/dino.h +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation + +@MainActor +final class DinoGame: Game { + + let name = "DINO" + let gameIndex = 7 + let animFrames = GameAssets.dino + + // ── State ────────────────────────────────────────────────────────────────── + private var dinoY: Int = 5 + private var dinoVel: Int = 0 + private var dinoJumping: Bool = false + private var dinoDucking: Bool = false + private var obsX: Int = 9 + private var obsType: Int = 0 // 0=cactus 1=bird 2=giant-bird 3=wide-cactus 4=flying-bar + private var score: Int = 0 + private var dinoOver: Bool = false + private var dinoSpeed: Int = 160 // ms per tick + private var lastTick: Double = 0 + + // ── Game protocol ────────────────────────────────────────────────────────── + + func reset(fromHighScore: Bool, startingScore: UInt8) { + dinoY = 5 + dinoVel = 0 + dinoJumping = false + dinoDucking = false + obsX = 9 + obsType = 0 + dinoOver = false + if fromHighScore && startingScore > 0 { + score = Int(startingScore) / 2 + dinoSpeed = max(60, 160 - score * 5) + } else { + score = 0 + dinoSpeed = 160 + } + lastTick = 0 + } + + func loop(gamer: GamerHardware) async { + gamer.updateLEDFlash() + if gamer.soundEnabled { gamer.stopTone() } + + if dinoOver { + gamer.showScore(score / 10, score % 10) + HighScoreStore.saveHighScore(score, gameIndex) + await gamer.delay(2000) + reset(fromHighScore: false, startingScore: 0) + return + } + + // Input + if gamer.isPressed(.up) && !dinoJumping { + dinoJumping = true; dinoDucking = false; dinoVel = -2 + gamer.startLEDFlash() + if gamer.soundEnabled { gamer.playTone(NOTE_A8) } + } + if gamer.isHeld(.down) { + if dinoJumping { dinoY = 5; dinoVel = 0; dinoJumping = false } + if !dinoDucking { gamer.startLEDFlash() } + dinoDucking = true + } else if !dinoJumping { + dinoDucking = false + } + + // Tick-gated update + let now = gamer.millis() + if lastTick == 0 { lastTick = now } + + if now - lastTick >= Double(dinoSpeed) { + lastTick = now + + // Jump physics + if dinoJumping { + dinoY += dinoVel + dinoVel += 1 + if dinoY >= 5 { dinoY = 5; dinoVel = 0; dinoJumping = false } + } + + // Advance obstacle + obsX -= 1 + if obsX < 0 { + obsX = Int.random(in: 8..<11) + let roll = Int.random(in: 0..<5) + switch roll { + case 0: obsType = 1 + case 1: obsType = 2 + case 2: obsType = 3 + case 3: obsType = 4 + default: obsType = 0 + } + score += 1 + if score > 99 { score = 99 } + gamer.startLEDFlash() + if gamer.soundEnabled { gamer.playTone(NOTE_E8) } + if dinoSpeed > 60 { dinoSpeed -= 4 } + } + + // Collision at column 1 + if obsX == 1 { if collisionCheck() { triggerGameOver(gamer: gamer); return } } + if obsX == 0 && obsType == 3 { if collisionCheck() { triggerGameOver(gamer: gamer); return } } + if (obsX == 1 || obsX == 0 || obsX == -1) && obsType == 4 { + if collisionCheck4() { triggerGameOver(gamer: gamer); return } + } + } + + // Render + for i in 0..<8 { for j in 0..<8 { gamer.display[i][j] = 0 } } + for i in 0..<8 { gamer.display[i][7] = 1 } + + if dinoDucking { + gamer.display[1][6] = 1 + } else { + if dinoY >= 0 && dinoY < 8 { gamer.display[1][dinoY] = 1 } + if dinoY+1 >= 0 && dinoY+1 < 8 { gamer.display[1][dinoY + 1] = 1 } + } + + drawObstacle(gamer: gamer) + + gamer.updateDisplay() + await gamer.delay(10) + } + + // MARK: - Helpers + + private func collisionCheck() -> Bool { + switch obsType { + case 0: return dinoDucking || dinoY + 1 >= 5 + case 1: return !dinoDucking && dinoY <= 5 && dinoY + 1 >= 5 + case 2: return !dinoDucking || dinoJumping + case 3: return dinoDucking || dinoY + 1 >= 5 + case 4: return !dinoDucking && dinoY <= 5 && dinoY + 1 >= 5 + default: return false + } + } + + private func collisionCheck4() -> Bool { + return !dinoDucking && dinoY <= 5 && dinoY + 1 >= 5 + } + + private func triggerGameOver(gamer: GamerHardware) async { + dinoOver = true + await gamer.playLossTune() + } + + private func drawObstacle(gamer: GamerHardware) { + if obsX >= 0 && obsX < 8 { + switch obsType { + case 0: + gamer.display[obsX][5] = 1; gamer.display[obsX][6] = 1 + case 1: + gamer.display[obsX][5] = 1 + case 2: + gamer.display[obsX][5] = 1; gamer.display[obsX][4] = 1; gamer.display[obsX][3] = 1 + case 3: + gamer.display[obsX][5] = 1; gamer.display[obsX][6] = 1 + if obsX + 1 < 8 { gamer.display[obsX+1][5] = 1; gamer.display[obsX+1][6] = 1 } + case 4: + gamer.display[obsX][5] = 1 + if obsX+1 < 8 { gamer.display[obsX+1][5] = 1 } + if obsX+2 < 8 { gamer.display[obsX+2][5] = 1 } + default: break + } + } + if obsX == -1 && obsType == 3 { gamer.display[0][5] = 1; gamer.display[0][6] = 1 } + if obsX == -1 && obsType == 4 { gamer.display[0][5] = 1; gamer.display[1][5] = 1 } + if obsX == -2 && obsType == 4 { gamer.display[0][5] = 1 } + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Games/FlappyGame.swift b/ios/GamerEmulator/GamerEmulator/Games/FlappyGame.swift new file mode 100644 index 0000000..703069a --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Games/FlappyGame.swift @@ -0,0 +1,160 @@ +// FlappyGame.swift — Faithful port of src/games/flappy.h +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation + +@MainActor +final class FlappyGame: Game { + + let name = "FLAPPY" + let gameIndex = 3 + let animFrames = GameAssets.flappy + + // ── State ────────────────────────────────────────────────────────────────── + private var menu: Bool = true + private var gameOver: Bool = false + private var displayScore: Bool = false + private var birdPos: Int = 2 + private var pipePos: Int = 8 + private var pipeGap: Int = 3 + private var ticks: Int = 0 + private var tickCount: Int = 14 + private var score: Int = 0 + + private var inGameScreen: [UInt8] = Array(repeating: 0, count: 64) + private var menuScreen: [UInt8] = [ + 0,0,0,1,1,0,0,0, + 0,0,1,0,0,1,0,0, + 0,0,1,0,1,1,0,0, + 1,1,1,0,0,1,0,0, + 1,0,0,1,1,1,1,0, + 1,0,1,0,1,0,0,1, + 0,1,0,0,1,1,1,0, + 0,0,1,1,1,0,0,0, + ] + + // ── Game protocol ────────────────────────────────────────────────────────── + + func reset(fromHighScore: Bool, startingScore: UInt8) { + menu = true + resetFlappy(fromHighScore: fromHighScore, startingScore: startingScore) + } + + private func resetFlappy(fromHighScore: Bool, startingScore: UInt8) { + if fromHighScore && startingScore > 0 { + score = Int(startingScore) / 4 + tickCount = max(6, 14 - (score / 7)) + } else { + score = 0 + tickCount = 14 + } + displayScore = false + ticks = 0 + birdPos = 2 + pipePos = 20 + pipeGap = 3 + gameOver = false + drawInGame(colour: 1) + } + + func loop(gamer: GamerHardware) async { + gamer.updateLEDFlash() + if gamer.soundEnabled { gamer.stopTone() } + + if menu { + ticks += 1 + if (ticks % 12) == 0 { + if Int.random(in: 28..<40) % 30 == 0 { + menuScreen[19] = 1; menuScreen[20] = 0 + } else { + menuScreen[19] = 0; menuScreen[20] = 1 + } + } + if gamer.isPressed(.up) { + menu = false + resetFlappy(fromHighScore: false, startingScore: 0) + } + } else if displayScore { + gamer.clear() + gamer.showScore(score / 10, score % 10) + HighScoreStore.saveHighScore(score, gameIndex) + await gamer.delay(800) + displayScore = false + for i in 0..<64 { inGameScreen[i] = 0 } + resetFlappy(fromHighScore: false, startingScore: 0) + menu = true + } else { + drawInGame(colour: 0) + ticks += 1 + + if !gameOver && (ticks % tickCount == 0) { + let lastPipePos = pipePos + pipePos -= 1 + if pipePos < -1 { + score += 1 + if score % 7 == 0 { tickCount = max(6, tickCount - 1) } + gamer.startLEDFlash() + pipePos = 7 + pipeGap = 1 + Int.random(in: 0..<4) + } + + let lastBirdPos = birdPos + if gamer.isPressed(.up) { + birdPos = max(birdPos - 1, 0) + if gamer.soundEnabled { gamer.playTone(NOTE_A8) } + } else { + birdPos += 1 + if birdPos >= 8 { + gameOver = true + await gamer.playLossTune() + pipePos = lastPipePos + birdPos = lastBirdPos + ticks = 0 + } + } + + if pipePos == 1 || pipePos == 0 { gamer.startLEDFlash() } + + if (pipePos == 1 || pipePos == 0) && + (birdPos < pipeGap || birdPos >= pipeGap + 3) { + gameOver = true + await gamer.playLossTune() + pipePos = lastPipePos + birdPos = lastBirdPos + ticks = 0 + } + } else if gameOver && ticks >= 96 { + displayScore = true + } + + if !menu { drawInGame(colour: 1) } + } + + if !displayScore { + let screen: [UInt8] = menu ? menuScreen : inGameScreen + for i in 0..<64 { + let x = i & 7 + let y = i >> 3 + gamer.display[x][y] = screen[i] + } + gamer.updateDisplay() + } + + await gamer.delay(10) + } + + // MARK: - Helpers + + private func drawInGame(colour: UInt8) { + if birdPos >= 0 && birdPos < 8 { + let show = !gameOver || (gameOver && ((ticks / 24) % 2) == 1) + if show { inGameScreen[1 + birdPos * 8] = colour } + } + for y in 0..<8 { + guard y < pipeGap || y >= pipeGap + 3 else { continue } + if pipePos >= 0 && pipePos < 8 { inGameScreen[pipePos + y * 8] = colour } + if pipePos >= -1 && pipePos < 7 { inGameScreen[pipePos + 1 + y * 8] = colour } + } + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Games/GameProtocol.swift b/ios/GamerEmulator/GamerEmulator/Games/GameProtocol.swift new file mode 100644 index 0000000..1fcc875 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Games/GameProtocol.swift @@ -0,0 +1,27 @@ +// GameProtocol.swift — Base protocol for all playable games. +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation + +/// Every game implements this protocol so the launcher can manage them uniformly. +@MainActor +protocol Game: AnyObject { + /// Short display name shown in the launcher menu. + var name: String { get } + + /// Launcher animation frames (each element is an 8-byte image row). + var animFrames: [[UInt8]] { get } + + /// EEPROM/UserDefaults game index for high-score storage. + var gameIndex: Int { get } + + /// Reset game state. Called once before entering the game loop. + /// - Parameters: + /// - fromHighScore: true when launched from the high-score menu (handicap mode). + /// - startingScore: the stored high score to seed the handicap. + func reset(fromHighScore: Bool, startingScore: UInt8) + + /// One iteration of the game loop. May suspend with `await gamer.delay()`. + func loop(gamer: GamerHardware) async +} diff --git a/ios/GamerEmulator/GamerEmulator/Games/SimonGame.swift b/ios/GamerEmulator/GamerEmulator/Games/SimonGame.swift new file mode 100644 index 0000000..1faeefe --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Games/SimonGame.swift @@ -0,0 +1,144 @@ +// SimonGame.swift — Faithful port of src/games/simon.h +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation + +@MainActor +final class SimonGame: Game { + + let name = "SIMON" + let gameIndex = 2 + let animFrames = GameAssets.simon + + private static let maxSeq = 30 + private static let numDirs = 4 + private static let notes = [NOTE_E8, NOTE_C8, NOTE_G8, NOTE_D8] + + // ── State ────────────────────────────────────────────────────────────────── + private var simonStep: Int = 0 + private var delayMils: Int = 300 + private var sequence: [Int] = Array(repeating: 0, count: 30) + private var score: Int = 0 + + // ── Game protocol ────────────────────────────────────────────────────────── + + func reset(fromHighScore: Bool, startingScore: UInt8) { + gamer_clear_pending = true + if fromHighScore && startingScore > 0 { + simonStep = min(7, Int(startingScore) / 3) + for b in 0..= Self.maxSeq { + score = simonStep + HighScoreStore.saveHighScore(score, gameIndex) + await gamer.playWinTune() + gamer.printImage(GameAssets.simonRight) + await gamer.delay(600) + reset(fromHighScore: false, startingScore: 0) + return + } + + // Add a new item to the sequence (safe: simonStep is now guaranteed < maxSeq) + sequence[simonStep] = Int.random(in: 0.. 0 { + // Countdown + for p in stride(from: 3, through: 1, by: -1) { + if gamer.isHeld(.start) { return } + gamer.showScore(0, p) + await gamer.delay(delayMils) + } + // "GO" + if gamer.isHeld(.start) { return } + gamer.printImage(GameAssets.simonGo) + await gamer.delay(delayMils) + + // Play sequence + for i in 0.. 28 { + score = simonStep + simonStep = 28 + await gamer.playWinTune() + } + } + + await gamer.delay(400) + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Games/SnakeGame.swift b/ios/GamerEmulator/GamerEmulator/Games/SnakeGame.swift new file mode 100644 index 0000000..852af13 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Games/SnakeGame.swift @@ -0,0 +1,110 @@ +// SnakeGame.swift — Faithful port of src/games/snake.h +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation + +@MainActor +final class SnakeGame: Game { + + let name = "SNAKE" + let gameIndex = 0 + let animFrames = GameAssets.snake + + // ── State ────────────────────────────────────────────────────────────────── + private var dir: Int = 1 // 1=up 2=right 3=down 4=left + private var goalX: Int = 0 + private var goalY: Int = 0 + private var snakeMap: [[Int]] = Array(repeating: Array(repeating: 0, count: 8), count: 8) + private var snakeLength: Int = 2 + private var score: Int = 0 + private var currentX: Int = 0 + private var currentY: Int = 0 + + // ── Game protocol ────────────────────────────────────────────────────────── + + func reset(fromHighScore: Bool, startingScore: UInt8) { + if fromHighScore && startingScore > 0 { + score = Int(startingScore) / 4 + snakeLength = min(6, 2 + score) + } else { + snakeLength = 2 + score = 0 + } + dir = 1 + currentX = 0 + currentY = 0 + for x in 0..<8 { for y in 0..<8 { snakeMap[x][y] = 0 } } + repeat { + goalX = Int.random(in: 0..<8) + goalY = Int.random(in: 0..<8) + } while goalX == 0 && goalY == 0 + } + + func loop(gamer: GamerHardware) async { + // Common preamble + gamer.updateLEDFlash() + if gamer.soundEnabled { gamer.stopTone() } + + // Clear display + for x in 0..<8 { for y in 0..<8 { gamer.display[x][y] = 0 } } + + // Input + var moved = false + if gamer.isPressed(.up) && dir != 3 { dir = 1; moved = true } + if gamer.isPressed(.right) && dir != 4 { dir = 2; moved = true } + if gamer.isPressed(.down) && dir != 1 { dir = 3; moved = true } + if gamer.isPressed(.left) && dir != 2 { dir = 4; moved = true } + if gamer.soundEnabled && moved { gamer.playTone(NOTE_E8) } + + // Move head + switch dir { + case 1: currentY = (currentY - 1 + 8) & 7 + case 2: currentX = (currentX + 1) & 7 + case 3: currentY = (currentY + 1) & 7 + case 4: currentX = (currentX - 1 + 8) & 7 + default: break + } + + gamer.display[currentX][currentY] = 1 + + // Age the snake map, check collision, update head + for x in 0..<8 { for y in 0..<8 { if snakeMap[x][y] > 0 { snakeMap[x][y] -= 1 } } } + + // Self-collision + if snakeMap[currentX][currentY] > 0 { + gamer.clear() + await gamer.delay(20) + await gamer.playLossTune() + HighScoreStore.saveHighScore(score, gameIndex) + gamer.showScore(score / 10, score % 10) + await gamer.delay(800) + reset(fromHighScore: false, startingScore: 0) + return + } + + snakeMap[currentX][currentY] = snakeLength + + for x in 0..<8 { for y in 0..<8 { if snakeMap[x][y] > 0 { gamer.display[x][y] = 1 } } } + + // Food collection + if currentX == goalX && currentY == goalY { + repeat { + goalX = Int.random(in: 0..<8) + goalY = Int.random(in: 0..<8) + } while snakeMap[goalX][goalY] > 0 + + snakeLength += 1 + score = snakeLength - 2 + if gamer.soundEnabled { gamer.playTone(NOTE_A8) } + gamer.startLEDFlash() + + for x in 0..<8 { for y in 0..<8 { snakeMap[x][y] += 1 } } + } else { + gamer.display[goalX][goalY] = 1 + } + + await gamer.delay(80) + gamer.updateDisplay() + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Games/TetrisGame.swift b/ios/GamerEmulator/GamerEmulator/Games/TetrisGame.swift new file mode 100644 index 0000000..5f06bac --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Games/TetrisGame.swift @@ -0,0 +1,238 @@ +// TetrisGame.swift — Faithful port of src/games/tetris.h +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation + +@MainActor +final class TetrisGame: Game { + + let name = "TETRIS" + let gameIndex = 4 + let animFrames = GameAssets.tetris + + // ── State ────────────────────────────────────────────────────────────────── + private var moveInterval: Double = 1200 // ms + private var level: Int = 1 + private var linesCleared: Int = 0 + private var gameOver: Bool = false + private var lastDownPressTime: Double = 0 + private var isInDownPress: Bool = false + private var score: Int = 0 + private var currentX: Int = 3 + private var currentY: Int = -1 + + private var grid: [[Int]] = Array(repeating: Array(repeating: 0, count: 8), count: 8) + private var piece: [[Int]] = Array(repeating: Array(repeating: 0, count: 3), count: 3) + + private enum PieceType: Int, CaseIterable { case I, O, T, S, Z, J, L } + private var pieceType: PieceType = .O + + private var lastMoveTime: Double = 0 + + // Tetris melody playback + private var lastNoteTime: Double = 0 + private var noteIdx: Int = 0 + private var chirpPending: Bool = false + + // ── Game protocol ────────────────────────────────────────────────────────── + + func reset(fromHighScore: Bool, startingScore: UInt8) { + if fromHighScore && startingScore > 0 { + score = Int(startingScore) / 2 + level = 1 + (score / 7) + linesCleared = (level - 1) * 3 + moveInterval = Double(max(300, 1200 - (level - 1) * 200)) + } else { + score = 0 + level = 1 + linesCleared = 0 + moveInterval = 1200 + } + gameOver = false + isInDownPress = false + currentX = 3 + currentY = -1 + for i in 0..<8 { for j in 0..<8 { grid[i][j] = 0 } } + chirpPending = false + noteIdx = 0 + lastNoteTime = 0 + lastMoveTime = 0 + spawnPiece() + } + + func loop(gamer: GamerHardware) async { + // Melody management (updateGameInput with stopTone=false) + gamer.updateLEDFlash() + + if gamer.soundEnabled && chirpPending { + gamer.stopTone() + chirpPending = false + } + + if gamer.soundEnabled { + let now = gamer.millis() + if now - lastNoteTime >= 200 { + gamer.playTone(GameAssets.tetrisMelody[noteIdx]) + noteIdx = (noteIdx + 1) % GameAssets.tetrisMelody.count + lastNoteTime = now + } + } + + // Game-over sequence + if gameOver { + await gamer.playLossTune() + HighScoreStore.saveHighScore(min(score, 99), gameIndex) + gamer.showScore(min(score, 99) / 10, min(score, 99) % 10) + await gamer.delay(2000) + reset(fromHighScore: false, startingScore: 0) + return + } + + // Gravity + let now = gamer.millis() + if now - lastMoveTime > moveInterval { + lastMoveTime = now + if canMove(x: currentX, y: currentY + 1) { + currentY += 1 + gamer.startLEDFlash() + renderGridAndPiece(gamer: gamer) + } else { + // Lock piece + lockPiece(gamer: gamer) + if gamer.soundEnabled { gamer.playTone(NOTE_C8); chirpPending = true } + await checkLines(gamer: gamer) + spawnPiece(gamer: gamer) + currentX = 3 + currentY = -3 + if !canMove(x: currentX, y: currentY) || !canMove(x: currentX, y: currentY + 2) { + gameOver = true + } + await gamer.delay(80) + renderGridAndPiece(gamer: gamer) + currentY = -1 + } + } + + // Button input + var btnPressed = false + if gamer.isPressed(.left) && canMove(x: currentX - 1, y: currentY) { + currentX -= 1; isInDownPress = false; renderGridAndPiece(gamer: gamer) + } else if gamer.isPressed(.right) && canMove(x: currentX + 1, y: currentY) { + currentX += 1; isInDownPress = false; renderGridAndPiece(gamer: gamer) + } else if gamer.isPressed(.down) && canMove(x: currentX, y: currentY + 1) { + currentY += 1; btnPressed = true; renderGridAndPiece(gamer: gamer) + isInDownPress = true; lastDownPressTime = now + } else if gamer.isHeld(.down) && isInDownPress && now - lastDownPressTime >= 300 { + if canMove(x: currentX, y: currentY + 1) { + currentY += 1; btnPressed = true; renderGridAndPiece(gamer: gamer) + lastDownPressTime = now + } + } else if gamer.isPressed(.up) { + isInDownPress = false + rotatePiece() + renderGridAndPiece(gamer: gamer) + } else { + isInDownPress = false + } + + if btnPressed { gamer.startLEDFlash() } + if gamer.soundEnabled && btnPressed { gamer.playTone(NOTE_D8); chirpPending = true } + } + + // MARK: - Helpers + + private func canMove(x: Int, y: Int, p: [[Int]]? = nil) -> Bool { + let testPiece = p ?? piece + for i in 0..<3 { + for j in 0..<3 { + guard testPiece[i][j] == 1 else { continue } + let nx = x + j, ny = y + i + if nx < 0 || nx >= 8 || ny < -3 || ny >= 8 { return false } + if ny >= 0 && grid[ny][nx] == 1 { return false } + } + } + return true + } + + private func renderGridAndPiece(gamer: GamerHardware) { + for i in 0..<8 { for j in 0..<8 { gamer.display[j][i] = UInt8(grid[i][j]) } } + for i in 0..<3 { + for j in 0..<3 { + guard piece[i][j] == 1 else { continue } + let x = currentX + j, y = currentY + i + if x >= 0 && x < 8 && y >= 0 && y < 8 { gamer.display[x][y] = 1 } + } + } + gamer.updateDisplay() + } + + private func lockPiece(gamer: GamerHardware) { + for i in 0..<3 { + for j in 0..<3 { + guard piece[i][j] == 1 else { continue } + let x = currentX + j, y = currentY + i + if x >= 0 && x < 8 && y >= 0 && y < 8 { grid[y][x] = 1 } + } + } + } + + private func checkLines(gamer: GamerHardware) async { + var cleared = 0 + var i = 0 + while i < 8 { + var full = true + for j in 0..<8 { if grid[i][j] == 0 { full = false; break } } + if full { + cleared += 1 + if gamer.soundEnabled { gamer.playTone(NOTE_A8); chirpPending = true } + gamer.startLEDFlash() + // Animate clear + for j in 0..<8 { + grid[i][j] = 0 + renderGridAndPiece(gamer: gamer) + await gamer.delay(25) + } + // Cascade rows down + for k in stride(from: i, through: 1, by: -1) { + for j in 0..<8 { grid[k][j] = grid[k-1][j] } + } + for j in 0..<8 { grid[0][j] = 0 } + linesCleared += 1 + if linesCleared % 7 == 0 { + level += 1 + moveInterval = max(300, moveInterval - 200) + } + // Don't increment i – recheck same row after cascade + } else { + i += 1 + } + } + if cleared > 0 { + let bonusMultipliers = [0, 1, 3, 5] + score += level * bonusMultipliers[min(cleared, 3)] + } + } + + private func spawnPiece(gamer: GamerHardware? = nil) { + for i in 0..<3 { for j in 0..<3 { piece[i][j] = 0 } } + pieceType = PieceType(rawValue: Int.random(in: 0..<7))! + switch pieceType { + case .I: piece[1][0]=1; piece[1][1]=1; piece[1][2]=1 + case .O: piece[0][0]=1; piece[0][1]=1; piece[1][0]=1; piece[1][1]=1 + case .T: piece[0][1]=1; piece[1][0]=1; piece[1][1]=1; piece[1][2]=1 + case .S: piece[0][1]=1; piece[0][2]=1; piece[1][0]=1; piece[1][1]=1 + case .Z: piece[0][0]=1; piece[0][1]=1; piece[1][1]=1; piece[1][2]=1 + case .J: piece[0][0]=1; piece[1][0]=1; piece[1][1]=1; piece[1][2]=1 + case .L: piece[0][2]=1; piece[1][0]=1; piece[1][1]=1; piece[1][2]=1 + } + if let g = gamer { if g.soundEnabled { g.playTone(NOTE_G8); chirpPending = true } } + } + + private func rotatePiece() { + guard pieceType != .O else { return } + var temp = Array(repeating: Array(repeating: 0, count: 3), count: 3) + for i in 0..<3 { for j in 0..<3 { temp[j][2-i] = piece[i][j] } } + if canMove(x: currentX, y: currentY, p: temp) { piece = temp } + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Hardware/GamerHardware.swift b/ios/GamerEmulator/GamerEmulator/Hardware/GamerHardware.swift new file mode 100644 index 0000000..dc65be1 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Hardware/GamerHardware.swift @@ -0,0 +1,211 @@ +// GamerHardware.swift — Observable emulation of the TWSU Gamer hardware. +// Mirrors the public interface of Gamer.h / Gamer.cpp. +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation +import Combine + +// Button indices – match #define constants in Gamer.h +enum GamerButton: Int, CaseIterable { + case up = 0 + case left = 1 + case right = 2 + case down = 3 + case start = 4 +} + +@MainActor +final class GamerHardware: ObservableObject { + + // MARK: - Published display state + + /// Column-major pixel buffer: display[x][y], x=col 0-7, y=row 0-7. + @Published var display: [[UInt8]] = Array( + repeating: Array(repeating: 0, count: 8), count: 8) + + /// Red indicator LED on/off. + @Published var ledOn: Bool = false + + /// User-set brightness 1-8. + @Published var brightness: Int = 8 + + /// LED compensation enabled flag (affects display only – not true hw boost in emulator). + @Published var ledCompensation: Bool = true + + /// Sound enabled (toggled via cap-touch pad). + @Published var soundEnabled: Bool = false + + // MARK: - Private state + + private var buttonHeld: [Bool] = Array(repeating: false, count: 5) + private var buttonQueue: [Bool] = Array(repeating: false, count: 5) + + private var ledFlashStart: Date? = nil + private var lastCapTouchState: Bool = false + + private let soundEngine = SoundEngine() + + // MARK: - Display + + /// Convert display[][] to row-image bitmap (same as updateDisplay() in Gamer.cpp). + /// In the emulator the display array IS the source of truth – call this after + /// every frame write so the SwiftUI view picks up the change. + func updateDisplay() { + objectWillChange.send() + } + + func clear() { + for x in 0..<8 { for y in 0..<8 { display[x][y] = 0 } } + objectWillChange.send() + } + + func allOn() { + for x in 0..<8 { for y in 0..<8 { display[x][y] = 1 } } + objectWillChange.send() + } + + /// Print an 8-byte image (one byte per row, bit 7 = column 0). + /// Matches Gamer::printImage() logic: + /// display[i][j] = (img[j] >> (7-i)) & 1 + func printImage(_ img: [UInt8]) { + for j in 0..<8 { + for i in 0..<8 { + display[i][j] = (img[j] >> (7 - i)) & 1 + } + } + objectWillChange.send() + } + + /// Show a two-digit score using the 3-pixel-wide number bitmaps. + /// Matches showScore() in TWSUGamerPlus.ino. + func showScore(_ dig1: Int, _ dig2: Int) { + var result = [UInt8](repeating: 0, count: 8) + let d1 = max(0, min(9, dig1)) + let d2 = max(0, min(9, dig2)) + for p in 0..<8 { + result[p] = (GameAssets.numbers[d1][p] << 5) | GameAssets.numbers[d2][p] + } + printImage(result) + } + + // MARK: - Input + + /// Returns true once per physical press (edge detection, clears flag). + func isPressed(_ button: Int) -> Bool { + guard button >= 0 && button < 5 else { return false } + if buttonQueue[button] { + buttonQueue[button] = false + return true + } + return false + } + + func isPressed(_ button: GamerButton) -> Bool { isPressed(button.rawValue) } + + /// Returns true while the button is physically held. + func isHeld(_ button: Int) -> Bool { + guard button >= 0 && button < 5 else { return false } + return buttonHeld[button] + } + + func isHeld(_ button: GamerButton) -> Bool { isHeld(button.rawValue) } + + // MARK: - UI → hardware bridge + + func buttonDown(_ button: GamerButton) { + buttonHeld[button.rawValue] = true + buttonQueue[button.rawValue] = true + } + + func buttonUp(_ button: GamerButton) { + buttonHeld[button.rawValue] = false + } + + /// Resets all button held and queued state (call when transitioning between screens). + func clearAllButtons() { + for i in 0..<5 { + buttonHeld[i] = false + buttonQueue[i] = false + } + } + + /// Called when the user taps the cap-touch pad — toggles sound. + func capTouchTap() { + soundEnabled.toggle() + if !soundEnabled { soundEngine.stopTone() } + } + + // MARK: - LED flash + + func startLEDFlash() { + ledOn = true + ledFlashStart = Date() + } + + /// Must be called each game loop frame to auto-extinguish the LED after 175 ms. + func updateLEDFlash() { + guard ledOn, let start = ledFlashStart, + Date().timeIntervalSince(start) >= 0.175 else { return } + ledOn = false + ledFlashStart = nil + } + + func setLED(_ on: Bool) { ledOn = on } + + // MARK: - Sound + + func playTone(_ note: Int) { + guard soundEnabled else { return } + soundEngine.playTone(note: note) + } + + func stopTone() { + soundEngine.stopTone() + } + + // MARK: - Brightness + + func setBrightness(_ level: Int) { + brightness = max(1, min(8, level)) + } + + func getBrightness() -> Int { brightness } + func getLEDCompensation() -> Bool { ledCompensation } + func setLEDCompensation(_ enabled: Bool) { ledCompensation = enabled } + + // MARK: - Timing helpers (replace Arduino millis() and delay()) + + /// Current time in milliseconds (wall-clock uptime). + func millis() -> Double { + ProcessInfo.processInfo.systemUptime * 1000 + } + + /// Async non-blocking delay — replaces Arduino delay(). + func delay(_ milliseconds: Int) async { + guard milliseconds > 0 else { return } + try? await Task.sleep(nanoseconds: UInt64(milliseconds) * 1_000_000) + } + + // MARK: - Shared game helpers (ported from TWSUGamerPlus.ino) + + func playWinTune() async { + guard soundEnabled else { return } + let notes = [NOTE_C8, NOTE_E8, NOTE_G8, NOTE_B8] + for note in notes { + playTone(note) + await delay(100) + } + stopTone() + } + + func playLossTune() async { + guard soundEnabled else { return } + let notes = [NOTE_B8, NOTE_G8, NOTE_E8, NOTE_B7] + for note in notes { + playTone(note) + await delay(180) + } + stopTone() + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Hardware/SoundEngine.swift b/ios/GamerEmulator/GamerEmulator/Hardware/SoundEngine.swift new file mode 100644 index 0000000..bb2f788 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Hardware/SoundEngine.swift @@ -0,0 +1,85 @@ +// SoundEngine.swift — Sine-wave tone generator using AVAudioEngine. +// Converts the OCR2A register-value notes used by the hardware into +// audio frequencies: f = 1,000,000 / (note + 1) Hz. +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import AVFoundation + +final class SoundEngine { + + private let engine = AVAudioEngine() + private let player = AVAudioPlayerNode() + private let mixer = AVAudioMixerNode() + + private let sampleRate: Double = 44100 + private let bufferSize: AVAudioFrameCount = 4096 + + private var isSetUp = false + private var currentNote: Int? = nil + + init() { + setup() + } + + // MARK: - Public API + + func playTone(note: Int) { + guard isSetUp, note != currentNote else { return } + currentNote = note + let frequency = 1_000_000.0 / Double(note + 1) + scheduleBuffer(frequency: frequency, looping: true) + if !player.isPlaying { player.play() } + } + + func stopTone() { + guard isSetUp else { return } + currentNote = nil + player.stop() + } + + // MARK: - Private + + private func setup() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, options: .mixWithOthers) + try session.setActive(true) + } catch { + // Silent failure – sound is optional in emulator + return + } + + engine.attach(player) + engine.attach(mixer) + + let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)! + engine.connect(player, to: mixer, format: format) + engine.connect(mixer, to: engine.mainMixerNode, format: format) + mixer.outputVolume = 0.25 + + do { + try engine.start() + isSetUp = true + } catch { + isSetUp = false + } + } + + private func scheduleBuffer(frequency: Double, looping: Bool) { + let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)! + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else { return } + buffer.frameLength = bufferSize + + let channelData = buffer.floatChannelData![0] + let angularFrequency = 2.0 * Double.pi * frequency / sampleRate + + for frame in 0.. + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + + diff --git a/ios/GamerEmulator/GamerEmulator/Launcher/Launcher.swift b/ios/GamerEmulator/GamerEmulator/Launcher/Launcher.swift new file mode 100644 index 0000000..22cadae --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Launcher/Launcher.swift @@ -0,0 +1,157 @@ +// Launcher.swift — Game selection menu ported from src/launcher/launcher.h +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation +import Combine + +/// Manages the game list, animation cycles, and launching/exiting games. +@MainActor +final class Launcher: ObservableObject { + + // MARK: - Published state + + @Published var currentGameIndex: Int = 0 + @Published var isInLauncher: Bool = true // false = showing high scores + @Published var isInGame: Bool = false + @Published var animFrame: Int = 0 + + // MARK: - Hardware + games + + let gamer = GamerHardware() + + private(set) var games: [any Game] + private let conwayGame: ConwayGame // kept for preview access + + private var gameTask: Task? = nil + private var launcherTask: Task? = nil + + // MARK: - Init + + init() { + let snake = SnakeGame() + let breakout = BreakoutGame() + let simon = SimonGame() + let flappy = FlappyGame() + let tetris = TetrisGame() + let alien = AlienGame() + let conway = ConwayGame() + let dino = DinoGame() + let bright = BrightnessGame() + + conwayGame = conway + games = [snake, breakout, simon, flappy, tetris, alien, conway, dino, bright] + + conwayGame.randomize() + // Show startup splash (all LEDs on briefly) + Task { @MainActor in + self.gamer.allOn() + await self.gamer.delay(100) + self.gamer.clear() + self.startLauncherLoop() + } + } + + // MARK: - Launcher loop + + private func startLauncherLoop() { + launcherTask?.cancel() + launcherTask = Task { @MainActor [weak self] in + guard let self else { return } + while !Task.isCancelled && !self.isInGame { + await self.launcherTick() + } + } + } + + private func launcherTick() async { + guard !isInGame else { return } + + gamer.updateLEDFlash() + + // Sound toggle (cap touch emulated via UI button) + // Navigation + if gamer.isPressed(.start) { + launchCurrentGame() + return + } else if gamer.isPressed(.left) { + gamer.clear() + currentGameIndex = (currentGameIndex == 0) ? games.count - 1 : currentGameIndex - 1 + animFrame = 0 + if currentGameIndex == 6 { conwayGame.randomize() } + } else if gamer.isPressed(.right) { + gamer.clear() + currentGameIndex = (currentGameIndex + 1) % games.count + animFrame = 0 + if currentGameIndex == 6 { conwayGame.randomize() } + } else if gamer.isPressed(.up) { + isInLauncher = false + } else if gamer.isPressed(.down) { + isInLauncher = true + } + + // Render + if isInLauncher { + if gamer.ledOn { + gamer.setLED(false) + } + if currentGameIndex == 6 { + // Live Conway preview in centre 4×4 + let changed = conwayGame.stepSmall() + if !changed { conwayGame.randomize() } + let curr = conwayGame.getCurr() + for x in 0..<8 { for y in 0..<8 { gamer.display[x][y] = 0 } } + for x in 2..<6 { for y in 2..<6 { gamer.display[x][y] = (curr[y] >> x) & 1 } } + gamer.updateDisplay() + } else { + let frames = games[currentGameIndex].animFrames + let frame = frames[animFrame % frames.count] + gamer.printImage(frame) + animFrame = (animFrame + 1) % frames.count + } + } else { + // High score view + let hs = HighScoreStore.getHighScore(currentGameIndex) + gamer.showScore(hs / 10, hs % 10) + if !gamer.ledOn { gamer.startLEDFlash() } + } + + await gamer.delay(300) + } + + // MARK: - Game launch / exit + + func launchCurrentGame() { + launcherTask?.cancel() + launcherTask = nil + + let game = games[currentGameIndex] + let fromHS = !isInLauncher + let startingScore = UInt8(min(255, HighScoreStore.getHighScore(currentGameIndex))) + + game.reset(fromHighScore: fromHS, startingScore: startingScore) + + // Clear any lingering button state so it doesn't bleed into the game + gamer.clearAllButtons() + + isInGame = true + + gameTask = Task { @MainActor [weak self] in + guard let self else { return } + while !Task.isCancelled { + await game.loop(gamer: self.gamer) + } + } + } + + func exitCurrentGame() { + gameTask?.cancel() + gameTask = nil + gamer.stopTone() + gamer.clear() + gamer.clearAllButtons() + isInGame = false + // Return to launcher loop + startLauncherLoop() + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Persistence/HighScoreStore.swift b/ios/GamerEmulator/GamerEmulator/Persistence/HighScoreStore.swift new file mode 100644 index 0000000..a4895bc --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Persistence/HighScoreStore.swift @@ -0,0 +1,33 @@ +// HighScoreStore.swift — UserDefaults-backed high-score persistence. +// Mirrors the EEPROM layout from src/persistence/highscore.h. +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import Foundation + +/// One high-score slot per game index (0-8 matching launcher order). +struct HighScoreStore { + + private static let keyPrefix = "highscore_" + + static func getHighScore(_ gameIndex: Int) -> Int { + let key = keyPrefix + "\(gameIndex)" + return UserDefaults.standard.integer(forKey: key) + } + + /// Saves `score` only when it exceeds the stored high score. + static func saveHighScore(_ score: Int, _ gameIndex: Int) { + let clamped = min(score, 255) + let current = getHighScore(gameIndex) + if clamped > current { + UserDefaults.standard.set(clamped, forKey: keyPrefix + "\(gameIndex)") + } + } + + /// Wipes all high scores (called on first launch, mirroring EEPROM init). + static func resetAll() { + for i in 0..<9 { + UserDefaults.standard.removeObject(forKey: keyPrefix + "\(i)") + } + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Views/ButtonsView.swift b/ios/GamerEmulator/GamerEmulator/Views/ButtonsView.swift new file mode 100644 index 0000000..5caf10e --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Views/ButtonsView.swift @@ -0,0 +1,100 @@ +// ButtonsView.swift — Physical button layout for the Gamer emulator. +// Replicates the TWSU Gamer Kit's five-button layout plus a cap-touch pad. +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import SwiftUI + +// MARK: - Single hardware button + +struct HardwareButton: View { + + let label: String + let button: GamerButton + let gamer: GamerHardware + var size: CGFloat = 52 + var color: Color = Color(red: 0.2, green: 0.2, blue: 0.25) + var fgColor: Color = .white + + @State private var isDown = false + + var body: some View { + ZStack { + Circle() + .fill(isDown ? Color.orange.opacity(0.7) : color) + .frame(width: size, height: size) + .shadow(color: .black.opacity(0.5), radius: 4, x: 0, y: 3) + Text(label) + .font(.system(size: size * 0.32, weight: .bold, design: .rounded)) + .foregroundColor(fgColor) + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !isDown { + isDown = true + gamer.buttonDown(button) + } + } + .onEnded { _ in + isDown = false + gamer.buttonUp(button) + } + ) + } +} + +// MARK: - D-pad cluster (UP / DOWN / LEFT / RIGHT) + +struct DPadView: View { + + let gamer: GamerHardware + var btnSize: CGFloat = 52 + + var body: some View { + VStack(spacing: 4) { + HardwareButton(label: "▲", button: .up, gamer: gamer, size: btnSize) + HStack(spacing: 4) { + HardwareButton(label: "◀", button: .left, gamer: gamer, size: btnSize) + // Centre dead-zone + Circle() + .fill(Color(red: 0.15, green: 0.15, blue: 0.2)) + .frame(width: btnSize, height: btnSize) + HardwareButton(label: "▶", button: .right, gamer: gamer, size: btnSize) + } + HardwareButton(label: "▼", button: .down, gamer: gamer, size: btnSize) + } + } +} + +// MARK: - Capacitive-touch / sound toggle pad + +struct CapTouchButton: View { + + @ObservedObject var gamer: GamerHardware + @State private var tapped = false + + var body: some View { + Button { + tapped = true + gamer.capTouchTap() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { tapped = false } + } label: { + ZStack { + Capsule() + .fill(tapped ? Color.orange.opacity(0.6) : Color(red: 0.2, green: 0.2, blue: 0.25)) + .frame(width: 90, height: 34) + .shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 2) + HStack(spacing: 4) { + Image(systemName: gamer.soundEnabled ? "speaker.wave.2.fill" : "speaker.slash.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(gamer.soundEnabled ? .orange : .gray) + Text("SOUND") + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundColor(.white.opacity(0.7)) + } + } + } + .buttonStyle(.plain) + } +} diff --git a/ios/GamerEmulator/GamerEmulator/Views/GamerView.swift b/ios/GamerEmulator/GamerEmulator/Views/GamerView.swift new file mode 100644 index 0000000..e431a75 --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Views/GamerView.swift @@ -0,0 +1,131 @@ +// GamerView.swift — Root emulator view: display + controls + status bar. +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import SwiftUI + +// MARK: - Root shell (creates the shared objects) + +struct GamerView: View { + + @StateObject private var launcher = Launcher() + + var body: some View { + // Pass gamer as a separate @ObservedObject so hardware state changes + // (display, ledOn, soundEnabled, brightness) trigger redraws. + GamerBodyView(launcher: launcher, gamer: launcher.gamer) + } +} + +// MARK: - Body view (observes both launcher and gamer) + +private struct GamerBodyView: View { + + @ObservedObject var launcher: Launcher + @ObservedObject var gamer: GamerHardware + + var body: some View { + ZStack { + // Background gradient + LinearGradient( + colors: [Color(red: 0.08, green: 0.08, blue: 0.12), + Color(red: 0.03, green: 0.03, blue: 0.06)], + startPoint: .top, endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 16) { + // ── Top bar ──────────────────────────────────────────────── + HStack { + // Game name / mode label + VStack(alignment: .leading, spacing: 2) { + Text(launcher.games[launcher.currentGameIndex].name) + .font(.system(size: 18, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + Text(launcher.isInGame + ? "PLAYING" + : (launcher.isInLauncher ? "SELECT" : "HI SCORE")) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundColor(.orange.opacity(0.8)) + } + Spacer() + + // Red indicator LED + Circle() + .fill(gamer.ledOn ? Color.red : Color(red: 0.3, green: 0.05, blue: 0.05)) + .frame(width: 12, height: 12) + .shadow(color: gamer.ledOn ? .red.opacity(0.8) : .clear, radius: 6) + .animation(.easeInOut(duration: 0.1), value: gamer.ledOn) + + // Sound toggle + CapTouchButton(gamer: gamer) + } + .padding(.horizontal, 16) + + // ── 8×8 LED matrix ───────────────────────────────────────── + MatrixDisplayView(gamer: gamer, cellSize: 36) + .padding(.horizontal, 12) + + // ── Controls ─────────────────────────────────────────────── + HStack(alignment: .center, spacing: 32) { + + DPadView(gamer: gamer, btnSize: 50) + + Spacer() + + VStack(spacing: 12) { + // START / EXIT button — does NOT queue a button press; + // launch/exit is handled directly in the view model. + Button { + handleStartPress() + } label: { + ZStack { + Circle() + .fill(Color(red: 0.7, green: 0.1, blue: 0.1)) + .frame(width: 58, height: 58) + .shadow(color: .black.opacity(0.5), radius: 4, x: 0, y: 3) + Text(launcher.isInGame ? "EXIT" : "START") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundColor(.white) + } + } + .buttonStyle(.plain) + + // High-score indicator + if !launcher.isInGame { + let hs = HighScoreStore.getHighScore(launcher.currentGameIndex) + Text("BEST: \(hs)") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundColor(.orange.opacity(0.7)) + } + } + } + .padding(.horizontal, 24) + + Spacer(minLength: 8) + + // ── Footer ───────────────────────────────────────────────── + Text("TWSU GAMER EMULATOR") + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.2)) + .padding(.bottom, 4) + } + } + .statusBarHidden(true) + } + + // MARK: - Helpers + + private func handleStartPress() { + if launcher.isInGame { + launcher.exitCurrentGame() + } else { + launcher.launchCurrentGame() + } + } +} + +#Preview { + GamerView() +} + diff --git a/ios/GamerEmulator/GamerEmulator/Views/MatrixDisplayView.swift b/ios/GamerEmulator/GamerEmulator/Views/MatrixDisplayView.swift new file mode 100644 index 0000000..739108b --- /dev/null +++ b/ios/GamerEmulator/GamerEmulator/Views/MatrixDisplayView.swift @@ -0,0 +1,56 @@ +// MatrixDisplayView.swift — 8×8 LED matrix display emulation. +// © 2026 28pins — https://github.com/28pins/TWSUGamerPlus +// SPDX-License-Identifier: MIT + +import SwiftUI + +/// Renders the 8×8 LED matrix as a grid of rounded squares. +/// LED colour: amber (#FFA500) when on, dark charcoal when off. +struct MatrixDisplayView: View { + + @ObservedObject var gamer: GamerHardware + + /// Size of each LED cell in points (including gap). + var cellSize: CGFloat = 38 + + private let gap: CGFloat = 3 + private let ledOnColor = Color(red: 1.0, green: 0.65, blue: 0.0) // amber + private let ledOffColor = Color(red: 0.12, green: 0.12, blue: 0.12) // near-black + private let bgColor = Color(red: 0.05, green: 0.05, blue: 0.05) + + var body: some View { + let totalSize = cellSize * 8 + gap * 7 + + ZStack { + bgColor + .cornerRadius(8) + + VStack(spacing: gap) { + ForEach(0..<8, id: \.self) { row in + HStack(spacing: gap) { + ForEach(0..<8, id: \.self) { col in + let pixel = gamer.display[col][row] + let brightness = CGFloat(gamer.brightness) / 8.0 + RoundedRectangle(cornerRadius: 4) + .fill(pixel == 1 + ? ledOnColor.opacity(Double(brightness)) + : ledOffColor) + .frame(width: cellSize, height: cellSize) + } + } + } + } + .padding(6) + } + .frame(width: totalSize + 12, height: totalSize + 12) + } +} + +#Preview { + let g = GamerHardware() + // Draw a test pattern + g.display[0][0] = 1; g.display[2][2] = 1; g.display[4][4] = 1; g.display[6][6] = 1 + return MatrixDisplayView(gamer: g) + .padding() + .background(Color.black) +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..241d34b --- /dev/null +++ b/ios/README.md @@ -0,0 +1,114 @@ +# GamerEmulator — iOS SwiftUI Emulator + +A complete iOS/iPadOS SwiftUI emulator of the **TWSU DIY Gamer Kit** hardware and all nine built-in games from [TWSUGamerPlus](https://github.com/28pins/TWSUGamerPlus). + +--- + +## Features + +| Feature | Details | +|---------|---------| +| **Display** | 8 × 8 LED matrix rendered as amber rounded-rect pixels with 8-level brightness | +| **Input** | D-pad (▲ ▼ ◀ ▶) + START button; cap-touch pad emulated as SOUND toggle | +| **Sound** | Sine-wave tone generator via AVAudioEngine; frequency derived from hardware OCR2A values | +| **Persistence** | High scores stored in UserDefaults (one slot per game, 0–255) | +| **Games** | Snake · Breakout · Simon · Flappy Bird · Tetris · Alien · Conway · Dino · Brightness | +| **Launcher** | Left/Right to browse, UP for high-score mode, START to launch, EXIT button in-game | + +--- + +## Requirements + +| Requirement | Minimum | +|-------------|---------| +| iOS / iPadOS | 16.0 | +| Xcode | 15.0 | +| Swift | 5.9 | + +--- + +## Building + +1. Open **`GamerEmulator.xcodeproj`** in Xcode. +2. Select your device or a simulator. +3. Press **⌘R** to build and run. + +No third-party dependencies — the project uses only Apple's standard frameworks +(`SwiftUI`, `AVFoundation`, `Foundation`). + +--- + +## Project structure + +``` +GamerEmulator/ +├── GamerEmulatorApp.swift – @main entry point +├── ContentView.swift – Root SwiftUI view +├── Info.plist +├── Assets.xcassets/ +├── Hardware/ +│ ├── GamerHardware.swift – Emulated Gamer hardware (display, input, LED, sound) +│ └── SoundEngine.swift – AVAudioEngine sine-wave generator +├── GameAssets/ +│ └── GameAssets.swift – All bitmaps & melody data (ported from PROGMEM) +├── Persistence/ +│ └── HighScoreStore.swift – UserDefaults high-score storage +├── Games/ +│ ├── GameProtocol.swift – Game protocol +│ ├── SnakeGame.swift +│ ├── TetrisGame.swift +│ ├── BreakoutGame.swift +│ ├── SimonGame.swift +│ ├── FlappyGame.swift +│ ├── DinoGame.swift +│ ├── AlienGame.swift +│ ├── ConwayGame.swift +│ └── BrightnessGame.swift +├── Launcher/ +│ └── Launcher.swift – ObservableObject managing the game menu +└── Views/ + ├── MatrixDisplayView.swift – 8×8 LED grid + ├── ButtonsView.swift – D-pad, START, cap-touch buttons + └── GamerView.swift – Root emulator screen +``` + +--- + +## Architecture + +### Game loop (async/await) +Each game implements the `Game` protocol with two methods: +- `reset(fromHighScore:startingScore:)` – initialise state +- `loop(gamer:) async` – one game-tick; may `await gamer.delay(N)` for animations + +The `Launcher` runs the active game inside a `Task { @MainActor }`, which lets +`await Task.sleep()` suspend the main actor between frames — freeing SwiftUI to +re-render the LED matrix — while keeping all state on the main thread. + +### Display +`GamerHardware.display[col][row]` (UInt8, 0 or 1) is the exact analogue of +`gamer.display[x][y]` in the C++ firmware. `MatrixDisplayView` reads this array +directly via `@ObservedObject`. + +### Sound toggle +The physical capacitive-touch pad maps to the **SOUND** button in the UI. +Tapping it toggles `gamer.soundEnabled` and calls `SoundEngine.stopTone()` when +muting, mirroring `checkSoundToggle()` in `TWSUGamerPlus.ino`. + +--- + +## Game controls reference + +| Button | Snake | Tetris | Breakout | Simon | Flappy | Dino | Alien | Conway | +|--------|-------|--------|----------|-------|--------|------|-------|--------| +| ▲ UP | steer up | rotate | – | answer UP | flap | jump | shoot | – | +| ▼ DOWN | steer dn | soft-drop | – | answer DN | – | duck | – | – | +| ◀ LEFT | steer lt | move left | paddle lt | answer LT | – | – | move left | – | +| ▶ RIGHT | steer rt | move right | paddle rt | answer RT | – | – | move right | randomise | +| START | exit | exit | exit | exit | exit | exit | exit | exit | + +--- + +## License + +MIT — see [LICENSE](../../LICENSE) at the repository root.