commit b7b209b70c4f51b88c64d59a77cae7e8369c6e83 Author: Kyle Albanese Date: Wed Sep 16 21:52:32 2026 -0400 Initial commit: Arcade85 (5-in-1 TI-85 HD remakes) + AGENTS.md runbook diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d47ece7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Xcode / build +build/ +DerivedData/ +*.xcuserstate +xcuserdata/ +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# macOS +.DS_Store + +# Swift Package Manager +.build/ +.swiftpm/ + +# Secrets / signing — never commit +*.p8 +*.env +.env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a9b095d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,149 @@ +# Arcade85 + +A 5-in-1 iOS collection of faithful **HD remakes** of five classic TI-85 calculator +games. Native **SwiftUI + SpriteKit**, one shared shell, each game a +`Scene` (SpriteKit) + `Sim` (pure Swift engine) pair. + +This file is the operational runbook for an agent building/deploying Arcade85 on the +Mac Studio **without the human in the loop**. Read it top to bottom before touching +signing, git, or the build. It follows the same pattern as `~/dev/KyleCode/AGENTS.md` +(same Gitea, same `~/dev/.env` token, same signing identity). + +--- + +## 1. Repo layout + +``` +Arcade85/ + Arcade85.xcodeproj/ # hand-maintained (NOT xcodegen) — edit via Xcode/pbxproj + Arcade85/ + Arcade85App.swift # @main + Shell/ # app chrome: RootView, GameHostView, GameCatalog, + # Theme, Haptics, HighScoreStore + Games/ + SceneArt.swift # shared procedural texture/sprite helpers + DopeWars/ DopeWarsScene.swift + DopeWarsSim.swift + Lander/ LanderScene.swift + LanderSim.swift + Phoenix/ PhoenixScene.swift + PhoenixSim.swift + PhoenixData.swift + ZTetris/ TetrisScene.swift + TetrisSim.swift + ZMercury/ MercuryScene.swift + MercurySim.swift + MercuryData.swift + Galaxian/ (ported, dormant — see GameCatalog note) + ComingSoonScene.swift + README.md +``` + +**Convention:** every game is a `*Scene.swift` (SpriteKit, all UI/input) paired with a +`*Sim.swift` (pure Swift state machine — no UIKit/SpriteKit imports). The Sim is the +engine and is where mechanics live; the Scene only renders and routes touches. Keep +them separate. Dope Wars' `DopeWarsSim` is the reference for a well-factored Sim +(seeded RNG, a separate `extRng` stream for non-classic extensions, `#if DEBUG` debug +hooks, a `messages: [String]` queue the Scene drains one-per-tap). + +The original TI-85 sources this was remade from live in sibling folders under +`~/dev/TI/` (`lander/`, `zetris/`, `phx4.0ti85/`, `zmercury-ti85/`, `galaxian-ti85/`). +They are **not** part of this repo. + +## 2. Signing + +- **Team:** `TG8X9PP8VT` (Kyle Albanese). **Identity:** "Apple Development: Kyle + Albanese (5SRT78F8BG)". **Bundle ID:** `com.albanese.arcade85`. +- **Automatic signing** (`CODE_SIGN_STYLE = Automatic`), `GENERATE_INFOPLIST_FILE = YES` + (no Info.plist file exists — don't add one). +- The current pbxproj does **not** pin `DEVELOPMENT_TEAM`; the identity in the login + keychain is used. If a device build ever fails to sign, set + `DEVELOPMENT_TEAM = TG8X9PP8VT` on the target (don't switch to manual profiles). + +### Environment prerequisites (why this works) + +Commands run as the user's **own launchd job in `gui/501` (Aqua session)**, so the +login keychain is readable by `xcodebuild`/`codesign`. Verify before a device build +(do NOT change anything if it fails — stop and tell the user): + +```bash +launchctl managername # must print: Aqua +security find-identity -v -p codesigning /Users/fung/Library/Keychains/login.keychain-db +# must list: "Apple Development: Kyle Albanese (5SRT78F8BG)" +``` + +**Hard rules:** never revoke/recreate the Apple Development cert, don't touch keychain +settings, keep automatic signing, never commit `*.p8` or `*.env` secrets. + +## 3. Build + +Run from `/Users/fung/dev/TI/Arcade85`. + +### Simulator (no signing — default for quick checks) +```bash +xcodebuild -project Arcade85.xcodeproj -scheme Arcade85 \ + -destination 'generic/platform=iOS Simulator' -configuration Debug \ + -derivedDataPath build/dd build CODE_SIGNING_ALLOWED=NO +``` +Success: `** BUILD SUCCEEDED **`. (Verified 2026-09-16.) + +### Device (real iPhone, auto-signed) +```bash +xcodebuild -project Arcade85.xcodeproj -scheme Arcade85 \ + -destination 'generic/platform=iOS' -derivedDataPath build/dd \ + -allowProvisioningUpdates build +``` +Install/launch a paired device (list with `xcrun devicectl list devices`): +```bash +APP=build/dd/Build/Products/Debug-iphoneos/Arcade85.app +xcrun devicectl device install app --device "$APP" +xcrun devicectl device process launch --device com.albanese.arcade85 +``` + +**There are no automated tests in this project.** A passing build is the check. For +gameplay, run in the simulator and play, or use the Dope Wars debug env vars below. + +### Dope Wars debug env vars (DEBUG builds only) +`DopeWarsScene.didMove(to:)` reads `ProcessInfo` env vars for screenshot/demo poses: +`ARCADE_AUTOPLAY=1` (auto-start a run), `ARCADE_DEMO=1` (deterministic day-12 demo), +`ARCADE_DEMO_VIEW=buy|chase|tier3|over|ride|keypad|sharkchase|endless`. The +`DopeWarsSim` also exposes `debug*` mutators (wallet, debt, price, tier, held, forced +RNG rolls) gated behind `#if DEBUG`. + +## 4. Git & Gitea + +Remote: Gitea `https://git.kwalb.com/kyle/Arcade85` (branch `main`). No git +credentials in the keychain — use the API token from `~/dev/.env` inline, and let the +credential prompt fail to store (harmless). **The repo is created on first push:** + +```bash +TOKEN=$(sed -n 's/^GITEA_API=//p' ~/dev/.env) +curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"Arcade85","private":false,"default_branch":"main"}' \ + https://git.kwalb.com/api/v1/user/repos +# then: +git push "https://kyle:$TOKEN@git.kwalb.com/kyle/Arcade85.git" main +``` + +API: `curl -H "Authorization: token $TOKEN" https://git.kwalb.com/api/v1/repos/kyle/Arcade85/…` + +**Only commit/push when asked.** Stage only intended files. Never stage `*.p8` or +`*.env`. + +### `.env` — do not `source` it +`~/dev/.env` holds `GITEA_URL`, `GITEA_API` (token), `GITEA_USERNAME`, `GITEA_EMAIL` +plus other service keys. **Do not `source` it** — a later line is a bare +`https://claude.ai/magic-link…` URL (not `key=value`) which breaks `source` in zsh. +Read values with sed: +```bash +TOKEN=$(sed -n 's/^GITEA_API=//p' ~/dev/.env) +``` + +## 5. `.gitignore` (already in place — don't remove) +`build/`, `DerivedData/`, `*.xcuserstate`, `xcuserdata/`, `*.moved-aside`, `*.hmap`, +`*.ipa`, `*.dSYM.zip`, `*.dSYM`, `.DS_Store`, `.build/`, `.swiftpm/`. + +Add `*.p8` and `*.env` to this ignore list if/when they could appear here (none do +today, but the rule is: never commit signing keys or secret files). + +## 6. Adding a game + +1. Create `Games//Scene.swift` + `Sim.swift` (mirror Dope Wars). +2. Add both files to the Xcode target in `Arcade85.xcodeproj/project.pbxproj` + (hand-maintained — there is **no** `project.yml`/xcodegen step; edit the pbxproj or + use Xcode's "Add Files to Arcade85"). +3. Register the slot in `Shell/GameCatalog.swift`. +4. Build (simulator) to verify. diff --git a/Arcade85.xcodeproj/project.pbxproj b/Arcade85.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c825734 --- /dev/null +++ b/Arcade85.xcodeproj/project.pbxproj @@ -0,0 +1,321 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + A00000000000000000000005 /* Arcade85.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Arcade85.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A0000000000000000000000F /* Arcade85 */ = {isa = PBXFileSystemSynchronizedRootGroup; path = Arcade85; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A00000000000000000000007 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A00000000000000000000002 = { + isa = PBXGroup; + children = ( + A0000000000000000000000F /* Arcade85 */, + A00000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + A00000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + A00000000000000000000005 /* Arcade85.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A00000000000000000000004 /* Arcade85 */ = { + isa = PBXNativeTarget; + buildConfigurationList = A0000000000000000000000A /* Build configuration list for PBXNativeTarget "Arcade85" */; + buildPhases = ( + A00000000000000000000006 /* Sources */, + A00000000000000000000007 /* Frameworks */, + A00000000000000000000008 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A0000000000000000000000F /* Arcade85 */, + ); + name = Arcade85; + productName = Arcade85; + productReference = A00000000000000000000005 /* Arcade85.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A00000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2660; + TargetAttributes = { + A00000000000000000000004 = { + CreatedOnToolsVersion = 26.6; + }; + }; + }; + buildConfigurationList = A00000000000000000000009 /* Build configuration list for PBXProject "Arcade85" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A00000000000000000000002; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = A00000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A00000000000000000000004 /* Arcade85 */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A00000000000000000000008 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A00000000000000000000006 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A0000000000000000000000B /* 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_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_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = 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_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 = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + 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; + }; + A0000000000000000000000C /* 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_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_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = 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_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 = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A0000000000000000000000D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Arcade 85"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiresFullScreen = YES; + INFOPLIST_KEY_UIStatusBarHidden = YES; + 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.albanese.arcade85; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A0000000000000000000000E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Arcade 85"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiresFullScreen = YES; + INFOPLIST_KEY_UIStatusBarHidden = YES; + 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.albanese.arcade85; + 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 */ + A00000000000000000000009 /* Build configuration list for PBXProject "Arcade85" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A0000000000000000000000B /* Debug */, + A0000000000000000000000C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A0000000000000000000000A /* Build configuration list for PBXNativeTarget "Arcade85" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A0000000000000000000000D /* Debug */, + A0000000000000000000000E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A00000000000000000000001 /* Project object */; +} diff --git a/Arcade85.xcodeproj/xcshareddata/xcschemes/Arcade85.xcscheme b/Arcade85.xcodeproj/xcshareddata/xcschemes/Arcade85.xcscheme new file mode 100644 index 0000000..03936d9 --- /dev/null +++ b/Arcade85.xcodeproj/xcshareddata/xcschemes/Arcade85.xcscheme @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Arcade85/Arcade85App.swift b/Arcade85/Arcade85App.swift new file mode 100644 index 0000000..85dd9e9 --- /dev/null +++ b/Arcade85/Arcade85App.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct Arcade85App: App { + var body: some Scene { + WindowGroup { + RootView() + .preferredColorScheme(.dark) + .statusBarHidden(true) + } + } +} diff --git a/Arcade85/Assets.xcassets/AccentColor.colorset/Contents.json b/Arcade85/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..1fb8f22 --- /dev/null +++ b/Arcade85/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.706", + "green" : "0.937", + "red" : "0.361" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Arcade85/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Arcade85/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..51fc702 Binary files /dev/null and b/Arcade85/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/Arcade85/Assets.xcassets/AppIcon.appiconset/Contents.json b/Arcade85/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f22e10c --- /dev/null +++ b/Arcade85/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Arcade85/Assets.xcassets/Contents.json b/Arcade85/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Arcade85/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Arcade85/Games/ComingSoonScene.swift b/Arcade85/Games/ComingSoonScene.swift new file mode 100644 index 0000000..6be0db9 --- /dev/null +++ b/Arcade85/Games/ComingSoonScene.swift @@ -0,0 +1,69 @@ +import SpriteKit +import SwiftUI + +/// Placeholder scene shown for the four titles that aren't built yet. Keeps the +/// shell fully navigable and previews each game's identity. +final class ComingSoonScene: SKScene { + private let info: GameInfo + + init(size: CGSize, info: GameInfo) { + self.info = info + super.init(size: size) + self.scaleMode = .resizeFill + self.anchorPoint = CGPoint(x: 0, y: 0) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been used") } + + override func didMove(to view: SKView) { + backgroundColor = Palette.space1 + removeAllChildren() + + let bg = SKSpriteNode(texture: SceneArt.verticalGradient(size: size, top: Palette.space0, bottom: Palette.space1)) + bg.anchorPoint = CGPoint(x: 0, y: 0) + bg.position = .zero + bg.zPosition = -10 + addChild(bg) + + addChild(SceneArt.starfield(size: size, count: 70, seed: UInt64(abs(info.id.hashValue) & 0xFFFFFF))) + + let tint = UIColor(info.tint) + let center = CGPoint(x: size.width / 2, y: size.height * 0.58) + + if let tex = SceneArt.symbolTexture(info.symbol, pointSize: 120, color: tint) { + let icon = SKSpriteNode(texture: tex) + icon.position = center + icon.setScale(0.6) + addChild(icon) + let pulse = SKAction.sequence([ + .group([.scale(to: 0.66, duration: 1.4), .fadeAlpha(to: 1.0, duration: 1.4)]), + .group([.scale(to: 0.60, duration: 1.4), .fadeAlpha(to: 0.7, duration: 1.4)]), + ]) + icon.run(.repeatForever(pulse)) + } + + addLabel(info.title, font: "AvenirNext-Heavy", size: 34, color: .white, y: center.y - 120) + addLabel("HD REMAKE · COMING SOON", font: "Menlo-Bold", size: 13, color: tint, y: center.y - 156) + addLabel(info.tagline, font: "AvenirNext-Medium", size: 15, color: Palette.white70, + y: center.y - 196, maxWidth: size.width - 80) + addLabel("Original: \(info.origin)", font: "Menlo", size: 12, color: Palette.white40, y: size.height * 0.16) + } + + @discardableResult + private func addLabel(_ text: String, font: String, size fontSize: CGFloat, color: UIColor, + y: CGFloat, maxWidth: CGFloat? = nil) -> SKLabelNode { + let label = SKLabelNode(fontNamed: font) + label.text = text + label.fontSize = fontSize + label.fontColor = color + label.horizontalAlignmentMode = .center + label.verticalAlignmentMode = .center + label.position = CGPoint(x: size.width / 2, y: y) + if let maxWidth { + label.preferredMaxLayoutWidth = maxWidth + label.numberOfLines = 0 + } + addChild(label) + return label + } +} diff --git a/Arcade85/Games/DopeWars/DopeWarsScene.swift b/Arcade85/Games/DopeWars/DopeWarsScene.swift new file mode 100644 index 0000000..1ae7723 --- /dev/null +++ b/Arcade85/Games/DopeWars/DopeWarsScene.swift @@ -0,0 +1,1340 @@ +import SpriteKit +import UIKit + +/// HD remake of the classic Drug Wars (John E. Dell, 1984). `DopeWarsSim` is +/// the faithful engine; this scene dresses its menu-driven flow in a modern +/// touch trading board: a night-city backdrop, live market card with price +/// movement arrows (tier-aware product names), quantity sheets, event +/// interstitials (the original's Pause screens), the Officer Hardass chase, +/// and a net-worth-in-dollars high score. +final class DopeWarsScene: SKScene { + + // MARK: Modes + + private enum AmountMode: Equatable { + case buy(DopeWarsSim.Item), sell(DopeWarsSim.Item), repay, borrow, deposit, withdraw + + var isItemTrade: Bool { + switch self { case .buy, .sell: return true; default: return false } + } + } + + private enum Mode: Equatable { + case ready, board, message(String), offer, chase + case pickTrade(DopeWarsSim.Item) // BUY or SELL this row? + case amount(AmountMode) + case keypad(AmountMode) // type the amount directly + case ride, shark, bank, confirmEnd, gameOver + } + + private var mode: Mode = .ready + private var sim = DopeWarsSim() + private var cardQueue: [String] = [] + private var scoreSubmitted = false + private var newRecord = false + + // Month length chosen on the ready screen (0 = endless, not scored). + private var chosenLen = UserDefaults.standard.object(forKey: "dopewars.monthLen") as? Int ?? 30 + + // Amount sheet state + private var qty = 0 + private var typedAmount = 0 // keypad entry buffer + + // Yesterday's prices, for the ▲▼ movement arrows. Empty until day 2. + private var prevPrices: [Int] = [] + private var lastTierShown = 0 + + // MARK: Layout + + private var topInset: CGFloat = 0 + private var bottomInset: CGFloat = 0 + private var builtSize: CGSize = .zero + private var boardRect: CGRect = .zero + + // MARK: Persistent nodes + + private let dayValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let locationValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let walletValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let debtValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let bankValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let coatValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let statusLine = SKLabelNode(fontNamed: "Menlo-Bold") + + private var rowNames: [SKLabelNode] = [] + private var rowPrices: [SKLabelNode] = [] + private var rowHeld: [SKLabelNode] = [] + private var rowArrows: [SKLabelNode] = [] + private var rowBacks: [SKSpriteNode] = [] + private var lastWalletShown = Int.min + + private var pillNodes: [String: SKShapeNode] = [:] + private let boardGroup = SKNode() + private let sheet = SKNode() // rebuilt per mode + private var hitRects: [(key: String, rect: CGRect)] = [] + + private static let moneyFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + return f + }() + private func money(_ n: Int) -> String { + let s = Self.moneyFormatter.string(from: NSNumber(value: abs(n))) ?? "\(abs(n))" + return (n < 0 ? "−$" : "$") + s + } + + // MARK: - Lifecycle + + override func didMove(to view: SKView) { + anchorPoint = .zero + scaleMode = .resizeFill + // One-time migration: the high score used to be the 0…100 √ rating; + // it is now final net worth in dollars, so the old best is meaningless. + if !UserDefaults.standard.bool(forKey: "dopewars.moneyScore") { + UserDefaults.standard.set(true, forKey: "dopewars.moneyScore") + HighScoreStore.shared.reset("dopewars") + } + layoutScene() + #if DEBUG + let env = ProcessInfo.processInfo.environment + if env["ARCADE_AUTOPLAY"] == "1" { startRun() } + if env["ARCADE_DEMO"] == "1" { + startRun() + cardQueue.removeAll() + sim.debugClearMessages() + if case .offer = sim.phase { sim.answerOffer(yes: false) } + sim.debugSetDay(12) + sim.debugSetWallet(8450) + sim.debugSetDebt(9205) + sim.debugSetBank(3120) + sim.debugSetLocation(.manhattan) + sim.debugSetGuns(1) + sim.debugSetWounds(9) + sim.debugSetHeld(.acid, 11) + sim.debugSetHeld(.weed, 24) + sim.debugSetHeld(.ludes, 8) + prevPrices = sim.price.map { max(2, $0 - 40) } // deterministic-ish arrows + switch env["ARCADE_DEMO_VIEW"] { // screenshot poses + case "buy": + qty = 5 + setMode(.amount(.buy(.acid))) + case "chase": + sim.debugSetHeld(.ludes, 50) + sim.debugApplyEvent(10) + sim.debugClearMessages() + setMode(.chase) + case "tier3": + sim.debugSetTier(3) + sim.debugSetDay(21) + sim.debugSetWallet(1_837_400) + sim.debugSetBank(920_000) + sim.debugSetDebt(0) + sim.debugSetHeld(.ludes, 0) + sim.debugSetHeld(.weed, 0) + sim.debugSetHeld(.speed, 34) + sim.debugSetPrice(.speed, 118_400) + sim.debugSetPrice(.ludes, 9_200) + prevPrices = [] + setMode(.board) + case "over": + sim.debugSetWallet(9_318_400) + sim.debugSetBank(4_206_300) + sim.debugSetDebt(0) + sim.quit() + syncFromSim() + case "ride": + setMode(.ride) + case "keypad": + typedAmount = 1250 + setMode(.keypad(.buy(.acid))) + case "sharkchase": + sim.debugSetDebt(24_200) + sim.debugTriggerShark() + sim.debugClearMessages() + setMode(.chase) + case "endless": + chosenLen = 0 + startRun() + sim.debugClearMessages() + if case .offer = sim.phase { sim.answerOffer(yes: false) } + sim.debugSetDay(47) + sim.debugSetWallet(214_600) + setMode(.board) + default: + setMode(.board) + } + } + #endif + } + + override func didChangeSize(_ oldSize: CGSize) { + guard view != nil, size != builtSize, size.width > 0 else { return } + layoutScene() + } + + private func layoutScene() { + builtSize = size + removeAllChildren() + pillNodes.removeAll() + backgroundColor = Palette.space1 + + let insets = view?.safeAreaInsets ?? .zero + topInset = insets.top + bottomInset = insets.bottom + + buildBackdrop() + buildHUD() + buildBoard() + buildActionBar() + + sheet.zPosition = 40 + addChild(sheet) + + refreshAll() + applyMode() + } + + // MARK: - Night-city backdrop + + private func buildBackdrop() { + let bg = SKSpriteNode(texture: SceneArt.verticalGradient(size: size, top: Palette.space0, bottom: Palette.space1)) + bg.anchorPoint = .zero + bg.zPosition = -10 + addChild(bg) + + let stars = SceneArt.starfield(size: size, count: 46, seed: 0xD09E) + stars.zPosition = -9.5 + addChild(stars) + + // Sodium-lamp glow rising from the streets. + let glow = SKSpriteNode(texture: SceneArt.verticalGradient( + size: CGSize(width: size.width, height: 190), + top: UIColor.clear, bottom: Palette.amber.withAlphaComponent(0.16))) + glow.anchorPoint = .zero + glow.position = .zero + glow.blendMode = .add + glow.zPosition = -9 + addChild(glow) + + // Two silhouette skyline layers, windows lit here and there. + let far = SKSpriteNode(texture: skylineTexture( + width: size.width, maxHeight: 150, seed: 0x8541, + body: UIColor(red: 0.045, green: 0.065, blue: 0.125, alpha: 1), litAlpha: 0.45)) + far.anchorPoint = .zero + far.position = .zero + far.zPosition = -8.5 + addChild(far) + + let near = SKSpriteNode(texture: skylineTexture( + width: size.width, maxHeight: 96, seed: 0x1984, + body: UIColor(red: 0.022, green: 0.032, blue: 0.070, alpha: 1), litAlpha: 0.7)) + near.anchorPoint = .zero + near.position = .zero + near.zPosition = -8 + addChild(near) + + // Calm the ground floor so the action pills stay legible. + let groundScrim = SKSpriteNode(texture: SceneArt.verticalGradient( + size: CGSize(width: size.width, height: 150), + top: UIColor.clear, bottom: Palette.space1.withAlphaComponent(0.85))) + groundScrim.anchorPoint = .zero + groundScrim.position = .zero + groundScrim.zPosition = -7.5 + addChild(groundScrim) + } + + /// A procedurally built row of tower silhouettes with a scattering of lit + /// windows — amber mostly, the odd cyan office floor. + private func skylineTexture(width: CGFloat, maxHeight: CGFloat, seed: UInt64, + body: UIColor, litAlpha: CGFloat) -> SKTexture { + var rng = SeededGenerator(seed: seed) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: maxHeight)) + let image = renderer.image { ctx in + let cg = ctx.cgContext + var x: CGFloat = -8 + while x < width { + let w = CGFloat.random(in: 22...52, using: &rng) + let h = CGFloat.random(in: maxHeight * 0.30...maxHeight * 0.96, using: &rng) + let top = maxHeight - h + body.setFill() + cg.fill(CGRect(x: x, y: top, width: w, height: h)) + // rooftop bulkhead or antenna + if Bool.random(using: &rng) { + cg.fill(CGRect(x: x + w * 0.3, y: top - 6, width: w * 0.28, height: 6)) + } else if Int.random(in: 0...3, using: &rng) == 0 { + cg.fill(CGRect(x: x + w / 2 - 1, y: top - 14, width: 2, height: 14)) + } + // windows: sparse grid of lit cells + let cols = max(2, Int(w / 9)) + let rows = max(3, Int(h / 13)) + for c in 0.. SKLabelNode { + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = text + label.fontSize = 10 + label.fontColor = Palette.white40 + label.horizontalAlignmentMode = align + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + label.zPosition = 20 + return label + } + + private func hudValue(_ node: SKLabelNode, size fontSize: CGFloat, color: UIColor, + x: CGFloat, y: CGFloat, align: SKLabelHorizontalAlignmentMode) { + node.removeFromParent() + node.fontSize = fontSize + node.fontColor = color + node.horizontalAlignmentMode = align + node.verticalAlignmentMode = .center + node.position = CGPoint(x: x, y: y) + node.zPosition = 20 + addChild(node) + } + + private func buildHUD() { + let hudY = size.height - topInset - 26 + + // A soft scrim so the HUD reads as its own layer over the sky. + let scrim = SKSpriteNode(texture: SceneArt.verticalGradient( + size: CGSize(width: size.width, height: topInset + 96), + top: Palette.space1.withAlphaComponent(0.9), bottom: UIColor.clear)) + scrim.anchorPoint = .zero + scrim.position = CGPoint(x: 0, y: size.height - topInset - 96) + scrim.zPosition = 15 + addChild(scrim) + + addChild(caption("DAY", x: 74, y: hudY + 12, align: .left)) + hudValue(dayValue, size: 18, color: .white, x: 74, y: hudY - 8, align: .left) + + addChild(caption("BOROUGH", x: size.width / 2, y: hudY + 12, align: .center)) + hudValue(locationValue, size: 18, color: Palette.cyan, x: size.width / 2, y: hudY - 8, align: .center) + + addChild(caption("CASH", x: size.width - 20, y: hudY + 12, align: .right)) + hudValue(walletValue, size: 18, color: .white, x: size.width - 20, y: hudY - 8, align: .right) + + let row2 = hudY - 40 + addChild(caption("DEBT", x: 20, y: row2 + 11, align: .left)) + hudValue(debtValue, size: 14, color: Palette.red, x: 20, y: row2 - 6, align: .left) + addChild(caption("BANK", x: size.width / 2, y: row2 + 11, align: .center)) + hudValue(bankValue, size: 14, color: Palette.mint, x: size.width / 2, y: row2 - 6, align: .center) + addChild(caption("COAT", x: size.width - 20, y: row2 + 11, align: .right)) + hudValue(coatValue, size: 14, color: Palette.amber, x: size.width - 20, y: row2 - 6, align: .right) + } + + // MARK: - Market board + + private func buildBoard() { + boardGroup.removeAllChildren() + boardGroup.removeFromParent() + boardGroup.zPosition = 5 + addChild(boardGroup) + rowNames.removeAll(); rowPrices.removeAll(); rowHeld.removeAll() + rowArrows.removeAll(); rowBacks.removeAll() + + let hudBottom = size.height - topInset - 82 + let barTop = bottomInset + 88 + boardRect = CGRect(x: 16, y: barTop + 34, width: size.width - 32, + height: hudBottom - (barTop + 34) - 8) + + // Explicit z layering inside the group: card < row backs < text. + // (Sibling order alone is not guaranteed when sprites and labels mix.) + let card = SKSpriteNode(texture: SceneArt.panelTexture( + size: boardRect.size, cornerRadius: 18, + top: UIColor(red: 0.075, green: 0.105, blue: 0.185, alpha: 0.94), + bottom: UIColor(red: 0.035, green: 0.050, blue: 0.100, alpha: 0.94), + stroke: Palette.mint.withAlphaComponent(0.28))) + card.anchorPoint = .zero + card.position = boardRect.origin + card.zPosition = 0 + boardGroup.addChild(card) + + let header = SKLabelNode(fontNamed: "Menlo-Bold") + header.text = "TAP A DRUG TO BUY / SELL" + header.fontSize = 10 + header.fontColor = Palette.white40 + header.horizontalAlignmentMode = .center + header.verticalAlignmentMode = .center + header.position = CGPoint(x: boardRect.midX, y: boardRect.maxY - 18) + header.zPosition = 2 + boardGroup.addChild(header) + + let rowsTop = boardRect.maxY - 32 + let rowsBottom = boardRect.minY + 30 + let rowH = (rowsTop - rowsBottom) / 6 + + for i in 0..<6 { + let item = DopeWarsSim.Item(rawValue: i)! + let rowY = rowsTop - rowH * CGFloat(i) - rowH / 2 + + let backSize = CGSize(width: boardRect.width - 16, height: rowH - 8) + let back = SKSpriteNode(texture: SceneArt.panelTexture( + size: backSize, cornerRadius: 12, + top: UIColor(white: 1, alpha: 0.10), bottom: UIColor(white: 1, alpha: 0.04), + stroke: UIColor(white: 1, alpha: 0.09))) + back.position = CGPoint(x: boardRect.midX, y: rowY) + back.zPosition = 1 + boardGroup.addChild(back) + rowBacks.append(back) + + let name = SKLabelNode(fontNamed: "AvenirNext-DemiBold") + name.text = sim.displayName(item) + name.fontSize = 19 + name.fontColor = .white + name.horizontalAlignmentMode = .left + name.verticalAlignmentMode = .center + name.position = CGPoint(x: boardRect.minX + 26, y: rowY + 11) + name.zPosition = 2 + boardGroup.addChild(name) + rowNames.append(name) + + let held = SKLabelNode(fontNamed: "Menlo-Bold") + held.fontSize = 12 + held.fontColor = Palette.amber + held.horizontalAlignmentMode = .left + held.verticalAlignmentMode = .center + held.position = CGPoint(x: boardRect.minX + 26, y: rowY - 13) + held.zPosition = 2 + boardGroup.addChild(held) + rowHeld.append(held) + + let price = SKLabelNode(fontNamed: "Menlo-Bold") + price.fontSize = 19 + price.fontColor = Palette.mint + price.horizontalAlignmentMode = .right + price.verticalAlignmentMode = .center + price.position = CGPoint(x: boardRect.maxX - 26, y: rowY + 10) + price.zPosition = 2 + boardGroup.addChild(price) + rowPrices.append(price) + + let arrow = SKLabelNode(fontNamed: "Menlo-Bold") + arrow.fontSize = 11 + arrow.horizontalAlignmentMode = .right + arrow.verticalAlignmentMode = .center + arrow.position = CGPoint(x: boardRect.maxX - 26, y: rowY - 13) + arrow.zPosition = 2 + boardGroup.addChild(arrow) + rowArrows.append(arrow) + } + + statusLine.removeFromParent() + statusLine.fontSize = 11 + statusLine.fontColor = Palette.white70 + statusLine.horizontalAlignmentMode = .center + statusLine.verticalAlignmentMode = .center + statusLine.position = CGPoint(x: boardRect.midX, y: boardRect.minY + 15) + statusLine.zPosition = 2 + boardGroup.addChild(statusLine) + } + + private func rowRect(_ i: Int) -> CGRect { + let rowsTop = boardRect.maxY - 32 + let rowsBottom = boardRect.minY + 30 + let rowH = (rowsTop - rowsBottom) / 6 + return CGRect(x: boardRect.minX + 8, y: rowsTop - rowH * CGFloat(i + 1) + 3, + width: boardRect.width - 16, height: rowH - 6) + } + + // MARK: - Action bar + + private func buildActionBar() { + let labels: [(String, String, UIColor)] = [ + ("act.subway", "SUBWAY", Palette.mint), + ("act.shark", "SHARK", Palette.amber), + ("act.bank", "BANK", Palette.cyan), + ("act.end", "END", Palette.white40), + ] + let gap: CGFloat = 10 + let sideMargin: CGFloat = 16 + let w = (size.width - sideMargin * 2 - gap * 3) / 4 + let h: CGFloat = 52 + let y = bottomInset + 24 + + for (i, (key, text, color)) in labels.enumerated() { + let x = sideMargin + (w + gap) * CGFloat(i) + let rect = CGRect(x: x, y: y, width: w, height: h) + let pill = SKShapeNode(rect: rect, cornerRadius: 14) + pill.fillColor = color.withAlphaComponent(0.13) + pill.strokeColor = color.withAlphaComponent(0.55) + pill.lineWidth = 1.5 + pill.glowWidth = 1.5 + pill.zPosition = 20 + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = text + label.fontSize = 13 + label.fontColor = color + label.horizontalAlignmentMode = .center + label.verticalAlignmentMode = .center + label.position = CGPoint(x: rect.midX, y: rect.midY) + pill.addChild(label) + addChild(pill) + pillNodes[key] = pill + } + } + + private func actionRect(_ index: Int) -> CGRect { + let gap: CGFloat = 10 + let sideMargin: CGFloat = 16 + let w = (size.width - sideMargin * 2 - gap * 3) / 4 + return CGRect(x: sideMargin + (w + gap) * CGFloat(index), y: bottomInset + 24, width: w, height: 52) + } + + // MARK: - Refresh + + private func refreshAll() { + dayValue.text = sim.monthLen == Int.max + ? "\(sim.day)" + : "\(min(sim.day, sim.monthLen))/\(sim.monthLen)" + locationValue.text = sim.location.name.uppercased() + locationValue.fontColor = sim.inBronx ? Palette.mint : Palette.cyan + walletValue.text = money(sim.wallet) + if lastWalletShown != Int.min, lastWalletShown != sim.wallet { + walletValue.removeAllActions() + walletValue.setScale(1) + walletValue.run(.sequence([.scale(to: 1.18, duration: 0.08), .scale(to: 1, duration: 0.14)])) + } + lastWalletShown = sim.wallet + debtValue.text = money(sim.debt) + bankValue.text = money(sim.bank) + coatValue.text = "\(sim.coatFree)/\(sim.coatTotal)" + + // A tier shift renames slots mid-run; yesterday's prices belong to the + // old products, so the ▲▼ arrows sit out the swap day. + if sim.tier != lastTierShown { + if lastTierShown != 0 { prevPrices = [] } + lastTierShown = sim.tier + } + + for i in 0..<6 { + let item = DopeWarsSim.Item(rawValue: i)! + rowNames[i].text = sim.displayName(item) + rowPrices[i].text = money(sim.price[i]) + let count = sim.held[i] + rowHeld[i].text = count == 0 ? "—" : "HOLDING \(count)" + rowHeld[i].fontColor = count == 0 ? Palette.white40 : Palette.amber + if prevPrices.count == 6 { + let delta = sim.price[i] - prevPrices[i] + let pct = min(abs(delta) * 100 / max(prevPrices[i], 1), 999) + if delta == 0 || pct == 0 { + rowArrows[i].text = "" + } else { + rowArrows[i].text = (delta > 0 ? "▲" : "▼") + "\(pct)%" + rowArrows[i].fontColor = delta > 0 ? Palette.mint : Palette.red + } + } else { + rowArrows[i].text = "" + } + } + + var status: [String] = [] + if sim.guns > 0 { status.append("GUNS \(sim.guns)") } + if sim.wounds > 0 { status.append("WOUNDS \(sim.wounds)/50") } + if status.isEmpty { // keep the line inside the card + status.append(sim.inBronx ? "LOAN SHARK & BANK AVAILABLE" : "SHARK & BANK ARE IN THE BRONX") + } + statusLine.text = status.joined(separator: " · ") + statusLine.fontColor = sim.wounds >= 30 ? Palette.red : Palette.white70 + + // Absolute alphas every refresh — the sheet dim in applyMode() must + // never stick to a pill after the board returns. + pillNodes["act.subway"]?.alpha = 1 + pillNodes["act.end"]?.alpha = 1 + pillNodes["act.shark"]?.alpha = sim.inBronx ? 1 : 0.35 + pillNodes["act.bank"]?.alpha = sim.inBronx ? 1 : 0.35 + } + + /// Called after a day roll: remember today's prices for tomorrow's arrows + /// and flash the rows whose prices moved. + private func notePriceMovement(from old: [Int]) { + prevPrices = old + guard rowBacks.count == 6 else { return } + for i in 0..<6 where old.count == 6 && old[i] != sim.price[i] { + let up = sim.price[i] > old[i] + let tint: UIColor = up ? Palette.mint : Palette.red + rowBacks[i].run(.sequence([ + .colorize(with: tint, colorBlendFactor: 0.30, duration: 0.10), + .wait(forDuration: 0.25), + .colorize(withColorBlendFactor: 0, duration: 0.5), + ])) + } + } + + // MARK: - Mode machine + + private func setMode(_ new: Mode) { + mode = new + applyMode() + } + + /// Central sync: drain sim messages into cards, then land on whatever view + /// the sim's phase demands. + private func syncFromSim() { + while let m = sim.popMessage() { cardQueue.append(m) } + refreshAll() + if !cardQueue.isEmpty { + setMode(.message(cardQueue.removeFirst())) + } else if sim.offerPrompt != nil { + setMode(.offer) + } else if sim.phase == .chase { + setMode(.chase) + } else if sim.phase == .gameOver { + if !scoreSubmitted { + scoreSubmitted = true + // Endless runs are for fun — they never post to the leaderboard. + newRecord = sim.monthLen == Int.max + ? false + : HighScoreStore.shared.submit(id: "dopewars", score: max(0, sim.finalNet)) + if sim.endCause == .monthOver || sim.endCause == .quit { Haptics.success() } else { Haptics.error() } + } + setMode(.gameOver) + } else { + setMode(.board) + } + } + + private func applyMode() { + sheet.removeAllChildren() + hitRects.removeAll() + let boardVisible: Bool + switch mode { + case .ready: boardVisible = false + case .gameOver: boardVisible = false + default: boardVisible = true + } + boardGroup.alpha = boardVisible ? 1 : 0.12 + if boardVisible { + refreshAll() // also restores every pill's alpha + } else { + for pill in pillNodes.values { pill.alpha = 0.2 } + } + + switch mode { + case .ready: buildReadySheet() + case .board: break + case .message(let text): buildMessageSheet(text) + case .offer: buildOfferSheet() + case .chase: buildChaseSheet() + case .pickTrade(let item): buildPickTradeSheet(item) + case .amount(let am): buildAmountSheet(am) + case .keypad(let am): buildKeypadSheet(am) + case .ride: buildRideSheet() + case .shark: buildSharkSheet() + case .bank: buildBankSheet() + case .confirmEnd: buildConfirmEndSheet() + case .gameOver: buildGameOverSheet() + } + } + + // MARK: - Sheet builders + + private func dimLayer(_ alpha: CGFloat = 0.6) { + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: alpha), size: size) + dim.anchorPoint = .zero + dim.zPosition = 0 + sheet.addChild(dim) + } + + private func sheetCard(height: CGFloat) -> CGRect { + let w = min(size.width - 44, 380) + let rect = CGRect(x: (size.width - w) / 2, y: (size.height - height) / 2 + 20, width: w, height: height) + let card = SKSpriteNode(texture: SceneArt.panelTexture( + size: rect.size, cornerRadius: 20, + top: UIColor(red: 0.085, green: 0.115, blue: 0.20, alpha: 0.98), + bottom: UIColor(red: 0.04, green: 0.055, blue: 0.105, alpha: 0.98), + stroke: Palette.mint.withAlphaComponent(0.4))) + card.anchorPoint = .zero + card.position = rect.origin + card.zPosition = 1 // dim < card < labels/buttons + sheet.addChild(card) + return rect + } + + private func sheetLabel(_ text: String, font: String = "AvenirNext-Medium", size fontSize: CGFloat, + color: UIColor, x: CGFloat, y: CGFloat, maxWidth: CGFloat? = nil, + align: SKLabelHorizontalAlignmentMode = .center) -> SKLabelNode { + let label = SKLabelNode(fontNamed: font) + label.text = text + label.fontSize = fontSize + label.fontColor = color + label.horizontalAlignmentMode = align + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + if let maxWidth { + label.preferredMaxLayoutWidth = maxWidth + label.numberOfLines = 0 + } + label.zPosition = 2 + sheet.addChild(label) + return label + } + + @discardableResult + private func sheetButton(_ key: String, _ text: String, color: UIColor, + rect: CGRect, fontSize: CGFloat = 15, + sub: String? = nil) -> SKShapeNode { + let node = SKShapeNode(rect: rect, cornerRadius: 12) + node.fillColor = color.withAlphaComponent(0.16) + node.strokeColor = color.withAlphaComponent(0.6) + node.lineWidth = 1.5 + if let sub { + let subLabel = SKLabelNode(fontNamed: "Menlo") + subLabel.text = sub + subLabel.fontSize = 9.5 + subLabel.fontColor = Palette.white40 + subLabel.horizontalAlignmentMode = .center + subLabel.verticalAlignmentMode = .center + subLabel.position = CGPoint(x: rect.midX, y: rect.midY - 10) + node.addChild(subLabel) + } + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = text + label.fontSize = fontSize + label.fontColor = color + label.horizontalAlignmentMode = .center + label.verticalAlignmentMode = .center + label.position = CGPoint(x: rect.midX, y: sub == nil ? rect.midY : rect.midY + 8) + node.addChild(label) + node.zPosition = 2 + sheet.addChild(node) + hitRects.append((key, rect)) + return node + } + + private func tapPrompt(_ text: String, x: CGFloat, y: CGFloat) { + let prompt = sheetLabel(text, font: "Menlo-Bold", size: 13, color: Palette.mint, x: x, y: y) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + } + + private func buildReadySheet() { + dimLayer(0.45) + let cx = size.width / 2 + let midY = size.height * 0.6 + + let glow = SceneArt.glowSprite(color: Palette.mint, radius: 150) + glow.position = CGPoint(x: cx, y: midY + 124) + glow.alpha = 0.5 + sheet.addChild(glow) + sheetLabel("DOPE WARS", font: "AvenirNext-Heavy", size: 36, color: .white, x: cx, y: midY + 128) + sheetLabel("Buy low. Sell high. Square the loan shark.", + size: 15, color: Palette.white70, x: cx, y: midY + 92, maxWidth: size.width - 70) + + let rules = [ + chosenLen == 0 ? "No clock — deal until they get you." + : "\(chosenLen) days, then the month ends and you're scored.", + "You owe the shark $5,500. Debt grows 10% a day.", + "A ride burns a day. Every borough prices its own way.", + "Your trench coat holds 100 units.", + "50+ units in the coat draws Officer Hardass.", + "The Bronx is home: loan shark + 6%-a-day bank.", + chosenLen == 0 ? "Endless runs are for fun — no score is posted." + : "Score = final net worth (cash + bank − debt).", + ] + let rulesLeft = cx - (size.width - 80) / 2 + for (i, rule) in rules.enumerated() { + sheetLabel("• " + rule, font: "AvenirNext-Medium", size: 12.5, color: Palette.white70, + x: rulesLeft, y: midY + 44 - CGFloat(i) * 31, align: .left) + } + + sheetLabel("Get rich enough and word spreads — bigger product finds you.", + font: "AvenirNext-DemiBold", size: 12, color: Palette.amber, + x: cx, y: midY - 186, maxWidth: size.width - 84) + + // Month length picker: 30 / 60 / 90 / endless. The first rules bullet + // restates the selection in words, so the chips stay bare numbers. + let options: [(len: Int, label: String)] = [(30, "30"), (60, "60"), (90, "90"), (0, "∞")] + let chipW: CGFloat = 62, chipH: CGFloat = 38, gap: CGFloat = 10 + let rowW = chipW * 4 + gap * 3 + for (i, opt) in options.enumerated() { + let x = cx - rowW / 2 + CGFloat(i) * (chipW + gap) + let chosen = opt.len == chosenLen + let chip = sheetButton("len.\(opt.len)", opt.label, + color: chosen ? Palette.mint : Palette.white40, + rect: CGRect(x: x, y: midY - 258, width: chipW, height: chipH), + fontSize: 15) + if chosen { chip.fillColor = Palette.mint.withAlphaComponent(0.30) } + } + + tapPrompt("TAP TO HIT THE STREETS", x: cx, y: midY - 296) + } + + private func buildMessageSheet(_ text: String) { + dimLayer(0.55) + let rect = sheetCard(height: 190) + sheetLabel(text, font: "AvenirNext-DemiBold", size: 17, color: .white, + x: rect.midX, y: rect.midY + 18, maxWidth: rect.width - 44) + tapPrompt("TAP", x: rect.midX, y: rect.minY + 30) + } + + private func buildOfferSheet() { + guard let prompt = sim.offerPrompt else { return } + dimLayer(0.55) + let rect = sheetCard(height: 230) + sheetLabel(prompt, font: "AvenirNext-DemiBold", size: 17, color: .white, + x: rect.midX, y: rect.midY + 34, maxWidth: rect.width - 44) + let bw = (rect.width - 60) / 2 + sheetButton("offer.yes", "YES", color: Palette.mint, + rect: CGRect(x: rect.minX + 22, y: rect.minY + 22, width: bw, height: 50)) + sheetButton("offer.no", "NO", color: Palette.red, + rect: CGRect(x: rect.maxX - 22 - bw, y: rect.minY + 22, width: bw, height: 50)) + } + + private func buildChaseSheet() { + dimLayer(0.68) + + // Cop-light strobe: red and blue washes sweeping opposite edges. + let redLight = SceneArt.glowSprite(color: Palette.red, radius: 260) + redLight.position = CGPoint(x: 40, y: size.height * 0.72) + redLight.alpha = 0 + redLight.run(.repeatForever(.sequence([ + .fadeAlpha(to: 0.55, duration: 0.22), .fadeAlpha(to: 0.05, duration: 0.22)]))) + sheet.addChild(redLight) + // Cops flash red/blue; the shark's goons come under sodium amber. + let blueLight = SceneArt.glowSprite(color: sim.sharkChase ? Palette.amber : Palette.cyan, radius: 260) + blueLight.position = CGPoint(x: size.width - 40, y: size.height * 0.72) + blueLight.alpha = 0.4 + blueLight.run(.repeatForever(.sequence([ + .fadeAlpha(to: 0.05, duration: 0.22), .fadeAlpha(to: 0.55, duration: 0.22)]))) + sheet.addChild(blueLight) + + let rect = sheetCard(height: 320) + let shark = sim.sharkChase + let title = sheetLabel(shark ? "LEG-BREAKERS!" : "COPS!", + font: "AvenirNext-Heavy", size: 28, color: Palette.red, + x: rect.midX, y: rect.maxY - 40) + title.run(.repeatForever(.sequence([.fadeAlpha(to: 0.55, duration: 0.35), .fadeAlpha(to: 1, duration: 0.35)]))) + sheetLabel(shark ? "The shark wants his money" : "Officer Hardass wants your stash", + size: 13, color: Palette.white70, x: rect.midX, y: rect.maxY - 68) + + let stats = [ + (shark ? "GOONS LEFT" : "COPS LEFT", "\(sim.chaseCops + 1)", Palette.red), + ("GUNS", "\(sim.guns)", Palette.cyan), + ("WOUNDS", "\(sim.wounds)/50", sim.wounds >= 30 ? Palette.red : Palette.amber), + ] + for (i, (cap, value, color)) in stats.enumerated() { + let y = rect.maxY - 110 - CGFloat(i) * 34 + sheetLabel(cap, font: "Menlo-Bold", size: 12, color: Palette.white40, + x: rect.minX + 30, y: y, align: .left) + sheetLabel(value, font: "Menlo-Bold", size: 16, color: color, + x: rect.maxX - 30, y: y, align: .right) + } + + let bw = (rect.width - 60) / 2 + sheetButton("chase.run", "RUN", color: Palette.mint, + rect: CGRect(x: rect.minX + 22, y: rect.minY + 22, width: bw, height: 56)) + sheetButton("chase.fight", "FIGHT", color: Palette.red, + rect: CGRect(x: rect.maxX - 22 - bw, y: rect.minY + 22, width: bw, height: 56)) + } + + private func buildPickTradeSheet(_ item: DopeWarsSim.Item) { + dimLayer(0.5) + let rect = sheetCard(height: 250) + sheetLabel(sim.displayName(item), font: "AvenirNext-Heavy", size: 22, color: .white, + x: rect.midX, y: rect.maxY - 36) + sheetLabel("Price \(money(sim.price[item.rawValue])) · Holding \(sim.held[item.rawValue])", + font: "Menlo-Bold", size: 13, color: Palette.mint, x: rect.midX, y: rect.maxY - 66) + let bw = rect.width - 44 + sheetButton("pick.buy", "BUY", color: Palette.mint, + rect: CGRect(x: rect.minX + 22, y: rect.midY - 12, width: bw, height: 48)) + sheetButton("pick.sell", "SELL", color: Palette.amber, + rect: CGRect(x: rect.minX + 22, y: rect.midY - 70, width: bw, height: 48)) + sheetButton("sheet.cancel", "CANCEL", color: Palette.white40, + rect: CGRect(x: rect.minX + 22, y: rect.minY + 14, width: bw, height: 40), fontSize: 13) + } + + private func amountLimit(_ am: AmountMode) -> Int { + switch am { + case .buy(let item): return min(sim.affordable(item), sim.coatFree) + case .sell(let item): return sim.sellable(item) + case .repay: return min(sim.wallet, sim.debt) + case .borrow: return 5000 + case .deposit: return sim.wallet + case .withdraw: return sim.bank + } + } + + private func amountTitle(_ am: AmountMode) -> String { + switch am { + case .buy(let item): return "BUY \(sim.displayName(item).uppercased())" + case .sell(let item): return "SELL \(sim.displayName(item).uppercased())" + case .repay: return "REPAY THE SHARK" + case .borrow: return "BORROW FROM THE SHARK" + case .deposit: return "DEPOSIT" + case .withdraw: return "WITHDRAW" + } + } + + private func amountContext(_ am: AmountMode) -> String { + switch am { + case .buy(let item): + return "Price \(money(sim.price[item.rawValue])) · Afford \(sim.affordable(item)) · Space \(sim.coatFree)" + case .sell(let item): + return "Price \(money(sim.price[item.rawValue])) · You have \(sim.sellable(item))" + case .repay: return "Debt \(money(sim.debt)) · Cash \(money(sim.wallet))" + case .borrow: return "He'll front up to $5,000 per visit" + case .deposit: return "Cash \(money(sim.wallet)) · Earns 6% a day" + case .withdraw: return "Balance \(money(sim.bank))" + } + } + + private func buildAmountSheet(_ am: AmountMode) { + dimLayer(0.5) + let rect = sheetCard(height: 330) + sheetLabel(amountTitle(am), font: "AvenirNext-Heavy", size: 19, color: .white, + x: rect.midX, y: rect.maxY - 32) + sheetLabel(amountContext(am), font: "Menlo", size: 11.5, color: Palette.white70, + x: rect.midX, y: rect.maxY - 58, maxWidth: rect.width - 36) + + let qtyLabel = sheetLabel(am.isItemTrade ? "\(qty)" : money(qty), + font: "Menlo-Bold", size: 34, color: Palette.mint, + x: rect.midX, y: rect.maxY - 102) + qtyLabel.name = "amount.qty" + // The number itself is a button: tap it to type the amount. + let typeRect = CGRect(x: rect.midX - 105, y: rect.maxY - 126, width: 210, height: 48) + let typeBox = SKShapeNode(rect: typeRect, cornerRadius: 12) + typeBox.fillColor = .clear + typeBox.strokeColor = Palette.mint.withAlphaComponent(0.30) + typeBox.lineWidth = 1 + typeBox.zPosition = 2 + sheet.addChild(typeBox) + hitRects.append(("amt.type", typeRect)) + sheetLabel("tap the number to type it", font: "Menlo", size: 9.5, + color: Palette.white40, x: rect.midX, y: rect.maxY - 150) + + let totalLabel = sheetLabel("", font: "Menlo-Bold", size: 13, color: Palette.amber, + x: rect.midX, y: rect.maxY - 134) + totalLabel.name = "amount.total" + updateAmountLabels(am) + + let steps = am.isItemTrade ? [1, 10, 100] : [10, 100, 1000] + let chipW = (rect.width - 44 - 5 * 8) / 6 + let chipY = rect.maxY - 186 + let chips: [(String, String)] = [ + ("amt.m\(steps[2])", "−\(steps[2])"), ("amt.m\(steps[1])", "−\(steps[1])"), ("amt.m\(steps[0])", "−\(steps[0])"), + ("amt.p\(steps[0])", "+\(steps[0])"), ("amt.p\(steps[1])", "+\(steps[1])"), ("amt.p\(steps[2])", "+\(steps[2])"), + ] + for (i, (key, text)) in chips.enumerated() { + let x = rect.minX + 22 + (chipW + 8) * CGFloat(i) + sheetButton(key, text, color: Palette.cyan, + rect: CGRect(x: x, y: chipY, width: chipW, height: 40), fontSize: 11) + } + sheetButton("amt.max", "MAX \(am.isItemTrade ? "\(amountLimit(am))" : money(amountLimit(am)))", + color: Palette.violet, + rect: CGRect(x: rect.minX + 22, y: chipY - 50, width: rect.width - 44, height: 40), fontSize: 12) + + let bw = (rect.width - 60) / 2 + sheetButton("amt.confirm", "CONFIRM", color: Palette.mint, + rect: CGRect(x: rect.minX + 22, y: rect.minY + 16, width: bw, height: 48)) + sheetButton("sheet.cancel", "CANCEL", color: Palette.white40, + rect: CGRect(x: rect.maxX - 22 - bw, y: rect.minY + 16, width: bw, height: 48), fontSize: 13) + } + + private func updateAmountLabels(_ am: AmountMode) { + guard let qtyLabel = sheet.childNode(withName: "//amount.qty") as? SKLabelNode, + let totalLabel = sheet.childNode(withName: "//amount.total") as? SKLabelNode else { return } + qtyLabel.text = am.isItemTrade ? "\(qty)" : money(qty) + switch am { + case .buy(let item): totalLabel.text = "COST \(money(qty * sim.price[item.rawValue]))" + case .sell(let item): totalLabel.text = "YOU GET \(money(qty * sim.price[item.rawValue]))" + case .repay: totalLabel.text = "DEBT AFTER \(money(sim.debt - qty))" + case .borrow: totalLabel.text = "DEBT AFTER \(money(sim.debt + qty))" + case .deposit, .withdraw: totalLabel.text = "" + } + } + + /// In-scene numeric keypad: type any amount, DONE clamps to the limit. + private func buildKeypadSheet(_ am: AmountMode) { + dimLayer(0.55) + let rect = sheetCard(height: 470) + sheetLabel(amountTitle(am), font: "AvenirNext-Heavy", size: 18, color: .white, + x: rect.midX, y: rect.maxY - 30) + + let value = sheetLabel(am.isItemTrade ? "\(typedAmount)" : money(typedAmount), + font: "Menlo-Bold", size: 30, color: Palette.mint, + x: rect.midX, y: rect.maxY - 68) + value.name = "pad.value" + let limit = amountLimit(am) + sheetLabel("LIMIT \(am.isItemTrade ? "\(limit)" : money(limit)) · DONE trims to it", + font: "Menlo", size: 10.5, color: Palette.white40, + x: rect.midX, y: rect.maxY - 98) + + let keys: [[(String, String, UIColor)]] = [ + [("pad.1", "1", Palette.cyan), ("pad.2", "2", Palette.cyan), ("pad.3", "3", Palette.cyan)], + [("pad.4", "4", Palette.cyan), ("pad.5", "5", Palette.cyan), ("pad.6", "6", Palette.cyan)], + [("pad.7", "7", Palette.cyan), ("pad.8", "8", Palette.cyan), ("pad.9", "9", Palette.cyan)], + [("pad.del", "⌫", Palette.amber), ("pad.0", "0", Palette.cyan), ("pad.done", "DONE", Palette.mint)], + ] + let kw = (rect.width - 44 - 16) / 3 + for (r, row) in keys.enumerated() { + let y = rect.maxY - 124 - CGFloat(r + 1) * 58 + for (c, (key, text, color)) in row.enumerated() { + sheetButton(key, text, color: color, + rect: CGRect(x: rect.minX + 22 + (kw + 8) * CGFloat(c), y: y, + width: kw, height: 50), + fontSize: key == "pad.done" ? 13 : 17) + } + } + sheetButton("sheet.cancel", "CANCEL", color: Palette.white40, + rect: CGRect(x: rect.minX + 22, y: rect.minY + 14, width: rect.width - 44, height: 40), + fontSize: 13) + } + + private func updateKeypadValue(_ am: AmountMode) { + guard let value = sheet.childNode(withName: "//pad.value") as? SKLabelNode else { return } + value.text = am.isItemTrade ? "\(typedAmount)" : money(typedAmount) + value.fontColor = typedAmount > amountLimit(am) ? Palette.amber : Palette.mint + } + + /// Each borough's economy hint, tier-aware for the swappable slots. + private func boroughCaption(_ loc: DopeWarsSim.Location) -> String { + switch loc { + case .bronx: return "home turf · shark & bank" + case .ghetto: return "heroin & \(sim.displayName(.speed).lowercased()) run cheap" + case .park: return "acid & weed run cheap" + case .manhattan: return "rich buyers pay top dollar" + case .coney: return "everything cheap & sleepy" + case .brooklyn: return "fences pay over market" + } + } + + private func buildRideSheet() { + dimLayer(0.5) + let rect = sheetCard(height: 478) + sheetLabel("TAKE THE SUBWAY", font: "AvenirNext-Heavy", size: 19, color: .white, + x: rect.midX, y: rect.maxY - 32) + sheetLabel("A ride burns a day: debt +10%, bank +6%", font: "Menlo", size: 11, + color: Palette.white70, x: rect.midX, y: rect.maxY - 58) + + for (i, loc) in DopeWarsSim.Location.allCases.enumerated() { + let here = sim.location == loc + let y = rect.maxY - 114 - CGFloat(i) * 52 + let title = here ? "\(loc.name.uppercased()) (HERE)" : loc.name.uppercased() + let button = sheetButton("dest.\(loc.rawValue)", title, color: Palette.cyan, + rect: CGRect(x: rect.minX + 22, y: y, width: rect.width - 44, height: 44), + fontSize: 13, sub: boroughCaption(loc)) + if here { button.alpha = 0.45 } + } + sheetButton("sheet.cancel", "CANCEL", color: Palette.white40, + rect: CGRect(x: rect.minX + 22, y: rect.minY + 14, width: rect.width - 44, height: 40), fontSize: 13) + } + + private func buildSharkSheet() { + dimLayer(0.5) + let rect = sheetCard(height: 268) + sheetLabel("LOAN SHARK", font: "AvenirNext-Heavy", size: 20, color: .white, + x: rect.midX, y: rect.maxY - 32) + sheetLabel("Debt \(money(sim.debt)) · Cash \(money(sim.wallet))", + font: "Menlo-Bold", size: 13, color: Palette.red, x: rect.midX, y: rect.maxY - 62) + let bw = rect.width - 44 + sheetButton("shark.repay", "REPAY DEBT", color: Palette.mint, + rect: CGRect(x: rect.minX + 22, y: rect.midY - 8, width: bw, height: 48)) + sheetButton("shark.borrow", "BORROW (UP TO $5,000)", color: Palette.amber, + rect: CGRect(x: rect.minX + 22, y: rect.midY - 66, width: bw, height: 48), fontSize: 13) + sheetButton("sheet.cancel", "CANCEL", color: Palette.white40, + rect: CGRect(x: rect.minX + 22, y: rect.minY + 14, width: bw, height: 40), fontSize: 13) + } + + private func buildBankSheet() { + dimLayer(0.5) + let rect = sheetCard(height: 268) + sheetLabel("BANK", font: "AvenirNext-Heavy", size: 20, color: .white, + x: rect.midX, y: rect.maxY - 32) + sheetLabel("Balance \(money(sim.bank)) · +6% every day", + font: "Menlo-Bold", size: 13, color: Palette.mint, x: rect.midX, y: rect.maxY - 62) + let bw = rect.width - 44 + sheetButton("bank.deposit", "DEPOSIT", color: Palette.mint, + rect: CGRect(x: rect.minX + 22, y: rect.midY - 8, width: bw, height: 48)) + sheetButton("bank.withdraw", "WITHDRAW", color: Palette.amber, + rect: CGRect(x: rect.minX + 22, y: rect.midY - 66, width: bw, height: 48)) + sheetButton("sheet.cancel", "CANCEL", color: Palette.white40, + rect: CGRect(x: rect.minX + 22, y: rect.minY + 14, width: bw, height: 40), fontSize: 13) + } + + private func buildConfirmEndSheet() { + dimLayer(0.55) + let rect = sheetCard(height: 210) + sheetLabel(sim.monthLen == Int.max ? "End the run and count your money?" + : "End the month early and get scored?", + font: "AvenirNext-DemiBold", size: 16, + color: .white, x: rect.midX, y: rect.midY + 34, maxWidth: rect.width - 44) + let bw = (rect.width - 60) / 2 + sheetButton("end.yes", "END GAME", color: Palette.red, + rect: CGRect(x: rect.minX + 22, y: rect.minY + 20, width: bw, height: 48), fontSize: 13) + sheetButton("sheet.cancel", "KEEP PLAYING", color: Palette.mint, + rect: CGRect(x: rect.maxX - 22 - bw, y: rect.minY + 20, width: bw, height: 48), fontSize: 12) + } + + private func buildGameOverSheet() { + dimLayer(0.66) + let cx = size.width / 2 + let midY = size.height * 0.58 + + let (title, color): (String, UIColor) + switch sim.endCause { + case .monthOver: (title, color) = ("MONTH OVER", Palette.mint) + case .quit: (title, color) = ("CASHED OUT", .white) + case .gunnedDown: (title, color) = ("GUNNED DOWN", Palette.red) + case .badTrip: (title, color) = ("BAD TRIP", Palette.violet) + case .sharkCollected: (title, color) = ("THE SHARK COLLECTED", Palette.red) + case nil: (title, color) = ("GAME OVER", .white) + } + let glow = SceneArt.glowSprite(color: color, radius: 130) + glow.position = CGPoint(x: cx, y: midY + 96) + glow.alpha = 0.4 + sheet.addChild(glow) + sheetLabel(title, font: "AvenirNext-Heavy", size: 28, color: color, x: cx, y: midY + 96, + maxWidth: size.width - 60) + + sheetLabel("NET WORTH", font: "Menlo-Bold", size: 13, color: Palette.white40, x: cx, y: midY + 52) + sheetLabel(money(sim.finalNet), font: "AvenirNext-Heavy", size: 40, + color: sim.finalNet < 0 ? Palette.red : Palette.mint, x: cx, y: midY + 12) + sheetLabel("CASH + BANK − DEBT", font: "Menlo", size: 11, color: Palette.white40, + x: cx, y: midY - 24) + if sim.monthLen == Int.max { + sheetLabel("ENDLESS RUN · NOT SCORED", font: "Menlo-Bold", size: 13, + color: Palette.white40, x: cx, y: midY - 56) + } else { + sheetLabel("BEST \(money(HighScoreStore.shared.best("dopewars")))", + font: "Menlo-Bold", size: 13, color: .white, x: cx, y: midY - 56) + if newRecord { + sheetLabel("★ NEW BEST ★", font: "Menlo-Bold", size: 14, color: Palette.amber, + x: cx, y: midY - 84) + } + } + tapPrompt("TAP TO PLAY AGAIN", x: cx, y: midY - 126) + } + + // MARK: - Run control + + private func startRun() { + sim = DopeWarsSim(monthLen: chosenLen) + cardQueue.removeAll() + scoreSubmitted = false + newRecord = false + qty = 0 + prevPrices = [] + lastTierShown = 0 + lastWalletShown = Int.min + syncFromSim() // day 1 already rolled — may open with an event + } + + // MARK: - Touch handling + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + let p = touch.location(in: self) + + switch mode { + case .ready: + if let key = hitKey(at: p), key.hasPrefix("len.") { + chosenLen = Int(key.dropFirst(4)) ?? 30 + UserDefaults.standard.set(chosenLen, forKey: "dopewars.monthLen") + Haptics.selection() + applyMode() // repaint the picker + bullets + return + } + Haptics.selection() + startRun() + + case .board: + handleBoardTap(p) + + case .message: + Haptics.selection() + advanceCards() + + case .offer: + if let key = hitKey(at: p) { + Haptics.selection() + sim.answerOffer(yes: key == "offer.yes") + syncFromSim() + } + + case .chase: + if let key = hitKey(at: p) { + Haptics.impact(.medium, intensity: 0.8) + let before = sim.price + if key == "chase.run" { sim.chaseRun() } else { sim.chaseFight() } + if sim.price != before { notePriceMovement(from: before) } // escape reroll + syncFromSim() + } + + case .pickTrade(let item): + switch hitKey(at: p) { + case "pick.buy": qty = 0; setMode(.amount(.buy(item))); Haptics.selection() + case "pick.sell": qty = 0; setMode(.amount(.sell(item))); Haptics.selection() + case "sheet.cancel": setMode(.board); Haptics.selection() + default: break + } + + case .amount(let am): + handleAmountTap(p, am) + + case .keypad(let am): + handleKeypadTap(p, am) + + case .ride: + if let key = hitKey(at: p) { + if key == "sheet.cancel" { setMode(.board); Haptics.selection(); return } + if key.hasPrefix("dest."), let raw = Int(key.dropFirst(5)), + let dest = DopeWarsSim.Location(rawValue: raw) { + Haptics.impact(.light, intensity: 0.6) + let before = sim.price + if sim.ride(to: dest) { + cardQueue.append("Riding the subway…") + notePriceMovement(from: before) + } + syncFromSim() + } + } + + case .shark: + switch hitKey(at: p) { + case "shark.repay": qty = 0; setMode(.amount(.repay)); Haptics.selection() + case "shark.borrow": qty = 0; setMode(.amount(.borrow)); Haptics.selection() + case "sheet.cancel": setMode(.board); Haptics.selection() + default: break + } + + case .bank: + switch hitKey(at: p) { + case "bank.deposit": qty = 0; setMode(.amount(.deposit)); Haptics.selection() + case "bank.withdraw": qty = 0; setMode(.amount(.withdraw)); Haptics.selection() + case "sheet.cancel": setMode(.board); Haptics.selection() + default: break + } + + case .confirmEnd: + switch hitKey(at: p) { + case "end.yes": + sim.quit() + syncFromSim() + case "sheet.cancel": setMode(.board); Haptics.selection() + default: break + } + + case .gameOver: + Haptics.selection() + setMode(.ready) + } + } + + private func hitKey(at p: CGPoint) -> String? { + hitRects.first { $0.rect.insetBy(dx: -6, dy: -6).contains(p) }?.key + } + + private func handleBoardTap(_ p: CGPoint) { + for i in 0..<6 where rowRect(i).contains(p) { + guard let item = DopeWarsSim.Item(rawValue: i) else { continue } + Haptics.selection() + setMode(.pickTrade(item)) + return + } + if actionRect(0).contains(p) { Haptics.selection(); setMode(.ride); return } + if actionRect(1).contains(p) { + Haptics.selection() + if sim.inBronx { setMode(.shark) } else { cardQueue.append("The loan shark works out of the Bronx."); advanceCards() } + return + } + if actionRect(2).contains(p) { + Haptics.selection() + if sim.inBronx { setMode(.bank) } else { cardQueue.append("Your bank branch is in the Bronx…"); advanceCards() } + return + } + if actionRect(3).contains(p) { Haptics.selection(); setMode(.confirmEnd); return } + } + + private func handleAmountTap(_ p: CGPoint, _ am: AmountMode) { + guard let key = hitKey(at: p) else { return } + let limit = amountLimit(am) + switch key { + case "sheet.cancel": + Haptics.selection() + // Money sheets return to their parent menu; trades to the board. + switch am { + case .repay, .borrow: setMode(.shark) + case .deposit, .withdraw: setMode(.bank) + default: setMode(.board) + } + return + case "amt.max": + qty = limit + case "amt.type": + typedAmount = 0 + Haptics.selection() + setMode(.keypad(am)) + return + case "amt.confirm": + confirmAmount(am) + return + default: + guard key.hasPrefix("amt.") else { return } + let body = key.dropFirst(4) + let sign = body.hasPrefix("m") ? -1 : 1 + let step = Int(body.dropFirst()) ?? 0 + qty = max(0, min(limit, qty + sign * step)) + } + Haptics.selection() + updateAmountLabels(am) + } + + private func handleKeypadTap(_ p: CGPoint, _ am: AmountMode) { + guard let key = hitKey(at: p) else { return } + switch key { + case "sheet.cancel": + Haptics.selection() + setMode(.amount(am)) // keep the old qty + case "pad.done": + qty = min(typedAmount, amountLimit(am)) + Haptics.selection() + setMode(.amount(am)) + case "pad.del": + typedAmount /= 10 + Haptics.selection() + updateKeypadValue(am) + default: + guard key.hasPrefix("pad."), let digit = Int(key.dropFirst(4)) else { return } + typedAmount = min(typedAmount * 10 + digit, 999_999_999) + Haptics.selection() + updateKeypadValue(am) + } + } + + private func confirmAmount(_ am: AmountMode) { + let ok: Bool + switch am { + case .buy(let item): ok = sim.buy(item, count: qty) + case .sell(let item): ok = sim.sell(item, count: qty) + case .repay: ok = sim.repay(qty) + case .borrow: ok = sim.borrow(qty) + case .deposit: ok = sim.deposit(qty) + case .withdraw: ok = sim.withdraw(qty) + } + if ok && qty > 0 { Haptics.success() } else { Haptics.selection() } + qty = 0 + syncFromSim() + } + + private func advanceCards() { + if !cardQueue.isEmpty { + setMode(.message(cardQueue.removeFirst())) + } else { + syncFromSim() + } + } +} diff --git a/Arcade85/Games/DopeWars/DopeWarsSim.swift b/Arcade85/Games/DopeWars/DopeWarsSim.swift new file mode 100644 index 0000000..4d01042 --- /dev/null +++ b/Arcade85/Games/DopeWars/DopeWarsSim.swift @@ -0,0 +1,628 @@ +import Foundation + +/// The classic "Drug Wars" (John E. Dell, 1984) — one month on the New York +/// streets: $2,000 in cash, $5,500 owed to the loan shark, a 100-pocket trench +/// coat, and the subway between six boroughs. Buy low, sell high, and settle up. +/// +/// The engine is the verbatim TI-85 port of Joey Gannon's NerdWars (2000), +/// which turned out to be Dell's game with the serial numbers filed off — its +/// BASIC kept Dell's item variables C,H,A,W,S,L (Cocaine, Heroin, Acid, Weed, +/// Speed, Ludes) and merely rescaled the price bands. This restores the classic +/// setup: Dell's six drugs at their iconic prices, the Bronx as home base +/// (loan shark + bank), Officer Hardass on your tail, $400 guns and $200 +/// trench coats. Interest (debt 10%/day, bank 6%/day), the 0…20 daily event +/// roll, chase/punch mechanics, and √(net ÷ 31.5) scoring are untouched. +/// +/// Quirks kept from the source engine: +/// - The dead-dude giveaway rolls int(rand*5) = 0…4 over the cheap-to-dear +/// item order, so you can never find free cocaine. +/// - Smoking the weed you find on the sidewalk (event 14) is always fatal. +/// - Day-31 arrival ends the month with no final price roll. +/// Quirk dropped with the re-theme: NerdWars matched trade items BY PRICE +/// (`If F==C …`), a bug that could fill or empty several pockets at once. +/// Trading here is by item, the way Dell's original worked. +/// +/// Remake extension — the market grows with you. Below $100k net worth the +/// game is exactly the classic. Cross $100k and word gets around: the ludes +/// slot retires (any stock is bought out at yesterday's price) and METH moves +/// in at $2,000–16,000 — the widest swing on the board. Cross $1M and the +/// speed slot retires for CHINA WHITE at $40,000–160,000, the whale product +/// that dwarfs even a cocaine spike. Tier shifts are one-way, checked on +/// arrival, and bring their own events (meth lab bust/superlab glut, cartel +/// dock flood) on formerly idle rolls of the 0…20 event table. +/// +/// Remake extensions, round 5 — the streets get real: +/// - **Borough economies**: each borough multiplies the daily band rolls +/// (the Bronx is the neutral reference; event prices are absolute news and +/// ignore the bias). Heroin runs cheap in the Ghetto, acid and weed in the +/// Park, everything's sleepy-cheap on Coney, Manhattan's buyers pay up, +/// Brooklyn's fences pay a little over. +/// - **Word on the street**: some days a tip names a product and a borough +/// for tomorrow — ride there and find out if it was good (×3 spike, ÷3 +/// glut) or street garbage (~1 in 4). Rumors and shark muscle roll on a +/// SEPARATE rng stream, so classic price/event sequences per seed are +/// untouched. +/// - **The shark collects**: run a tab of $15,000+ past day 10 and you get +/// one warning; after that his leg-breakers find you — the classic chase +/// engine with the shark's collection money as the prize and a nastier +/// ending if they put you down. +/// - **Month lengths**: 30/60/90 days, or endless (no clock, no leaderboard; +/// debt and bank interest cap at $999,999,999,999 so long runs stay sane). +struct DopeWarsSim { + + // MARK: Items & locations + + /// Dell's C,H,A,W,S,L — priciest to cheapest. + enum Item: Int, CaseIterable { + case cocaine = 0, heroin, acid, weed, speed, ludes + + var name: String { + switch self { + case .cocaine: return "Cocaine" + case .heroin: return "Heroin" + case .acid: return "Acid" + case .weed: return "Weed" + case .speed: return "Speed" + case .ludes: return "Ludes" + } + } + /// Lowercase form used by the dead-dude loot message. + var lootName: String { name.lowercased() } + } + + /// The subway map. Mechanically the original only distinguishes the Bronx + /// (loan shark + bank) from everywhere else, but the boroughs are the game. + enum Location: Int, CaseIterable { + case bronx = 0, ghetto, park, manhattan, coney, brooklyn + var name: String { + ["Bronx", "Ghetto", "Central Park", "Manhattan", "Coney Island", "Brooklyn"][rawValue] + } + } + + // MARK: Interaction model + + enum Offer: Equatable { + case coat // +10 pockets for $200 + case gun(String) // $400, takes 5 pockets + case weed // smoking it is fatal + case doctor // $1000 clears all wounds + } + + enum EndCause: Equatable { case monthOver, quit, gunnedDown, badTrip, sharkCollected } + + enum Phase: Equatable { case trading, offer(Offer), chase, gameOver } + + /// A next-day tip: a product, a borough, and whether the street was right. + struct Rumor: Equatable { + let slot: Int + let location: Location + let dayDue: Int + let isSpike: Bool + let isTrue: Bool + } + + /// Percent price multipliers per borough (rows follow `Location` order), + /// applied to the daily band rolls. The Bronx is the neutral reference + /// market; event prices are absolute news and ignore the bias. + static let boroughBias: [[Int]] = [ + [100, 100, 100, 100, 100, 100], // Bronx — home turf + [ 95, 75, 90, 100, 85, 90], // Ghetto — heroin & speed country + [105, 110, 75, 80, 100, 95], // Central Park — acid & weed run cheap + [130, 115, 110, 115, 110, 110], // Manhattan — rich buyers pay up + [ 90, 95, 95, 85, 90, 80], // Coney Island — sleepy boardwalk prices + [110, 105, 105, 105, 105, 105], // Brooklyn — fences pay over market + ] + + // MARK: State (original BASIC variable in comments) + + private(set) var wallet = 2000 // Z + private(set) var debt = 5500 // Y — the classic's $5,500 + private(set) var bank = 0 // V + private(set) var day = 1 // B (reaching 31 ends the game) + private(set) var coatTotal = 100 // T + private(set) var coatFree = 100 // K = T − ΣMNOPQR + private(set) var guns = 0 // I + private(set) var wounds = 0 // J (50 = death) + private(set) var location: Location = .bronx // home flag: 2 home / 1 away + private(set) var price = [Int](repeating: 0, count: 6) // C H A W S L + private(set) var held = [Int](repeating: 0, count: 6) // M N O P Q R + private(set) var chaseCops = 0 // D deputies; −1 ⇔ all down + private(set) var phase: Phase = .trading + /// Market tier — 1 classic, 2 (net ≥ $100k) meth replaces ludes, + /// 3 (net ≥ $1M) China White replaces speed. One-way per run. + private(set) var tier = 1 + /// Round-5 extension state. + private(set) var rumor: Rumor? + private(set) var sharkChase = false // current chase is leg-breakers + private var sharkWarned = false + private var lastSharkDay = 0 + /// Days in the month; `Int.max` = endless (never ends on its own). + let monthLen: Int + /// Interstitial texts, shown one per tap like the original's Pause screens. + private(set) var messages: [String] = [] + private(set) var finalScore = 0 + private(set) var finalNet = 0 + private(set) var endCause: EndCause? + + var inBronx: Bool { location == .bronx } + var totalHeld: Int { held.reduce(0, +) } + var netWorth: Int { bank + wallet - debt } + + /// What a market slot is called at the current tier: the ludes slot deals + /// meth from tier 2, the speed slot deals China White from tier 3. + func displayName(_ item: Item) -> String { + switch item { + case .ludes where tier >= 2: return "Meth" + case .speed where tier >= 3: return "China White" + default: return item.name + } + } + func lootName(_ item: Item) -> String { displayName(item).lowercased() } + + var offerPrompt: String? { + guard case .offer(let offer) = phase else { return nil } + switch offer { + case .coat: return "Will you buy a new trench coat with more pockets for $200?" + case .gun(let name): return "Will you buy \(name)?" + case .weed: return "There's some weed on the sidewalk that smells like good stuff. Will you smoke it?" + case .doctor: return "Will you pay $1000 for a doctor to sew you up?" + } + } + + private var rng: SeededGenerator + /// Separate stream for the round-5 extras (rumors, shark muscle) so the + /// classic price/event sequence per seed stays byte-identical. + private var extRng: SeededGenerator + + init(seed: UInt64? = nil, monthLen: Int = 30) { + let s = seed ?? UInt64.random(in: 1...UInt64.max) + rng = SeededGenerator(seed: s) + extRng = SeededGenerator(seed: s ^ 0x5EED_0FF5) + self.monthLen = monthLen <= 0 ? Int.max : monthLen + rollDay() // G8 runs before the first menu + } + + /// TI-85 `rand`: uniform in [0, 1). + private mutating func rand() -> Double { + #if DEBUG + if !debugForcedRolls.isEmpty { return debugForcedRolls.removeFirst() } + #endif + return Double(rng.next() >> 11) * 0x1.0p-53 + } + + /// Extension-stream uniform in [0, 1) — see `extRng`. + private mutating func extRand() -> Double { + #if DEBUG + if !debugExtForcedRolls.isEmpty { return debugExtForcedRolls.removeFirst() } + #endif + return Double(extRng.next() >> 11) * 0x1.0p-53 + } + + // MARK: - Day roll (Lbl G8) + + /// Fresh prices at the tier's bands, then the day's 0…20 event roll + /// (most values do nothing). Roll shapes mirror the BASIC's ×100/×10 + /// quantisation for the cheaper drugs. One rand() per slot at every tier, + /// so classic (tier 1) runs stay roll-for-roll identical to the original. + private mutating func rollDay() { + checkMarketShift() + price[Item.cocaine.rawValue] = Int((rand() * 15000 + 15000).rounded()) // 15000…30000 + price[Item.heroin.rawValue] = Int((rand() * 9000 + 5000).rounded()) // 5000…14000 + price[Item.acid.rawValue] = Int((rand() * 35 + 10).rounded()) * 100 // 1000…4500 + price[Item.weed.rawValue] = Int((rand() * 60 + 30).rounded()) * 10 // 300…900 + price[Item.speed.rawValue] = tier >= 3 + ? Int((rand() * 1200 + 400).rounded()) * 100 // CW: 40000…160000 + : Int((rand() * 16 + 9).rounded()) * 10 // 90…250 + price[Item.ludes.rawValue] = tier >= 2 + ? Int((rand() * 140 + 20).rounded()) * 100 // meth: 2000…16000 + : Int((rand() * 5 + 1).rounded()) * 10 // 10…60 + let bias = Self.boroughBias[location.rawValue] + for i in 0..<6 { price[i] = max(1, price[i] * bias[i] / 100) } + resolveRumor() + applyEvent(Int((rand() * 20).rounded())) + if phase == .trading { sharkPressure() } + if phase == .trading { maybeSpawnRumor() } + } + + // MARK: - Word on the street (remake extension) + + /// A due rumor pays out (or doesn't) only if you actually rode to the + /// named borough; ride anywhere else and it just expires unverified. + private mutating func resolveRumor() { + guard let r = rumor, day >= r.dayDue else { return } + rumor = nil + guard day == r.dayDue, location == r.location else { return } + let name = displayName(Item(rawValue: r.slot)!) + if !r.isTrue { + messages.append("The word was garbage. \(name) is trading like any other day.") + } else if r.isSpike { + price[r.slot] *= 3 + messages.append("The word was good — \(name) is going crazy here today!!") + } else { + price[r.slot] = max(1, price[r.slot] / 3) + messages.append("The word was good — \(name) is practically free here today!") + } + } + + private mutating func maybeSpawnRumor() { + guard rumor == nil, day < monthLen else { return } + guard extRand() < 0.35 else { return } + let slot = min(5, Int(extRand() * 6)) + let loc = Location(rawValue: min(5, Int(extRand() * 6)))! + let spike = extRand() < 0.5 + let isTrue = extRand() < 0.75 + rumor = Rumor(slot: slot, location: loc, dayDue: day + 1, isSpike: spike, isTrue: isTrue) + let name = displayName(Item(rawValue: slot)!).lowercased() + messages.append(spike + ? "Word on the street: \(name) is about to go scarce in \(loc.name) — tomorrow it pays." + : "Word on the street: a big load of \(name) hits \(loc.name) tomorrow — prices'll tank.") + } + + // MARK: - The shark collects (remake extension) + + /// Run a $15k+ tab past day 10 and the shark sends people: one warning, + /// then a 30%-per-arrival chance his two leg-breakers jump you — the + /// classic chase engine with nastier stakes. + private mutating func sharkPressure() { + guard debt >= 15_000, day >= 10, day != lastSharkDay else { return } + if !sharkWarned { + sharkWarned = true + lastSharkDay = day + messages.append("A kid runs up: \"The shark says your tab's a problem. Square it, or his boys will.\"") + return + } + guard extRand() < 0.30 else { return } + lastSharkDay = day + sharkChase = true + chaseCops = 1 // two goons; D counts down to −1 + messages.append("Two of the loan shark's leg-breakers step out of a doorway!!") + phase = .chase + } + + /// One-way tier promotion, checked as a day begins. Retiring a slot buys + /// out any stock at yesterday's price — the collector always pays. + private mutating func checkMarketShift() { + if tier < 2, netWorth >= 100_000 { + tier = 2 + messages.append("Word on the street: you're a heavy roller now. Nobody wants ludes anymore — but a new product called METH is moving, and it moves big.") + liquidate(.ludes, calledAs: "ludes") + } + if tier < 3, netWorth >= 1_000_000 { + tier = 3 + messages.append("Word is out: you're a millionaire. Speed is small-time — the cartel wants a buyer for CHINA WHITE. Pure, scarce, priced like it.") + liquidate(.speed, calledAs: "speed") + } + } + + private mutating func liquidate(_ item: Item, calledAs name: String) { + let n = held[item.rawValue] + guard n > 0 else { return } + let payout = price[item.rawValue] * n + wallet += payout + held[item.rawValue] = 0 + recomputeFree() + messages.append("A collector bought out your \(n) units of \(name) for $\(payout). That market is done.") + } + + private mutating func applyEvent(_ d: Int) { + switch d { + case 1: + if tier >= 2 { + // The ludes slot deals meth now — its crash event becomes a spike. + messages.append("A meth lab blew up in Jersey! Local product is suddenly precious!!") + price[Item.ludes.rawValue] = Int(30000 + rand() * 18000) // 30000…47999 + } else { + messages.append("Rival dealers raided a pharmacy and are selling cheap ludes!!") + price[Item.ludes.rawValue] = 2 + } + case 2: + messages.append("The market has been flooded with cheap home-grown weed!") + price[Item.weed.rawValue] = 122 + case 3: + messages.append("The market has been flooded with cheap heroin!") + price[Item.heroin.rawValue] = Int(850 + rand() * 1150) // 850…1999 + case 4, 5: + messages.append("Cops made a big bust! Heroin prices are outrageous!") + price[Item.heroin.rawValue] = Int(18000 + rand() * 25000) // 18000…42999 + case 6, 7: + messages.append("Addicts are buying cocaine at outrageous prices!") + price[Item.cocaine.rawValue] = Int(80000 + rand() * 60000) // 80000…139999 + case 8: + messages.append("You were mugged in the subway!") + wallet = (wallet / 3) * 2 // int(Z/3) then ×2 — keep ~2/3 + case 9, 10, 11: + guard totalHeld >= 50 else { return } // big carriers draw the heat + chaseCops = d == 9 ? 1 : (d == 10 ? 3 : 4) + messages.append("Officer Hardass and \(chaseCops) of his deputies are chasing you!!") + phase = .chase + case 12, 13: + guard wallet >= 500, coatFree >= 5 else { return } + let names = ["a $400 Baretta", "a $400 .38 Special", "a $400 Saturday Night Special"] + phase = .offer(.gun(names[Int((rand() * 2).rounded())])) + case 14: + phase = .offer(.weed) + case 15: + guard wallet >= 300 else { return } + phase = .offer(.coat) + case 16: + guard coatFree >= 8 else { return } + let f = Int(rand() * 7 + 1) // 1…7 units + let items: [Item] = [.ludes, .speed, .weed, .acid, .heroin, .cocaine] + let item = items[Int(rand() * 5)] // 0…4 — cocaine unreachable + held[item.rawValue] += f + recomputeFree() + messages.append("You found \(f) units of \(lootName(item)) on a dead dude in the subway!") + case 17: + messages.append("The market has been flooded with cheap home-made acid!") + price[Item.acid.rawValue] = Int(250 + rand() * 550) // 250…799 + case 18: + guard tier >= 3 else { return } // idle roll in the classic game + messages.append("The cartel flooded the docks! China White is going for nothing!!") + price[Item.speed.rawValue] = Int(8000 + rand() * 12000) // 8000…19999 + case 19: + guard tier >= 2 else { return } // idle roll in the classic game + messages.append("A superlab upstate dumped its stock — meth is dirt cheap!!") + price[Item.ludes.rawValue] = Int(400 + rand() * 600) // 400…999 + default: + break // 0, 20 and gated rolls + } + } + + private mutating func recomputeFree() { // (T−M−N−O−P−Q−R)→K + coatFree = coatTotal - totalHeld + } + + // MARK: - Trading + + /// int(Z/F): how many the wallet covers at today's price. + func affordable(_ item: Item) -> Int { + let f = price[item.rawValue] + return f > 0 ? wallet / f : 0 + } + + func sellable(_ item: Item) -> Int { + held[item.rawValue] + } + + @discardableResult + mutating func buy(_ item: Item, count: Int) -> Bool { + guard phase == .trading, messages.isEmpty, count >= 0 else { return false } + guard count <= coatFree, count <= affordable(item) else { return false } + wallet -= price[item.rawValue] * count + held[item.rawValue] += count + recomputeFree() + return true + } + + @discardableResult + mutating func sell(_ item: Item, count: Int) -> Bool { + guard phase == .trading, messages.isEmpty, count >= 0 else { return false } + guard count <= sellable(item) else { return false } + held[item.rawValue] -= count + wallet += price[item.rawValue] * count + recomputeFree() + return true + } + + // MARK: - The subway (Lbl G5/S) + + /// Rides to a borough: day advances, debt grows 10%, bank grows 6% + /// (int-truncated exactly like the calculator's decimal arithmetic), then + /// either the month ends or a fresh day is rolled. Returns false for + /// "You're already in the Bronx!". + @discardableResult + mutating func ride(to destination: Location) -> Bool { + guard phase == .trading, messages.isEmpty else { return false } + if destination == .bronx && inBronx { + messages.append("You're already in the Bronx!") + return false + } + location = destination + day += 1 + // int(Y*1.1) / int(V*1.06), capped so endless runs can't overflow. + debt = min(debt * 11 / 10, 999_999_999_999) + bank = min(bank * 106 / 100, 999_999_999_999) + if day > monthLen { endGame(.monthOver) } else { rollDay() } + return true + } + + // MARK: - Loan shark (Lbl G6) & bank (Lbl G7) — Bronx only + + @discardableResult + mutating func repay(_ amount: Int) -> Bool { + guard phase == .trading, messages.isEmpty, inBronx else { return false } + guard amount >= 0, amount <= wallet, amount <= debt else { return false } + debt -= amount + wallet -= amount + return true + } + + /// The shark fronts at most $5,000 per visit. + @discardableResult + mutating func borrow(_ amount: Int) -> Bool { + guard phase == .trading, messages.isEmpty, inBronx else { return false } + guard amount >= 0, amount <= 5000 else { return false } + debt += amount + wallet += amount + return true + } + + @discardableResult + mutating func deposit(_ amount: Int) -> Bool { + guard phase == .trading, messages.isEmpty, inBronx else { return false } + guard amount >= 0, amount <= wallet else { return false } + bank += amount + wallet -= amount + return true + } + + @discardableResult + mutating func withdraw(_ amount: Int) -> Bool { + guard phase == .trading, messages.isEmpty, inBronx else { return false } + guard amount >= 0, amount <= bank else { return false } + bank -= amount + wallet += amount + return true + } + + // MARK: - Offers + + mutating func answerOffer(yes: Bool) { + guard case .offer(let offer) = phase, messages.isEmpty else { return } + phase = .trading + guard yes else { return } // every "No" path just continues + switch offer { + case .coat: + coatTotal += 10 // (T+10)→T + recomputeFree() + wallet -= 200 + case .gun: + guns += 1 // (I+1)→I + wallet -= 400 + coatTotal -= 5 // a gun costs 5 pockets + recomputeFree() + case .weed: + messages.append("You hallucinated for three days on the wildest trip of your life.") + messages.append("Then you wandered onto the tracks at 42nd Street. Game over, dealer.") + endGame(.badTrip) + case .doctor: + wallet -= 1000 + wounds = 0 + } + } + + // MARK: - The chase (Lbl M/K/L/O/N) + + /// 50/50: lose them in an alley (fresh G8 roll — new prices, possibly a + /// new event) or fail and take a fire round. + mutating func chaseRun() { + guard phase == .chase, messages.isEmpty else { return } + if Int(rand().rounded()) == 0 { + messages.append("You lost them in an alley!") + sharkChase = false + phase = .trading + rollDay() + } else { + messages.append("You can't shake them!") + fireRound() + } + } + + /// Needs a gun; 50/50 miss or drop one deputy. Either way (unless the last + /// one falls) they answer with a fire round, exactly like the original's + /// fall-through to Lbl O. + mutating func chaseFight() { + guard phase == .chase, messages.isEmpty else { return } + guard guns > 0 else { + messages.append("You don't have any guns! You have to run!") + return + } + if Int(rand().rounded()) == 0 { + messages.append("You missed!!!") + } else { + chaseCops -= 1 + messages.append("You dropped one!!!") + if chaseCops == -1 { // If D==(−1) Goto N + chaseVictory() + return + } + } + fireRound() + } + + private mutating func fireRound() { + if Int(rand().rounded()) == 0 { + messages.append("They're firing at you! They missed!!!") + } else { + wounds += 3 + messages.append("They're firing at you! You've been hit!") + if wounds >= 50 { + if sharkChase { + messages.append("The shark collected — in blood.") + endGame(.sharkCollected) + } else { + messages.append("They wasted you, man!!!") + endGame(.gunnedDown) + } + } + } + } + + private mutating func chaseVictory() { + if sharkChase { + sharkChase = false + let loot = Int(rand() * 500 + 250) // 250…749 collection money + wallet += loot + messages.append("You took them both down!!!") + messages.append("You found $\(loot) of the shark's collection money. He won't forget this.") + phase = wallet >= 1200 ? .offer(.doctor) : .trading + return + } + let loot = Int(rand() * 1250 + 750) // 750…1999 + wallet += loot + messages.append("You took them all down!!!") + messages.append("You found $\(loot) on Officer Hardass's body.") + phase = wallet >= 1200 ? .offer(.doctor) : .trading + } + + // MARK: - Ending (Lbl C) + + mutating func quit() { + guard phase == .trading, messages.isEmpty else { return } + endGame(.quit) + } + + /// Score = √((bank + wallet − debt) / 31.5), floored at 0 and capped at + /// 100 — so a perfect 100 takes a net worth of $315,000. + private mutating func endGame(_ cause: EndCause) { + let net = bank + wallet - debt + finalNet = net + finalScore = net < 0 ? 0 : min(100, Int((Double(net) / 31.5).squareRoot().rounded())) + endCause = cause + phase = .gameOver + } + + // MARK: - Message queue (the original's Pause screens) + + mutating func popMessage() -> String? { + messages.isEmpty ? nil : messages.removeFirst() + } + + // MARK: - Debug hooks + + #if DEBUG + /// Values consumed by `rand()` before the real generator — lets tests force + /// specific 50/50 outcomes and price rolls. + var debugForcedRolls: [Double] = [] + /// Same, for the extension stream (rumor spawn, shark pressure chance). + var debugExtForcedRolls: [Double] = [] + + mutating func debugSetRumor(slot: Int, location: Location, dayDue: Int, + isSpike: Bool, isTrue: Bool) { + rumor = Rumor(slot: slot, location: location, dayDue: dayDue, + isSpike: isSpike, isTrue: isTrue) + } + mutating func debugTriggerShark() { + sharkChase = true + chaseCops = 1 + messages.append("Two of the loan shark's leg-breakers step out of a doorway!!") + phase = .chase + } + mutating func debugClearRumor() { rumor = nil } + + mutating func debugSetWallet(_ v: Int) { wallet = v } + mutating func debugSetDebt(_ v: Int) { debt = v } + mutating func debugSetBank(_ v: Int) { bank = v } + mutating func debugSetDay(_ v: Int) { day = v } + mutating func debugSetGuns(_ v: Int) { guns = v } + mutating func debugSetWounds(_ v: Int) { wounds = v } + mutating func debugSetCoatTotal(_ v: Int) { coatTotal = v; recomputeFree() } + mutating func debugSetLocation(_ l: Location) { location = l } + mutating func debugSetPrice(_ item: Item, _ v: Int) { price[item.rawValue] = v } + mutating func debugSetTier(_ t: Int) { tier = min(3, max(1, t)) } + mutating func debugSetHeld(_ item: Item, _ v: Int) { held[item.rawValue] = v; recomputeFree() } + mutating func debugApplyEvent(_ d: Int) { applyEvent(d) } + mutating func debugRollDay() { rollDay() } + mutating func debugClearMessages() { messages.removeAll() } + #endif +} diff --git a/Arcade85/Games/Galaxian/GalaxianData.swift b/Arcade85/Games/Galaxian/GalaxianData.swift new file mode 100644 index 0000000..490cfa7 --- /dev/null +++ b/Arcade85/Games/Galaxian/GalaxianData.swift @@ -0,0 +1,229 @@ +import Foundation + +/// Static data for Galaxian, transcribed verbatim from `GALAXIAN.ASM` +/// (Patrick Davidson, v3.0, 1998): every sprite bitmap, the six formation +/// patterns, and the 20-level campaign table. +enum GalaxianData { + + // MARK: - Sprites + + /// A 1-bit sprite. `rows[i]` holds row `i` with bit 31 as the leftmost + /// pixel (the ASM's row bytes packed big-endian, as drawn by `drw_spr`). + struct Sprite { + let w: Int + let h: Int + let rows: [UInt32] + + init(_ w: Int, _ h: Int, _ byteRows: [[UInt8]]) { + self.w = w + self.h = h + self.rows = byteRows.map { bytes in + var v: UInt32 = 0 + for (i, b) in bytes.enumerated() { v |= UInt32(b) << (24 - 8 * i) } + return v + } + } + } + + enum Img: Hashable { + case enemy1, enemy2, enemy3, enemy4, cross, operatorShip + case boss1, boss2, boss3 + case ship, playerBullet, enemyBullet + case x1, x2, x3, x4, x5, x6, x7, x8 // explosion frames + } + + static let explosionFrames: [Img] = [.x1, .x2, .x3, .x4, .x5, .x6, .x7, .x8] + + static let sprites: [Img: Sprite] = [ + // enemy_warped (the "cross" trainer) + .cross: Sprite(7, 7, [ + [0b1100_0110], [0b1110_1110], [0b0111_1100], [0b0011_1000], + [0b0111_1100], [0b1110_1110], [0b1100_0110], + ]), + .enemy1: Sprite(6, 6, [ + [0b1000_0100], [0b1000_0100], [0b1111_1100], [0b1111_1100], + [0b0111_1000], [0b0011_0000], + ]), + .enemy2: Sprite(6, 7, [ + [0b1000_0100], [0b1100_1100], [0b1111_1100], [0b1000_0100], + [0b1000_0100], [0b0100_1000], [0b0011_0000], + ]), + .enemy3: Sprite(6, 5, [ + [0b1000_0100], [0b1111_1100], [0b1000_0100], [0b0100_1000], + [0b0011_0000], + ]), + .enemy4: Sprite(7, 5, [ + [0b1000_0010], [0b1100_0110], [0b1010_1010], [0b1001_0010], + [0b1000_0010], + ]), + .operatorShip: Sprite(7, 7, [ + [0b0111_1100], [0b1111_1110], [0b1100_0110], [0b1100_0110], + [0b1100_0110], [0b1111_1110], [0b0111_1100], + ]), + .boss1: Sprite(16, 8, [ + [0b1000_0000, 0b0000_0001], [0b1100_0000, 0b0000_0011], + [0b1011_1111, 0b1111_1101], [0b1000_0001, 0b1000_0001], + [0b1000_0001, 0b1000_0001], [0b0100_0001, 0b1000_0010], + [0b0010_0001, 0b1000_0100], [0b0001_1111, 0b1111_1000], + ]), + .boss2: Sprite(16, 8, [ + [0b1111_1111, 0b1111_1111], [0b1110_0000, 0b0000_0111], + [0b1110_0000, 0b0000_0111], [0b1110_0000, 0b0000_0111], + [0b1110_0000, 0b0000_0111], [0b1111_0000, 0b0000_1111], + [0b1110_1000, 0b0001_0111], [0b0100_0111, 0b1110_0010], + ]), + .boss3: Sprite(31, 8, [ + [0b1111_1111, 0b1111_1111, 0b1111_1111, 0b1111_1110], + [0b1010_1000, 0b0000_0000, 0b0000_0000, 0b0010_1010], + [0b1010_1001, 0b0101_0101, 0b0101_0101, 0b0010_1010], + [0b1010_1000, 0b0000_0000, 0b0000_0000, 0b0010_1010], + [0b1010_1000, 0b0000_0111, 0b1100_0000, 0b0010_1010], + [0b1000_1111, 0b1100_0010, 0b1000_0111, 0b1110_0010], + [0b0101_0000, 0b0010_0001, 0b0000_1000, 0b0001_0100], + [0b0010_0000, 0b0001_1111, 0b1111_0000, 0b0000_1000], + ]), + .enemyBullet: Sprite(3, 3, [ + [0b0100_0000], [0b1110_0000], [0b0100_0000], + ]), + .playerBullet: Sprite(3, 5, [ + [0b0100_0000], [0b1110_0000], [0b1110_0000], [0b1110_0000], + [0b1110_0000], + ]), + .ship: Sprite(7, 7, [ + [0b0001_0000], [0b0011_1000], [0b1011_1010], [0b1011_1010], + [0b1011_1010], [0b1111_1110], [0b1111_1110], + ]), + .x1: Sprite(8, 6, [ + [0b0000_0000], [0b0001_1100], [0b0011_1110], [0b0101_0110], + [0b0011_1000], [0b0000_0000], + ]), + .x2: Sprite(8, 6, [ + [0b0011_0000], [0b0100_1110], [0b1011_1110], [0b0100_1111], + [0b0011_1000], [0b0001_1010], + ]), + .x3: Sprite(8, 6, [ + [0b1111_0011], [0b0100_1110], [0b1011_0101], [0b0100_0101], + [0b0011_1110], [0b1101_1010], + ]), + .x4: Sprite(8, 6, [ + [0b1111_0011], [0b0100_1110], [0b1011_0101], [0b0100_0101], + [0b0011_1110], [0b1101_1010], + ]), + .x5: Sprite(8, 6, [ + [0b0100_0001], [0b0010_0110], [0b0001_0101], [0b0100_0100], + [0b0001_0010], [0b1001_1010], + ]), + .x6: Sprite(8, 6, [ + [0b0100_0010], [0b0010_0000], [0b0000_0001], [0b0100_0100], + [0b0010_0010], [0b1000_1010], + ]), + .x7: Sprite(8, 6, [ + [0b0000_1000], [0b1100_0010], [0b0000_0000], [0b0010_0000], + [0b0000_0001], [0b0011_0000], + ]), + .x8: Sprite(8, 6, [ + [0b0000_0100], [0b0000_0000], [0b0100_0000], [0b0000_0000], + [0b0000_0001], [0b0010_0100], + ]), + ] + + // MARK: - Formation patterns + + /// 20 (x, depth) pairs per pattern. The depth byte does double duty in the + /// original: an enemy triggers its entry when `timer & 127 == 128 - depth` + /// and descends 1 px/frame until the timer wraps — arriving at row `depth` + /// exactly on the wrap. + typealias Pattern = [(x: Int, y: Int)] + + static let pattern1: Pattern = [ + (10, 16), (20, 16), (30, 16), (40, 16), (50, 16), + (5, 24), (15, 24), (25, 24), (35, 24), (45, 24), + (50, 32), (10, 32), (20, 32), (30, 32), (40, 32), + (5, 40), (15, 40), (25, 40), (35, 40), (45, 40), + ] + + static let pattern2: Pattern = [ + (1, 16), (14, 16), (27, 16), (40, 16), (53, 16), + (1, 24), (14, 24), (27, 24), (40, 24), (53, 24), + (1, 32), (14, 32), (27, 32), (40, 32), (53, 32), + (1, 40), (14, 40), (27, 40), (40, 40), (53, 40), + ] + + /// Shared by levels 3/7/11/16 (the boss levels): formation sits lower. + static let patternBoss: Pattern = [ + (1, 26), (14, 26), (27, 26), (40, 26), (53, 26), + (1, 34), (14, 34), (27, 34), (40, 34), (53, 34), + (1, 42), (14, 42), (27, 42), (40, 42), (53, 42), + (1, 50), (14, 50), (27, 50), (40, 50), (53, 50), + ] + + /// Shared by the mixed-type levels 4/8/12 (7 + 6 + 7 layout). + static let patternWeak: Pattern = [ + (5, 18), (13, 18), (21, 18), (29, 18), (37, 18), (45, 18), (53, 18), + (9, 28), (17, 28), (25, 28), (33, 28), (41, 28), (49, 28), + (5, 38), (13, 38), (21, 38), (29, 38), (37, 38), (45, 38), (53, 38), + ] + + /// Level 6's ring of enemies. + static let patternRing: Pattern = [ + (53, 28), (52, 32), (48, 35), (42, 38), (35, 39), (27, 40), (19, 39), + (12, 38), (6, 35), (2, 32), (1, 28), (2, 24), (6, 21), (12, 18), + (19, 17), (27, 16), (35, 17), (42, 18), (48, 21), (52, 24), + ] + + /// Levels 10/14: four columns of five. + static let patternColumns: Pattern = [ + (1, 16), (1, 22), (1, 28), (1, 34), (1, 40), + (19, 16), (19, 22), (19, 28), (19, 34), (19, 40), + (37, 16), (37, 22), (37, 28), (37, 34), (37, 40), + (55, 16), (55, 22), (55, 28), (55, 34), (55, 40), + ] + + // MARK: - Levels + + enum LevelKind { + /// 20 formation enemies of one type. + case standard + /// 20 formation enemies + a patrolling boss (21 to clear). + case boss(img: Img, pwr: Int) + /// 13 weak (power-1, enemy-1 art, slots 0…12) + 7 of the level type. + case weak + /// 10 bouncing cross trainers (half of them can flee). + case cross + /// 10 bouncing operator ships that fire aimed shots. + case operators + /// Sweeping superboss (98 hits) + 8 formation enemies + drone deploys. + case superBoss + } + + struct Level { + let kind: LevelKind + let enemyImg: Img + let enemyPwr: Int + let pattern: Pattern + } + + /// levels[0] is level 1. Mirrors `levels_jump_table` exactly. + static let levels: [Level] = [ + Level(kind: .standard, enemyImg: .enemy1, enemyPwr: 1, pattern: pattern1), + Level(kind: .standard, enemyImg: .enemy1, enemyPwr: 1, pattern: pattern2), + Level(kind: .boss(img: .boss1, pwr: 10), enemyImg: .enemy1, enemyPwr: 1, pattern: patternBoss), + Level(kind: .weak, enemyImg: .enemy2, enemyPwr: 2, pattern: patternWeak), + Level(kind: .cross, enemyImg: .cross, enemyPwr: 5, pattern: pattern1), + Level(kind: .standard, enemyImg: .enemy2, enemyPwr: 2, pattern: patternRing), + Level(kind: .boss(img: .boss2, pwr: 10), enemyImg: .enemy2, enemyPwr: 2, pattern: patternBoss), + Level(kind: .weak, enemyImg: .enemy3, enemyPwr: 3, pattern: patternWeak), + Level(kind: .cross, enemyImg: .cross, enemyPwr: 5, pattern: pattern1), + Level(kind: .standard, enemyImg: .enemy3, enemyPwr: 3, pattern: patternColumns), + Level(kind: .boss(img: .boss2, pwr: 10), enemyImg: .enemy3, enemyPwr: 3, pattern: patternBoss), + Level(kind: .weak, enemyImg: .enemy4, enemyPwr: 4, pattern: patternWeak), + Level(kind: .cross, enemyImg: .cross, enemyPwr: 5, pattern: pattern1), + Level(kind: .standard, enemyImg: .enemy4, enemyPwr: 4, pattern: patternColumns), + Level(kind: .operators, enemyImg: .operatorShip, enemyPwr: 8, pattern: pattern1), + Level(kind: .boss(img: .boss2, pwr: 10), enemyImg: .enemy4, enemyPwr: 4, pattern: patternBoss), + Level(kind: .operators, enemyImg: .operatorShip, enemyPwr: 8, pattern: pattern1), + Level(kind: .superBoss, enemyImg: .enemy1, enemyPwr: 1, pattern: pattern1), + Level(kind: .operators, enemyImg: .operatorShip, enemyPwr: 8, pattern: pattern1), + Level(kind: .superBoss, enemyImg: .enemy4, enemyPwr: 4, pattern: pattern1), + ] +} diff --git a/Arcade85/Games/Galaxian/GalaxianScene.swift b/Arcade85/Games/Galaxian/GalaxianScene.swift new file mode 100644 index 0000000..a9e588a --- /dev/null +++ b/Arcade85/Games/Galaxian/GalaxianScene.swift @@ -0,0 +1,679 @@ +import SpriteKit +import UIKit + +/// HD remake of Galaxian 3.0. `GalaxianSim` is the faithful Z80 port; this +/// scene renders the original sprites as neon pixel art, drives the ship with +/// drag-anywhere touch + autofire, puts the limited lasers on a button, and +/// wraps the 20-level campaign in ready / game-over / victory flow. +final class GalaxianScene: SKScene { + + typealias D = GalaxianData + + // MARK: State + private enum GameState { case ready, playing, paused, gameOver, victory } + private var state: GameState = .ready + + private var sim = GalaxianSim() + + // MARK: Timing + private var lastUpdate: TimeInterval = 0 + private var accumulator: TimeInterval = 0 + private var fireCooldown = 0 + + // MARK: Layout + private var unit: CGFloat = 4 // scene points per sim unit, horizontal + private var unitY: CGFloat = 5 // vertical — unit × 1.25 (taller pixels) + private var fieldRect = CGRect.zero // visible window: 96 × 64 sim units (y 16…79) + private var topInset: CGFloat = 0 + + // MARK: Touch control + private var moveTouch: UITouch? + private var touchAnchor = CGPoint.zero + private var shipAnchor = CGPoint.zero + private var targetX = 70 + private var targetY = 70 + private var pendingLaser = false + + // MARK: Nodes + private let worldCrop = SKCropNode() + private var playerNode = SKSpriteNode() + private var bossNode = SKSpriteNode() + private var bossBoomNode = SKSpriteNode() + private var enemyNodes: [SKSpriteNode] = [] + private var pbNodes: [SKSpriteNode] = [] + private var ebNodes: [SKSpriteNode] = [] + private var textureCache: [String: SKTexture] = [:] + + // HUD + private let scoreValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let waveValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let livesValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let laserButton = ControlButton(symbol: "bolt.fill") + private let laserCount = SKLabelNode(fontNamed: "Menlo-Bold") + private let pauseButton = SKShapeNode(circleOfRadius: 16) + private let waveBanner = SKLabelNode(fontNamed: "AvenirNext-Heavy") + + // Overlays + private let readyOverlay = SKNode() + private let pausedOverlay = SKNode() + private let gameOverOverlay = SKNode() + private let victoryOverlay = SKNode() + private let gameOverDetail = SKLabelNode(fontNamed: "Menlo-Bold") + private let gameOverRecord = SKLabelNode(fontNamed: "Menlo-Bold") + private let victoryDetail = SKLabelNode(fontNamed: "Menlo-Bold") + private let victoryRecord = SKLabelNode(fontNamed: "Menlo-Bold") + + private var builtSize: CGSize = .zero + #if DEBUG + private var demoFreeze = false + #endif + + // MARK: Sprite tints + + static func tint(for img: D.Img) -> UIColor { + switch img { + case .enemy1: return Palette.mint + case .enemy2: return Palette.cyan + case .enemy3: return UIColor(red: 0.55, green: 0.80, blue: 1.0, alpha: 1) + case .enemy4: return Palette.violet + case .cross: return UIColor(red: 1.0, green: 0.45, blue: 0.85, alpha: 1) + case .operatorShip: return UIColor(red: 1.0, green: 0.62, blue: 0.25, alpha: 1) + case .boss1, .boss2: return Palette.red + case .boss3: return UIColor(red: 1.0, green: 0.55, blue: 0.30, alpha: 1) + case .ship: return .white + case .playerBullet: return Palette.mint + case .enemyBullet: return Palette.amber + case .x1, .x2, .x3, .x4, .x5, .x6, .x7, .x8: return Palette.amber + } + } + + // MARK: - Lifecycle + + override func didMove(to view: SKView) { + anchorPoint = .zero + scaleMode = .resizeFill + layoutScene() + #if DEBUG + let env = ProcessInfo.processInfo.environment + if env["ARCADE_AUTOPLAY"] == "1" { startGame() } + if env["ARCADE_DEMO"] == "1" { + startGame() + sim.debugSetLevel(6) // the ring formation + for _ in 0..<300 { sim.tick(GalaxianSim.Input()) } + sim.debugPlacePlayer(x: 44, y: 70) + demoFreeze = true + renderFrame() + } + #endif + } + + override func didChangeSize(_ oldSize: CGSize) { + guard view != nil, size != builtSize, size.width > 0 else { return } + layoutScene() + } + + // MARK: - Textures + + private func texture(for img: D.Img) -> SKTexture { + let key = "\(img)" + if let t = textureCache[key] { return t } + let sprite = D.sprites[img]! + let cell: CGFloat = 10 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: CGFloat(sprite.w) * cell, + height: CGFloat(sprite.h) * cell)) + let color = Self.tint(for: img) + let image = renderer.image { ctx in + let cg = ctx.cgContext + cg.setShadow(offset: .zero, blur: cell * 0.6, color: color.withAlphaComponent(0.85).cgColor) + for pass in 0..<2 { + if pass == 1 { cg.setShadow(offset: .zero, blur: 0, color: nil) } + for (row, bits) in sprite.rows.enumerated() { + for col in 0..> UInt32(col))) != 0 { + let rect = CGRect(x: CGFloat(col) * cell, y: CGFloat(row) * cell, + width: cell, height: cell) + color.setFill() + UIBezierPath(roundedRect: rect.insetBy(dx: cell * 0.05, dy: cell * 0.05), + cornerRadius: cell * 0.18).fill() + } + } + } + } + let t = SKTexture(image: image) + textureCache[key] = t + return t + } + + // MARK: - Layout + + private func layoutScene() { + builtSize = size + removeAllChildren() + textureCache.removeAll() + backgroundColor = Palette.space1 + + let insets = view?.safeAreaInsets ?? .zero + topInset = insets.top + let bottomInset = insets.bottom + + let bg = SKSpriteNode(texture: SceneArt.verticalGradient(size: size, top: Palette.space0, bottom: Palette.space1)) + bg.anchorPoint = .zero + bg.zPosition = -10 + addChild(bg) + let stars = SceneArt.starfield(size: size, count: 90, seed: 0x6A1AC5) + stars.zPosition = -9 + addChild(stars) + + // Field: 96 × 64 visible sim units (rows 16…79 of the back buffer), + // width-fit, drawn with 1.25× tall pixels for a fuller portrait field. + let margin: CGFloat = 14 + unit = (size.width - margin * 2) / 96 + unitY = unit * 1.25 + let fieldH = unitY * 64 + let hudBottom = size.height - topInset - 92 + fieldRect = CGRect(x: margin, y: hudBottom - fieldH, width: unit * 96, height: fieldH) + + let frame = SKShapeNode(rect: fieldRect.insetBy(dx: -6, dy: -6), cornerRadius: 14) + frame.strokeColor = Palette.mint.withAlphaComponent(0.30) + frame.lineWidth = 1.5 + frame.glowWidth = 1 + frame.fillColor = UIColor(white: 0, alpha: 0.30) + frame.zPosition = -1 + addChild(frame) + + worldCrop.removeAllChildren() + let mask = SKSpriteNode(color: .white, size: fieldRect.size) + mask.position = CGPoint(x: fieldRect.midX, y: fieldRect.midY) + worldCrop.maskNode = mask + worldCrop.zPosition = 0 + addChild(worldCrop) + + buildEntityNodes() + buildHUD(bottomInset: bottomInset) + buildReadyOverlay() + buildPausedOverlay() + buildGameOverOverlay() + buildVictoryOverlay() + + applyStateVisibility() + renderFrame() + } + + private func buildEntityNodes() { + func makeNode() -> SKSpriteNode { + let n = SKSpriteNode() + n.isHidden = true + worldCrop.addChild(n) + return n + } + enemyNodes = (0..<20).map { _ in let n = makeNode(); n.zPosition = 4; return n } + pbNodes = (0..<10).map { _ in let n = makeNode(); n.zPosition = 3; return n } + ebNodes = (0..<10).map { _ in let n = makeNode(); n.zPosition = 3; return n } + bossNode = makeNode() + bossNode.zPosition = 4 + bossBoomNode = makeNode() + bossBoomNode.zPosition = 5 + playerNode = makeNode() + playerNode.zPosition = 5 + } + + /// Sim → scene coordinates (sim y grows downward; visible window starts at 16). + private func point(x: Int, y: Int, w: Int, h: Int) -> CGPoint { + CGPoint(x: fieldRect.minX + (CGFloat(x) + CGFloat(w) / 2) * unit, + y: fieldRect.maxY - (CGFloat(y) - CGFloat(GalaxianSim.visibleTop) + CGFloat(h) / 2) * unitY) + } + + private func place(_ node: SKSpriteNode, img: D.Img, x: Int, y: Int) { + let sprite = D.sprites[img]! + node.texture = texture(for: img) + node.size = CGSize(width: CGFloat(sprite.w) * unit, height: CGFloat(sprite.h) * unitY) + node.position = point(x: x, y: y, w: sprite.w, h: sprite.h) + node.isHidden = false + } + + // MARK: - HUD + + private func buildHUD(bottomInset: CGFloat) { + let hudY = size.height - topInset - 26 + + addChild(caption("LIVES", x: 74, y: hudY + 12, align: .left)) + livesValue.fontSize = 17 + livesValue.fontColor = .white + livesValue.horizontalAlignmentMode = .left + livesValue.verticalAlignmentMode = .center + livesValue.position = CGPoint(x: 74, y: hudY - 8) + livesValue.zPosition = 20 + addChild(livesValue) + + addChild(caption("WAVE", x: size.width / 2, y: hudY + 12, align: .center)) + waveValue.fontSize = 17 + waveValue.fontColor = .white + waveValue.horizontalAlignmentMode = .center + waveValue.verticalAlignmentMode = .center + waveValue.position = CGPoint(x: size.width / 2, y: hudY - 8) + waveValue.zPosition = 20 + addChild(waveValue) + + addChild(caption("SCORE", x: size.width - 20, y: hudY + 12, align: .right)) + scoreValue.fontSize = 17 + scoreValue.fontColor = .white + scoreValue.horizontalAlignmentMode = .right + scoreValue.verticalAlignmentMode = .center + scoreValue.position = CGPoint(x: size.width - 20, y: hudY - 8) + scoreValue.zPosition = 20 + addChild(scoreValue) + + // Laser button in the thumb zone, count badge underneath the bolt. + let ly = max(bottomInset + 96, fieldRect.minY - 116) + laserButton.configure(radius: 40, tint: Palette.cyan) + laserButton.position = CGPoint(x: size.width - 66, y: ly) + laserButton.zPosition = 20 + addChild(laserButton) + laserCount.fontSize = 13 + laserCount.fontColor = Palette.cyan + laserCount.horizontalAlignmentMode = .center + laserCount.verticalAlignmentMode = .center + laserCount.position = CGPoint(x: size.width - 66, y: ly - 58) + laserCount.zPosition = 20 + addChild(laserCount) + + pauseButton.fillColor = UIColor(white: 1, alpha: 0.08) + pauseButton.strokeColor = UIColor(white: 1, alpha: 0.25) + pauseButton.lineWidth = 1 + pauseButton.position = CGPoint(x: fieldRect.maxX - 6, y: fieldRect.maxY - 6) + pauseButton.zPosition = 22 + pauseButton.removeAllChildren() + if let tex = SceneArt.symbolTexture("pause.fill", pointSize: 13, color: Palette.white70) { + let icon = SKSpriteNode(texture: tex) + icon.size = tex.size() + pauseButton.addChild(icon) + } + addChild(pauseButton) + + // Wave banner (fades in/out on level entry). + waveBanner.fontSize = 24 + waveBanner.fontColor = Palette.mint + waveBanner.horizontalAlignmentMode = .center + waveBanner.verticalAlignmentMode = .center + waveBanner.position = CGPoint(x: fieldRect.midX, y: fieldRect.midY) + waveBanner.zPosition = 24 + waveBanner.alpha = 0 + addChild(waveBanner) + + let hintY = bottomInset + (max(fieldRect.minY - bottomInset, 60)) * 0.30 + addChild(caption("DRAG ANYWHERE TO FLY — CANNON AUTO-FIRES", x: size.width / 2, + y: max(bottomInset + 16, hintY), align: .center)) + } + + private func caption(_ text: String, x: CGFloat, y: CGFloat, align: SKLabelHorizontalAlignmentMode) -> SKLabelNode { + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = text + label.fontSize = 10 + label.fontColor = Palette.white40 + label.horizontalAlignmentMode = align + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + label.zPosition = 20 + return label + } + + // MARK: - Overlays + + private func bigLabel(_ text: String, fontNamed: String = "AvenirNext-Heavy", size fontSize: CGFloat, + color: UIColor, x: CGFloat, y: CGFloat, maxWidth: CGFloat? = nil) -> SKLabelNode { + let label = SKLabelNode(fontNamed: fontNamed) + label.text = text + label.fontSize = fontSize + label.fontColor = color + label.horizontalAlignmentMode = .center + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + if let maxWidth { + label.preferredMaxLayoutWidth = maxWidth + label.numberOfLines = 0 + } + return label + } + + private func buildReadyOverlay() { + readyOverlay.removeAllChildren() + readyOverlay.zPosition = 40 + addChild(readyOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.66), size: size) + dim.anchorPoint = .zero + readyOverlay.addChild(dim) + + let cx = size.width / 2 + let midY = size.height * 0.62 + readyOverlay.addChild(bigLabel("GALAXIAN", size: 36, color: .white, x: cx, y: midY + 64)) + readyOverlay.addChild(bigLabel("20 waves. 9 lives. 9 lasers. Hold the line.", + fontNamed: "AvenirNext-Medium", size: 14.5, + color: Palette.white70, x: cx, y: midY + 30)) + readyOverlay.addChild(bigLabel("Cannon auto-fires — the LASER button wipes a column", + fontNamed: "AvenirNext-Medium", size: 13, + color: Palette.white40, x: cx, y: midY - 2, + maxWidth: size.width - 80)) + + let prompt = bigLabel("TAP TO LAUNCH", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint, + x: cx, y: midY - 64) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + readyOverlay.addChild(prompt) + } + + private func buildPausedOverlay() { + pausedOverlay.removeAllChildren() + pausedOverlay.zPosition = 40 + addChild(pausedOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.01, alpha: 0.80), size: size) + dim.anchorPoint = .zero + pausedOverlay.addChild(dim) + pausedOverlay.addChild(bigLabel("PAUSED", size: 30, color: .white, x: size.width / 2, y: size.height * 0.55)) + let hint = bigLabel("TAP TO RESUME", fontNamed: "Menlo-Bold", size: 15, color: Palette.mint, + x: size.width / 2, y: size.height * 0.55 - 44) + hint.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + pausedOverlay.addChild(hint) + } + + private func buildGameOverOverlay() { + gameOverOverlay.removeAllChildren() + gameOverOverlay.zPosition = 40 + addChild(gameOverOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.72), size: size) + dim.anchorPoint = .zero + gameOverOverlay.addChild(dim) + let cx = size.width / 2 + let midY = size.height * 0.6 + gameOverOverlay.addChild(bigLabel("FLEET DESTROYED", size: 30, color: Palette.red, x: cx, y: midY + 36)) + gameOverDetail.fontSize = 15 + gameOverDetail.fontColor = .white + gameOverDetail.horizontalAlignmentMode = .center + gameOverDetail.position = CGPoint(x: cx, y: midY - 4) + gameOverOverlay.addChild(gameOverDetail) + gameOverRecord.fontSize = 14 + gameOverRecord.fontColor = Palette.amber + gameOverRecord.horizontalAlignmentMode = .center + gameOverRecord.position = CGPoint(x: cx, y: midY - 30) + gameOverOverlay.addChild(gameOverRecord) + let prompt = bigLabel("TAP TO FLY AGAIN", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint, + x: cx, y: midY - 70) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + gameOverOverlay.addChild(prompt) + } + + private func buildVictoryOverlay() { + victoryOverlay.removeAllChildren() + victoryOverlay.zPosition = 40 + addChild(victoryOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.82), size: size) + dim.anchorPoint = .zero + victoryOverlay.addChild(dim) + let cx = size.width / 2 + let midY = size.height * 0.60 + victoryOverlay.addChild(bigLabel("INVASION REPELLED", size: 28, color: Palette.mint, x: cx, y: midY + 48)) + victoryDetail.fontSize = 17 + victoryDetail.fontColor = Palette.amber + victoryDetail.horizontalAlignmentMode = .center + victoryDetail.position = CGPoint(x: cx, y: midY) + victoryOverlay.addChild(victoryDetail) + victoryRecord.fontSize = 14 + victoryRecord.fontColor = Palette.amber + victoryRecord.horizontalAlignmentMode = .center + victoryRecord.position = CGPoint(x: cx, y: midY - 28) + victoryOverlay.addChild(victoryRecord) + let prompt = bigLabel("TAP TO RETURN", fontNamed: "Menlo-Bold", size: 15, color: Palette.mint, + x: cx, y: midY - 68) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + victoryOverlay.addChild(prompt) + } + + // MARK: - Game flow + + private func startGame() { + sim = GalaxianSim() + targetX = 70 + targetY = 70 + accumulator = 0 + fireCooldown = 0 + pendingLaser = false + state = .playing + applyStateVisibility() + renderFrame() + } + + private func applyStateVisibility() { + readyOverlay.isHidden = state != .ready + pausedOverlay.isHidden = state != .paused + gameOverOverlay.isHidden = state != .gameOver + victoryOverlay.isHidden = state != .victory + pauseButton.isHidden = state != .playing + laserButton.alpha = state == .playing ? 1 : 0.25 + } + + private func endGame(won: Bool) { + let record = HighScoreStore.shared.submit(id: "galaxian", score: sim.score) + if won { + victoryDetail.text = "SCORE \(sim.score)" + victoryRecord.text = record ? "★ NEW BEST ★" : "BEST \(HighScoreStore.shared.best("galaxian"))" + state = .victory + Haptics.success() + } else { + gameOverDetail.text = "WAVE \(max(1, sim.level))/20 · SCORE \(sim.score)" + gameOverRecord.text = record ? "★ NEW BEST ★" : "BEST \(HighScoreStore.shared.best("galaxian"))" + state = .gameOver + Haptics.error() + } + applyStateVisibility() + } + + // MARK: - Game loop + + override func update(_ currentTime: TimeInterval) { + if lastUpdate == 0 { lastUpdate = currentTime } + var dt = currentTime - lastUpdate + lastUpdate = currentTime + if dt > 0.25 { dt = 0.25 } + guard state == .playing else { return } + #if DEBUG + if demoFreeze { return } + #endif + + let interval = 1.0 / GalaxianSim.fps + accumulator += dt + var steps = 0 + while accumulator >= interval, steps < 5 { + accumulator -= interval + steps += 1 + stepSim() + if state != .playing { break } + } + renderFrame() + } + + private func stepSim() { + var input = GalaxianSim.Input() + let dx = targetX - sim.playerX + let dy = targetY - sim.playerY + if dx != 0 { input.dx = dx > 0 ? 1 : -1 } + if dy != 0 { input.dy = dy > 0 ? 1 : -1 } + if fireCooldown > 0 { fireCooldown -= 1 } + if fireCooldown == 0 { + input.fire = true + fireCooldown = 6 // ~6 shots/sec at 35 fps + } + if pendingLaser { + input.laser = true + pendingLaser = false + } + sim.tick(input) + + for event in sim.events { + switch event { + case .levelLoaded(let n): + showWaveBanner(n) + case .enemyDestroyed: + Haptics.impact(.light, intensity: 0.5) + case .bossDestroyed: + Haptics.success() + case .playerHit: + Haptics.impact(.heavy, intensity: 0.9) + case .laserFired(let x): + fireLaserBeam(atColumn: x) + Haptics.impact(.medium, intensity: 0.8) + case .droneDeployed: + Haptics.warning() + case .crossFled: + break + case .gameOver: + endGame(won: false) + case .victory: + endGame(won: true) + } + } + } + + private func showWaveBanner(_ n: Int) { + waveBanner.removeAllActions() + waveBanner.text = "WAVE \(n) / 20" + waveBanner.alpha = 0 + waveBanner.run(.sequence([ + .fadeIn(withDuration: 0.2), + .wait(forDuration: 1.0), + .fadeOut(withDuration: 0.5), + ])) + Haptics.selection() + } + + // MARK: - Rendering + + private func renderFrame() { + // Player: normal, invulnerable shimmer, or explosion frames. + if sim.over && !sim.won { + playerNode.isHidden = true + } else if let frame = sim.playerExplosionFrame { + place(playerNode, img: D.explosionFrames[frame], x: sim.playerX, y: sim.playerY) + playerNode.alpha = 1 + } else { + place(playerNode, img: .ship, x: sim.playerX, y: sim.playerY) + playerNode.alpha = sim.playerInvulnerable ? 0.45 : 1.0 + } + + // Enemies (formation, swoopers, bouncers, explosions). + for (i, node) in enemyNodes.enumerated() { + if let d = sim.enemyDisplay(i) { + place(node, img: d.img, x: d.x, y: d.y) + } else { + node.isHidden = true + } + } + + // Boss: patrolling sprite or twin explosions. + if sim.bossStatus > 0, let img = sim.bossImg { + place(bossNode, img: img, x: sim.bossX, y: sim.bossY) + bossBoomNode.isHidden = true + } else if sim.bossStatus < 0 { + let f = D.explosionFrames[min(sim.bossPwr >> 1, 7)] + place(bossNode, img: f, x: sim.bossX, y: sim.bossY) + place(bossBoomNode, img: f, x: sim.bossX + 8, y: sim.bossY + 4) + } else { + bossNode.isHidden = true + bossBoomNode.isHidden = true + } + + // Bullets. + for (i, node) in pbNodes.enumerated() { + let b = sim.playerBullets[i] + if b.active { place(node, img: .playerBullet, x: b.x, y: b.y) } + else { node.isHidden = true } + } + for (i, node) in ebNodes.enumerated() { + let b = sim.enemyBullets[i] + if b.type != 0 { place(node, img: .enemyBullet, x: b.x, y: b.y) } + else { node.isHidden = true } + } + + // HUD. + scoreValue.text = "\(sim.score)" + waveValue.text = "\(max(1, sim.level))/20" + livesValue.text = "\(max(0, sim.lives))" + laserCount.text = "LASER ×\(sim.lasers)" + laserButton.setPressed(false) + laserButton.alpha = state != .playing ? 0.25 : (sim.lasers > 0 ? 1.0 : 0.3) + } + + // MARK: - Effects + + private func fireLaserBeam(atColumn x: Int) { + let bx = fieldRect.minX + (CGFloat(x) + 0.5) * unit + let path = CGMutablePath() + path.move(to: CGPoint(x: bx, y: fieldRect.minY)) + path.addLine(to: CGPoint(x: bx, y: fieldRect.maxY)) + let beam = SKShapeNode(path: path) + beam.strokeColor = Palette.cyan + beam.lineWidth = 2.5 + beam.glowWidth = 6 + beam.zPosition = 8 + worldCrop.addChild(beam) + beam.run(.sequence([.fadeOut(withDuration: 0.28), .removeFromParent()])) + } + + // MARK: - Touch handling + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + for touch in touches { + let p = touch.location(in: self) + switch state { + case .ready: + Haptics.selection() + startGame() + case .paused: + Haptics.selection() + state = .playing + applyStateVisibility() + case .gameOver, .victory: + Haptics.selection() + state = .ready + applyStateVisibility() + case .playing: + let dp = hypot(p.x - pauseButton.position.x, p.y - pauseButton.position.y) + if dp <= 30 { + Haptics.selection() + state = .paused + moveTouch = nil + applyStateVisibility() + continue + } + if laserButton.covers(p) { + if sim.lasers > 0 { + pendingLaser = true + laserButton.setPressed(true) + } else { + Haptics.warning() + } + continue + } + moveTouch = touch + touchAnchor = p + shipAnchor = CGPoint(x: CGFloat(sim.playerX), y: CGFloat(sim.playerY)) + targetX = sim.playerX + targetY = sim.playerY + } + } + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard state == .playing, let moveTouch, touches.contains(moveTouch) else { return } + let p = moveTouch.location(in: self) + // Finger travel maps ~1.35× on-screen ship travel (per-axis). + targetX = Int(shipAnchor.x + (p.x - touchAnchor.x) * 1.35 / unit) + targetY = Int(shipAnchor.y - (p.y - touchAnchor.y) * 1.35 / unitY) + targetX = min(max(targetX, 1), 88) + targetY = min(max(targetY, 61), 72) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil } + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil } + } +} diff --git a/Arcade85/Games/Galaxian/GalaxianSim.swift b/Arcade85/Games/Galaxian/GalaxianSim.swift new file mode 100644 index 0000000..7f4fa64 --- /dev/null +++ b/Arcade85/Games/Galaxian/GalaxianSim.swift @@ -0,0 +1,628 @@ +import Foundation + +/// A faithful port of Galaxian 3.0 (Patrick Davidson, 1998) from +/// `GALAXIAN.ASM`. Runs the original's exact main-loop order, enemy state +/// machine, timers, random rolls, hitboxes and scoring at ~35 fps. +/// +/// Coordinates: the original composes into a 96-wide back buffer whose rows +/// 16…79 are copied to the LCD — so x is 0…95, y grows downward, and the +/// visible window is y 16…79. Enemies stage above (y < 16) and bullets die +/// just below (y = 81). The player roams x 1…88, y 61…72. +/// +/// Enemy slots are 4 "bytes" (type, pwr, x, y) exactly as in TEXT_MEM — and +/// on cross/operator levels the bounce velocities live in the *following* +/// (empty) slot's pwr/x fields, faithfully mirroring the original's layout. +struct GalaxianSim { + typealias D = GalaxianData + + static let fps = 35.0 // per the doc: "approximately 35 fps" + static let fieldWidth = 96 + static let visibleTop = 16 // first LCD row of the back buffer + static let visibleHeight = 64 + + // MARK: - State + + struct Enemy { + var type = 0 // 0 empty · 1 waiting · 2 descending + // 3 formation · 4 exploding + // 5…12 swoop stages · 13 warp/bounce + var pwr = 0 + var x = 0 + var y = 0 + } + + struct PlayerBullet { var active = false; var x = 0; var y = 0 } + struct EnemyBullet { var type = 0; var x = 0; var y = 0 } // 1 straight · 2/4 left · 3/5 right + + enum Event: Equatable { + case levelLoaded(Int) + case enemyDestroyed(x: Int, y: Int) + case bossDestroyed + case playerHit + case laserFired(x: Int) + case droneDeployed + case crossFled + case gameOver + case victory + } + + struct Input { + var dx = 0 // -1 / 0 / +1 + var dy = 0 // -1 up / +1 down + var fire = false // one cannon shot (per press) + var laser = false // one laser (per press) + init(dx: Int = 0, dy: Int = 0, fire: Bool = false, laser: Bool = false) { + self.dx = dx; self.dy = dy; self.fire = fire; self.laser = laser + } + } + + private(set) var enemies = [Enemy](repeating: Enemy(), count: 20) + private(set) var playerBullets = [PlayerBullet](repeating: PlayerBullet(), count: 10) + private(set) var enemyBullets = [EnemyBullet](repeating: EnemyBullet(), count: 10) + + private(set) var playerX = 70 + private(set) var playerY = 70 + private(set) var yourStatus = 0 // 0 normal · 16…31 exploding · 1…15 respawn shield + private(set) var lives = 9 + private(set) var lasers = 9 + private(set) var score = 0 + private(set) var level = 0 // 1…20 once loaded + private(set) var enemiesRemaining = 0 + private(set) var timer: UInt8 = 0 + + private(set) var bossStatus = 0 // 0 none · 1…4 patrol · 5/6 super sweep · -1 exploding + private(set) var bossPwr = 0 // hit points; explosion frame while exploding + private(set) var bossX = 0 + private(set) var bossY = 0 + + private(set) var over = false + private(set) var won = false + private(set) var events: [Event] = [] + + private var rng: SeededGenerator + + init(seed: UInt64? = nil) { + rng = SeededGenerator(seed: seed ?? UInt64.random(in: 1...UInt64.max)) + } + + // MARK: - Level config helpers + + var currentLevel: D.Level { D.levels[max(0, min(level, 20) - 1)] } + + private var isWeakLevel: Bool { if case .weak = currentLevel.kind { return true }; return false } + private var isWarpLevel: Bool { + switch currentLevel.kind { case .cross, .operators: return true; default: return false } + } + private var isOperatorLevel: Bool { if case .operators = currentLevel.kind { return true }; return false } + private var isSuperBoss: Bool { if case .superBoss = currentLevel.kind { return true }; return false } + + /// R-register read: pseudo-random 0…127. + private mutating func randR() -> Int { Int(rng.next() & 0x7F) } + /// One `rra`-on-R carry test: ~50/50. + private mutating func rngBit() -> Bool { rng.next() & 1 == 1 } + + private mutating func subScore(_ n: Int) { score = max(0, score - n) } + + // MARK: - Main loop (original call order) + + mutating func tick(_ input: Input) { + guard !over else { return } + events.removeAll(keepingCapacity: true) + + if enemiesRemaining == 0 { nextLevel() } + if over { return } + timer &+= 1 + + youShoot(input) + youShootLaser(input) + yourShip(input) + if over { return } + moveYourBullets() + enemySwoop() + handleEnemies() + enemiesHitYou() + boss() + bossShootPeriodic() + enemiesShootYou() + moveEnemyBullets() + } + + // MARK: - Level loading (Next_Level) + + private mutating func nextLevel() { + enemies = [Enemy](repeating: Enemy(), count: 20) + timer = 0 + score += 200 + level += 1 + if level == 21 { + score += 5000 + won = true + over = true + events.append(.victory) + return + } + + bossStatus = 0 + let lev = currentLevel + + func fillFormation(_ count: Int) { + for i in 0.. 0 else { return } + lasers -= 1 + let lx = playerX + 3 + events.append(.laserFired(x: lx)) + for i in 0..<20 { + let t = enemies[i].type + if t == 0 || t == 1 || t == 4 { continue } + let dx = lx - enemies[i].x + if dx >= 0 && dx <= 7 { // beam crosses the 8-px sprite + enemies[i].pwr = 0 + enemies[i].type = 4 + events.append(.enemyDestroyed(x: enemies[i].x, y: enemies[i].y)) + } + } + } + + // MARK: - Player ship (Your_Ship) + + private mutating func yourShip(_ input: Input) { + if yourStatus & 16 != 0 { // exploding: -16 points a frame + subScore(16) + yourStatus -= 1 + return + } + if yourStatus != 0 { + yourStatus -= 1 + if lives == 0 { + over = true + events.append(.gameOver) + return + } + } + // Original key order: down, left, right, up — each edge-blocked. + if input.dy > 0, playerY + 1 != 73 { playerY += 1 } + if input.dx < 0, playerX - 1 != 0 { playerX -= 1 } + if input.dx > 0, playerX + 1 != 89 { playerX += 1 } + if input.dy < 0, playerY - 1 != 60 { playerY -= 1 } + } + + private mutating func damageYou() { + yourStatus = 31 + lives -= 1 + events.append(.playerHit) + } + + // MARK: - Player bullets (Move_Your_Bullets) + + private mutating func moveYourBullets() { + for b in 0..<10 where playerBullets[b].active { + playerBullets[b].y -= 1 + if playerBullets[b].y == 0 { playerBullets[b].active = false; continue } + playerBullets[b].y -= 1 + if playerBullets[b].y == 0 { playerBullets[b].active = false; continue } + + let bx = playerBullets[b].x + let by = playerBullets[b].y + + // Enemies. Original quirk kept: every overlapped enemy is hit + // (+10 each), stopping only when one is destroyed. + var killed = false + for i in 0..<20 { + let t = enemies[i].type + if t == 0 || t == 1 || t == 4 { continue } + let dx = enemies[i].x - bx + 5 + if dx < 0 || dx >= 8 { continue } + let dy = enemies[i].y - by + 5 + if dy < 0 || dy >= 10 { continue } + + playerBullets[b].active = false + score += 10 + enemies[i].pwr -= 1 + if enemies[i].pwr == 0 { + enemies[i].type = 4 // pwr doubles as explosion frame + events.append(.enemyDestroyed(x: enemies[i].x, y: enemies[i].y)) + killed = true + break + } + } + if killed { continue } + + // Boss (SMC-widened hitbox for the superboss). + if bossStatus > 0 { + let k1 = isSuperBoss ? 31 : 15 + let k2 = isSuperBoss ? 34 : 18 + let dx = bossX - bx + k1 + let dy = bossY - by + 8 + if dx >= 0 && dx < k2 && dy >= 0 && dy < 13 { + playerBullets[b].active = false + score += 25 + bossPwr -= 1 + if bossPwr == 0 { bossStatus = -1 } // pwr now counts the explosion + } + } + } + } + + // MARK: - Swoop trigger (Enemy_Swoop) + + private mutating func enemySwoop() { + guard timer & 3 == 2 else { return } + let r = randR() + guard r < 19 else { return } + guard enemies[r].type == 3 else { return } + var t = 5 + (rngBit() ? 1 : 0) + let x = enemies[r].x + if x & 64 != 0 { t = 6 } // right side: swoop left + else if x & 32 == 0 { t = 5 } // left side: swoop right + enemies[r].type = t // middle keeps the random pick + } + + // MARK: - Enemy state machine (Handle_Enemies) + + private mutating func handleEnemies() { + for i in 0..<20 { + switch enemies[i].type { + case 1: enemyWaiting(i) + case 2: enemyDescending(i) + case 3: enemyFormation(i) + case 4: enemyExploding(i) + case 5: swoopStage(i, dx: 1, dy: -1, next: 7) + case 6: swoopStage(i, dx: -1, dy: -1, next: 8) + case 7: swoopStage(i, dx: 1, dy: 0, next: 9) + case 8: swoopStage(i, dx: -1, dy: 0, next: 10) + case 9: swoopStage(i, dx: 1, dy: 1, next: 11) + case 10: swoopStage(i, dx: -1, dy: 1, next: 11) + case 11: swoopStage(i, dx: 0, dy: 1, next: 12) + case 12: enemyDiving(i) + case 13: enemyWarp(i) + default: break + } + } + } + + /// Type 1: enters when the masked timer matches its pattern byte. The + /// pattern byte is both entry time and formation depth: the enemy descends + /// 1 px/frame and the timer wraps exactly `depth` frames later. + private mutating func enemyWaiting(_ i: Int) { + let pair = currentLevel.pattern[19 - i] // original indexes coords from the end + guard (Int(timer) & 127) == 128 - pair.y else { return } + enemies[i].type = 2 + enemies[i].x = pair.x + enemies[i].y = 0 + } + + /// Type 2: descend until the 128-frame wrap, then join the formation. + private mutating func enemyDescending(_ i: Int) { + if timer == 0 || timer == 128 { + enemies[i].type = 3 + enemyFormation(i) // original falls through + return + } + enemies[i].y += 1 + } + + /// Type 3: formation sway — even frames only, direction from timer bit 6. + private mutating func enemyFormation(_ i: Int) { + guard timer & 1 == 0 else { return } + enemies[i].x += 1 + if timer & 64 != 0 { enemies[i].x -= 2 } + } + + /// Type 4: 16-frame explosion, then +100 and the slot frees up. + private mutating func enemyExploding(_ i: Int) { + if enemies[i].pwr == 15 { + enemies[i].type = 0 + enemiesRemaining -= 1 + score += 100 + return + } + enemies[i].pwr += 1 + } + + /// Types 5…11: constant-velocity swoop stages, advancing every 4th frame. + private mutating func swoopStage(_ i: Int, dx: Int, dy: Int, next: Int) { + enemies[i].x += dx + enemies[i].y += dy + if timer & 3 == 0 { enemies[i].type = next } + } + + /// Type 12: fast dive off the bottom, then rejoin the entry queue (-1 pt). + private mutating func enemyDiving(_ i: Int) { + enemies[i].y += 2 + if enemies[i].y > 78 { + enemies[i].type = 1 + subScore(1) + } + } + + /// Type 13: cross/operator bounce box. Velocities live in the next slot + /// (pwr = vx, x = vy). Half of the crosses can flee off the top (-50). + private mutating func enemyWarp(_ i: Int) { + var vx = enemies[i + 1].pwr + var vy = enemies[i + 1].x + + let nx = enemies[i].x + vx + if nx == 1 { vx = 1 } + if nx == 23 { vx = rngBit() ? 1 : -1 } + if nx == 45 { vx = rngBit() ? 1 : -1 } + if nx == 87 { vx = -1 } + enemies[i].x = nx + enemies[i + 1].pwr = vx + + var ny = enemies[i].y + vy + if ny == 0 { // escaped off the top + enemies[i].type = 0 + enemiesRemaining -= 1 + subScore(50) + events.append(.crossFled) + return + } + if ny == 15 { vy = 1 } + if ny == 27 { vy = rngBit() ? 1 : -1 } + if ny == 39 { + vy = -1 + if !rngBit(), !rngBit(), // 1-in-4 roll + !isOperatorLevel, (40 - 2 * i) & 8 != 0 { + vy = -2 // flee: rises 2/frame to exactly 0 + ny -= 1 + } + } + enemies[i].y = ny + enemies[i + 1].x = vy + } + + // MARK: - Enemies ram you (Enemies_Hit_You) + + private mutating func enemiesHitYou() { + guard yourStatus == 0 else { return } + for i in 0..<20 { + guard enemies[i].type != 0 else { continue } + let dx = enemies[i].x - playerX + 6 + if dx < 0 || dx >= 12 { continue } + let dy = enemies[i].y - playerY + 6 + if dy < 0 || dy >= 12 { continue } + enemies[i].pwr = 0 + enemies[i].type = 4 + events.append(.enemyDestroyed(x: enemies[i].x, y: enemies[i].y)) + damageYou() + return // one ram per frame + } + } + + // MARK: - Boss (Boss) + + private mutating func boss() { + switch bossStatus { + case 0: + return + case ..<0: // exploding + if bossPwr == 15 { + bossStatus = 0 + enemiesRemaining -= 1 + score += 1000 + events.append(.bossDestroyed) + } else { + bossPwr += 1 + } + case 1: // patrol: down to y 20 + bossY += 1 + if bossY == 20 { bossStatus = 2 } + case 2: // right to x 80 + bossX += 1 + if bossX == 80 { bossStatus = 3 } + case 3: // up to y 16 + bossY -= 1 + if bossY == 16 { bossStatus = 4 } + case 4: // left to x 0 + bossX -= 1 + if bossX == 0 { bossStatus = 1 } + case 5: // superboss: sweep left to 0 + bossX -= 1 + if bossX == 0 { bossStatus = 6 } + deployEnemies() + case 6: // descend to y 16, sweep right to 65 + if bossY != 16 { + bossY += 1 + } else { + bossX += 1 + if bossX == 65 { bossStatus = 5 } + } + deployEnemies() + default: + break + } + } + + /// Superboss drone factory: ~7/128 chance a frame, into slots 0…6 (-200). + private mutating func deployEnemies() { + let r = randR() + guard r < 7 else { return } + guard enemies[r].type == 0 else { return } + enemies[r] = Enemy(type: 11, pwr: currentLevel.enemyPwr, x: bossX + 12, y: 24) + enemiesRemaining += 1 + subScore(200) + events.append(.droneDeployed) + } + + // MARK: - Firing (Boss_Shoot / Enemies_Shoot_You) + + /// The original's 5-zone aim: picks a straight/drifting bullet type from + /// the player's horizontal offset against the muzzle x. + private mutating func spawnAimedBullet(fromX d: Int, y e: Int) { + guard let slot = enemyBullets.firstIndex(where: { $0.type == 0 }) else { return } + var t = 1 + let a = playerX - d + 18 + if a < 0 { + t = 2 + if a + 18 < 0 { t = 4 } + } else if a - 54 >= 0 { + t = 3 + if a - 72 >= 0 { t = 5 } + } + enemyBullets[slot] = EnemyBullet(type: t, x: d, y: e) + } + + private mutating func spawnStraightBullet(x: Int, y: Int) { + guard let slot = enemyBullets.firstIndex(where: { $0.type == 0 }) else { return } + enemyBullets[slot] = EnemyBullet(type: 1, x: x, y: y) + } + + private mutating func bossShootPeriodic() { + guard timer & 63 == 0, bossStatus > 0 else { return } + let e = bossY + 5 + if isSuperBoss { + for off in [0, 10, 20, 30] { spawnAimedBullet(fromX: bossX + off, y: e) } + } else { + spawnAimedBullet(fromX: bossX, y: e) + spawnAimedBullet(fromX: bossX + 13, y: e) + } + } + + private mutating func enemiesShootYou() { + let r = randR() + guard r < 19 else { return } + let t = enemies[r].type + guard t != 0, t != 1 else { return } + let d = enemies[r].x + 2 + let e = enemies[r].y + 5 + if isOperatorLevel { + spawnAimedBullet(fromX: d, y: e) // operators aim like bosses + } else { + spawnStraightBullet(x: d, y: e) + } + } + + // MARK: - Enemy bullets (Move_Enemy_Bullets) + + private mutating func moveEnemyBullets() { + for b in 0..<10 { + let t = enemyBullets[b].type + guard t != 0 else { continue } + + switch t { + case 2: + if timer & 1 == 0 { + enemyBullets[b].x -= 1 + if enemyBullets[b].x == 0 { enemyBullets[b].type = 0; continue } + } + case 4: + enemyBullets[b].x -= 1 + if enemyBullets[b].x == 0 { enemyBullets[b].type = 0; continue } + case 3: + if timer & 1 == 0 { + enemyBullets[b].x += 1 + if enemyBullets[b].x == 95 { enemyBullets[b].type = 0; continue } + } + case 5: + enemyBullets[b].x += 1 + if enemyBullets[b].x == 95 { enemyBullets[b].type = 0; continue } + default: + break + } + + if enemyBullets[b].y == 81 { enemyBullets[b].type = 0; continue } + enemyBullets[b].y += 1 + + if yourStatus == 0 { + let dx = playerX - enemyBullets[b].x + 6 + let dy = playerY - enemyBullets[b].y + 6 + if dx >= 0 && dx < 9 && dy >= 0 && dy < 9 { + damageYou() + enemyBullets[b].type = 0 + } + } + } + } + + // MARK: - Display helpers + + /// What to draw for enemy slot `i`, or nil if nothing. + func enemyDisplay(_ i: Int) -> (img: D.Img, x: Int, y: Int)? { + let e = enemies[i] + switch e.type { + case 0, 1: + return nil + case 4: + return (D.explosionFrames[min(e.pwr >> 1, 7)], e.x, e.y) + default: + let img: D.Img = (isWeakLevel && i <= 12) ? .enemy1 : currentLevel.enemyImg + return (img, e.x, e.y) + } + } + + var bossImg: D.Img? { + guard bossStatus != 0 else { return nil } + if case .boss(let img, _) = currentLevel.kind { return img } + if isSuperBoss { return .boss3 } + return nil + } + + /// Player explosion frame (0…7) while exploding, else nil. + var playerExplosionFrame: Int? { + guard yourStatus & 16 != 0 else { return nil } + return ((((yourStatus - 1) & 14) ^ 14) >> 1) + } + var playerInvulnerable: Bool { yourStatus != 0 } + + #if DEBUG + mutating func debugSetLevel(_ n: Int) { // next tick loads level n + level = n - 1 + enemiesRemaining = 0 + enemies = [Enemy](repeating: Enemy(), count: 20) + bossStatus = 0 + } + mutating func debugPlacePlayer(x: Int, y: Int) { playerX = x; playerY = y } + mutating func debugSetLives(_ n: Int) { lives = n } + mutating func debugSetLasers(_ n: Int) { lasers = n } + mutating func debugSetScore(_ n: Int) { score = n } + mutating func debugSetTimer(_ t: UInt8) { timer = t } + mutating func debugSetEnemy(_ i: Int, type: Int, pwr: Int, x: Int, y: Int) { + enemies[i] = Enemy(type: type, pwr: pwr, x: x, y: y) + } + mutating func debugSetRemaining(_ n: Int) { enemiesRemaining = n } + mutating func debugSetBoss(status: Int, pwr: Int, x: Int, y: Int) { + bossStatus = status; bossPwr = pwr; bossX = x; bossY = y + } + #endif +} diff --git a/Arcade85/Games/Lander/LanderScene.swift b/Arcade85/Games/Lander/LanderScene.swift new file mode 100644 index 0000000..f305f94 --- /dev/null +++ b/Arcade85/Games/Lander/LanderScene.swift @@ -0,0 +1,1016 @@ +import SpriteKit +import UIKit + +/// HD remake of TI-85 Lunar Lander. The simulation (`LanderSim`) is the faithful +/// original in a 128×64 integer space; this scene scales it into a framed +/// "viewport", renders crisp neon art, drives it with multi-touch thruster pads, +/// and wraps it in ready / landed / game-over flow. +final class LanderScene: SKScene { + + // MARK: State + private enum GameState { case ready, playing, landed, gameOver } + private var state: GameState = .ready + + private var sim = LanderSim() + private var totalScore = 0 + + // Difficulty selected on the ready screen (fuel 30…80 as in the original; + // speed 4…7 — the original allowed 3, but there terminal velocity equals + // the safe-touchdown limit and the game can't be lost). The classic's own + // defaults (40/7) are its hardest speed setting, so the remake defaults + // gentler and remembers the player's last choice. Gravity is the remake's + // third knob: heavier worlds fall harder, keep sideways momentum, and pay + // ×2/×3 score. + private var chosenFuel = UserDefaults.standard.object(forKey: "lander.fuel") as? Int ?? 60 + private var chosenVel = max(4, UserDefaults.standard.object(forKey: "lander.vel") as? Int ?? 4) + private var chosenGravity = LanderSim.Gravity( + rawValue: UserDefaults.standard.object(forKey: "lander.gravity") as? Int ?? 0) ?? .moon + + // MARK: Timing + /// One tick per pass of the original game loop. LANDER.C ends every pass + /// with `delay(20)` — the same delay() whose `delay(200)` calls are the + /// ~2-second message pauses — so the classic ran at roughly 5 loops/sec. + /// Rendering interpolates between ticks, so motion stays smooth. + private let simInterval: TimeInterval = 0.2 + private var accumulator: TimeInterval = 0 + private var lastUpdate: TimeInterval = 0 + private var stateEnteredTime: TimeInterval = 0 + private var prevX = 0, prevY = 0 + + // MARK: Controls + private var thrustLeft = false, thrustUp = false, thrustRight = false + private var lastLeft = false, lastUp = false, lastRight = false + // A tap can begin and end inside one 200 ms tick; latch it so it still + // burns for exactly one tick (one keypress-loop in the original). + private var latchLeft = false, latchUp = false, latchRight = false + private var activeTouches = Set() + + // MARK: Layout metrics + private var fieldOriginX: CGFloat = 0 + private var fieldOriginY: CGFloat = 0 + private var unit: CGFloat = 3 // scene points per logical pixel + private var topInset: CGFloat = 0 + private var controlBarHeight: CGFloat = 190 + + // MARK: Nodes + private let worldCrop = SKCropNode() + private var terrainSprite = SKSpriteNode() + private var ridgeSprite = SKSpriteNode() + private var padLine = SKShapeNode() + private let padPostL = SKShapeNode(circleOfRadius: 3) + private let padPostR = SKShapeNode(circleOfRadius: 3) + private var padCone = SKSpriteNode() + private var padBeacons: [SKShapeNode] = [] + private var lander = SKSpriteNode() + private var mainJet = SKEmitterNode() + private var leftJet = SKEmitterNode() + private var rightJet = SKEmitterNode() + private var engineGlow = SKSpriteNode() + + // HUD + private let scoreValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let vspdValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let fuelBarBg = SKShapeNode() + private let fuelBarFill = SKShapeNode() + private var fuelBarWidth: CGFloat = 130 + + // Controls + private let btnLeft = ControlButton(symbol: "arrowtriangle.left.fill") + private let btnUp = ControlButton(symbol: "flame.fill") + private let btnRight = ControlButton(symbol: "arrowtriangle.right.fill") + + // Overlays + private let readyOverlay = SKNode() + private let landedOverlay = SKNode() + private let gameOverOverlay = SKNode() + private let fuelValueLabel = SKLabelNode(fontNamed: "Menlo-Bold") + private let speedValueLabel = SKLabelNode(fontNamed: "Menlo-Bold") + private let gravityValueLabel = SKLabelNode(fontNamed: "Menlo-Bold") + private var stepperRects: [String: CGRect] = [:] + private let landedTitle = SKLabelNode(fontNamed: "AvenirNext-Heavy") + private let landedDetail = SKLabelNode(fontNamed: "Menlo-Bold") + private let gameOverTitle = SKLabelNode(fontNamed: "AvenirNext-Heavy") + private let gameOverDetail = SKLabelNode(fontNamed: "Menlo-Bold") + private let gameOverRecord = SKLabelNode(fontNamed: "Menlo-Bold") + + private var builtSize: CGSize = .zero + #if DEBUG + private var demoFreeze = false + #endif + + // MARK: - Lifecycle + + override func didMove(to view: SKView) { + anchorPoint = CGPoint(x: 0, y: 0) + scaleMode = .resizeFill + layoutScene() + #if DEBUG + let env = ProcessInfo.processInfo.environment + if env["ARCADE_AUTOPLAY"] == "1" { + startFromReady() + } + if env["ARCADE_DEMO"] == "1" { + startFromReady() + sim.debugPose(x: max(0, sim.padStart - 6), y: 42, vy: -2) + prevX = sim.x; prevY = sim.y + thrustUp = true + updateButtonVisuals() + demoFreeze = true + renderLander(currentTime: 0) + updateHUD() + } + #endif + } + + override func didChangeSize(_ oldSize: CGSize) { + guard view != nil, size != builtSize, size.width > 0 else { return } + layoutScene() + } + + // MARK: - Coordinate mapping + + private func scenePoint(_ lx: CGFloat, _ ly: CGFloat) -> CGPoint { + CGPoint(x: fieldOriginX + lx * unit, y: fieldOriginY + ly * unit) + } + + // MARK: - Build / layout + + private func layoutScene() { + builtSize = size + removeAllChildren() + backgroundColor = Palette.space1 + + let insets = view?.safeAreaInsets ?? .zero + topInset = insets.top + let bottomInset = insets.bottom + + // Background gradient + stars fill the whole scene. + let bg = SKSpriteNode(texture: SceneArt.verticalGradient(size: size, top: Palette.space0, bottom: Palette.space1)) + bg.anchorPoint = .zero + bg.zPosition = -10 + addChild(bg) + + // Two star layers for depth: dim far field + brighter near field. + let farStars = SceneArt.starfield(size: size, count: 70, seed: 0xFA57) + farStars.zPosition = -9.5 + farStars.alpha = 0.5 + farStars.setScale(0.7) + addChild(farStars) + let stars = SceneArt.starfield(size: size, count: 60, seed: 0xA11E5) + stars.zPosition = -9 + addChild(stars) + + // Soft nebula blooms in the upper sky. + let nebula1 = SceneArt.glowSprite(color: Palette.violet, radius: 210) + nebula1.position = CGPoint(x: size.width * 0.78, y: size.height * 0.80) + nebula1.alpha = 0.16 + nebula1.zPosition = -9.2 + addChild(nebula1) + let nebula2 = SceneArt.glowSprite(color: Palette.cyan, radius: 150) + nebula2.position = CGPoint(x: size.width * 0.14, y: size.height * 0.62) + nebula2.alpha = 0.10 + nebula2.zPosition = -9.2 + addChild(nebula2) + + let dust = SceneArt.ambientDust(size: size) + dust.position = CGPoint(x: size.width / 2, y: size.height / 2) + dust.zPosition = -8.8 + addChild(dust) + + // The field spans the full width — the world, not a windowed port. + // (128×96 logical: the original terrain plus the taller remake sky.) + unit = size.width / CGFloat(LanderSim.fieldWidth) + fieldOriginX = 0 + let fieldH = CGFloat(LanderSim.fieldHeight) * unit + let fieldW = CGFloat(LanderSim.fieldWidth) * unit + + controlBarHeight = max(bottomInset + 176, size.height * 0.24) + let hudHeight: CGFloat = 58 + let viewportTop = size.height - topInset - hudHeight + let viewportBottom = controlBarHeight + // Sit the field just above the controls; the remaining slack becomes + // open sky above (the space the lander descends from). + let slack = max(0, (viewportTop - viewportBottom) - fieldH) + fieldOriginY = viewportBottom + min(slack, 30) + + let fieldRect = CGRect(x: fieldOriginX, y: fieldOriginY, width: fieldW, height: fieldH) + + // Bedrock continues the terrain below the field so the ground runs + // under the control pads instead of stopping at an arbitrary line. + let bedrock = SKSpriteNode(texture: SceneArt.verticalGradient( + size: CGSize(width: size.width, height: fieldOriginY + 2), + top: UIColor(red: 0.045, green: 0.070, blue: 0.098, alpha: 1), // = terrain rockBottom + bottom: UIColor(red: 0.018, green: 0.030, blue: 0.046, alpha: 1))) + bedrock.anchorPoint = .zero + bedrock.position = .zero + bedrock.zPosition = -1 + addChild(bedrock) + + // Crop node clips the lander to the field (enforces the ceiling/edges). + worldCrop.removeAllChildren() + let mask = SKSpriteNode(color: .white, size: fieldRect.size) + mask.position = CGPoint(x: fieldRect.midX, y: fieldRect.midY) + worldCrop.maskNode = mask + worldCrop.position = .zero + worldCrop.zPosition = 0 + addChild(worldCrop) + + let vignette = SceneArt.vignette(size: size, alpha: 0.42) + vignette.position = CGPoint(x: size.width / 2, y: size.height / 2) + addChild(vignette) + + buildLander() + buildHUD() + buildControls(bottomInset: bottomInset) + buildReadyOverlay(fieldRect: fieldRect) + buildLandedOverlay(fieldRect: fieldRect) + buildGameOverOverlay() + + refreshTerrain() + prevX = sim.x; prevY = sim.y + + // Restore visuals for the current state (layout can happen mid-run on rotation). + applyStateVisibility() + renderLander(currentTime: lastUpdate) + updateHUD() + } + + private func buildLander() { + let px = CGFloat(LanderSim.landerSize) * unit + lander = SKSpriteNode(texture: SceneArt.landerTexture()) + // Drawn a touch over the 8×8 logical footprint for presence; the sim's + // collision box is unchanged. + lander.size = CGSize(width: px * 1.18, height: px * 1.18) + lander.zPosition = 5 + + // Particle exhaust jets replace the old triangle flames. Birth rates + // are toggled from renderLander; target the world so plumes linger. + mainJet = SceneArt.thrustEmitter(color: Palette.amber, angle: -.pi / 2, + speed: 180, birthRate: 0, scale: 0.34, lifetime: 0.32) + mainJet.position = CGPoint(x: 0, y: -px * 0.52) + leftJet = SceneArt.thrustEmitter(color: Palette.cyan, angle: .pi, + speed: 130, birthRate: 0, scale: 0.22, lifetime: 0.22) + leftJet.position = CGPoint(x: -px * 0.55, y: -px * 0.08) + rightJet = SceneArt.thrustEmitter(color: Palette.cyan, angle: 0, + speed: 130, birthRate: 0, scale: 0.22, lifetime: 0.22) + rightJet.position = CGPoint(x: px * 0.55, y: -px * 0.08) + [mainJet, leftJet, rightJet].forEach { + $0.zPosition = -1 + lander.addChild($0) + $0.targetNode = worldCrop + } + + engineGlow = SceneArt.glowSprite(color: Palette.amber, radius: px * 0.7) + engineGlow.position = CGPoint(x: 0, y: -px * 0.45) + engineGlow.alpha = 0 + engineGlow.zPosition = -2 + lander.addChild(engineGlow) + + worldCrop.addChild(lander) + } + + /// Rebuilds terrain + pad geometry from the current sim (call after new terrain). + private func refreshTerrain() { + let s = sim.surface + let fieldW = CGFloat(LanderSim.fieldWidth) * unit + let fieldH = CGFloat(LanderSim.fieldHeight) * unit + + // A distant ridge behind the terrain — pure set dressing for depth. + ridgeSprite.removeFromParent() + ridgeSprite = SKSpriteNode(texture: ridgeTexture(width: fieldW, height: fieldH * 0.5)) + ridgeSprite.anchorPoint = .zero + ridgeSprite.position = CGPoint(x: fieldOriginX, y: fieldOriginY) + ridgeSprite.alpha = 0.55 + ridgeSprite.zPosition = 0.5 + worldCrop.addChild(ridgeSprite) + + terrainSprite.removeFromParent() + terrainSprite = SKSpriteNode(texture: terrainTexture(width: fieldW, height: fieldH)) + terrainSprite.anchorPoint = .zero + terrainSprite.position = CGPoint(x: fieldOriginX, y: fieldOriginY) + terrainSprite.zPosition = 1 + worldCrop.addChild(terrainSprite) + + let padY = CGFloat(sim.padHeight) + let padL = scenePoint(CGFloat(sim.padStart), padY) + let padR = scenePoint(CGFloat(sim.padStart + LanderSim.padWidth), padY) + + // Landing light cone rising from the pad. + padCone.removeFromParent() + padCone = SKSpriteNode(texture: SceneArt.verticalGradient( + size: CGSize(width: padR.x - padL.x, height: 64), + top: UIColor.clear, bottom: Palette.amber.withAlphaComponent(0.16))) + padCone.anchorPoint = CGPoint(x: 0.5, y: 0) + padCone.position = CGPoint(x: (padL.x + padR.x) / 2, y: padL.y) + padCone.blendMode = .add + padCone.zPosition = 2.5 + padCone.run(.repeatForever(.sequence([ + .fadeAlpha(to: 0.55, duration: 1.1), .fadeAlpha(to: 1.0, duration: 1.1)]))) + worldCrop.addChild(padCone) + + let pad = CGMutablePath() + pad.move(to: padL) + pad.addLine(to: padR) + padLine.removeFromParent() + padLine = SKShapeNode(path: pad) + padLine.strokeColor = Palette.amber + padLine.lineWidth = 3 + padLine.lineCap = .round + padLine.glowWidth = 3 + padLine.zPosition = 3 + worldCrop.addChild(padLine) + + padPostL.fillColor = Palette.amber + padPostR.fillColor = Palette.amber + padPostL.strokeColor = .clear + padPostR.strokeColor = .clear + padPostL.zPosition = 3 + padPostR.zPosition = 3 + padPostL.position = padL + padPostR.position = padR + padPostL.removeFromParent(); padPostR.removeFromParent() + worldCrop.addChild(padPostL); worldCrop.addChild(padPostR) + + // Blinking approach beacons along the pad. + padBeacons.forEach { $0.removeFromParent() } + padBeacons.removeAll() + for k in 0..<3 { + let beacon = SKShapeNode(circleOfRadius: 2.2) + beacon.fillColor = Palette.mint + beacon.strokeColor = .clear + beacon.glowWidth = 3 + let t = CGFloat(k + 1) / 4 + beacon.position = CGPoint(x: padL.x + (padR.x - padL.x) * t, y: padL.y + 4) + beacon.zPosition = 3.5 + beacon.alpha = 0.15 + beacon.run(.repeatForever(.sequence([ + .wait(forDuration: 0.22 * Double(k)), + .fadeAlpha(to: 1, duration: 0.16), + .fadeAlpha(to: 0.15, duration: 0.5), + .wait(forDuration: 0.22 * Double(2 - k)), + ]))) + worldCrop.addChild(beacon) + padBeacons.append(beacon) + } + } + + /// Renders the sim's surface as gradient rock with speckle grain and a + /// glowing rim light along the skyline. + private func terrainTexture(width: CGFloat, height: CGFloat) -> SKTexture { + let s = sim.surface + let n = LanderSim.fieldWidth + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) + let image = renderer.image { ctx in + let cg = ctx.cgContext + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: height)) + for i in 0.. SKTexture { + var rng = SeededGenerator(seed: 0x51D6E) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) + let image = renderer.image { ctx in + let cg = ctx.cgContext + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: height)) + var y = CGFloat.random(in: height * 0.35...height * 0.7, using: &rng) + var x: CGFloat = 0 + while x < width { + let step = CGFloat.random(in: 26...64, using: &rng) + x += step + y = max(height * 0.16, min(height * 0.85, + y + CGFloat.random(in: -height * 0.28...height * 0.28, using: &rng))) + path.addLine(to: CGPoint(x: x, y: y)) + } + path.addLine(to: CGPoint(x: width, y: height)) + path.closeSubpath() + cg.addPath(path) + UIColor(red: 0.058, green: 0.086, blue: 0.155, alpha: 1).setFill() + cg.fillPath() + } + return SKTexture(image: image) + } + + // MARK: - HUD + + private func buildHUD() { + let hudY = size.height - topInset - 26 + + // V-SPEED (left, clear of the back button); the caption teaches the + // original's landing rule — touchdown is survivable at descent ≤3. + let vspdCaption = caption("V-SPEED · SAFE ≤3", x: 74, y: hudY + 12, align: .left) + addChild(vspdCaption) + vspdValue.fontSize = 20 + vspdValue.horizontalAlignmentMode = .left + vspdValue.verticalAlignmentMode = .center + vspdValue.position = CGPoint(x: 74, y: hudY - 8) + vspdValue.fontColor = Palette.mint + vspdValue.zPosition = 20 + addChild(vspdValue) + + // SCORE (right) + let scoreCaption = caption("SCORE", x: size.width - 20, y: hudY + 12, align: .right) + addChild(scoreCaption) + scoreValue.fontSize = 20 + scoreValue.horizontalAlignmentMode = .right + scoreValue.verticalAlignmentMode = .center + scoreValue.position = CGPoint(x: size.width - 20, y: hudY - 8) + scoreValue.fontColor = .white + scoreValue.zPosition = 20 + addChild(scoreValue) + + // FUEL bar (center) + fuelBarWidth = min(150, size.width * 0.36) + let fuelCaption = caption("FUEL", x: size.width / 2, y: hudY + 12, align: .center) + addChild(fuelCaption) + let barY = hudY - 8 + let barRect = CGRect(x: -fuelBarWidth / 2, y: -5, width: fuelBarWidth, height: 10) + fuelBarBg.path = CGPath(roundedRect: barRect, cornerWidth: 5, cornerHeight: 5, transform: nil) + fuelBarBg.fillColor = UIColor(white: 1, alpha: 0.10) + fuelBarBg.strokeColor = UIColor(white: 1, alpha: 0.18) + fuelBarBg.lineWidth = 1 + fuelBarBg.position = CGPoint(x: size.width / 2, y: barY) + fuelBarBg.zPosition = 20 + addChild(fuelBarBg) + fuelBarFill.position = CGPoint(x: size.width / 2 - fuelBarWidth / 2, y: barY) + fuelBarFill.zPosition = 21 + fuelBarFill.strokeColor = .clear + addChild(fuelBarFill) + } + + private func caption(_ text: String, x: CGFloat, y: CGFloat, align: SKLabelHorizontalAlignmentMode) -> SKLabelNode { + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = text + label.fontSize = 10 + label.fontColor = Palette.white40 + label.horizontalAlignmentMode = align + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + label.zPosition = 20 + return label + } + + // MARK: - Controls + + private func buildControls(bottomInset: CGFloat) { + let cy = bottomInset + (controlBarHeight - bottomInset) * 0.5 + let sideR: CGFloat = 46 + let upR: CGFloat = 54 + + btnLeft.configure(radius: sideR, tint: Palette.cyan) + btnRight.configure(radius: sideR, tint: Palette.cyan) + btnUp.configure(radius: upR, tint: Palette.mint) + + btnLeft.position = CGPoint(x: size.width * 0.19, y: cy) + btnRight.position = CGPoint(x: size.width * 0.81, y: cy) + btnUp.position = CGPoint(x: size.width * 0.5, y: cy - 2) + [btnLeft, btnUp, btnRight].forEach { $0.zPosition = 20; addChild($0) } + } + + // MARK: - Overlays + + private func buildReadyOverlay(fieldRect: CGRect) { + readyOverlay.removeAllChildren() + readyOverlay.zPosition = 40 + addChild(readyOverlay) + + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.62), size: size) + dim.anchorPoint = .zero + readyOverlay.addChild(dim) + + let cx = size.width / 2 + let midY = size.height * 0.62 + + readyOverlay.addChild(bigLabel("LUNAR LANDER", size: 32, color: .white, x: cx, y: midY + 70)) + readyOverlay.addChild(bigLabel("Kill your descent. Set down gently on the pad.", + fontNamed: "AvenirNext-Medium", size: 15, color: Palette.white70, + x: cx, y: midY + 36, maxWidth: size.width - 70)) + + // Difficulty steppers (fuel/speed are the original's title-screen + // settings; gravity is the remake's third knob). + buildStepper(title: "FUEL", value: fuelValueLabel, valueText: "\(chosenFuel)", + y: midY - 18, keyMinus: "fuelMinus", keyPlus: "fuelPlus", + hint: "more fuel = easier · smaller bonus") + buildStepper(title: "MAX SPEED", value: speedValueLabel, valueText: "\(chosenVel)", + y: midY - 86, keyMinus: "speedMinus", keyPlus: "speedPlus", + hint: "top fall speed · touch down at ▼3 or slower") + buildStepper(title: "GRAVITY", value: gravityValueLabel, valueText: chosenGravity.name, + y: midY - 154, keyMinus: "gravMinus", keyPlus: "gravPlus", + hint: "heavier world: harder pull, real drift · score ×2 ×3") + + let prompt = bigLabel("TAP TO LAUNCH", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint, + x: cx, y: midY - 216) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + readyOverlay.addChild(prompt) + + readyOverlay.addChild(bigLabel("◄ ► side thrusters ▲ main thrust", + fontNamed: "Menlo", size: 12, color: Palette.white40, + x: cx, y: max(controlBarHeight + 26, midY - 248))) + } + + private func buildStepper(title: String, value: SKLabelNode, valueText: String, y: CGFloat, + keyMinus: String, keyPlus: String, hint: String) { + let cx = size.width / 2 + readyOverlay.addChild(bigLabel(title, fontNamed: "Menlo-Bold", size: 12, color: Palette.white70, + x: cx, y: y + 26)) + readyOverlay.addChild(bigLabel(hint, fontNamed: "Menlo", size: 11, color: Palette.white40, + x: cx, y: y - 30)) + + let minusX = cx - 78 + let plusX = cx + 78 + let btnSize = CGSize(width: 44, height: 40) + readyOverlay.addChild(stepperButton("−", center: CGPoint(x: minusX, y: y))) + readyOverlay.addChild(stepperButton("+", center: CGPoint(x: plusX, y: y))) + stepperRects[keyMinus] = CGRect(x: minusX - btnSize.width / 2, y: y - btnSize.height / 2, + width: btnSize.width, height: btnSize.height) + stepperRects[keyPlus] = CGRect(x: plusX - btnSize.width / 2, y: y - btnSize.height / 2, + width: btnSize.width, height: btnSize.height) + + value.text = valueText + value.fontSize = 26 + value.fontColor = .white + value.horizontalAlignmentMode = .center + value.verticalAlignmentMode = .center + value.position = CGPoint(x: cx, y: y) + readyOverlay.addChild(value) + } + + private func stepperButton(_ glyph: String, center: CGPoint) -> SKNode { + let node = SKShapeNode(rectOf: CGSize(width: 44, height: 40), cornerRadius: 12) + node.fillColor = UIColor(white: 1, alpha: 0.10) + node.strokeColor = Palette.mint.withAlphaComponent(0.5) + node.lineWidth = 1 + node.position = center + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = glyph + label.fontSize = 24 + label.fontColor = Palette.mint + label.verticalAlignmentMode = .center + label.horizontalAlignmentMode = .center + node.addChild(label) + return node + } + + private func buildLandedOverlay(fieldRect: CGRect) { + landedOverlay.removeAllChildren() + landedOverlay.zPosition = 40 + addChild(landedOverlay) + let cx = size.width / 2 + let y = fieldRect.maxY + 30 + + landedTitle.text = "THE HAWK HAS LANDED" + landedTitle.fontSize = 22 + landedTitle.fontColor = Palette.mint + landedTitle.horizontalAlignmentMode = .center + landedTitle.position = CGPoint(x: cx, y: min(y + 18, size.height - topInset - 40)) + landedOverlay.addChild(landedTitle) + + landedDetail.fontSize = 15 + landedDetail.fontColor = .white + landedDetail.horizontalAlignmentMode = .center + landedDetail.position = CGPoint(x: cx, y: landedTitle.position.y - 26) + landedOverlay.addChild(landedDetail) + } + + private func buildGameOverOverlay() { + gameOverOverlay.removeAllChildren() + gameOverOverlay.zPosition = 40 + addChild(gameOverOverlay) + + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.66), size: size) + dim.anchorPoint = .zero + gameOverOverlay.addChild(dim) + + let cx = size.width / 2 + let midY = size.height * 0.6 + gameOverTitle.fontSize = 34 + gameOverTitle.horizontalAlignmentMode = .center + gameOverTitle.position = CGPoint(x: cx, y: midY + 40) + gameOverOverlay.addChild(gameOverTitle) + + gameOverDetail.fontSize = 16 + gameOverDetail.fontColor = .white + gameOverDetail.horizontalAlignmentMode = .center + gameOverDetail.position = CGPoint(x: cx, y: midY) + gameOverOverlay.addChild(gameOverDetail) + + gameOverRecord.fontSize = 14 + gameOverRecord.fontColor = Palette.amber + gameOverRecord.horizontalAlignmentMode = .center + gameOverRecord.position = CGPoint(x: cx, y: midY - 26) + gameOverOverlay.addChild(gameOverRecord) + + let prompt = bigLabel("TAP TO PLAY AGAIN", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint, + x: cx, y: midY - 66) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + gameOverOverlay.addChild(prompt) + } + + private func bigLabel(_ text: String, fontNamed: String = "AvenirNext-Heavy", size fontSize: CGFloat, + color: UIColor, x: CGFloat, y: CGFloat, maxWidth: CGFloat? = nil) -> SKLabelNode { + let label = SKLabelNode(fontNamed: fontNamed) + label.text = text + label.fontSize = fontSize + label.fontColor = color + label.horizontalAlignmentMode = .center + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + if let maxWidth { + label.preferredMaxLayoutWidth = maxWidth + label.numberOfLines = 0 + } + return label + } + + // MARK: - Game loop + + override func update(_ currentTime: TimeInterval) { + if lastUpdate == 0 { lastUpdate = currentTime; stateEnteredTime = currentTime } + #if DEBUG + if demoFreeze { renderLander(currentTime: currentTime); updateHUD(); return } + #endif + var dt = currentTime - lastUpdate + lastUpdate = currentTime + if dt > 0.25 { dt = 0.25 } + + if state == .playing { + accumulator += dt + while accumulator >= simInterval { + prevX = sim.x; prevY = sim.y + let outcome = sim.tick(left: thrustLeft || latchLeft, + up: thrustUp || latchUp, + right: thrustRight || latchRight) + latchLeft = false; latchUp = false; latchRight = false + accumulator -= simInterval + if outcome != .flying { handleOutcome(outcome); break } + } + } else if state == .landed, currentTime - stateEnteredTime > 1.9 { + advanceToNextTerrain() + } + + renderLander(currentTime: currentTime) + updateHUD() + } + + private func renderLander(currentTime: TimeInterval) { + let frac: CGFloat = state == .playing ? CGFloat(min(1, accumulator / simInterval)) : 0 + let rx = state == .playing ? lerp(prevX, sim.x, frac) : CGFloat(sim.x) + let ry = state == .playing ? lerp(prevY, sim.y, frac) : CGFloat(sim.y) + // Sprite centre sits at the middle of the 8×8 footprint. + lander.position = scenePoint(rx + 3.5, ry - 3.5) + + let firing = state == .playing && sim.fuel > 0 + mainJet.particleBirthRate = (firing && thrustUp) ? 150 : 0 + leftJet.particleBirthRate = (firing && thrustLeft) ? 90 : 0 + rightJet.particleBirthRate = (firing && thrustRight) ? 90 : 0 + let glowTarget: CGFloat = (firing && thrustUp) ? 0.75 : 0 + engineGlow.alpha += (glowTarget - engineGlow.alpha) * 0.35 + } + + private func updateHUD() { + scoreValue.text = "\(totalScore)" + + let rate = sim.descentRate + if rate > 0 { vspdValue.text = "▼\(rate)" } + else if rate < 0 { vspdValue.text = "▲\(-rate)" } + else { vspdValue.text = "0" } + vspdValue.fontColor = sim.isSafeDescent ? Palette.mint : Palette.red + + let frac = CGFloat(sim.fuelFraction) + let w = max(0, fuelBarWidth * frac) + fuelBarFill.path = CGPath(roundedRect: CGRect(x: 0, y: -5, width: w, height: 10), + cornerWidth: 5, cornerHeight: 5, transform: nil) + fuelBarFill.fillColor = frac > 0.5 ? Palette.mint : (frac > 0.22 ? Palette.amber : Palette.red) + } + + private func lerp(_ a: Int, _ b: Int, _ t: CGFloat) -> CGFloat { + CGFloat(a) + (CGFloat(b) - CGFloat(a)) * t + } + + // MARK: - State transitions + + private func handleOutcome(_ outcome: LanderSim.Outcome) { + renderLander(currentTime: lastUpdate) // settle the sprite on the outcome position + switch outcome { + case .landed: + totalScore += sim.lastLandingScore + enter(.landed) + landedDetail.text = "+\(sim.lastLandingScore) · SCORE \(totalScore) · TAP TO CONTINUE" + spawnTouchdownDust() + worldCrop.run(SceneArt.shake(intensity: 3, duration: 0.2)) + Haptics.success() + case .crashed: + let record = HighScoreStore.shared.submit(id: "lander", score: totalScore) + spawnCrashExplosion() + enter(.gameOver) + gameOverTitle.text = "CRASHED" + gameOverTitle.fontColor = Palette.red + gameOverDetail.text = "SCORE \(totalScore) BEST \(HighScoreStore.shared.best("lander"))" + gameOverRecord.text = record ? "★ NEW BEST ★" : "" + Haptics.error() + case .flying: + break + } + thrustLeft = false; thrustUp = false; thrustRight = false + latchLeft = false; latchUp = false; latchRight = false + updateButtonVisuals() + } + + /// A soft ring of kicked-up dust on a good touchdown. + private func spawnTouchdownDust() { + let dust = SceneArt.sparkBurst(color: UIColor(white: 0.85, alpha: 1), + count: 18, speed: 70, lifetime: 0.8, scale: 0.16) + dust.position = CGPoint(x: lander.position.x, y: lander.position.y - lander.size.height * 0.45) + dust.emissionAngle = .pi / 2 + dust.emissionAngleRange = .pi * 0.9 + dust.yAcceleration = -140 + dust.zPosition = 6 + worldCrop.addChild(dust) + } + + /// Flash + two-tone spark burst + tumbling debris + a hard shake. + private func spawnCrashExplosion() { + let at = lander.position + addChild(SceneArt.flash(size: size, color: Palette.amber, peak: 0.26, duration: 0.3)) + + let fire = SceneArt.sparkBurst(color: Palette.amber, count: 34, speed: 250, lifetime: 0.8, scale: 0.3) + fire.position = at + fire.zPosition = 7 + worldCrop.addChild(fire) + let embers = SceneArt.sparkBurst(color: Palette.red, count: 22, speed: 150, lifetime: 1.0, scale: 0.24) + embers.position = at + embers.zPosition = 7 + worldCrop.addChild(embers) + + let core = SceneArt.glowSprite(color: Palette.amber, radius: 60) + core.position = at + core.zPosition = 6.5 + core.run(.sequence([ + .group([.scale(to: 2.2, duration: 0.28), .fadeOut(withDuration: 0.32)]), + .removeFromParent()])) + worldCrop.addChild(core) + + for _ in 0..<6 { + let chunk = SKSpriteNode(color: Palette.mint.withAlphaComponent(0.9), + size: CGSize(width: 4, height: 4)) + chunk.position = at + chunk.zPosition = 6 + let dx = CGFloat.random(in: -70...70) + let dy = CGFloat.random(in: 20...110) + chunk.run(.sequence([ + .group([ + .moveBy(x: dx, y: dy, duration: 0.5), + .rotate(byAngle: CGFloat.random(in: -6...6), duration: 0.5), + .sequence([.wait(forDuration: 0.25), .fadeOut(withDuration: 0.25)]), + ]), + .removeFromParent()])) + worldCrop.addChild(chunk) + } + + worldCrop.run(SceneArt.shake(intensity: 11, duration: 0.5)) + } + + private func advanceToNextTerrain() { + sim.newTerrain() + refreshTerrain() + prevX = sim.x; prevY = sim.y + accumulator = 0 + enter(.playing) + } + + private func startFromReady() { + sim.setDifficulty(maxFuel: chosenFuel, maxVel: chosenVel, gravity: chosenGravity) + sim.resetLander() + totalScore = 0 + prevX = sim.x; prevY = sim.y + accumulator = 0 + enter(.playing) + } + + private func gameOverToReady() { + sim.newTerrain() + refreshTerrain() + prevX = sim.x; prevY = sim.y + enter(.ready) + } + + private func enter(_ newState: GameState) { + state = newState + stateEnteredTime = lastUpdate + applyStateVisibility() + } + + private func applyStateVisibility() { + readyOverlay.isHidden = state != .ready + landedOverlay.isHidden = state != .landed + gameOverOverlay.isHidden = state != .gameOver + lander.isHidden = state == .ready || state == .gameOver + let controlsVisible = state == .playing + [btnLeft, btnUp, btnRight].forEach { $0.alpha = controlsVisible ? 1 : 0.28 } + } + + // MARK: - Touch handling + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + for touch in touches { + let p = touch.location(in: self) + // A tap that drives a state transition is consumed — it must not + // double as a thruster press (a start tap on the flame button used + // to burn on the very first tick). + switch state { + case .ready: + if !handleStepperTap(at: p) { startFromReady() } + continue + case .landed: + advanceToNextTerrain() + continue + case .gameOver: + gameOverToReady() + continue + case .playing: + activeTouches.insert(touch) + } + } + recomputeControls() + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + recomputeControls() + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + for touch in touches { activeTouches.remove(touch) } + recomputeControls() + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + for touch in touches { activeTouches.remove(touch) } + recomputeControls() + } + + private func handleStepperTap(at p: CGPoint) -> Bool { + for (key, rect) in stepperRects where rect.contains(p) { + switch key { + case "fuelMinus": chosenFuel = max(30, chosenFuel - 1) + case "fuelPlus": chosenFuel = min(80, chosenFuel + 1) + case "speedMinus": chosenVel = max(4, chosenVel - 1) + case "speedPlus": chosenVel = min(7, chosenVel + 1) + case "gravMinus": + chosenGravity = LanderSim.Gravity(rawValue: max(0, chosenGravity.rawValue - 1)) ?? .moon + case "gravPlus": + chosenGravity = LanderSim.Gravity(rawValue: min(2, chosenGravity.rawValue + 1)) ?? .earth + default: break + } + fuelValueLabel.text = "\(chosenFuel)" + speedValueLabel.text = "\(chosenVel)" + gravityValueLabel.text = chosenGravity.name + UserDefaults.standard.set(chosenFuel, forKey: "lander.fuel") + UserDefaults.standard.set(chosenVel, forKey: "lander.vel") + UserDefaults.standard.set(chosenGravity.rawValue, forKey: "lander.gravity") + Haptics.selection() + return true + } + return false + } + + private func recomputeControls() { + var l = false, u = false, r = false + if state == .playing { + for touch in activeTouches { + let p = touch.location(in: self) + if btnLeft.covers(p) { l = true } + if btnUp.covers(p) { u = true } + if btnRight.covers(p) { r = true } + } + } + thrustLeft = l; thrustUp = u; thrustRight = r + + // Rising edges: latch for the next tick + haptic feedback. + if l && !lastLeft { latchLeft = true } + if u && !lastUp { latchUp = true } + if r && !lastRight { latchRight = true } + if (l && !lastLeft) || (u && !lastUp) || (r && !lastRight) { + Haptics.impact(.light, intensity: 0.5) + } + lastLeft = l; lastUp = u; lastRight = r + updateButtonVisuals() + } + + private func updateButtonVisuals() { + btnLeft.setPressed(thrustLeft) + btnUp.setPressed(thrustUp) + btnRight.setPressed(thrustRight) + } +} + +/// A round on-screen control pad with an SF-symbol glyph and a pressed state. +final class ControlButton: SKNode { + private let base = SKShapeNode() + private let glyph = SKSpriteNode() + private let symbolName: String + private var radius: CGFloat = 46 + private var tint: UIColor = .white + + init(symbol: String) { + self.symbolName = symbol + super.init() + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been used") } + + func configure(radius: CGFloat, tint: UIColor) { + self.radius = radius + self.tint = tint + base.removeFromParent() + glyph.removeFromParent() + base.path = CGPath(ellipseIn: CGRect(x: -radius, y: -radius, width: radius * 2, height: radius * 2), transform: nil) + base.fillColor = tint.withAlphaComponent(0.12) + base.strokeColor = tint.withAlphaComponent(0.55) + base.lineWidth = 1.5 + base.glowWidth = 0.5 + addChild(base) + + if let tex = SceneArt.symbolTexture(symbolName, pointSize: radius * 0.9, color: tint) { + glyph.texture = tex + glyph.size = tex.size() + let maxDim = radius * 0.9 + let s = min(maxDim / max(tex.size().width, 1), maxDim / max(tex.size().height, 1)) + glyph.size = CGSize(width: tex.size().width * s, height: tex.size().height * s) + addChild(glyph) + } + } + + func covers(_ p: CGPoint) -> Bool { + let dx = p.x - position.x, dy = p.y - position.y + return dx * dx + dy * dy <= (radius + 8) * (radius + 8) + } + + func setPressed(_ pressed: Bool) { + base.fillColor = tint.withAlphaComponent(pressed ? 0.34 : 0.12) + setScale(pressed ? 1.06 : 1.0) + } +} diff --git a/Arcade85/Games/Lander/LanderSim.swift b/Arcade85/Games/Lander/LanderSim.swift new file mode 100644 index 0000000..682ce2a --- /dev/null +++ b/Arcade85/Games/Lander/LanderSim.swift @@ -0,0 +1,214 @@ +import Foundation + +/// A faithful port of the TI-85 "Lunar Lander" (Tiger/Istar, 1997) game logic +/// from `LANDER.C`. Physics, terrain generation, landing tolerances and scoring +/// are the original's integer rules verbatim on the Moon setting; the SpriteKit +/// scene scales the field up for HD rendering. +/// +/// Coordinate convention (from the original): `y` is height above the floor, +/// larger = higher; gravity decrements `vy` each tick; `x` is the left edge of +/// the 8-wide lander. `surface[i]` is the terrain height (0…32) at column `i`. +/// +/// HD-remake liberties (everything else is the original's integer rules): +/// - The field is 128×96 instead of 128×64 — extra sky above the same 0…32 +/// terrain, giving a phone-shaped playfield and room to fly the descent. +/// - The field edges are soft walls: drifting into the top or sides stops the +/// lander instead of ending the run (the original's screen edge was fatal, +/// which on touch turned a mistimed start tap into an instant loss). +/// - A gravity setting (Moon/Mars/Earth). Moon is the original: pull 1/tick, +/// side drift auto-damped every tick. Heavier worlds pull harder AND keep +/// more of your sideways momentum, so you must burn to counter-correct — +/// and multiply the landing score ×2/×3. +struct LanderSim { + static let fieldWidth = 128 + static let fieldHeight = 96 // original: 64 (see header note) + static let landerSize = 8 // sprite is 8 px wide/tall in the original + static let padWidth = 10 + + /// The gravity/handling worlds. `pull(_:)`/`damps(_:)` are phase patterns + /// over the tick counter so everything stays integer-exact: + /// - moon: pull 1 every tick, damping every tick (the original) + /// - mars: pull 2 every 4th, damping every other tick (avg 1.25) + /// - earth: pull 2 every other, no damping — Newtonian (avg 1.5) + enum Gravity: Int, CaseIterable { + case moon = 0, mars, earth + var name: String { ["MOON", "MARS", "EARTH"][rawValue] } + var scoreMultiplier: Int { rawValue + 1 } + func pull(_ phase: Int) -> Int { + switch self { + case .moon: return 1 + case .mars: return phase % 4 == 3 ? 2 : 1 + case .earth: return phase % 2 == 1 ? 2 : 1 + } + } + func damps(_ phase: Int) -> Bool { + switch self { + case .moon: return true + case .mars: return phase % 2 == 0 + case .earth: return false + } + } + } + + // Difficulty — fuel/speed are the original's main-menu settings (fuel + // 30…80, speed now 4…7: at 3 terminal velocity equals the safe landing + // speed and the game can't be lost). Gravity is the remake's third knob. + var maxFuel: Int + var maxVel: Int + var gravity: Gravity = .moon + + // Terrain + private(set) var surface = [Int](repeating: 0, count: fieldWidth) + private(set) var padStart = 0 + private(set) var padHeight = 0 + + // Lander state + private(set) var x = 0 + private(set) var y = 0 + private(set) var vx = 0 + private(set) var vy = 0 + private(set) var fuel = 0 + + enum Outcome: Equatable { case flying, landed, crashed } + private(set) var outcome: Outcome = .flying + private(set) var lastLandingScore = 0 + private var gravPhase = 0 + + private var rng: SeededGenerator + + init(maxFuel: Int = 40, maxVel: Int = 7, gravity: Gravity = .moon, seed: UInt64? = nil) { + self.maxFuel = maxFuel + self.maxVel = maxVel + self.gravity = gravity + self.rng = SeededGenerator(seed: seed ?? UInt64.random(in: 1...UInt64.max)) + newTerrain() + } + + /// 15-bit pseudo-random value, mirroring the original C `rand()` range. + private mutating func rand() -> Int { Int(rng.next() & 0x7FFF) } + + // MARK: - Terrain + + /// Generates a fresh landscape + landing pad and resets the lander. This is + /// the "landscape generator" from LANDER.C (© Dape/Istar) plus pad carving. + mutating func newTerrain() { + var s = [Int](repeating: 0, count: Self.fieldWidth) + + s[0] = (rand() & 0x1F) + (rand() & 0x1F) + if s[0] > 32 { s[0] = 32 } + for i in 1.. 32 { k = 32 } + if k < 0 { k = 0 } + s[i] = k + } + + // Erosion: repeated weighted smoothing passes. + let passes = (rand() & 0x0F) + 2 + for _ in 0..> 2 + } + } + + // Carve a flat landing pad, dropped 4 below the surrounding terrain. + let start = 32 + (rand() & 0x3F) // 32…95 + var h = s[start] - 4 + if h < 0 { h = 0 } + for i in start.. Outcome { + guard outcome == .flying else { return outcome } + + vy -= gravity.pull(gravPhase) // gravity (original: −1 every tick) + if left, fuel > 0 { vx -= 2; fuel -= 1 } + if right, fuel > 0 { vx += 2; fuel -= 1 } + if up, fuel > 0 { vy += 2; fuel -= 1 } + + if gravity.damps(gravPhase) { // horizontal damping (Moon: every tick) + if vx > 0 { vx -= 1 } else if vx < 0 { vx += 1 } + } + gravPhase += 1 + + vx = clamp(vx, -maxVel, maxVel) + vy = clamp(vy, -maxVel, maxVel) + + x += vx + y += vy + + // Soft walls: the field edge stops the lander, it doesn't end the run. + if y > Self.fieldHeight - 1 { y = Self.fieldHeight - 1; if vy > 0 { vy = 0 } } + if x < 0 { x = 0; if vx < 0 { vx = 0 } } + if x > Self.fieldWidth - Self.landerSize { + x = Self.fieldWidth - Self.landerSize + if vx > 0 { vx = 0 } + } + + // Landing / crash test across the 8-pixel footprint. + let l = surface[x] + var flat = 0 + for i in x..<(x + Self.landerSize) { + let sfc = surface[i] + if sfc > y - 9 && sfc == l && vy >= -3 { + flat += 1 + } else if (sfc > y - 9 && vy < -3) || (sfc > y - 4) { + outcome = .crashed + return outcome + } + } + if flat > 6 { + outcome = .landed + lastLandingScore = ((90 - maxFuel) * fuel / maxFuel) * maxVel * gravity.scoreMultiplier + } + return outcome + } + + // MARK: - Derived values for the HUD + + /// Positive when descending; this is the value the original crash test gates on. + var descentRate: Int { -vy } + /// A landing at the current vertical speed would be survivable (vy >= -3). + var isSafeDescent: Bool { vy >= -3 } + var fuelFraction: Double { maxFuel > 0 ? Double(fuel) / Double(maxFuel) : 0 } + + private func clamp(_ v: Int, _ lo: Int, _ hi: Int) -> Int { min(max(v, lo), hi) } + + #if DEBUG + /// Places the lander at a fixed state for screenshots/tests. + mutating func debugPose(x: Int, y: Int, vx: Int = 0, vy: Int = -2) { + self.x = x; self.y = y; self.vx = vx; self.vy = vy; self.outcome = .flying + } + #endif +} diff --git a/Arcade85/Games/Phoenix/PhoenixData.swift b/Arcade85/Games/Phoenix/PhoenixData.swift new file mode 100644 index 0000000..fe9034d --- /dev/null +++ b/Arcade85/Games/Phoenix/PhoenixData.swift @@ -0,0 +1,390 @@ +import Foundation + +/// Data tables for Phoenix 4.0 (Patrick Davidson, 2008), transcribed from +/// `images.asm` and `levels.asm`. Sprites are stored as 16-bit rows with bit 15 +/// = leftmost pixel; multi-part wide sprites from the original are already +/// merged. The campaign is the original's level list, command for command +/// (L_GOTO chains are inlined, which is exactly what the interpreter did). +enum PhoenixData { + + // MARK: - Sprites + + struct Sprite { + let w: Int + let h: Int + let rows: [UInt16] // bit 15 = leftmost pixel of the sprite + } + + enum Img: Hashable { + case enemy1, enemy2, enemy2a, enemy4, silo // silo = img_operator + case bounce, boss, boss2, swoop + case spin1a, spin1b, spin1c, spin1d + case spin2a, spin2b, spin2c, spin2d + case enemy3a, enemy3b, enemy3c, enemy3d + case playerShip, playerShipDamaged, companion + case pb0, pb1, pb2l, pb2r, pb3, pb5, pbQuad + case eb1, eb2, eb3, eb4, money + case x1, x2, x3, x4, x5, x6, x7, x8 + } + + /// Animated image sequences: (frame image, duration in game frames). + /// All original sequences loop from the start; the explosion instead ends + /// with a kill marker, which the sim special-cases. + enum Anim { + case spin1, spin2, enemy3, swoop + var frames: [(img: Img, time: Int)] { + switch self { + case .spin1: return [(.spin1a, 2), (.spin1b, 2), (.spin1c, 2), (.spin1d, 2)] + case .spin2: return [(.spin2a, 2), (.spin2b, 2), (.spin2c, 2), (.spin2d, 2)] + case .enemy3: return [(.enemy3a, 4), (.enemy3b, 4), (.enemy3c, 4), (.enemy3d, 4)] + case .swoop: return [(.swoop, 2), (.swoop, 2)] + } + } + } + + static let explosionFrames: [Img] = [.x1, .x2, .x3, .x4, .x5, .x6, .x7, .x8] + + private static func s8(_ w: Int, _ h: Int, _ rows: [UInt8]) -> Sprite { + Sprite(w: w, h: h, rows: rows.map { UInt16($0) << 8 }) + } + + static let sprites: [Img: Sprite] = [ + // Standard enemies + .enemy4: s8(7, 9, [0b10000010, 0b11000110, 0b11111110, 0b10000010, 0b10000010, + 0b10000010, 0b11111110, 0b01111100, 0b00111000]), + .enemy1: s8(6, 6, [0b10000100, 0b10000100, 0b11111100, 0b11111100, 0b01111000, 0b00110000]), + .enemy2: s8(8, 5, [0b10000001, 0b11011011, 0b11111111, 0b11011011, 0b10000001]), + .enemy2a: s8(5, 5, [0b10001000, 0b11111000, 0b10001000, 0b01010000, 0b00100000]), + .silo: s8(8, 8, [0b01111110, 0b11111111, 0b11000011, 0b11000011, 0b11000011, + 0b11000011, 0b11111111, 0b01111110]), + // Spinners (4-frame rotations) + .spin1a: s8(8, 8, [0b00100000, 0b00111100, 0b01111111, 0b01100110, 0b01100110, + 0b11111110, 0b00111100, 0b00000100]), + .spin1b: s8(8, 8, [0b00001000, 0b00111100, 0b01111110, 0b11100110, 0b01100111, + 0b01111110, 0b00111100, 0b00010000]), + .spin1c: s8(8, 8, [0b00000010, 0b10111100, 0b01111110, 0b01100110, 0b01100110, + 0b01111110, 0b00111101, 0b01000000]), + .spin1d: s8(8, 8, [0b01000000, 0b01111111, 0b01111110, 0b01100110, 0b01100110, + 0b01111110, 0b11111110, 0b00000010]), + .spin2a: s8(8, 8, [0b00100000, 0b00111100, 0b01111111, 0b01111110, 0b01111110, + 0b11111110, 0b00111100, 0b00000100]), + .spin2b: s8(8, 8, [0b00001000, 0b00111100, 0b01111110, 0b11111110, 0b01111111, + 0b01111110, 0b00111100, 0b00010000]), + .spin2c: s8(8, 8, [0b00000010, 0b10111100, 0b01111110, 0b01111110, 0b01111110, + 0b01111110, 0b00111101, 0b01000000]), + .spin2d: s8(8, 8, [0b01000000, 0b01111111, 0b01111110, 0b01111110, 0b01111110, + 0b01111110, 0b11111110, 0b00000010]), + // Bouncers & ring enemies + .bounce: s8(8, 9, [0b00011000, 0b00011000, 0b00011000, 0b00100100, 0b01000010, + 0b10000001, 0b10011001, 0b10100101, 0b11000011]), + .enemy3a: s8(8, 8, [0b00111100, 0b01000010, 0b10000001, 0b11111111, 0b11111111, + 0b10000001, 0b01000010, 0b00111100]), + .enemy3b: s8(8, 8, [0b00111100, 0b01000110, 0b10001111, 0b10011101, 0b10111001, + 0b11110001, 0b01100010, 0b00111100]), + // Note: row 4 of enemy3c assembles from the original's 7-digit literal + // (%1011001); the shipped byte is $59, kept faithfully. + .enemy3c: s8(8, 8, [0b00111100, 0b01011010, 0b10011001, 0b01011001, 0b10011001, + 0b10011001, 0b01011010, 0b00111100]), + .enemy3d: s8(8, 8, [0b00111100, 0b01100010, 0b11110001, 0b10111001, 0b10011101, + 0b10001111, 0b01000110, 0b00111100]), + // Bosses (16 wide, merged from the two 8-px sections) + .boss2: Sprite(w: 16, h: 12, rows: [ + 0b11111000_00011111, 0b10001111_11110001, 0b10001000_00010001, 0b10001000_00010001, + 0b10001000_00010001, 0b10001000_00010001, 0b10001000_00010001, 0b10001111_11110001, + 0b11111000_00011111, 0b11111000_00011111, 0b01110000_00001110, 0b00100000_00000100, + ]), + .boss: Sprite(w: 16, h: 12, rows: [ + 0b10000000_00000001, 0b11000000_00000011, 0b11100000_00000111, 0b11111111_11111111, + 0b11000111_11100011, 0b11000011_11000011, 0b11000001_10000011, 0b11000001_10000011, + 0b11100001_10000111, 0b01110001_10001110, 0b00111111_11111100, 0b00011111_11111000, + ]), + // Swooper (11 wide, merged from 8+3 sections) + .swoop: Sprite(w: 11, h: 8, rows: [ + 0b11100000_11100000, 0b10101110_10100000, 0b11101110_11100000, 0b10111111_10100000, + 0b11111111_11100000, 0b10101110_10100000, 0b11101110_11100000, 0b10100100_10100000, + ]), + // Player craft + .playerShip: s8(8, 6, [0b10000001, 0b10000001, 0b10011001, 0b10100101, 0b11000011, 0b10000001]), + .playerShipDamaged: s8(8, 6, [0b10000000, 0b00000001, 0b10010000, 0b00000101, 0b11000000, 0b00000001]), + .companion: s8(7, 7, [0b00010000, 0b00111000, 0b10111010, 0b10111010, 0b10111010, + 0b11111110, 0b11111110]), + // Player bullets + .pb0: s8(2, 6, [0b11000000, 0b11000000, 0b11000000, 0b11000000, 0b11000000, 0b11000000]), + .pb1: s8(4, 8, [0b01100000, 0b11110000, 0b10100000, 0b01010000, 0b10100000, + 0b01010000, 0b10100000, 0b01010000]), + .pb2l: s8(3, 7, [0b00100000, 0b01100000, 0b11100000, 0b11100000, 0b11100000, 0b11000000, 0b10000000]), + .pb2r: s8(3, 7, [0b10000000, 0b11000000, 0b11100000, 0b11100000, 0b11100000, 0b01100000, 0b00100000]), + .pb3: s8(4, 4, [0b01100000, 0b11110000, 0b11110000, 0b01100000]), + .pb5: s8(5, 7, [0b00100000, 0b01110000, 0b11111000, 0b11111000, 0b11111000, 0b11011000, 0b10001000]), + .pbQuad: s8(3, 5, [0b01000000, 0b11100000, 0b11100000, 0b11100000, 0b11100000]), + // Enemy bullets & drops + .eb1: s8(2, 2, [0b11000000, 0b11000000]), + .eb3: s8(3, 3, [0b01000000, 0b11100000, 0b01000000]), + .eb2: s8(3, 7, [0b10100000, 0b11100000, 0b11100000, 0b01000000, 0b01000000, 0b11100000, 0b01000000]), + .money: s8(5, 7, [0b00100000, 0b11111000, 0b10100000, 0b11111000, 0b00101000, 0b11111000, 0b00100000]), + .eb4: s8(5, 5, [0b01110000, 0b11111000, 0b11111000, 0b11111000, 0b01110000]), + // Explosion frames + .x1: s8(8, 6, [0b00000000, 0b00011100, 0b00111110, 0b01010110, 0b00111000, 0b00000000]), + .x2: s8(8, 6, [0b00110000, 0b01001110, 0b10111110, 0b01001111, 0b00111000, 0b00011010]), + .x3: s8(8, 6, [0b11110011, 0b01001110, 0b10110101, 0b01000101, 0b00111110, 0b11011010]), + .x4: s8(8, 6, [0b11110011, 0b01001110, 0b10110101, 0b01000101, 0b00111110, 0b11011010]), + .x5: s8(8, 6, [0b01000001, 0b00100110, 0b00010101, 0b01000100, 0b00010010, 0b10011010]), + .x6: s8(8, 6, [0b01000010, 0b00100000, 0b00000001, 0b01000100, 0b00100010, 0b10001010]), + .x7: s8(8, 6, [0b00001000, 0b11000010, 0b00000000, 0b00100000, 0b00000001, 0b00110000]), + .x8: s8(8, 6, [0b00000100, 0b00000000, 0b01000000, 0b00000000, 0b00000001, 0b00100100]), + ] + + // MARK: - Movement / firing / weapon constants (phoenixz.i values) + + enum Move { + static let standard = 0, none = 3, boss = 4, bounce = 7 + static let patternStart = 10, patternWait = 13, patternMain = 16 + static let rampage = 19, rampageWait = 22 + static let swoopHoriz = 25, swoopDown = 28, swoopUp = 31, swoopWait = 34 + static let rampageInit = 37 + } + + enum Fire { + static let random = 0, none = 2, periodic = 3 + } + + enum Weapon { + static let normal = 0, double = 3, semiAim = 6, big = 9, huge = 12 + static let arrow = 15, singleBig = 18, singleHuge = 21 + } + + /// Swoop path table: (Y destination, X destination, X entry) per pattern. + static let swoopPaths: [(yDest: Int, xDest: Int, xEntry: Int)] = [ + (59, 102, 14), (50, 30, 90), (41, 78, 42), (32, 54, 66), + ] + + /// Big/huge bullet velocity table indexed by angle code: (yv, xv) in + /// half-pixels per frame (`speed_table`). + static let bigBulletSpeeds: [(yv: Int, xv: Int)] = [ + (1, -2), (2, -2), (2, -1), (2, 0), (2, 1), (2, 2), (1, 2), + ] + + // MARK: - Patterns (velocities in 16ths of a pixel per frame) + + struct Pattern { + let segments: [(time: Int, xv: Int, yv: Int)] + let loopStart: Int // segment index the terminating goto returns to + } + + enum PatternID: Hashable { case hugeOval, figureEight, circle, oval } + + static let patterns: [PatternID: Pattern] = [ + .hugeOval: Pattern(segments: [ + (32, 0, 19), + (64, 20, 0), (10, 16, 0), (8, 12, -12), (28, 0, -12), (8, -12, -12), (10, -16, 0), + (64, -20, 0), (10, -16, 0), (8, -12, 12), (28, 0, 12), (8, 12, 12), (10, 16, 0), + ], loopStart: 1), + .figureEight: Pattern(segments: [ + (32, 0, 12), + (12, 12, 12), (16, 16, 0), (34, 16, -12), (16, 15, 0), (12, 12, 12), (8, 0, 14), + (12, -12, 12), (16, -15, 0), (34, -16, -12), (16, -16, 0), (12, -12, 12), (8, 0, 16), + ], loopStart: 1), + .circle: Pattern(segments: [ + (32, 0, 14), + (8, 12, 12), (16, 16, 0), (8, 12, -12), (16, 0, -16), + (8, -12, -12), (16, -16, 0), (8, -12, 12), (16, 0, 16), + ], loopStart: 1), + .oval: Pattern(segments: [ + (32, 0, 16), + (4, 12, 12), (76, 16, 0), (4, 12, -12), (6, 0, -16), + (4, -12, -12), (76, -16, 0), (4, -12, 12), (6, 0, 16), + ], loopStart: 1), + ] + + // MARK: - Campaign (levels.asm, L_GOTO chains inlined) + + enum ImageRef { + case still(Img) + case anim(Anim) + } + + enum Cmd { + case setPower(Int) + case setMoveType(Int) + case setMoveData(Int, Int) // movedata bytes 0 and 1 + case setMovePattern(PatternID) // word form of L_SET_MOVEDATA + case image(ImageRef) + case setFireType(Int) + case setFireRate(Int) + case setWeapon(Int) + case setFirePower(Int) + case installOne(x: Int, y: Int) + case installRow(x: Int, y: Int, n: Int, spacing: Int) + } + + enum LevelKind { + case combat(enemies: Int, commands: [Cmd]) + case shop + case gameEnd + } + + /// Shared command chunks (the original's labeled fragments reused by L_GOTO). + private static let lev1x: [Cmd] = [.installRow(x: 18, y: 10, n: 6, spacing: 15), + .installRow(x: 18, y: 0, n: 6, spacing: 15), + .installRow(x: 18, y: 20, n: 6, spacing: 15)] + private static let lev1y: [Cmd] = [.installRow(x: 18, y: 0, n: 6, spacing: 15), + .installRow(x: 18, y: 20, n: 6, spacing: 15)] + private static let lev1z: [Cmd] = [.installRow(x: 18, y: 20, n: 6, spacing: 15)] + private static let lev2x: [Cmd] = [.installRow(x: 20, y: 10, n: 6, spacing: 15)] + lev1y + private static let bossBlock: [Cmd] = [ // lev3y + .setMoveType(Move.boss), .setPower(61), .setMoveData(0, 32), + .image(.still(.boss)), .setFireType(Fire.periodic), .setFireRate(64), + .setWeapon(Weapon.double), .installOne(x: 90, y: 1), + ] + private static let lev3x: [Cmd] = [.installRow(x: 18, y: 20, n: 6, spacing: 15)] + bossBlock + private static let boss2Block: [Cmd] = [ // lev12x + .setMoveType(Move.boss), .setPower(101), .setMoveData(0, 31), + .image(.still(.boss2)), .setFirePower(2), .setFireType(Fire.periodic), + .setFireRate(64), .setWeapon(Weapon.big), .installOne(x: 90, y: 1), + ] + private static let lev13x: [Cmd] = [.setWeapon(Weapon.semiAim), .setMoveType(Move.rampageWait), + .setFireRate(8)] + lev1x + private static let lev14x: [Cmd] = [.setWeapon(Weapon.semiAim), .setMoveType(Move.rampageWait), + .setFireRate(8), + .installRow(x: 19, y: 10, n: 6, spacing: 15), + .installRow(x: 12, y: 0, n: 6, spacing: 15), + .installRow(x: 12, y: 20, n: 6, spacing: 15)] + private static let lev15x: [Cmd] = [.setWeapon(Weapon.semiAim), .setMoveType(Move.rampageWait), + .setFireRate(8), + .installRow(x: 16, y: 12, n: 7, spacing: 15), + .installRow(x: 22, y: 21, n: 6, spacing: 15)] + boss2Block + private static let levswx: [Cmd] = [.image(.anim(.swoop)), .setMoveType(Move.swoopWait), + .setFireRate(4), .setPower(14), + .installRow(x: 0, y: 0, n: 9, spacing: 0)] + private static let levox: [Cmd] = [.setMoveType(Move.rampageInit), .setFireRate(4), + .setPower(41), .image(.still(.silo)), + .setWeapon(Weapon.semiAim), + .installRow(x: 0, y: 0, n: 12, spacing: 0)] + private static let levl: LevelKind = .combat(enemies: 18, commands: [ + .setPower(14), .image(.anim(.enemy3)), .setMoveType(Move.patternStart), + .setFireRate(4), .setWeapon(Weapon.semiAim), .setMovePattern(.hugeOval), + .installRow(x: 14, y: 18, n: 17, spacing: 15), + .setMoveType(Move.boss), .setPower(151), .setMoveData(0, 31), + .image(.still(.boss2)), .setFirePower(2), .setFireType(Fire.periodic), + .setFireRate(64), .setWeapon(Weapon.huge), .installOne(x: 90, y: 15), + ]) + + /// The full built-in level table, in play order. + static let campaign: [LevelKind] = [ + // Stage 1 + .combat(enemies: 18, commands: lev1x), // lev1 + .combat(enemies: 18, commands: lev2x), // lev2 + .combat(enemies: 7, commands: lev3x), // lev3 (boss) + .shop, + // Stage 2 + .combat(enemies: 18, commands: [.setPower(6), .image(.still(.enemy2))] + lev1x), // lev4 + .combat(enemies: 18, commands: [.setPower(6), .image(.still(.enemy2))] + lev2x), // lev5 + .combat(enemies: 6, commands: bounceLevel(n: 6)), // bounce6 + .combat(enemies: 7, commands: [.setPower(6), .image(.still(.enemy2))] + lev3x), // lev6 + .shop, + // Stage 3 + .combat(enemies: 18, commands: [.setPower(6), .image(.still(.enemy2)), // lev4a + .installRow(x: 18, y: 0, n: 6, spacing: 15), + .setPower(8), .image(.still(.enemy2a)), + .installRow(x: 18, y: 10, n: 6, spacing: 15)] + lev1z), + .combat(enemies: 18, commands: [.setPower(8), .image(.still(.enemy2a))] + lev2x), // lev5a + .combat(enemies: 8, commands: bounceLevel(n: 8)), // bounce8 + .combat(enemies: 13, commands: [.setPower(6), .image(.still(.enemy2)), // lev6a + .installRow(x: 18, y: 15, n: 6, spacing: 15), + .setPower(8), .image(.still(.enemy2a)), + .installRow(x: 18, y: 23, n: 6, spacing: 15)] + bossBlock), + .shop, + // Stage 4 + .combat(enemies: 14, commands: [.setPower(14), .image(.anim(.enemy3)), // lev7 + .setMoveType(Move.patternStart), .setFireRate(4), + .setWeapon(Weapon.semiAim), .setMovePattern(.circle), + .installRow(x: 14, y: 18, n: 7, spacing: 27), + .installRow(x: 29, y: 74, n: 7, spacing: 27)]), + .combat(enemies: 13, commands: [.setPower(14), .image(.anim(.enemy3)), // lev8 + .setMoveType(Move.patternStart), .setFireRate(4), + .setWeapon(Weapon.semiAim), .setMovePattern(.figureEight), + .installRow(x: 14, y: 18, n: 13, spacing: 15)]), + .combat(enemies: 10, commands: bounceLevel(n: 10)), // bounce10 + .combat(enemies: 14, commands: [.setPower(14), .image(.anim(.enemy3)), // lev9 + .setMoveType(Move.patternStart), .setFireRate(4), + .setWeapon(Weapon.semiAim), .setMovePattern(.oval), + .installRow(x: 14, y: 18, n: 13, spacing: 15)] + bossBlock), + .shop, + // Stage 5 + .combat(enemies: 18, commands: [.setPower(17), .image(.still(.enemy4)), // lev10 + .setWeapon(Weapon.arrow)] + lev2x), + .combat(enemies: 18, commands: [.setPower(17), .image(.still(.enemy4)), // lev11 + .setWeapon(Weapon.arrow)] + lev1x), + .combat(enemies: 13, commands: [.setPower(6), .image(.still(.enemy2)), // lev12 + .installRow(x: 18, y: 12, n: 6, spacing: 15), + .installRow(x: 18, y: 21, n: 6, spacing: 15)] + boss2Block), + .shop, + // Stage 6 + .combat(enemies: 18, commands: [.image(.anim(.spin1)), .setPower(21)] + lev13x), // lev13 + .combat(enemies: 18, commands: [.image(.anim(.spin1)), .setPower(21)] + lev14x), // lev14 + .combat(enemies: 14, commands: [.image(.anim(.spin1)), .setPower(21)] + lev15x), // lev15 + .shop, + // Stage 7 + .combat(enemies: 18, commands: [.image(.anim(.spin2)), .setPower(31)] + lev13x), // lev13a + .combat(enemies: 18, commands: [.image(.anim(.spin2)), .setPower(31)] + lev14x), // lev14a + .combat(enemies: 14, commands: [.image(.anim(.spin2)), .setPower(31)] + lev15x), // lev15a + .shop, + // Stage 8 + .combat(enemies: 9, commands: [.setWeapon(Weapon.singleBig)] + levswx), // levsw1 + levl, // levl + .shop, + // Stage 9 + .combat(enemies: 8, commands: [.setMoveType(Move.rampageInit), .setFireRate(4), // levo1 + .setPower(41), .image(.still(.silo)), + .setWeapon(Weapon.semiAim), + .installRow(x: 0, y: 0, n: 8, spacing: 0)]), + .combat(enemies: 12, commands: levox), // levo2 + .combat(enemies: 13, commands: [.setMoveType(Move.boss), .setPower(151), // levo3 + .setMoveData(0, 31), .image(.still(.boss2)), + .setFirePower(2), .setFireType(Fire.periodic), + .setFireRate(64), .setWeapon(Weapon.huge), + .installOne(x: 90, y: 1), + .setFirePower(1)] + levox), + .shop, + // Stage 10 + .combat(enemies: 9, commands: [.setWeapon(Weapon.singleHuge)] + levswx), // levsw2 + levl, // levl (again) + .gameEnd, + ] + + private static func bounceLevel(n: Int) -> [Cmd] { + [.image(.still(.bounce)), .setMoveType(Move.bounce), .setPower(36), + .setMoveData(3, 3), .setFireRate(4), .setWeapon(Weapon.semiAim), + .installRow(x: 14, y: 20, n: n, spacing: 10)] + } + + // MARK: - Settings tables (title16.asm) + + /// Speed options: label, frames/sec (per the readme's TI-85 rates), bonus. + static let speedOptions: [(label: String, fps: Double, bonus: Int)] = [ + ("SLOW", 25, 0), ("MEDIUM", 29, 1000), ("FAST", 35, 5000), + ] + + /// Difficulty options: label, value of each $ drop, bonus. + static let difficultyOptions: [(label: String, moneyValue: Int, bonus: Int)] = [ + ("EASY", 100, 0), ("MEDIUM", 50, 5000), ("HARD", 25, 15000), + ] + + // MARK: - Shop (shop16.asm order and prices) + + struct ShopItem { + let name: String + let price: Int + let detail: String + } + + static let shopItems: [ShopItem] = [ + ShopItem(name: "Extra Shield", price: 100, detail: "+1 shield step (max 16)"), + ShopItem(name: "Weapon F2", price: 300, detail: "Twin cannons, faster shots"), + ShopItem(name: "Companion", price: 500, detail: "Escort ship, 5× firepower"), + ShopItem(name: "Weapon Plus", price: 750, detail: "Auto-fire + double F1 power"), + ShopItem(name: "Weapon F3", price: 1000, detail: "Triple spread"), + ShopItem(name: "Weapon F4", price: 1250, detail: "Alternating heavy shots"), + ShopItem(name: "Weapon F5", price: 2000, detail: "The most powerful of all"), + ] +} diff --git a/Arcade85/Games/Phoenix/PhoenixScene.swift b/Arcade85/Games/Phoenix/PhoenixScene.swift new file mode 100644 index 0000000..7b936e5 --- /dev/null +++ b/Arcade85/Games/Phoenix/PhoenixScene.swift @@ -0,0 +1,1035 @@ +import SpriteKit +import UIKit + +/// HD remake of Phoenix 4.0. `PhoenixSim` is the faithful Z80 port; this scene +/// renders the original sprites as neon pixel art, drives the ship with a +/// drag-anywhere touch control (original speed limits still apply), and wraps +/// the campaign in title / shop / victory / game-over flow. +final class PhoenixScene: SKScene { + + typealias D = PhoenixData + + // MARK: State + private enum GameState { case ready, playing, shop, paused, gameOver, victory } + private var state: GameState = .ready + + private var sim = PhoenixSim() + private var chosenSpeed = 2 // SLOW/MEDIUM/FAST (original default: fast) + private var chosenDifficulty = 0 + + // MARK: Timing + private var lastUpdate: TimeInterval = 0 + private var accumulator: TimeInterval = 0 + + // MARK: Layout + private var unit: CGFloat = 3 // scene points per sim unit, horizontal + private var unitY: CGFloat = 3.9 // vertical POSITION scale (unit × stretch) + private var unitYSprite: CGFloat = 3.6 // vertical SPRITE scale (mild arcade stretch) + private var fieldRect = CGRect.zero // visible field (128 × 64 sim units) + private var topInset: CGFloat = 0 + + // MARK: Touch control + private var moveTouch: UITouch? + private var touchAnchor = CGPoint.zero + private var shipAnchor = CGPoint.zero // sim coords at touch start + private var targetX = 60 + private var targetY = 70 + + // MARK: Nodes + private let worldCrop = SKCropNode() + private var playerNode = SKSpriteNode() + private var companionNode = SKSpriteNode() + private var enemyNodes: [SKSpriteNode] = [] + private var pbNodes: [SKSpriteNode] = [] + private var ebNodes: [SKSpriteNode] = [] + private var textureCache: [String: SKTexture] = [:] + + // HUD + private var shieldCells: [SKShapeNode] = [] + private let cashValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let waveValue = SKLabelNode(fontNamed: "Menlo-Bold") + private var weaponButtons: [ControlButton] = [] + private let pauseButton = SKShapeNode(circleOfRadius: 16) + + // Overlays + private let readyOverlay = SKNode() + private let shopOverlay = SKNode() + private let pausedOverlay = SKNode() + private let gameOverOverlay = SKNode() + private let victoryOverlay = SKNode() + private let difficultyValueLabel = SKLabelNode(fontNamed: "Menlo-Bold") + private let speedValueLabel = SKLabelNode(fontNamed: "Menlo-Bold") + private var stepperRects: [String: CGRect] = [:] + private let shopCashLabel = SKLabelNode(fontNamed: "Menlo-Bold") + private var shopRowRects: [CGRect] = [] + private var shopRowLabels: [SKLabelNode] = [] + private var shopRowStates: [SKLabelNode] = [] + private var shopDoneRect = CGRect.zero + private let gameOverDetail = SKLabelNode(fontNamed: "Menlo-Bold") + private let gameOverRecord = SKLabelNode(fontNamed: "Menlo-Bold") + private let victoryLines = SKLabelNode(fontNamed: "Menlo-Bold") + private let victoryTotal = SKLabelNode(fontNamed: "AvenirNext-Heavy") + private let victoryRecord = SKLabelNode(fontNamed: "Menlo-Bold") + + private var builtSize: CGSize = .zero + #if DEBUG + private var demoFreeze = false + #endif + + // MARK: Sprite tints + + static func tint(for img: D.Img) -> UIColor { + switch img { + case .enemy1: return Palette.mint + case .enemy2: return Palette.cyan + case .enemy2a: return UIColor(red: 0.55, green: 0.80, blue: 1.0, alpha: 1) + case .enemy4: return Palette.amber + case .silo: return UIColor(red: 0.45, green: 0.95, blue: 0.45, alpha: 1) + case .bounce: return Palette.red + case .boss: return Palette.red + case .boss2: return UIColor(red: 1.0, green: 0.55, blue: 0.30, alpha: 1) + case .swoop: return UIColor(red: 1.0, green: 0.45, blue: 0.85, alpha: 1) + case .spin1a, .spin1b, .spin1c, .spin1d: return UIColor(red: 1.0, green: 0.62, blue: 0.25, alpha: 1) + case .spin2a, .spin2b, .spin2c, .spin2d: return UIColor(red: 1.0, green: 0.40, blue: 0.30, alpha: 1) + case .enemy3a, .enemy3b, .enemy3c, .enemy3d: return Palette.violet + case .playerShip, .playerShipDamaged: return .white + case .companion: return Palette.cyan + case .pb0, .pb1, .pb2l, .pb2r, .pb3, .pb5, .pbQuad: return Palette.mint + case .eb1, .eb2, .eb3, .eb4: return Palette.amber + case .money: return UIColor(red: 1.0, green: 0.85, blue: 0.30, alpha: 1) + case .x1, .x2, .x3, .x4, .x5, .x6, .x7, .x8: return Palette.amber + } + } + + // MARK: - Lifecycle + + override func didMove(to view: SKView) { + anchorPoint = .zero + scaleMode = .resizeFill + layoutScene() + #if DEBUG + let env = ProcessInfo.processInfo.environment + if env["ARCADE_AUTOPLAY"] == "1" { startGame() } + if env["ARCADE_DEMO"] == "1" { + startGame() + sim.debugGiveWeapons() + sim.debugSetCash(3200) + sim.debugSetLevel(14) // lev7: circling enemies + sim.debugSetShield(11) + _ = sim.buy(itemIndex: 2) // companion on escort + for _ in 0..<150 { sim.tick(PhoenixSim.Input(fire: true, selectWeapon: 2)) } + demoFreeze = true + renderFrame() + } + if env["ARCADE_SHOP"] == "1" { + startGame() + sim.debugSetCash(1450) + sim.debugSetShield(12) + state = .shop + refreshShop() + applyStateVisibility() + } + if env["ARCADE_GAMEOVER"] == "1" { // screenshot pose: death score card + startGame() + sim.debugSetCash(900) + for _ in 0..<12 { sim.loadLevel() } // walk a third of the campaign + endGame() + } + #endif + } + + override func didChangeSize(_ oldSize: CGSize) { + guard view != nil, size != builtSize, size.width > 0 else { return } + layoutScene() + } + + // MARK: - Textures + + private func texture(for img: D.Img) -> SKTexture { + let key = "\(img)" + if let t = textureCache[key] { return t } + let sprite = D.sprites[img]! + let cell: CGFloat = 10 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: CGFloat(sprite.w) * cell, + height: CGFloat(sprite.h) * cell)) + let color = Self.tint(for: img) + // Rows shade from lit (top) to shadowed (bottom) so sprites read as + // solid bodies instead of flat pixel clusters. + func shaded(_ row: Int) -> UIColor { + let frac = sprite.h > 1 ? CGFloat(row) / CGFloat(sprite.h - 1) : 0 + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + color.getRed(&r, green: &g, blue: &b, alpha: &a) + let lift = 0.30 * (1 - frac) // toward white at the top + let drop = 0.28 * frac // toward black at the bottom + return UIColor(red: min(1, r + (1 - r) * lift) * (1 - drop), + green: min(1, g + (1 - g) * lift) * (1 - drop), + blue: min(1, b + (1 - b) * lift) * (1 - drop), + alpha: a) + } + let image = renderer.image { ctx in + let cg = ctx.cgContext + cg.setShadow(offset: .zero, blur: cell * 0.7, color: color.withAlphaComponent(0.9).cgColor) + for pass in 0..<2 { + if pass == 1 { cg.setShadow(offset: .zero, blur: 0, color: nil) } + for (row, bits) in sprite.rows.enumerated() { + for col in 0..> UInt16(col))) != 0 { + let rect = CGRect(x: CGFloat(col) * cell, y: CGFloat(row) * cell, + width: cell, height: cell) + (pass == 0 ? color : shaded(row)).setFill() + UIBezierPath(roundedRect: rect.insetBy(dx: cell * 0.05, dy: cell * 0.05), + cornerRadius: cell * 0.18).fill() + } + } + } + } + let t = SKTexture(image: image) + textureCache[key] = t + return t + } + + // MARK: - Layout + + private func layoutScene() { + builtSize = size + removeAllChildren() + textureCache.removeAll() + backgroundColor = Palette.space1 + + let insets = view?.safeAreaInsets ?? .zero + topInset = insets.top + let bottomInset = insets.bottom + + let bg = SKSpriteNode(texture: SceneArt.verticalGradient(size: size, top: Palette.space0, bottom: Palette.space1)) + bg.anchorPoint = .zero + bg.zPosition = -10 + addChild(bg) + + // Deep space: static far stars, two drifting near layers (the fleet is + // moving), nebula blooms, sparse dust. + let farStars = SceneArt.starfield(size: size, count: 50, seed: 0x0F0E17) + farStars.zPosition = -9.6 + farStars.alpha = 0.5 + farStars.setScale(0.7) + addChild(farStars) + addScrollingStars(count: 46, seed: 0xF17A, duration: 34, alpha: 0.8, z: -9.4) + addScrollingStars(count: 26, seed: 0xF17B, duration: 18, alpha: 1.0, z: -9.2) + + let nebula1 = SceneArt.glowSprite(color: Palette.violet, radius: 230) + nebula1.position = CGPoint(x: size.width * 0.20, y: size.height * 0.74) + nebula1.alpha = 0.15 + nebula1.zPosition = -9.5 + addChild(nebula1) + let nebula2 = SceneArt.glowSprite(color: UIColor(red: 0.9, green: 0.4, blue: 0.4, alpha: 1), radius: 170) + nebula2.position = CGPoint(x: size.width * 0.85, y: size.height * 0.42) + nebula2.alpha = 0.10 + nebula2.zPosition = -9.5 + addChild(nebula2) + + let dust = SceneArt.ambientDust(size: size) + dust.position = CGPoint(x: size.width / 2, y: size.height / 2) + dust.zPosition = -8.8 + addChild(dust) + + // Field: 128 × 64 visible sim units, full-bleed width. The TI-85 window + // is 2:1, which reads cramped on a tall phone, so entity POSITIONS are + // mapped with a 2× vertical stretch while sprites themselves keep a + // mild 1.25× arcade-monitor stretch. Sim geometry, collisions and the + // visible window are untouched (they can't widen: the original parks + // and spawns entities just outside y 32…96). + unit = size.width / 128 + unitY = unit * 2.0 + unitYSprite = unit * 1.25 + let fieldH = unitY * 64 + let hudBottom = size.height - topInset - 92 + fieldRect = CGRect(x: 0, y: hudBottom - fieldH, width: unit * 128, height: fieldH) + + worldCrop.removeAllChildren() + let mask = SKSpriteNode(color: .white, size: fieldRect.size) + mask.position = CGPoint(x: fieldRect.midX, y: fieldRect.midY) + worldCrop.maskNode = mask + worldCrop.zPosition = 0 + addChild(worldCrop) + + let vignette = SceneArt.vignette(size: size, alpha: 0.40) + vignette.position = CGPoint(x: size.width / 2, y: size.height / 2) + addChild(vignette) + + buildEntityNodes() + buildHUD(bottomInset: bottomInset) + buildReadyOverlay() + buildShopOverlay() + buildPausedOverlay() + buildGameOverOverlay() + buildVictoryOverlay() + + applyStateVisibility() + renderFrame() + } + + private func buildEntityNodes() { + func makeNode() -> SKSpriteNode { + let n = SKSpriteNode() + n.isHidden = true + worldCrop.addChild(n) + return n + } + enemyNodes = (0.. CGPoint { + CGPoint(x: fieldRect.minX + (CGFloat(x) + CGFloat(w) / 2) * unit, + y: fieldRect.maxY - (CGFloat(y) - 32 + CGFloat(h) / 2) * unitY) + } + + private func place(_ node: SKSpriteNode, img: D.Img, x: Int, y: Int) { + let sprite = D.sprites[img]! + node.texture = texture(for: img) + // Positions use the stretched map; the sprite itself stays near-square, + // drawn slightly over its logical cell for presence (hitboxes are sim-side). + node.size = CGSize(width: CGFloat(sprite.w) * unit * 1.15, + height: CGFloat(sprite.h) * unitYSprite * 1.15) + node.position = point(x: x, y: y, w: sprite.w, h: sprite.h) + node.isHidden = false + } + + // MARK: - HUD + + private func buildHUD(bottomInset: CGFloat) { + let hudY = size.height - topInset - 26 + + addChild(caption("SHIELD", x: 74, y: hudY + 12, align: .left)) + shieldCells = (0..<16).map { i in + let cell = SKShapeNode(rectOf: CGSize(width: 7, height: 12), cornerRadius: 2) + cell.position = CGPoint(x: 74 + 4 + CGFloat(i) * 9, y: hudY - 8) + cell.strokeColor = UIColor(white: 1, alpha: 0.15) + cell.lineWidth = 1 + cell.zPosition = 20 + addChild(cell) + return cell + } + + addChild(caption("CASH", x: size.width - 20, y: hudY + 12, align: .right)) + cashValue.fontSize = 17 + cashValue.fontColor = UIColor(red: 1.0, green: 0.85, blue: 0.30, alpha: 1) + cashValue.horizontalAlignmentMode = .right + cashValue.verticalAlignmentMode = .center + cashValue.position = CGPoint(x: size.width - 20, y: hudY - 8) + cashValue.zPosition = 20 + addChild(cashValue) + + addChild(caption("WAVE", x: size.width - 110, y: hudY + 12, align: .right)) + waveValue.fontSize = 17 + waveValue.fontColor = .white + waveValue.horizontalAlignmentMode = .right + waveValue.verticalAlignmentMode = .center + waveValue.position = CGPoint(x: size.width - 110, y: hudY - 8) + waveValue.zPosition = 20 + addChild(waveValue) + + // Weapon selector row under the field. + let wy = fieldRect.minY - 44 + weaponButtons = (0..<5).map { i in + let b = ControlButton(symbol: "\(i + 1).circle.fill") + b.configure(radius: 26, tint: Palette.mint) + b.position = CGPoint(x: size.width * (0.14 + 0.18 * CGFloat(i)), y: wy) + b.zPosition = 20 + addChild(b) + return b + } + + pauseButton.fillColor = UIColor(white: 1, alpha: 0.08) + pauseButton.strokeColor = UIColor(white: 1, alpha: 0.25) + pauseButton.lineWidth = 1 + pauseButton.position = CGPoint(x: fieldRect.maxX - 28, y: fieldRect.maxY - 24) + pauseButton.zPosition = 22 + pauseButton.removeAllChildren() + if let tex = SceneArt.symbolTexture("pause.fill", pointSize: 13, color: Palette.white70) { + let icon = SKSpriteNode(texture: tex) + icon.size = tex.size() + pauseButton.addChild(icon) + } + addChild(pauseButton) + + // The open area below the weapons row is the thumb zone — put the hint + // in its middle so the space reads as the control surface it is. + let zoneTop = wy - 30 + let hintY = bottomInset + (zoneTop - bottomInset) * 0.5 + let hint = caption("DRAG ANYWHERE TO FLY — WEAPONS AUTO-FIRE", x: size.width / 2, + y: max(bottomInset + 16, hintY), align: .center) + addChild(hint) + } + + private func caption(_ text: String, x: CGFloat, y: CGFloat, align: SKLabelHorizontalAlignmentMode) -> SKLabelNode { + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = text + label.fontSize = 10 + label.fontColor = Palette.white40 + label.horizontalAlignmentMode = align + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + label.zPosition = 20 + return label + } + + // MARK: - Overlays + + private func bigLabel(_ text: String, fontNamed: String = "AvenirNext-Heavy", size fontSize: CGFloat, + color: UIColor, x: CGFloat, y: CGFloat, maxWidth: CGFloat? = nil) -> SKLabelNode { + let label = SKLabelNode(fontNamed: fontNamed) + label.text = text + label.fontSize = fontSize + label.fontColor = color + label.horizontalAlignmentMode = .center + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + if let maxWidth { + label.preferredMaxLayoutWidth = maxWidth + label.numberOfLines = 0 + } + return label + } + + private func buildReadyOverlay() { + readyOverlay.removeAllChildren() + readyOverlay.zPosition = 40 + addChild(readyOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.66), size: size) + dim.anchorPoint = .zero + readyOverlay.addChild(dim) + + let cx = size.width / 2 + let midY = size.height * 0.62 + readyOverlay.addChild(bigLabel("PHOENIX", size: 36, color: .white, x: cx, y: midY + 78)) + readyOverlay.addChild(bigLabel("31 waves. Bank the cash. Upgrade the ship.", + fontNamed: "AvenirNext-Medium", size: 14.5, + color: Palette.white70, x: cx, y: midY + 44)) + + buildStepper(into: readyOverlay, title: "DIFFICULTY", value: difficultyValueLabel, + valueText: D.difficultyOptions[chosenDifficulty].label, + y: midY - 14, keyMinus: "diffMinus", keyPlus: "diffPlus") + buildStepper(into: readyOverlay, title: "SPEED", value: speedValueLabel, + valueText: D.speedOptions[chosenSpeed].label, + y: midY - 66, keyMinus: "speedMinus", keyPlus: "speedPlus") + + let prompt = bigLabel("TAP TO LAUNCH", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint, + x: cx, y: midY - 126) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + readyOverlay.addChild(prompt) + } + + private func buildStepper(into parent: SKNode, title: String, value: SKLabelNode, valueText: String, + y: CGFloat, keyMinus: String, keyPlus: String) { + let cx = size.width / 2 + parent.addChild(bigLabel(title, fontNamed: "Menlo-Bold", size: 12, color: Palette.white70, x: cx, y: y + 25)) + let minusX = cx - 92, plusX = cx + 92 + parent.addChild(stepperButton("−", center: CGPoint(x: minusX, y: y))) + parent.addChild(stepperButton("+", center: CGPoint(x: plusX, y: y))) + stepperRects[keyMinus] = CGRect(x: minusX - 22, y: y - 20, width: 44, height: 40) + stepperRects[keyPlus] = CGRect(x: plusX - 22, y: y - 20, width: 44, height: 40) + value.text = valueText + value.fontSize = 22 + value.fontColor = .white + value.horizontalAlignmentMode = .center + value.verticalAlignmentMode = .center + value.position = CGPoint(x: cx, y: y) + parent.addChild(value) + } + + private func stepperButton(_ glyph: String, center: CGPoint) -> SKNode { + let node = SKShapeNode(rectOf: CGSize(width: 44, height: 40), cornerRadius: 12) + node.fillColor = UIColor(white: 1, alpha: 0.10) + node.strokeColor = Palette.mint.withAlphaComponent(0.5) + node.lineWidth = 1 + node.position = center + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = glyph + label.fontSize = 24 + label.fontColor = Palette.mint + label.verticalAlignmentMode = .center + label.horizontalAlignmentMode = .center + node.addChild(label) + return node + } + + private func buildShopOverlay() { + shopOverlay.removeAllChildren() + shopOverlay.zPosition = 40 + addChild(shopOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.01, alpha: 0.90), size: size) + dim.anchorPoint = .zero + shopOverlay.addChild(dim) + + let cx = size.width / 2 + let top = size.height - topInset - 60 + shopOverlay.addChild(bigLabel("PHOENIX SHOP", size: 26, color: Palette.amber, x: cx, y: top)) + + shopCashLabel.fontSize = 16 + shopCashLabel.fontColor = UIColor(red: 1.0, green: 0.85, blue: 0.30, alpha: 1) + shopCashLabel.horizontalAlignmentMode = .center + shopCashLabel.verticalAlignmentMode = .center + shopCashLabel.position = CGPoint(x: cx, y: top - 30) + shopOverlay.addChild(shopCashLabel) + + shopRowRects = [] + shopRowLabels = [] + shopRowStates = [] + let rowH: CGFloat = 58 + let startY = top - 66 + for (i, item) in D.shopItems.enumerated() { + let y = startY - CGFloat(i) * rowH - rowH / 2 + let rect = CGRect(x: 22, y: y - rowH / 2 + 4, width: size.width - 44, height: rowH - 8) + let box = SKShapeNode(rect: rect, cornerRadius: 12) + box.fillColor = UIColor(white: 1, alpha: 0.06) + box.strokeColor = Palette.mint.withAlphaComponent(0.25) + box.lineWidth = 1 + shopOverlay.addChild(box) + shopRowRects.append(rect) + + let name = SKLabelNode(fontNamed: "AvenirNext-Bold") + name.text = "\(item.name) $\(item.price)" + name.fontSize = 15 + name.fontColor = .white + name.horizontalAlignmentMode = .left + name.verticalAlignmentMode = .center + name.position = CGPoint(x: rect.minX + 14, y: y + 9) + shopOverlay.addChild(name) + shopRowLabels.append(name) + + let detail = SKLabelNode(fontNamed: "AvenirNext-Medium") + detail.text = item.detail + detail.fontSize = 11.5 + detail.fontColor = Palette.white40 + detail.horizontalAlignmentMode = .left + detail.verticalAlignmentMode = .center + detail.position = CGPoint(x: rect.minX + 14, y: y - 11) + shopOverlay.addChild(detail) + + let stateLabel = SKLabelNode(fontNamed: "Menlo-Bold") + stateLabel.fontSize = 12 + stateLabel.horizontalAlignmentMode = .right + stateLabel.verticalAlignmentMode = .center + stateLabel.position = CGPoint(x: rect.maxX - 14, y: y) + shopOverlay.addChild(stateLabel) + shopRowStates.append(stateLabel) + } + + let doneY = startY - CGFloat(D.shopItems.count) * rowH - 30 + let doneRect = CGRect(x: cx - 110, y: doneY - 24, width: 220, height: 48) + let doneBox = SKShapeNode(rect: doneRect, cornerRadius: 14) + doneBox.fillColor = Palette.mint.withAlphaComponent(0.18) + doneBox.strokeColor = Palette.mint + doneBox.lineWidth = 1.5 + shopOverlay.addChild(doneBox) + shopOverlay.addChild(bigLabel("CONTINUE MISSION", fontNamed: "Menlo-Bold", size: 15, + color: Palette.mint, x: cx, y: doneY)) + shopDoneRect = doneRect + } + + private func refreshShop() { + shopCashLabel.text = "BANK $\(sim.cash)" + for (i, item) in D.shopItems.enumerated() { + let state: String + let color: UIColor + switch i { + case 0 where sim.playerPwr >= 16: state = "FULL"; color = Palette.white40 + case 2 where sim.companionPwr == 16: state = "ACTIVE"; color = Palette.white40 + case 3 where sim.weaponUpgrade: state = "OWNED"; color = Palette.white40 + case 1 where sim.hasWeapon[1], 4 where sim.hasWeapon[2], + 5 where sim.hasWeapon[3], 6 where sim.hasWeapon[4]: + state = "OWNED"; color = Palette.white40 + default: + if sim.cash >= item.price { state = "BUY"; color = Palette.mint } + else { state = "$\(item.price)"; color = Palette.red.withAlphaComponent(0.7) } + } + shopRowStates[i].text = state + shopRowStates[i].fontColor = color + } + } + + private func buildPausedOverlay() { + pausedOverlay.removeAllChildren() + pausedOverlay.zPosition = 40 + addChild(pausedOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.01, alpha: 0.80), size: size) + dim.anchorPoint = .zero + pausedOverlay.addChild(dim) + pausedOverlay.addChild(bigLabel("PAUSED", size: 30, color: .white, x: size.width / 2, y: size.height * 0.55)) + let hint = bigLabel("TAP TO RESUME", fontNamed: "Menlo-Bold", size: 15, color: Palette.mint, + x: size.width / 2, y: size.height * 0.55 - 44) + hint.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + pausedOverlay.addChild(hint) + } + + private func buildGameOverOverlay() { + gameOverOverlay.removeAllChildren() + gameOverOverlay.zPosition = 40 + addChild(gameOverOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.72), size: size) + dim.anchorPoint = .zero + gameOverOverlay.addChild(dim) + let cx = size.width / 2 + let midY = size.height * 0.6 + gameOverOverlay.addChild(bigLabel("SHIP DESTROYED", size: 30, color: Palette.red, x: cx, y: midY + 36)) + gameOverDetail.fontSize = 15 + gameOverDetail.fontColor = .white + gameOverDetail.horizontalAlignmentMode = .center + gameOverDetail.position = CGPoint(x: cx, y: midY - 4) + gameOverOverlay.addChild(gameOverDetail) + gameOverRecord.fontSize = 14 + gameOverRecord.fontColor = Palette.amber + gameOverRecord.horizontalAlignmentMode = .center + gameOverRecord.position = CGPoint(x: cx, y: midY - 32) + gameOverOverlay.addChild(gameOverRecord) + let prompt = bigLabel("TAP TO FLY AGAIN", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint, + x: cx, y: midY - 52) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + gameOverOverlay.addChild(prompt) + } + + private func buildVictoryOverlay() { + victoryOverlay.removeAllChildren() + victoryOverlay.zPosition = 40 + addChild(victoryOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.82), size: size) + dim.anchorPoint = .zero + victoryOverlay.addChild(dim) + let cx = size.width / 2 + let midY = size.height * 0.60 + victoryOverlay.addChild(bigLabel("EARTH IS SAFE", size: 30, color: Palette.mint, x: cx, y: midY + 84)) + + victoryLines.fontSize = 15 + victoryLines.fontColor = .white + victoryLines.horizontalAlignmentMode = .center + victoryLines.verticalAlignmentMode = .center + victoryLines.numberOfLines = 0 + victoryLines.position = CGPoint(x: cx, y: midY + 8) + victoryOverlay.addChild(victoryLines) + + victoryTotal.fontSize = 30 + victoryTotal.fontColor = Palette.amber + victoryTotal.horizontalAlignmentMode = .center + victoryTotal.position = CGPoint(x: cx, y: midY - 74) + victoryOverlay.addChild(victoryTotal) + + victoryRecord.fontSize = 14 + victoryRecord.fontColor = Palette.amber + victoryRecord.horizontalAlignmentMode = .center + victoryRecord.position = CGPoint(x: cx, y: midY - 106) + victoryOverlay.addChild(victoryRecord) + + let prompt = bigLabel("TAP TO RETURN", fontNamed: "Menlo-Bold", size: 15, color: Palette.mint, + x: cx, y: midY - 142) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + victoryOverlay.addChild(prompt) + } + + // MARK: - Game flow + + private func startGame() { + sim = PhoenixSim(speed: chosenSpeed, difficulty: chosenDifficulty) + targetX = 60 + targetY = 70 + accumulator = 0 + state = .playing + applyStateVisibility() + renderFrame() + } + + private func applyStateVisibility() { + readyOverlay.isHidden = state != .ready + shopOverlay.isHidden = state != .shop + pausedOverlay.isHidden = state != .paused + gameOverOverlay.isHidden = state != .gameOver + victoryOverlay.isHidden = state != .victory + pauseButton.isHidden = state != .playing + let playing = state == .playing + weaponButtons.forEach { $0.alpha = playing ? 1 : 0.25 } + } + + // MARK: - Game loop + + override func update(_ currentTime: TimeInterval) { + if lastUpdate == 0 { lastUpdate = currentTime } + var dt = currentTime - lastUpdate + lastUpdate = currentTime + if dt > 0.25 { dt = 0.25 } + guard state == .playing else { return } + #if DEBUG + if demoFreeze { return } + #endif + + let interval = 1.0 / sim.fps + accumulator += dt + var steps = 0 + while accumulator >= interval, steps < 4 { + accumulator -= interval + steps += 1 + stepSim() + if state != .playing { break } + } + renderFrame() + } + + private func stepSim() { + var input = PhoenixSim.Input() + let dx = targetX - sim.playerX + let dy = targetY - sim.playerY + if abs(dx) >= 2 { input.dx = dx > 0 ? 1 : -1 } + if abs(dy) >= 1 { input.dy = dy > 0 ? 1 : -1 } + input.fire = true + input.selectWeapon = pendingWeapon + pendingWeapon = nil + sim.tick(input) + + for event in sim.events { + switch event { + case .enemyDestroyed(let x, let y): + Haptics.impact(.light, intensity: 0.5) + spawnBoom(at: point(x: x, y: y, w: 8, h: 6)) + case .playerHit: + Haptics.impact(.heavy, intensity: 0.9) + flashPlayer() + worldCrop.run(SceneArt.shake(intensity: 7, duration: 0.3)) + addChild(SceneArt.flash(size: size, color: Palette.red, peak: 0.14, duration: 0.2)) + case .moneyCollected: + Haptics.selection() + popCash() + case .enteredShop: + state = .shop + refreshShop() + applyStateVisibility() + Haptics.success() + case .gameOver: + endGame() + case .gameFinished: + showVictory() + case .levelLoaded: + showBanner("WAVE \(waveNumber())", color: .white) + case .bossLevel: + showBanner("BOSS", color: Palette.red) + } + } + } + + private func endGame() { + state = .gameOver + // A death still posts the run's progress (waves beaten + $ earned). + let score = sim.progressScore + let record = HighScoreStore.shared.submit(id: "phoenix", score: score) + gameOverDetail.text = "WAVE \(waveNumber())/31 · SCORE \(score)" + gameOverRecord.text = record ? "★ NEW BEST ★" : "BEST \(HighScoreStore.shared.best("phoenix"))" + applyStateVisibility() + Haptics.error() + } + + private func showVictory() { + // Victory = run progress + the original score16 tally on top. + let score = sim.progressScore + sim.finalScore + let record = HighScoreStore.shared.submit(id: "phoenix", score: score) + victoryLines.text = """ + PROGRESS \(sim.progressScore) + SHIELDS \(sim.shieldScore) + TIME \(sim.timeScore) + BONUS \(sim.bonusScore) + MONEY \(sim.cash) + """ + victoryTotal.text = "\(score)" + victoryRecord.text = record ? "★ NEW BEST ★" : "BEST \(HighScoreStore.shared.best("phoenix"))" + state = .victory + applyStateVisibility() + Haptics.success() + } + + /// Combat waves completed+current out of 31 (shops don't count). + private func waveNumber() -> Int { + var n = 0 + for i in 0.. 0 { + place(companionNode, img: .companion, x: sim.companionX, y: sim.companionY) + } else if sim.companionPwr < 0 { + place(companionNode, img: D.explosionFrames[min(sim.companionExplodeFrame, 7)], + x: sim.companionX, y: sim.companionY) + } else { + companionNode.isHidden = true + } + + // Enemies + for (i, node) in enemyNodes.enumerated() { + let e = sim.enemies[i] + if e.pwr == 0 { node.isHidden = true; continue } + place(node, img: e.currentImg, x: e.x, y: e.y) + } + + // Player bullets + for (i, node) in pbNodes.enumerated() { + let b = sim.playerBullets[i] + guard b.type != 0 else { node.isHidden = true; continue } + let img: D.Img + switch b.type { + case 3: img = .pb1 + case 6: img = .pb0 + case 9: img = .pb2l + case 12: img = .pb2r + case 15, 18, 21: img = .pb3 + case 24, 27, 30, 36, 39: img = .pbQuad + case 33: img = .pb5 + default: img = .pb0 + } + place(node, img: img, x: b.x, y: b.y) + } + + // Enemy bullets / money / impact explosions + for (i, node) in ebNodes.enumerated() { + let b = sim.enemyBullets[i] + guard b.type != 0 else { node.isHidden = true; continue } + let img: D.Img + switch b.type { + case -5: img = D.explosionFrames[min(b.data, 7)] + case 6: img = .money + case 21: img = .eb2 + case 24...30: img = .eb3 + case 33...39: img = .eb4 + default: img = .eb1 + } + place(node, img: img, x: b.x, y: b.y) + } + + // HUD + for (i, cell) in shieldCells.enumerated() { + cell.fillColor = i < sim.playerPwr + ? (sim.playerPwr <= 4 ? Palette.red : (sim.playerPwr <= 8 ? Palette.amber : Palette.mint)) + : UIColor(white: 1, alpha: 0.06) + } + cashValue.text = "$\(sim.cash)" + waveValue.text = "\(waveNumber())/31" + + for (i, button) in weaponButtons.enumerated() { + let owned = sim.hasWeapon[i] + button.setPressed(sim.chosenWeapon == i && owned) + button.alpha = state != .playing ? 0.25 : (owned ? 1.0 : 0.30) + } + } + + // MARK: - Effects + + private func spawnBoom(at p: CGPoint) { + let core = SceneArt.glowSprite(color: Palette.amber, radius: 26) + core.position = p + core.zPosition = 6 + worldCrop.addChild(core) + core.run(.sequence([.group([.scale(to: 2.0, duration: 0.22), .fadeOut(withDuration: 0.25)]), + .removeFromParent()])) + let sparks = SceneArt.sparkBurst(color: Palette.amber, count: 14, speed: 150, + lifetime: 0.5, scale: 0.20) + sparks.position = p + sparks.zPosition = 6 + worldCrop.addChild(sparks) + } + + private func flashPlayer() { + playerNode.removeAction(forKey: "hitflash") + let flash = SKAction.sequence([ + .colorize(with: .red, colorBlendFactor: 0.8, duration: 0.05), + .wait(forDuration: 0.08), + .colorize(withColorBlendFactor: 0, duration: 0.15), + ]) + playerNode.run(flash, withKey: "hitflash") + } + + private func popCash() { + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = "+$\(sim.moneyValue)" + label.fontSize = 13 + label.fontColor = UIColor(red: 1.0, green: 0.85, blue: 0.30, alpha: 1) + label.position = point(x: sim.playerX, y: sim.playerY - 6, w: 8, h: 0) + label.zPosition = 8 + addChild(label) + label.run(.sequence([.group([.moveBy(x: 0, y: 22, duration: 0.55), .fadeOut(withDuration: 0.55)]), + .removeFromParent()])) + } + + // MARK: - Touch handling + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + for touch in touches { + let p = touch.location(in: self) + switch state { + case .ready: + if handleStepperTap(at: p) { continue } + Haptics.selection() + startGame() + case .paused: + Haptics.selection() + state = .playing + applyStateVisibility() + case .gameOver, .victory: + Haptics.selection() + state = .ready + applyStateVisibility() + case .shop: + handleShopTap(at: p) + case .playing: + let dp = hypot(p.x - pauseButton.position.x, p.y - pauseButton.position.y) + if dp <= 30 { + Haptics.selection() + state = .paused + moveTouch = nil + applyStateVisibility() + continue + } + if let index = weaponButtons.firstIndex(where: { $0.covers(p) }) { + if sim.hasWeapon[index] { + Haptics.selection() + pendingWeapon = index + } else { + Haptics.warning() + } + continue + } + // Movement drag anchor + moveTouch = touch + touchAnchor = p + shipAnchor = CGPoint(x: CGFloat(sim.playerX), y: CGFloat(sim.playerY)) + targetX = sim.playerX + targetY = sim.playerY + } + } + } + + private var pendingWeapon: Int? + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard state == .playing, let moveTouch, touches.contains(moveTouch) else { return } + let p = moveTouch.location(in: self) + // Finger travel maps ~1.35× on-screen ship travel for comfortable reach + // (per-axis, so the stretched field keeps the same feel both ways). + targetX = Int(shipAnchor.x + (p.x - touchAnchor.x) * 1.35 / unit) + targetY = Int(shipAnchor.y - (p.y - touchAnchor.y) * 1.35 / unitY) + targetX = min(max(targetX, 12), 108) + targetY = min(max(targetY, 68), 90) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil } + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil } + } + + private func handleStepperTap(at p: CGPoint) -> Bool { + for (key, rect) in stepperRects where rect.contains(p) { + switch key { + case "diffMinus": chosenDifficulty = max(0, chosenDifficulty - 1) + case "diffPlus": chosenDifficulty = min(2, chosenDifficulty + 1) + case "speedMinus": chosenSpeed = max(0, chosenSpeed - 1) + case "speedPlus": chosenSpeed = min(2, chosenSpeed + 1) + default: break + } + difficultyValueLabel.text = D.difficultyOptions[chosenDifficulty].label + speedValueLabel.text = D.speedOptions[chosenSpeed].label + Haptics.selection() + return true + } + return false + } + + private func handleShopTap(at p: CGPoint) { + if shopDoneRect.contains(p) { + Haptics.selection() + state = .playing + applyStateVisibility() + return + } + for (i, rect) in shopRowRects.enumerated() where rect.contains(p) { + switch sim.buy(itemIndex: i) { + case .bought: + Haptics.success() + case .insufficientFunds: + Haptics.warning() + case .alreadyOwned, .shieldFull: + Haptics.selection() + } + refreshShop() + renderFrame() + return + } + } +} diff --git a/Arcade85/Games/Phoenix/PhoenixSim.swift b/Arcade85/Games/Phoenix/PhoenixSim.swift new file mode 100644 index 0000000..5d75892 --- /dev/null +++ b/Arcade85/Games/Phoenix/PhoenixSim.swift @@ -0,0 +1,975 @@ +import Foundation + +/// A faithful port of Phoenix 4.0 (Patrick Davidson, 2008) game logic from the +/// Z80 sources. Runs in the original coordinate space: X 0…127, Y increases +/// downward, with the visible window being Y 32…95 (the region above is +/// off-screen staging that enemies descend from). All movement, firing, +/// collision, shop and scoring rules are ported routine-for-routine; see +/// PhoenixData for the campaign/sprite tables. +struct PhoenixSim { + + typealias D = PhoenixData + + // MARK: - Playfield constants + + static let fieldW = 128 + static let screenTop = 32 // first visible Y + static let screenBottom = 96 // bullets die at y >= 96 + static let maxEnemies = 18 + static let maxPlayerBullets = 16 + static let maxEnemyBullets = 15 + + // MARK: - Entities + + struct Enemy { + var pwr = 0 // 0 = empty slot, -1 = exploding + var moveType = D.Move.standard + var move0 = 0, move1 = 0, move2 = 0 // e_movedata (byte semantics where needed) + var pattern: D.PatternID? = nil // pattern pointer (word movedata) + var patternIndex = 0 // segment index within pattern + var x = 0, w = 0, y = 0, h = 0 + var image: D.ImageRef = .still(.enemy1) + var animIndex = 0 + var animCountdown = 0 // e_imageseq (0 = still image) + var exploding = false // pwr == -1 with explosion animation + var fireType = D.Fire.random + var fireRate = 2 + var fireData = 0 + var fireWeapon = D.Weapon.normal + var firePower = 1 + + var currentImg: D.Img { + if exploding { return D.explosionFrames[min(animIndex, 7)] } + switch image { + case .still(let img): return img + case .anim(let anim): return anim.frames[animIndex % anim.frames.count].img + } + } + } + + struct PlayerBullet { + var type = 0 // 0 = empty; type codes from shoot.asm + var dmg = 0 + var x = 0, w = 0, y = 0, h = 0 + var data = 0 + } + + struct EnemyBullet { + var type = 0 // 0 = empty, -5 = impact explosion effect + var dmg = 0 // 0 => money drop + var x = 0, w = 0, y = 0, h = 0 + var data = 0 // explosion phase for type -5 + } + + // MARK: - Game state + + private(set) var enemies = [Enemy](repeating: Enemy(), count: maxEnemies) + private(set) var playerBullets = [PlayerBullet](repeating: PlayerBullet(), count: maxPlayerBullets) + private(set) var enemyBullets = [EnemyBullet](repeating: EnemyBullet(), count: maxEnemyBullets) + + private(set) var playerX = 60 + private(set) var playerY = 70 + private(set) var playerPwr = 16 // shield, max 16 + private(set) var cash = 0 + private(set) var weaponUpgrade = false + private(set) var hasWeapon = [true, false, false, false, false] // F1…F5 + private(set) var chosenWeapon = 0 // 0…4 + private var fireCounter = 0 + private var weapon4Side = false + private var weapon5Alt = false + + private(set) var companionPwr = 0 // 0 none, -1 exploding + private(set) var companionX = 0 + private(set) var companionY = 0 + private(set) var companionExplodeFrame = 0 + private var posQueue: [(y: Int, x: Int)] = [] + + private(set) var level = 0 // index of NEXT level to load + private(set) var enemiesLeft = 0 + private(set) var gameTimer = 0 + private(set) var timeScore = 19000 + private(set) var bonusScore = 0 + // Remake addition: run progress for the high-score system. The original + // only scored a finished campaign (score16.asm); these track how far a + // run got so a death still posts a score. + private(set) var wavesCleared = 0 // combat levels fully cleared + private(set) var totalEarned = 0 // lifetime $ collected (shop spending doesn't reduce it) + private(set) var moneyValue = 100 // per $ collected + private(set) var moneyCounter = 4 // kills until next $ drop + private(set) var over = false + private(set) var finished = false + + let speedOption: Int + let difficultyOption: Int + var fps: Double { D.speedOptions[speedOption].fps } + + private var rng: TetrisRNG + + enum Event: Equatable { + case enteredShop + case levelLoaded(Int) + case gameOver + case gameFinished + case enemyDestroyed(x: Int, y: Int) + case playerHit + case moneyCollected + case bossLevel + } + private(set) var events: [Event] = [] + + // MARK: - Init + + init(speed: Int = 2, difficulty: Int = 0, seed: UInt64? = nil) { + self.speedOption = min(max(speed, 0), 2) + self.difficultyOption = min(max(difficulty, 0), 2) + self.rng = TetrisRNG(seed: seed ?? UInt64.random(in: 1...UInt64.max)) + self.moneyValue = D.difficultyOptions[difficultyOption].moneyValue + self.bonusScore = D.difficultyOptions[difficultyOption].bonus + D.speedOptions[speedOption].bonus + } + + private mutating func rand() -> Int { Int(rng.next() & 0xFF) } + + /// `Div_A_16`: divide by 16 with frame-averaged fractions — the timer's low + /// nibble is bit-reversed and added before the shift so x/16 rounds up on + /// exactly x of every 16 frames. + private func divA16(_ a: Int) -> Int { + let t = gameTimer & 15 + let rev = ((t & 1) << 3) | ((t & 2) << 1) | ((t & 4) >> 1) | ((t & 8) >> 3) + return (a + rev) >> 4 + } + + // MARK: - Level loading (init.asm) + + /// Loads the next level. Returns without enemies for shop/game-end levels + /// (events signal what happened). + mutating func loadLevel() { + // The previous level just finished; if it was a combat wave, bank it. + if level > 0, case .combat = D.campaign[level - 1] { wavesCleared += 1 } + guard level < D.campaign.count else { finished = true; return } + let def = D.campaign[level] + level += 1 + + switch def { + case .shop: + // Original only opens the shop if you have money; either way the + // level is over immediately and the next one loads next frame. + if cash > 0 { events.append(.enteredShop) } + enemiesLeft = 0 + return + case .gameEnd: + finished = true + events.append(.gameFinished) + return + case .combat(let count, let commands): + enemiesLeft = count + runLoader(commands) + events.append(.levelLoaded(level)) + } + } + + private mutating func runLoader(_ commands: [D.Cmd]) { + // default_data + var buf = Enemy() + buf.pwr = 4 + buf.moveType = D.Move.standard + buf.move0 = 0; buf.move1 = -60; buf.move2 = 0 + buf.x = 10; buf.y = 10 + buf.image = .still(.enemy1) + buf.fireType = D.Fire.random + buf.fireRate = 2 + buf.fireWeapon = D.Weapon.normal + buf.firePower = 1 + + var slot = 0 + func install(_ x: Int, _ y: Int) { + guard slot < Self.maxEnemies else { return } + var e = buf + e.x = x & 0xFF + e.y = y + let img = e.currentImg + let sprite = D.sprites[img]! + e.w = sprite.w + e.h = sprite.h + // Animated images start at their first frame's countdown. + if case .anim(let anim) = e.image { + e.animIndex = 0 + e.animCountdown = anim.frames[0].time + } else { + e.animCountdown = 0 + } + enemies[slot] = e + slot += 1 + } + + for cmd in commands { + switch cmd { + case .setPower(let p): buf.pwr = p + case .setMoveType(let t): buf.moveType = t + case .setMoveData(let a, let b): buf.move0 = a; buf.move1 = b; buf.pattern = nil + case .setMovePattern(let p): buf.pattern = p; buf.patternIndex = 0 + case .image(let ref): buf.image = ref; buf.animIndex = 0 + case .setFireType(let t): buf.fireType = t + case .setFireRate(let r): buf.fireRate = r + case .setWeapon(let w): buf.fireWeapon = w + case .setFirePower(let p): buf.firePower = p + case .installOne(let x, let y): + install(x, y) + case .installRow(let x, let y, let n, let spacing): + install(x, y) + var cx = x + for _ in 1.. 0 { timeScore -= 1 } + } + + // MARK: - Player (player16.asm + shoot.asm) + + private mutating func doPlayer(_ input: Input) { + if let sel = input.selectWeapon, sel >= 0, sel < 5, hasWeapon[sel] { + chosenWeapon = sel + } + + // Fire: cooldown 10 frames base, 4 with the upgrade; releasing resets. + if !input.fire { + fireCounter = 0 + } else { + if fireCounter == 0 { + fireCounter = weaponUpgrade ? 4 : 10 + playerShoot() + } else { + fireCounter -= 1 + } + } + + // Movement, exact bound checks from do_player (blocked at the limit). + if input.dy > 0 { + let ny = playerY + 1 + if ny != 90 { playerY = ny } + } else if input.dy < 0 { + let ny = playerY - 1 + if ny != 68 { playerY = ny } + } + if input.dx < 0 { + let nx = playerX - 2 + if nx != 12 { playerX = nx } + } else if input.dx > 0 { + let nx = playerX + 2 + if nx != 108 { playerX = nx } + } + } + + private mutating func firePlayerBullet(type: Int, dmg: Int, w: Int, h: Int, xOff: Int) { + guard let i = playerBullets.firstIndex(where: { $0.type == 0 }) else { return } + playerBullets[i] = PlayerBullet(type: type, dmg: dmg, + x: playerX + xOff, w: w, + y: playerY - 3, h: h, data: 7) + } + + private mutating func playerShoot() { + switch chosenWeapon { + case 0: + if weaponUpgrade { + firePlayerBullet(type: 3, dmg: 4, w: 4, h: 8, xOff: 4) + } else { + firePlayerBullet(type: 6, dmg: 2, w: 2, h: 6, xOff: 5) + } + case 1: + firePlayerBullet(type: 9, dmg: 2, w: 3, h: 7, xOff: 1) + firePlayerBullet(type: 12, dmg: 2, w: 3, h: 7, xOff: 8) + case 2: + firePlayerBullet(type: 21, dmg: 4, w: 4, h: 4, xOff: 4) + firePlayerBullet(type: 15, dmg: 3, w: 4, h: 4, xOff: 2) + firePlayerBullet(type: 18, dmg: 3, w: 4, h: 4, xOff: 6) + case 3: + weapon4Side.toggle() + let side = weapon4Side ? 7 : 0 + firePlayerBullet(type: 27, dmg: 8, w: 3, h: 5, xOff: side + 2) + firePlayerBullet(type: 24, dmg: 8, w: 3, h: 5, xOff: side) + case 4: + weapon5Alt.toggle() + firePlayerBullet(type: 33, dmg: 20, w: 5, h: 7, xOff: weapon5Alt ? 4 : 3) + firePlayerBullet(type: 36, dmg: 8, w: 4, h: 4, xOff: 0) + firePlayerBullet(type: 39, dmg: 8, w: 4, h: 4, xOff: 8) + default: break + } + } + + // MARK: - Player bullets (bullets.asm) + + private mutating func movePlayerBullets() { + for i in 0..= 124 { playerBullets[i].type = 0; continue } + bulletUp(i, dy: -4) + case 24, 27: // quad: up 4, drifting ±0.5/frame + if gameTimer & 1 == 0 { + playerBullets[i].x += (playerBullets[i].type == 27) ? 1 : -1 + if playerBullets[i].x <= 0 || playerBullets[i].x >= 124 { + playerBullets[i].type = 0; continue + } + } + bulletUp(i, dy: -4) + case 30: // companion shot: straight up 4 + bulletUp(i, dy: -4) + case 33: // super: up 5 + bulletUp(i, dy: -5) + case 36, 39: // swing: sideways 1, arcing Y + playerBullets[i].x += (playerBullets[i].type == 36) ? 1 : -1 + if playerBullets[i].x <= 0 || playerBullets[i].x >= 124 { + playerBullets[i].type = 0; continue + } + playerBullets[i].data -= 1 + let dy = playerBullets[i].data >> 1 // arithmetic: 3,2,2,1…0,-1… + bulletUp(i, dy: dy) + default: + playerBullets[i].type = 0 + } + } + } + + private mutating func bulletUp(_ i: Int, dy: Int) { + playerBullets[i].y += dy + if playerBullets[i].y < 24 { playerBullets[i].type = 0 } + } + + // MARK: - Bullets vs enemies (enemyhit.asm) + + private mutating func hitEnemies() { + for bi in 0.. 0 else { continue } + guard rectsOverlap(x1: e.x, w1: e.w, y1: e.y, h1: e.h, + x2: b.x, w2: b.w, y2: b.y, h2: b.h) else { continue } + playerBullets[bi].type = 0 + enemies[ei].pwr -= b.dmg + if enemies[ei].pwr <= 0 { + startExplosion(ei) + } + break + } + } + } + + private mutating func startExplosion(_ ei: Int) { + enemies[ei].pwr = -1 + enemies[ei].exploding = true + enemies[ei].moveType = D.Move.none + enemies[ei].animIndex = 0 + enemies[ei].animCountdown = 2 + enemies[ei].fireType = D.Fire.none + events.append(.enemyDestroyed(x: enemies[ei].x, y: enemies[ei].y)) + } + + // MARK: - Enemies (enemies.asm / emove.asm / eshoot.asm) + + private mutating func doEnemies() { + var swoopUsedThisFrame = 0 // misc_flags, reset each frame + for i in 0..= 9 { standardSwing(i) } + else { + enemies[i].moveType = D.Move.rampage + enemies[i].move1 = -1 + enemies[i].move2 = -1 + rampageApply(i) + } + case D.Move.swoopHoriz: swoopHoriz(i) + case D.Move.swoopDown: swoopDown(i) + case D.Move.swoopUp: + enemies[i].y -= 1 + if enemies[i].y == 0 { enemies[i].moveType = D.Move.swoopWait } + case D.Move.swoopWait: swoopWaitMove(i, flags: &swoopFlags) + case D.Move.rampageInit: + enemies[i].moveType = D.Move.rampage + enemies[i].move1 = 1 + enemies[i].move2 = 1 + enemies[i].x = (rand() & 63) + 28 + enemies[i].y = rand() & 8 + default: break + } + } + + /// EM_STANDARD: byte timer counts up (from -60 on entry), moves on odd + /// ticks, phase advances at 17 cycling down/right/up/left. + private mutating func standardSwing(_ i: Int) { + let phase = enemies[i].move0 + enemies[i].move1 = (enemies[i].move1 + 1) & 0xFF + let timer = enemies[i].move1 + guard timer & 1 == 1 else { return } + if timer == 17 { + enemies[i].move1 = 0 + enemies[i].move0 = (phase + 4) & 12 + } + switch phase { + case 0: enemies[i].y += 1 + case 4: enemies[i].x += 1 + case 8: enemies[i].y -= 1 + default: enemies[i].x -= 1 + } + } + + private mutating func bossMove(_ i: Int) { + if enemies[i].move0 == 0 { + enemies[i].move1 -= 1 + if enemies[i].move1 == 0 { + enemies[i].move0 = -1 + enemies[i].move1 = 83 + } else { + enemies[i].y += 1 + } + } else { + enemies[i].move1 -= 1 + if enemies[i].move1 == 0 { + enemies[i].move0 = -enemies[i].move0 + enemies[i].move1 = 90 + } + enemies[i].x += enemies[i].move0 + } + } + + /// EM_BOUNCE: move0 = Y velocity code, move1 = X velocity code. + /// Bit 1 = direction (down/right), bit 0 = fast (every frame vs every other). + private mutating func bounceMove(_ i: Int) { + let yv = enemies[i].move0 + let xv = enemies[i].move1 + if (gameTimer | xv) & 1 == 1 { + if xv & 2 != 0 { + enemies[i].x += 1 + if enemies[i].x == 107 { enemies[i].move1 = rand() & 1 } + } else { + enemies[i].x -= 1 + if enemies[i].x == 13 { enemies[i].move1 = (rand() & 1) | 2 } + } + } + if (gameTimer | yv) & 1 == 1 { + if yv & 2 != 0 { + enemies[i].y += 1 + if enemies[i].y == 60 { enemies[i].move0 = rand() & 1 } + } else { + enemies[i].y -= 1 + if enemies[i].y == 31 { enemies[i].move0 = (rand() & 1) | 2 } + } + } + } + + /// EM_PATTERNSTART: X was installed as the entry delay, Y as the start X. + private mutating func patternInit(_ i: Int) { + enemies[i].moveType = D.Move.patternWait + let givenY = enemies[i].y + let givenX = enemies[i].x + enemies[i].y = 110 // parked off-screen while waiting + enemies[i].x = givenY + enemies[i].move2 = givenX & 0xFF // delay counter + if enemies[i].move2 == 0 { enemies[i].move2 = 256 } + } + + private mutating func patternWait(_ i: Int) { + enemies[i].move2 -= 1 + if enemies[i].move2 == 0 { + enemies[i].move2 = -1 // original double-decrements + enemies[i].moveType = D.Move.patternMain + enemies[i].y = 24 + } + } + + private mutating func patternMove(_ i: Int) { + guard let pid = enemies[i].pattern, let pattern = D.patterns[pid] else { return } + var index = enemies[i].patternIndex + enemies[i].move2 += 1 + if enemies[i].move2 == pattern.segments[index].time { + index += 1 + if index >= pattern.segments.count { index = pattern.loopStart } + enemies[i].patternIndex = index + enemies[i].move2 = 0 + } + let seg = pattern.segments[index] + enemies[i].x += divA16(seg.xv) + enemies[i].y += divA16(seg.yv) + } + + private mutating func rampageApply(_ i: Int) { rampageMove(i) } + + private mutating func rampageMove(_ i: Int) { + var xv = enemies[i].move1 + var yv = enemies[i].move2 + let nx = enemies[i].x + xv + enemies[i].x = nx + if nx < 16 { xv = 1 } + else if nx == 40 || nx == 71 || nx == 93 { xv = (rand() & 1) == 1 ? -1 : 1 } + else if nx >= 105 { xv = -1 } + let ny = enemies[i].y + yv + enemies[i].y = ny + if ny < 41 { yv = 1 } + else if ny == 51 { yv = (rand() & 1) == 1 ? -1 : 1 } + else if ny >= 59 { yv = -1 } + enemies[i].move1 = xv + enemies[i].move2 = yv + } + + private mutating func swoopWaitMove(_ i: Int, flags: inout Int) { + guard gameTimer & 15 == 0 else { return } + let pick = rand() & 3 + let bit = 1 << pick + guard flags & bit == 0 else { return } + flags |= bit + let path = D.swoopPaths[pick] + enemies[i].moveType = D.Move.swoopDown + enemies[i].move1 = path.yDest + enemies[i].move2 = path.xDest + enemies[i].x = path.xEntry + enemies[i].y = 2 + } + + private mutating func swoopDown(_ i: Int) { + enemies[i].y += 1 + if enemies[i].y == enemies[i].move1 { + enemies[i].move1 = enemies[i].move2 >= 64 ? 2 : -2 + enemies[i].moveType = D.Move.swoopHoriz + } + } + + private mutating func swoopHoriz(_ i: Int) { + enemies[i].x += enemies[i].move1 + if enemies[i].x == enemies[i].move2 { + enemies[i].moveType = D.Move.swoopUp + } + } + + // --- Firing --- + + private mutating func enemyShoot(_ i: Int) { + let e = enemies[i] + switch e.fireType { + case D.Fire.random: + guard rand() < e.fireRate else { return } + case D.Fire.periodic: + enemies[i].fireData += 1 + guard enemies[i].fireData == e.fireRate else { return } + enemies[i].fireData = 0 + default: + return + } + fireEnemyWeapon(i) + } + + private mutating func fireEnemyWeapon(_ i: Int) { + let e = enemies[i] + switch e.fireWeapon { + case D.Weapon.normal: + spawnEnemyBullet(type: 3, from: e, dmg: e.firePower, w: 2, h: 2) + case D.Weapon.double: + fireAimed(from: e, xShift: 0) + fireAimed(from: e, xShift: 14) + case D.Weapon.semiAim: + fireSemiAim(i) + case D.Weapon.big: + fireLarge(i, huge: false, xShift: 0) + fireLarge(i, huge: false, xShift: 13) + case D.Weapon.huge: + fireLarge(i, huge: true, xShift: 0) + fireLarge(i, huge: true, xShift: 11) + case D.Weapon.arrow: + spawnEnemyBullet(type: 21, from: e, dmg: e.firePower, w: 3, h: 7) + case D.Weapon.singleBig: + fireLarge(i, huge: false, xShift: 2) + case D.Weapon.singleHuge: + fireLarge(i, huge: true, xShift: 1) + default: break + } + } + + /// W_DOUBLE component: straight, or diagonal when the player is >30 off. + private mutating func fireAimed(from e: Enemy, xShift: Int) { + var shifted = e + shifted.x += xShift + if playerX + 30 < shifted.x { + spawnEnemyBullet(type: 9, from: shifted, dmg: e.firePower, w: 2, h: 2) + } else if playerX - 30 > shifted.x { + spawnEnemyBullet(type: 12, from: shifted, dmg: e.firePower, w: 2, h: 2) + } else { + spawnEnemyBullet(type: 3, from: shifted, dmg: e.firePower, w: 2, h: 2) + } + } + + /// W_SEMIAIM: straight down, or half-angle toward the player. + private mutating func fireSemiAim(_ i: Int) { + let e = enemies[i] + let dy = playerY - e.y + guard dy >= 0 else { return } + let b = (dy >> 2) + (dy >> 3) // (3/8) * dy + guard b != 0 else { return } + let dx = playerX - e.x + if abs(dx) < b { + spawnEnemyBullet(type: 3, from: e, dmg: e.firePower, w: 2, h: 2) + } else if dx < 0 { + spawnEnemyBullet(type: 15, from: e, dmg: e.firePower, w: 2, h: 2) + } else { + spawnEnemyBullet(type: 18, from: e, dmg: e.firePower, w: 2, h: 2) + } + } + + /// W_BIG / W_HUGE: fully-aimed shot in one of 7 angle buckets. + private mutating func fireLarge(_ i: Int, huge: Bool, xShift: Int) { + var e = enemies[i] + e.x += xShift + let dy = playerY - e.y + guard dy >= 0 else { return } + let b = (dy >> 2) + (dy >> 3) + guard b != 0 else { return } + var dx = playerX - e.x + let left = dx < 0 + if left { dx = -dx } + var c = 0 + var rem = dx - b + while rem >= 0 && c < 3 { // get_angle buckets 0…3 + c += 1 + rem -= b + if c == 2 { rem -= b } // the doubled step in the original + } + let angle = left ? -min(c, 3) : min(c, 3) + let type = (huge ? 36 : 27) + angle // big 24…30, huge 33…39 + spawnEnemyBullet(type: type, from: e, dmg: e.firePower, + w: huge ? 5 : 5, h: huge ? 5 : 5) + } + + private mutating func spawnEnemyBullet(type: Int, from e: Enemy, dmg: Int, w: Int, h: Int) { + guard let i = enemyBullets.firstIndex(where: { $0.type == 0 }) else { return } + enemyBullets[i] = EnemyBullet(type: type, dmg: dmg, + x: e.x + 2, w: w, y: e.y + 7, h: h, data: 0) + } + + // --- Animation & death (enemy_display / kill_enemy) --- + + private mutating func enemyAnimate(_ i: Int) { + if enemies[i].exploding { + enemies[i].animCountdown -= 1 + if enemies[i].animCountdown == 0 { + enemies[i].animIndex += 1 + if enemies[i].animIndex >= D.explosionFrames.count { + killEnemy(i) + } else { + enemies[i].animCountdown = 2 + } + } + return + } + guard case .anim(let anim) = enemies[i].image, enemies[i].animCountdown > 0 else { return } + enemies[i].animCountdown -= 1 + if enemies[i].animCountdown == 0 { + enemies[i].animIndex = (enemies[i].animIndex + 1) % anim.frames.count + enemies[i].animCountdown = anim.frames[enemies[i].animIndex].time + } + } + + private mutating func killEnemy(_ i: Int) { + let x = enemies[i].x + let y = enemies[i].y + enemies[i] = Enemy() // free the slot + enemiesLeft -= 1 + moneyCounter -= 1 + if moneyCounter == 0 { + moneyCounter = (rand() & 7) + 4 + // Drop money at the dead enemy's position (a dmg-0 bonus bullet). + if let bi = enemyBullets.firstIndex(where: { $0.type == 0 }) { + enemyBullets[bi] = EnemyBullet(type: 6, dmg: 0, x: x + 2, w: 5, y: y + 7, h: 7, data: 0) + } + } + } + + // MARK: - Enemy bullets (ebullets.asm) + + private mutating func moveEnemyBullets() { + for i in 0..= 8 { enemyBullets[i].type = 0 } + } + case 3: // straight down 1 + ebDown(i, 1) + case 6: // money: falls every other frame + if gameTimer & 1 == 0 { ebDown(i, 1) } + case 9: // diagonal left + enemyBullets[i].x -= 1 + if enemyBullets[i].x <= 0 { enemyBullets[i].type = 0; continue } + ebDown(i, 1) + case 12: // diagonal right + enemyBullets[i].x += 1 + if enemyBullets[i].x >= 126 { enemyBullets[i].type = 0; continue } + ebDown(i, 1) + case 15: // half-left: alternate straight/diag + if gameTimer & 1 == 1 { + enemyBullets[i].x -= 1 + if enemyBullets[i].x <= 0 { enemyBullets[i].type = 0; continue } + } + ebDown(i, 1) + case 18: // half-right + if gameTimer & 1 == 1 { + enemyBullets[i].x += 1 + if enemyBullets[i].x >= 126 { enemyBullets[i].type = 0; continue } + } + ebDown(i, 1) + case 21: // arrow: straight down 1 + ebDown(i, 1) + case 24...30: // big, angle-coded + ebLarge(i, index: type - 24) + case 33...39: // huge, angle-coded + ebLarge(i, index: type - 33) + default: + enemyBullets[i].type = 0 + } + } + } + + private mutating func ebDown(_ i: Int, _ dy: Int) { + enemyBullets[i].y += dy + if enemyBullets[i].y >= Self.screenBottom { enemyBullets[i].type = 0 } + } + + /// Big/huge bullets: velocities in half-pixels with timer-dithered halves. + private mutating func ebLarge(_ i: Int, index: Int) { + guard index >= 0, index < D.bigBulletSpeeds.count else { enemyBullets[i].type = 0; return } + let speeds = D.bigBulletSpeeds[index] + let dx = halfStep(speeds.xv) + let dy = halfStep(speeds.yv) + enemyBullets[i].x += dx + if enemyBullets[i].x < 0 || enemyBullets[i].x >= Self.fieldW { + enemyBullets[i].type = 0 + return + } + ebDown(i, dy) + } + + /// div_by_2 with timer-based fraction: a/2, remainder applied on odd frames. + private func halfStep(_ v: Int) -> Int { + var out = v >> 1 + if v & 1 != 0, gameTimer & 1 != 0 { out += 1 } + return out + } + + // MARK: - Enemy bullets vs player/companion (hityou.asm) + + private mutating func hitPlayer() { + scanHits(targetX: playerX, targetW: 8, targetY: playerY, targetH: 6, isCompanion: false) + if companionPwr > 0 { + scanHits(targetX: companionX, targetW: 7, targetY: companionY, targetH: 7, isCompanion: true) + } + } + + private mutating func scanHits(targetX: Int, targetW: Int, targetY: Int, targetH: Int, isCompanion: Bool) { + for i in 0.. 0 else { continue } + guard rectsOverlap(x1: targetX, w1: targetW, y1: targetY, h1: targetH, + x2: b.x, w2: b.w, y2: b.y, h2: b.h) else { continue } + if b.dmg == 0 { + // Money pickup + enemyBullets[i].type = 0 + cash = min(cash + moneyValue, 9900) + totalEarned += moneyValue + events.append(.moneyCollected) + continue + } + // Convert the bullet into a small impact explosion. + enemyBullets[i].type = -5 + enemyBullets[i].data = 0 + enemyBullets[i].x -= 3 + enemyBullets[i].y -= 2 + if isCompanion { + companionPwr -= b.dmg + if companionPwr <= 0 { + companionPwr = -1 + companionExplodeFrame = 0 + } + } else { + playerPwr -= b.dmg + events.append(.playerHit) + if playerPwr < 0 { + over = true + events.append(.gameOver) + return + } + } + } + } + + // MARK: - Companion (helper.asm) + + private mutating func doCompanion() { + if companionPwr < 0 { + // Explosion animation, one frame every other tick. + if gameTimer & 1 == 0 { + companionExplodeFrame += 1 + if companionExplodeFrame >= 8 { companionPwr = 0 } + } + return + } + guard companionPwr > 0 else { return } + + // Age the position queue; the player's live position feeds the tail. + posQueue.append((y: playerY, x: playerX)) + if posQueue.count > 11 { posQueue.removeFirst(posQueue.count - 11) } + guard let oldest = posQueue.first else { return } + + // Destination: just above the oldest position, offset to the side. + let destY = oldest.y - 1 + let destX = oldest.x < 60 ? oldest.x + 11 : oldest.x - 10 + + if companionY < destY { companionY += 1 } else if companionY > destY { companionY -= 1 } + if companionX < destX { companionX += 1 } else if companionX > destX { companionX -= 1 } + + // Fires a heavy shot every 8 frames. + if gameTimer & 7 == 0 { + if let i = playerBullets.firstIndex(where: { $0.type == 0 }) { + playerBullets[i] = PlayerBullet(type: 30, dmg: 10, + x: companionX + 2, w: 3, + y: companionY - 5, h: 5, data: 0) + } + } + } + + // MARK: - Shop (shop16.asm) + + enum ShopResult { case bought, insufficientFunds, alreadyOwned, shieldFull } + + mutating func buy(itemIndex: Int) -> ShopResult { + let price = D.shopItems[itemIndex].price + switch itemIndex { + case 0: // extra shield + guard playerPwr < 16 else { return .shieldFull } + guard cash >= price else { return .insufficientFunds } + cash -= price + playerPwr += 1 + return .bought + case 2: // companion + guard companionPwr != 16 else { return .alreadyOwned } + guard cash >= price else { return .insufficientFunds } + cash -= price + companionPwr = 16 + companionY = 90 + companionX = 60 + posQueue.removeAll() + return .bought + case 3: // weapon upgrade + guard !weaponUpgrade else { return .alreadyOwned } + guard cash >= price else { return .insufficientFunds } + cash -= price + weaponUpgrade = true + return .bought + default: // weapons F2…F5 (items 1, 4, 5, 6 → weapons 1…4) + let weapon = [0: 0, 1: 1, 4: 2, 5: 3, 6: 4][itemIndex] ?? 0 + guard !hasWeapon[weapon] else { return .alreadyOwned } + guard cash >= price else { return .insufficientFunds } + cash -= price + hasWeapon[weapon] = true + chosenWeapon = weapon + return .bought + } + } + + // MARK: - Final score (score16.asm) + + var finalScore: Int { + max(0, playerPwr) * 1000 + timeScore + bonusScore + cash + } + + var shieldScore: Int { max(0, playerPwr) * 1000 } + + /// Remake addition: how far the run got, scoreable at any moment — waves + /// beaten plus everything ever earned. A death posts this; a victory posts + /// this plus the original score16 tally on top. + var progressScore: Int { wavesCleared * 400 + totalEarned } + + // MARK: - Helpers + + private func rectsOverlap(x1: Int, w1: Int, y1: Int, h1: Int, + x2: Int, w2: Int, y2: Int, h2: Int) -> Bool { + x1 < x2 + w2 && x2 < x1 + w1 && y1 < y2 + h2 && y2 < y1 + h1 + } + + #if DEBUG + mutating func debugSetCash(_ amount: Int) { cash = amount } + mutating func debugSetLevel(_ n: Int) { level = n } + mutating func debugSetShield(_ n: Int) { playerPwr = n } + mutating func debugGiveWeapons() { + hasWeapon = [true, true, true, true, true] + weaponUpgrade = true + } + mutating func debugPlacePlayer(x: Int, y: Int) { playerX = x; playerY = y } + #endif +} diff --git a/Arcade85/Games/SceneArt.swift b/Arcade85/Games/SceneArt.swift new file mode 100644 index 0000000..c3ae12b --- /dev/null +++ b/Arcade85/Games/SceneArt.swift @@ -0,0 +1,295 @@ +import SpriteKit +import UIKit + +/// Reusable procedural art for the SpriteKit scenes: gradients, starfields, +/// SF-symbol textures, and an HD rendering of the original TI-85 lander sprite. +enum SceneArt { + + /// A vertical gradient texture (top color at the top of the node). + static func verticalGradient(size: CGSize, top: UIColor, bottom: UIColor) -> SKTexture { + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { ctx in + let cg = ctx.cgContext + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, + colors: [top.cgColor, bottom.cgColor] as CFArray, + locations: [0, 1])! + cg.drawLinearGradient(gradient, + start: CGPoint(x: 0, y: 0), + end: CGPoint(x: 0, y: size.height), + options: []) + } + return SKTexture(image: image) + } + + /// A node full of small stars scattered across `size`. A handful twinkle. + static func starfield(size: CGSize, count: Int, seed: UInt64) -> SKNode { + let node = SKNode() + var rng = SeededGenerator(seed: seed) + for i in 0.. SKTexture? { + let config = UIImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold) + guard let image = UIImage(systemName: name, withConfiguration: config)? + .withTintColor(color, renderingMode: .alwaysOriginal) + else { return nil } + return SKTexture(image: image) + } + + // MARK: - HD toolkit (shared by the per-game overhauls) + + /// A soft radial falloff — the building block for glows and particles. + static func radialGlowTexture(color: UIColor, radius: CGFloat) -> SKTexture { + let side = radius * 2 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: side, height: side)) + let image = renderer.image { ctx in + let cg = ctx.cgContext + let colors = [color.withAlphaComponent(0.85).cgColor, + color.withAlphaComponent(0.25).cgColor, + color.withAlphaComponent(0).cgColor] as CFArray + let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: colors, locations: [0, 0.35, 1])! + cg.drawRadialGradient(gradient, + startCenter: CGPoint(x: radius, y: radius), startRadius: 0, + endCenter: CGPoint(x: radius, y: radius), endRadius: radius, + options: []) + } + return SKTexture(image: image) + } + + /// An additive-blended glow sprite ready to drop behind any node. + static func glowSprite(color: UIColor, radius: CGFloat) -> SKSpriteNode { + let sprite = SKSpriteNode(texture: radialGlowTexture(color: color, radius: radius)) + sprite.blendMode = .add + return sprite + } + + /// A small soft dot used as the particle texture everywhere. + static let particleTexture: SKTexture = radialGlowTexture(color: .white, radius: 12) + + /// Rounded panel with a vertical gradient fill, hairline stroke, and a + /// faint top highlight — the standard card/HUD surface. + static func panelTexture(size: CGSize, cornerRadius: CGFloat, + top: UIColor, bottom: UIColor, + stroke: UIColor = UIColor(white: 1, alpha: 0.10)) -> SKTexture { + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { ctx in + let cg = ctx.cgContext + let rect = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5) + let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius) + cg.saveGState() + path.addClip() + let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: [top.cgColor, bottom.cgColor] as CFArray, + locations: [0, 1])! + cg.drawLinearGradient(gradient, start: .zero, + end: CGPoint(x: 0, y: size.height), options: []) + // top highlight line (remember: UIKit origin is top-left here) + cg.setFillColor(UIColor(white: 1, alpha: 0.07).cgColor) + cg.fill(CGRect(x: cornerRadius * 0.6, y: 1, width: size.width - cornerRadius * 1.2, height: 1.5)) + cg.restoreGState() + stroke.setStroke() + path.lineWidth = 1 + path.stroke() + } + return SKTexture(image: image) + } + + /// One-shot spark burst (explosions, impacts). Self-removing. + static func sparkBurst(color: UIColor, count: Int = 26, speed: CGFloat = 190, + lifetime: CGFloat = 0.7, scale: CGFloat = 0.24) -> SKEmitterNode { + let e = SKEmitterNode() + e.particleTexture = particleTexture + e.particleBirthRate = CGFloat(count) * 40 + e.numParticlesToEmit = count + e.particleLifetime = lifetime + e.particleLifetimeRange = lifetime * 0.6 + e.emissionAngleRange = .pi * 2 + e.particleSpeed = speed + e.particleSpeedRange = speed * 0.8 + e.particleAlpha = 0.95 + e.particleAlphaSpeed = -1.4 / lifetime + e.particleScale = scale + e.particleScaleRange = scale * 0.5 + e.particleScaleSpeed = -scale / lifetime + e.particleColor = color + e.particleColorBlendFactor = 1 + e.particleBlendMode = .add + e.yAcceleration = -60 + e.run(.sequence([.wait(forDuration: Double(lifetime) * 2), .removeFromParent()])) + return e + } + + /// Continuous engine/thruster flame pointing along `angle` (radians). + static func thrustEmitter(color: UIColor, angle: CGFloat, speed: CGFloat = 150, + birthRate: CGFloat = 130, scale: CGFloat = 0.30, + lifetime: CGFloat = 0.28) -> SKEmitterNode { + let e = SKEmitterNode() + e.particleTexture = particleTexture + e.particleBirthRate = birthRate + e.particleLifetime = lifetime + e.particleLifetimeRange = lifetime * 0.5 + e.emissionAngle = angle + e.emissionAngleRange = 0.45 + e.particleSpeed = speed + e.particleSpeedRange = speed * 0.4 + e.particleAlpha = 0.9 + e.particleAlphaSpeed = -3.0 + e.particleScale = scale + e.particleScaleSpeed = -scale * 2.2 + e.particleColor = color + e.particleColorBlendFactor = 1 + e.particleBlendMode = .add + return e + } + + /// A lingering world-space trail behind a moving node. Set `targetNode` + /// to the scene (or a world layer) after adding, so the ribbon stays put. + static func trailEmitter(color: UIColor, scale: CGFloat = 0.20, + lifetime: CGFloat = 0.45, birthRate: CGFloat = 60) -> SKEmitterNode { + let e = SKEmitterNode() + e.particleTexture = particleTexture + e.particleBirthRate = birthRate + e.particleLifetime = lifetime + e.particleSpeed = 0 + e.particleAlpha = 0.5 + e.particleAlphaSpeed = -0.5 / lifetime - 0.6 + e.particleScale = scale + e.particleScaleSpeed = -scale / lifetime + e.particleColor = color + e.particleColorBlendFactor = 1 + e.particleBlendMode = .add + return e + } + + /// Slow ambient dust drifting through the playfield — cheap depth. + static func ambientDust(size: CGSize, color: UIColor = UIColor(white: 1, alpha: 1)) -> SKEmitterNode { + let e = SKEmitterNode() + e.particleTexture = particleTexture + e.particleBirthRate = 3 + e.particleLifetime = 14 + e.particleLifetimeRange = 6 + e.particlePositionRange = CGVector(dx: size.width, dy: size.height) + e.particleSpeed = 10 + e.particleSpeedRange = 8 + e.emissionAngle = .pi / 2 * 3 + e.emissionAngleRange = 0.6 + e.particleAlpha = 0.0 + e.particleAlphaSequence = SKKeyframeSequence(keyframeValues: [0.0, 0.28, 0.28, 0.0], + times: [0, 0.15, 0.75, 1]) + e.particleScale = 0.10 + e.particleScaleRange = 0.08 + e.particleColor = color + e.particleColorBlendFactor = 1 + e.particleBlendMode = .add + e.advanceSimulationTime(20) // pre-warm so it never starts empty + return e + } + + /// Darkened corners that focus the eye on the playfield. + static func vignette(size: CGSize, alpha: CGFloat = 0.55) -> SKSpriteNode { + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { ctx in + let cg = ctx.cgContext + let colors = [UIColor.black.withAlphaComponent(0).cgColor, + UIColor.black.withAlphaComponent(0).cgColor, + UIColor.black.withAlphaComponent(alpha).cgColor] as CFArray + let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: colors, locations: [0, 0.62, 1])! + let center = CGPoint(x: size.width / 2, y: size.height / 2) + cg.drawRadialGradient(gradient, startCenter: center, startRadius: 0, + endCenter: center, + endRadius: max(size.width, size.height) * 0.72, + options: []) + } + let sprite = SKSpriteNode(texture: SKTexture(image: image)) + sprite.zPosition = 900 + sprite.isUserInteractionEnabled = false + return sprite + } + + /// Quick camera-less screen shake: run it on a world container node. + static func shake(intensity: CGFloat, duration: TimeInterval = 0.35) -> SKAction { + var actions: [SKAction] = [] + var amp = intensity + while amp > 0.5 { + let dx = CGFloat.random(in: -amp...amp) + let dy = CGFloat.random(in: -amp...amp) + actions.append(.moveBy(x: dx, y: dy, duration: 0.02)) + actions.append(.moveBy(x: -dx, y: -dy, duration: 0.02)) + amp *= 0.72 + } + let count = max(actions.count, 1) + let step = duration / Double(count) + return .sequence(actions.map { a in a.duration = step; return a }) + } + + /// Additive white flash overlay for hits/explosions. Self-removing. + static func flash(size: CGSize, color: UIColor = .white, peak: CGFloat = 0.32, + duration: TimeInterval = 0.22) -> SKSpriteNode { + let sprite = SKSpriteNode(color: color, size: size) + sprite.alpha = peak + sprite.blendMode = .add + sprite.zPosition = 850 + sprite.run(.sequence([.fadeOut(withDuration: duration), .removeFromParent()])) + return sprite + } + + /// The original TI-85 Lunar Lander sprite (8×8, MSB = leftmost pixel), + /// taken verbatim from LANDER.C, rendered as crisp rounded neon pixels. + static let landerBitmap: [UInt8] = [ + 0b00011010, + 0b00111111, + 0b01001010, + 0b11111110, + 0b01111110, + 0b00100100, + 0b01000010, + 0b11100111, + ] + + static func landerTexture(cell: CGFloat = 40, color: UIColor = Palette.mint) -> SKTexture { + let side = cell * 8 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: side, height: side)) + let image = renderer.image { ctx in + let cg = ctx.cgContext + // First pass: a soft glow behind the whole silhouette. + cg.setShadow(offset: .zero, blur: cell * 0.5, color: color.withAlphaComponent(0.9).cgColor) + for (row, bits) in landerBitmap.enumerated() { + for col in 0..<8 where (bits & (0x80 >> UInt8(col))) != 0 { + let rect = CGRect(x: CGFloat(col) * cell, y: CGFloat(row) * cell, width: cell, height: cell) + color.setFill() + UIBezierPath(rect: rect.insetBy(dx: cell * 0.06, dy: cell * 0.06)).fill() + } + } + // Second pass: crisp merged pixels (no shadow) so the shape reads solid. + cg.setShadow(offset: .zero, blur: 0, color: nil) + for (row, bits) in landerBitmap.enumerated() { + for col in 0..<8 where (bits & (0x80 >> UInt8(col))) != 0 { + let rect = CGRect(x: CGFloat(col) * cell, y: CGFloat(row) * cell, width: cell, height: cell) + color.setFill() + UIBezierPath(roundedRect: rect.insetBy(dx: cell * 0.04, dy: cell * 0.04), cornerRadius: cell * 0.16).fill() + } + } + } + return SKTexture(image: image) + } +} diff --git a/Arcade85/Games/ZMercury/MercuryData.swift b/Arcade85/Games/ZMercury/MercuryData.swift new file mode 100644 index 0000000..edefd58 --- /dev/null +++ b/Arcade85/Games/ZMercury/MercuryData.swift @@ -0,0 +1,142 @@ +import Foundation + +/// Static data for ZMercury 0.34 (Patrick Davidson, 2004), transcribed from +/// the original sources: terrain tiles (`terrain.inc`), the looping level map +/// (`map0.inc`), the sine table (`sines.inc`) and every sprite (`images.asm`, +/// `sprites/`). Sprites in the original are stored as height + H mask rows + +/// H image rows and drawn `(bg | mask) ^ image`; the visible ink is the image +/// (XOR) half, which is what's transcribed here. +enum MercuryData { + + // MARK: - Sprites (all ≤ 8 px wide; bit 7 = leftmost pixel) + + struct Sprite { + let h: Int + let rows: [UInt8] + } + + enum Img: Hashable { + case player, bullet1, ebullet1, ebullet2 + case enemy1, enemy2, base, boss + case spin1, spin2, spin3, spin4, spin5 + case spint1, spint2, spint3, spint4 + case x1, x2, x3, x4, x5, x6, x7, x8 + } + + static let spinFrames: [Img] = [.spin1, .spin2, .spin3, .spin4, .spin5] + static let spintFrames: [Img] = [.spint1, .spint2, .spint3, .spint4] + static let explosionFrames: [Img] = [.x1, .x2, .x3, .x4, .x5, .x6, .x7, .x8] + + static let sprites: [Img: Sprite] = [ + .player: Sprite(h: 12, rows: [0xFF, 0xFE, 0xC0, 0xC0, 0xFE, 0xC3, + 0xC3, 0xFE, 0xC0, 0xC0, 0xFE, 0xFF]), + .bullet1: Sprite(h: 4, rows: [0x7E, 0xFF, 0xFF, 0x7E]), + .ebullet1: Sprite(h: 4, rows: [0x60, 0xF0, 0xF0, 0x60]), + .ebullet2: Sprite(h: 2, rows: [0xFF, 0xFF]), + .enemy1: Sprite(h: 8, rows: [0x1F, 0x07, 0x7F, 0xFF, 0xFF, 0x7F, 0x07, 0x1F]), + .enemy2: Sprite(h: 10, rows: [0xFF, 0x07, 0x0E, 0x1C, 0x38, 0x38, 0x1C, 0x0E, 0x07, 0xFF]), + // The base dome: mask half is used — its image half is just the core, + // and on-calc the dome shape comes from the mask carving the terrain. + .base: Sprite(h: 8, rows: [0x3C, 0x7E, 0xFF, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C]), + .boss: Sprite(h: 16, rows: [0x7F, 0xC1, 0xC1, 0x7F, 0x07, 0x0F, 0x7F, 0xFF, + 0xFF, 0x7F, 0x0F, 0x07, 0x7F, 0xC1, 0xC1, 0x7F]), + .spin1: Sprite(h: 8, rows: [0x81, 0x7E, 0x42, 0x42, 0x42, 0x42, 0x7E, 0x81]), + .spin2: Sprite(h: 8, rows: [0x20, 0x3C, 0x43, 0x42, 0x42, 0xC2, 0x3C, 0x04]), + .spin3: Sprite(h: 8, rows: [0x10, 0x3C, 0x42, 0x43, 0xC2, 0x42, 0x3C, 0x08]), + .spin4: Sprite(h: 8, rows: [0x08, 0x3C, 0x42, 0xC2, 0x43, 0x42, 0x3C, 0x10]), + .spin5: Sprite(h: 8, rows: [0x04, 0x3C, 0xC2, 0x42, 0x42, 0x43, 0x3C, 0x20]), + .spint1: Sprite(h: 8, rows: [0x20, 0x40, 0xB8, 0x24, 0x24, 0x1D, 0x02, 0x04]), + .spint2: Sprite(h: 8, rows: [0x3C, 0x10, 0x1C, 0x24, 0x24, 0x18, 0x08, 0x3C]), + .spint3: Sprite(h: 8, rows: [0x04, 0x02, 0x1D, 0x24, 0x24, 0xB8, 0x40, 0x20]), + .spint4: Sprite(h: 8, rows: [0x00, 0x00, 0x99, 0xA7, 0xE5, 0x9D, 0x00, 0x00]), + .x1: Sprite(h: 8, rows: [0b0000_1000, 0b0001_1100, 0b1011_1110, 0b0011_1111, + 0b0101_0110, 0b1111_1010, 0b0011_1000, 0b0001_0000]), + .x2: Sprite(h: 8, rows: [0b0011_0000, 0b0111_0010, 0b0100_1110, 0b1011_1110, + 0b0100_1111, 0b0011_1000, 0b0110_0111, 0b0001_1010]), + .x3: Sprite(h: 8, rows: [0b0011_0101, 0b1111_0011, 0b0100_1110, 0b1011_0101, + 0b0100_0101, 0b0011_1110, 0b1101_1010, 0b0011_0110]), + .x4: Sprite(h: 8, rows: [0b0011_0100, 0b1111_0011, 0b0100_1110, 0b1010_0011, + 0b1011_0101, 0b0100_0101, 0b0011_1110, 0b0101_1010]), + .x5: Sprite(h: 8, rows: [0b0010_0110, 0b0100_0001, 0b0010_0110, 0b0100_0101, + 0b0000_0101, 0b0100_0100, 0b0001_0010, 0b1001_1010]), + .x6: Sprite(h: 8, rows: [0b0001_1000, 0b0100_0010, 0b0010_0000, 0b0000_0001, + 0b0100_0100, 0b0010_0010, 0b1000_1010, 0b0010_0100]), + .x7: Sprite(h: 8, rows: [0b0000_1000, 0b0000_0001, 0b1100_0010, 0b0000_0000, + 0b0010_0000, 0b0100_0000, 0b0000_0001, 0b0011_0000]), + .x8: Sprite(h: 8, rows: [0b0000_0100, 0b0000_0000, 0b0000_0000, 0b0100_0000, + 0b0000_0000, 0b0000_0000, 0b0000_0001, 0b0010_0100]), + ] + + // MARK: - Terrain tiles (terrain.inc) + // + // Original polarity: bit set = open sky, bit clear = solid rock. Each tile + // is 8×8 (one byte per row). + + static let tiles: [[UInt8]] = [ + [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], // 0 open + [0x00, 0x80, 0xC0, 0xE0, 0xF0, 0xF8, 0xFC, 0xFE], // 1 slope + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // 2 solid + [0x01, 0x03, 0x07, 0x0F, 0x1F, 0x3F, 0x7F, 0xFF], // 3 slope + [0xFF, 0x7F, 0x3F, 0x1F, 0x0F, 0x07, 0x03, 0x01], // 4 slope + [0xFE, 0xFC, 0xF8, 0xF0, 0xE0, 0xC0, 0x80, 0x00], // 5 slope + [0x01, 0x83, 0xC7, 0xEF, 0xFF, 0xFF, 0xFF, 0xFF], // 6 spike + [0xFF, 0xFF, 0xFF, 0xFF, 0xEF, 0xC7, 0x83, 0x01], // 7 spike + [0x00, 0x00, 0x00, 0x00, 0x10, 0x38, 0x7C, 0xFE], // 8 notch + [0xFE, 0x7C, 0x38, 0x10, 0x00, 0x00, 0x00, 0x00], // 9 notch + [0x00, 0x08, 0x40, 0x02, 0x00, 0x88, 0x20, 0x00], // 10 base pad + ] + + // MARK: - Level map (map0.inc) + // + // Rows 0…7 are tile indices (64 looping columns); row 8 is the enemy-wave + // trigger for each column (0 = none, 1…7 = entry routine). + + static let map: [[Int]] = [ + [0x01, 0x02, 0x03, 0x00, 0x00, 0x00, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x00, 0x00, + 0x00, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x00, 0x00, 0x00, 0x01, + 0x02, 0x02, 0x02, 0x02, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, 0x01, 0x02, 0x02, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x02, 0x02, 0x02, 0x08, 0x02, 0x02, 0x02, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x08, 0x0A, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x02, 0x0A, 0x03, 0x01, 0x08, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [Int](repeating: 0, count: 64), + [0x00, 0x00, 0x05, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x05, 0x02, 0x02, 0x04, 0x05, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x05, 0x0A, 0x02, 0x09, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x05, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x05, 0x02, 0x02, 0x02, 0x02, 0x02, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x04, 0x00, + 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x07, 0x05, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x04], + ] + + // MARK: - Sine table (sines.inc) — 128 entries, values 40…80 + + static let sines: [Int] = [ + 60, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 71, 72, 73, + 74, 74, 75, 76, 76, 77, 77, 78, 78, 78, 79, 79, 79, 79, 79, 79, + 80, 79, 79, 79, 79, 79, 79, 78, 78, 78, 77, 77, 76, 76, 75, 74, + 74, 73, 72, 71, 71, 70, 69, 68, 67, 66, 65, 64, 63, 62, 61, 60, + 60, 59, 58, 57, 56, 55, 54, 53, 52, 51, 50, 49, 48, 48, 47, 46, + 45, 45, 44, 43, 43, 42, 42, 41, 41, 41, 40, 40, 40, 40, 40, 40, + 40, 40, 40, 40, 40, 40, 40, 41, 41, 41, 42, 42, 43, 43, 44, 45, + 45, 46, 47, 48, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, + ] +} diff --git a/Arcade85/Games/ZMercury/MercuryScene.swift b/Arcade85/Games/ZMercury/MercuryScene.swift new file mode 100644 index 0000000..26c1dfd --- /dev/null +++ b/Arcade85/Games/ZMercury/MercuryScene.swift @@ -0,0 +1,813 @@ +import SpriteKit +import UIKit + +/// HD remake of ZMercury. `MercurySim` is the faithful Z80 port; this scene +/// renders the scrolling terrain from prebuilt map-column textures, the +/// original sprites as neon pixel art, adds parallax stars, and drives the +/// ship with drag-anywhere touch + autofire. +final class MercuryScene: SKScene { + + typealias D = MercuryData + + // MARK: State + private enum GameState { case ready, playing, paused, gameOver } + private var state: GameState = .ready + + private var sim = MercurySim() + + // MARK: Timing + private var lastUpdate: TimeInterval = 0 + private var accumulator: TimeInterval = 0 + private var fireCooldown = 0 + private var lastHitHaptic: TimeInterval = 0 + + // MARK: Layout + private var unit: CGFloat = 3 // scene points per sim unit, horizontal + private var unitY: CGFloat = 3.9 // vertical POSITION/terrain scale + private var unitYSprite: CGFloat = 3.6 // vertical SPRITE scale (mild stretch) + private var fieldRect = CGRect.zero // visible window: 128 × 58 sim units (y 35…92) + private var topInset: CGFloat = 0 + + // MARK: Touch control + private var moveTouch: UITouch? + private var touchAnchor = CGPoint.zero + private var shipAnchor = CGPoint.zero + private var targetX = 42 + private var targetY = 60 + + // MARK: Nodes + private let worldCrop = SKCropNode() + private var playerNode = SKSpriteNode() + private var enemyNodes: [SKSpriteNode] = [] + private var pbNodes: [SKSpriteNode] = [] + private var ebNodes: [SKSpriteNode] = [] + private var textureCache: [String: SKTexture] = [:] + + // Terrain: one texture per map column, nodes keyed by spawn scroll. + private var columnTextures: [SKTexture] = [] + private var terrainNodes: [(node: SKSpriteNode, spawnScroll: Int)] = [] + + // HUD + private let shieldValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let sectorValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let scoreValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let pauseButton = SKShapeNode(circleOfRadius: 16) + private let banner = SKLabelNode(fontNamed: "AvenirNext-Heavy") + + // Overlays + private let readyOverlay = SKNode() + private let pausedOverlay = SKNode() + private let gameOverOverlay = SKNode() + private let gameOverDetail = SKLabelNode(fontNamed: "Menlo-Bold") + private let gameOverRecord = SKLabelNode(fontNamed: "Menlo-Bold") + + private var builtSize: CGSize = .zero + #if DEBUG + private var demoFreeze = false + #endif + + // MARK: Tints + + static let terrainColor = UIColor(red: 0.95, green: 0.52, blue: 0.28, alpha: 1) + + static func tint(for img: D.Img) -> UIColor { + switch img { + case .player: return .white + case .bullet1: return Palette.mint + case .ebullet1, .ebullet2: return Palette.amber + case .enemy1: return Palette.mint + case .enemy2: return Palette.cyan + case .base: return UIColor(red: 1.0, green: 0.62, blue: 0.25, alpha: 1) + case .boss: return Palette.red + case .spin1, .spin2, .spin3, .spin4, .spin5: return Palette.violet + case .spint1, .spint2, .spint3, .spint4: + return UIColor(red: 1.0, green: 0.45, blue: 0.85, alpha: 1) + case .x1, .x2, .x3, .x4, .x5, .x6, .x7, .x8: return Palette.amber + } + } + + // MARK: - Lifecycle + + override func didMove(to view: SKView) { + anchorPoint = .zero + scaleMode = .resizeFill + layoutScene() + #if DEBUG + let env = ProcessInfo.processInfo.environment + if env["ARCADE_AUTOPLAY"] == "1" { startGame() } + if env["ARCADE_DEMO"] == "1" { + startGame() + sim.debugWarpToColumn(0) // canyon + first waves + for _ in 0..<175 { stepSimRaw() } + demoFreeze = true + renderFrame() + } + #endif + } + + override func didChangeSize(_ oldSize: CGSize) { + guard view != nil, size != builtSize, size.width > 0 else { return } + layoutScene() + } + + // MARK: - Textures + + /// Neon pixel texture for an 8-px-wide sprite (bit 7 = leftmost). + private func texture(for img: D.Img) -> SKTexture { + let key = "\(img)" + if let t = textureCache[key] { return t } + let sprite = D.sprites[img]! + let cell: CGFloat = 10 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8 * cell, + height: CGFloat(sprite.h) * cell)) + let color = Self.tint(for: img) + let image = renderer.image { ctx in + let cg = ctx.cgContext + cg.setShadow(offset: .zero, blur: cell * 0.6, color: color.withAlphaComponent(0.85).cgColor) + for pass in 0..<2 { + if pass == 1 { cg.setShadow(offset: .zero, blur: 0, color: nil) } + for (row, bits) in sprite.rows.enumerated() { + for col in 0..<8 where (bits & UInt8(0x80 >> col)) != 0 { + let rect = CGRect(x: CGFloat(col) * cell, y: CGFloat(row) * cell, + width: cell, height: cell) + color.setFill() + UIBezierPath(roundedRect: rect.insetBy(dx: cell * 0.05, dy: cell * 0.05), + cornerRadius: cell * 0.18).fill() + } + } + } + } + let t = SKTexture(image: image) + textureCache[key] = t + return t + } + + /// One rock texture per map column (8 × 64 sim px): glowing rim pixels on + /// every edge facing open air, dark rock mass inside — cave walls with lit + /// edges instead of flat orange slabs. + private func buildColumnTextures() { + columnTextures = (0..<64).map { col in + // Solid grid for the column (bit clear = rock, per the inverted map). + var solid = [[Bool]](repeating: [Bool](repeating: false, count: 64), count: 8) + for tileRow in 0..<8 { + let tile = D.tiles[D.map[tileRow][col]] + for k in 0..<8 { + let bits = tile[k] + for px in 0..<8 where (bits & UInt8(0x80 >> px)) == 0 { + solid[px][tileRow * 8 + k] = true + } + } + } + // Out-of-column neighbours count as rock so column seams stay dark. + func isSolid(_ x: Int, _ y: Int) -> Bool { + guard y >= 0, y < 64 else { return true } + guard x >= 0, x < 8 else { return true } + return solid[x][y] + } + + let cell: CGFloat = 6 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8 * cell, height: 64 * cell)) + let rim = Self.terrainColor + let rock = UIColor(red: 0.255, green: 0.130, blue: 0.075, alpha: 1) + let rockDeep = UIColor(red: 0.210, green: 0.105, blue: 0.065, alpha: 1) + let image = renderer.image { ctx in + let cg = ctx.cgContext + // pass 0: glow behind rim pixels; pass 1: crisp fills + cg.setShadow(offset: .zero, blur: cell * 1.1, + color: rim.withAlphaComponent(0.75).cgColor) + for pass in 0..<2 { + if pass == 1 { cg.setShadow(offset: .zero, blur: 0, color: nil) } + for px in 0..<8 { + for y in 0..<64 where solid[px][y] { + let isRim = !isSolid(px, y - 1) || !isSolid(px, y + 1) + || !isSolid(px - 1, y) || !isSolid(px + 1, y) + if pass == 0 { + guard isRim else { continue } + rim.setFill() + } else if isRim { + rim.setFill() + } else { + // checker the interior slightly for grain + ((px + y) % 2 == 0 ? rock : rockDeep).setFill() + } + cg.fill(CGRect(x: CGFloat(px) * cell, y: CGFloat(y) * cell, + width: cell, height: cell)) + } + } + } + } + return SKTexture(image: image) + } + } + + /// Far cave wall silhouettes: stalactites along the top, stalagmites along + /// the bottom. Pure parallax dressing behind the real terrain. + private func cavernTexture(width: CGFloat, height: CGFloat, seed: UInt64) -> SKTexture { + var rng = SeededGenerator(seed: seed) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) + let body = UIColor(red: 0.11, green: 0.075, blue: 0.065, alpha: 1) + let image = renderer.image { ctx in + let cg = ctx.cgContext + for top in [true, false] { + let path = CGMutablePath() + let baseY: CGFloat = top ? 0 : height + path.move(to: CGPoint(x: 0, y: baseY)) + var x: CGFloat = 0 + while x < width { + let step = CGFloat.random(in: 30...80, using: &rng) + let depth = CGFloat.random(in: height * 0.03...height * 0.16, using: &rng) + path.addLine(to: CGPoint(x: x + step * 0.5, + y: top ? depth : height - depth)) + x += step + path.addLine(to: CGPoint(x: x, y: baseY)) + } + path.addLine(to: CGPoint(x: width, y: baseY)) + path.closeSubpath() + cg.addPath(path) + body.setFill() + cg.fillPath() + } + } + return SKTexture(image: image) + } + + // MARK: - Layout + + private func layoutScene() { + builtSize = size + removeAllChildren() + textureCache.removeAll() + terrainNodes.removeAll() + backgroundColor = Palette.space1 + + let insets = view?.safeAreaInsets ?? .zero + topInset = insets.top + let bottomInset = insets.bottom + + let bg = SKSpriteNode(texture: SceneArt.verticalGradient(size: size, top: Palette.space0, bottom: Palette.space1)) + bg.anchorPoint = .zero + bg.zPosition = -10 + addChild(bg) + let stars = SceneArt.starfield(size: size, count: 60, seed: 0x2E4C) + stars.zPosition = -9 + addChild(stars) + let nebula = SceneArt.glowSprite(color: UIColor(red: 0.95, green: 0.5, blue: 0.3, alpha: 1), radius: 220) + nebula.position = CGPoint(x: size.width * 0.80, y: size.height * 0.72) + nebula.alpha = 0.12 + nebula.zPosition = -9.4 + addChild(nebula) + + // Field: 128 × 58 visible sim units (y 35…92), full-bleed width. + // Positions and terrain map with a 1.9× vertical stretch (the canyon + // passages open up on a tall phone); ship/enemy sprites keep a mild + // 1.35× arcade stretch. Sim geometry and collisions are untouched. + unit = size.width / 128 + unitY = unit * 1.9 + unitYSprite = unit * 1.35 + let fieldH = unitY * 58 + let hudBottom = size.height - topInset - 92 + fieldRect = CGRect(x: 0, y: hudBottom - fieldH, width: unit * 128, height: fieldH) + + worldCrop.removeAllChildren() + let mask = SKSpriteNode(color: .white, size: fieldRect.size) + mask.position = CGPoint(x: fieldRect.midX, y: fieldRect.midY) + worldCrop.maskNode = mask + worldCrop.zPosition = 0 + addChild(worldCrop) + + // Parallax inside the cave: drifting stars plus a slow far-wall + // silhouette band (stalactites above, stalagmites below). + for (speed, count, seed) in [(14.0, 22, 0xAA01), (26.0, 14, 0xAA02)] { + let strip = SKNode() + let w = fieldRect.width + for i in 0..<2 { + let layer = SceneArt.starfield(size: CGSize(width: w, height: fieldRect.height), + count: count, seed: UInt64(seed)) + layer.position = CGPoint(x: fieldRect.minX + CGFloat(i) * w, y: fieldRect.minY) + strip.addChild(layer) + } + strip.zPosition = 0.5 + strip.run(.repeatForever(.sequence([ + .moveBy(x: -w, y: 0, duration: Double(w) / speed / Double(unit)), + .moveBy(x: w, y: 0, duration: 0), + ]))) + worldCrop.addChild(strip) + } + do { + let strip = SKNode() + let w = fieldRect.width + let tex = cavernTexture(width: w, height: fieldRect.height, seed: 0xCA4E) + for i in 0..<2 { + let layer = SKSpriteNode(texture: tex) + layer.anchorPoint = .zero + layer.position = CGPoint(x: fieldRect.minX + CGFloat(i) * w, y: fieldRect.minY) + strip.addChild(layer) + } + strip.alpha = 0.5 + strip.zPosition = 0.7 + strip.run(.repeatForever(.sequence([ + .moveBy(x: -w, y: 0, duration: Double(w) / 8.0 / Double(unit)), + .moveBy(x: w, y: 0, duration: 0), + ]))) + worldCrop.addChild(strip) + } + + let vignette = SceneArt.vignette(size: size, alpha: 0.40) + vignette.position = CGPoint(x: size.width / 2, y: size.height / 2) + addChild(vignette) + + buildColumnTextures() + buildEntityNodes() + buildHUD(bottomInset: bottomInset) + buildReadyOverlay() + buildPausedOverlay() + buildGameOverOverlay() + + applyStateVisibility() + rebuildTerrainNodes() + renderFrame() + } + + private func buildEntityNodes() { + func makeNode() -> SKSpriteNode { + let n = SKSpriteNode() + n.isHidden = true + worldCrop.addChild(n) + return n + } + enemyNodes = (0..<12).map { _ in let n = makeNode(); n.zPosition = 4; return n } + pbNodes = (0..<13).map { _ in let n = makeNode(); n.zPosition = 3; return n } + ebNodes = (0..<12).map { _ in let n = makeNode(); n.zPosition = 3; return n } + lastEnemyImgs = [D.Img?](repeating: nil, count: 12) + playerNode = makeNode() + playerNode.zPosition = 5 + + playerTrail.removeFromParent() + playerTrail = SceneArt.thrustEmitter(color: Palette.cyan, angle: .pi, + speed: 140, birthRate: 0, scale: 0.22, lifetime: 0.30) + playerTrail.position = CGPoint(x: -13, y: -1) + playerTrail.zPosition = -1 + playerNode.addChild(playerTrail) + playerTrail.targetNode = worldCrop + } + + private var playerTrail = SKEmitterNode() + private var lastEnemyImgs: [D.Img?] = [] + + /// Sim → scene coordinates. The visible window is sprite-space x 8…135, + /// y 35…92 (y grows downward). + private func point(x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat) -> CGPoint { + CGPoint(x: fieldRect.minX + (x - CGFloat(MercurySim.visibleLeft) + w / 2) * unit, + y: fieldRect.maxY - (y - CGFloat(MercurySim.minY) + h / 2) * unitY) + } + + private func place(_ node: SKSpriteNode, img: D.Img, x: Int, y: Int) { + let sprite = D.sprites[img]! + node.texture = texture(for: img) + // Positions/terrain use the stretched map; sprites stay near-square, + // drawn slightly over their cell for presence (hitboxes are sim-side). + node.size = CGSize(width: 8 * unit * 1.12, height: CGFloat(sprite.h) * unitYSprite * 1.12) + node.position = point(x: CGFloat(x), y: CGFloat(y), w: 8, h: CGFloat(sprite.h)) + node.isHidden = false + } + + // MARK: - Terrain nodes + + private func rebuildTerrainNodes() { + for (node, _) in terrainNodes { node.removeFromParent() } + terrainNodes.removeAll() + } + + private func spawnTerrainColumn(mapColumn: Int) { + let node = SKSpriteNode(texture: columnTextures[mapColumn]) + node.size = CGSize(width: 8 * unit, height: 64 * unitY) + node.zPosition = 1 + worldCrop.addChild(node) + terrainNodes.append((node, sim.totalScroll)) + positionTerrainNode(terrainNodes.count - 1) + } + + private func positionTerrainNode(_ i: Int) { + let (node, spawnScroll) = terrainNodes[i] + // A fresh column occupies the staging band (sprite x 136…143) and + // scrolls left 1 px per tick from there. + let leftEdge = CGFloat(136 - (sim.totalScroll - spawnScroll)) + // Column spans sim y 32…95; centre it against the visible window. + node.position = point(x: leftEdge, y: CGFloat(MercurySim.terrainTop), w: 8, h: 64) + } + + private func updateTerrainNodes() { + var i = 0 + while i < terrainNodes.count { + let leftEdge = 136 - (sim.totalScroll - terrainNodes[i].spawnScroll) + if leftEdge < 0 { + terrainNodes[i].node.removeFromParent() + terrainNodes.remove(at: i) + } else { + positionTerrainNode(i) + i += 1 + } + } + } + + // MARK: - HUD + + private func buildHUD(bottomInset: CGFloat) { + let hudY = size.height - topInset - 26 + + addChild(caption("SHIELD", x: 74, y: hudY + 12, align: .left)) + shieldValue.fontSize = 17 + shieldValue.fontColor = Palette.mint + shieldValue.horizontalAlignmentMode = .left + shieldValue.verticalAlignmentMode = .center + shieldValue.position = CGPoint(x: 74, y: hudY - 8) + shieldValue.zPosition = 20 + addChild(shieldValue) + + addChild(caption("SECTOR", x: size.width / 2, y: hudY + 12, align: .center)) + sectorValue.fontSize = 17 + sectorValue.fontColor = .white + sectorValue.horizontalAlignmentMode = .center + sectorValue.verticalAlignmentMode = .center + sectorValue.position = CGPoint(x: size.width / 2, y: hudY - 8) + sectorValue.zPosition = 20 + addChild(sectorValue) + + addChild(caption("SCORE", x: size.width - 20, y: hudY + 12, align: .right)) + scoreValue.fontSize = 17 + scoreValue.fontColor = .white + scoreValue.horizontalAlignmentMode = .right + scoreValue.verticalAlignmentMode = .center + scoreValue.position = CGPoint(x: size.width - 20, y: hudY - 8) + scoreValue.zPosition = 20 + addChild(scoreValue) + + pauseButton.fillColor = UIColor(white: 1, alpha: 0.08) + pauseButton.strokeColor = UIColor(white: 1, alpha: 0.25) + pauseButton.lineWidth = 1 + pauseButton.position = CGPoint(x: fieldRect.maxX - 28, y: fieldRect.maxY - 24) + pauseButton.zPosition = 22 + pauseButton.removeAllChildren() + if let tex = SceneArt.symbolTexture("pause.fill", pointSize: 13, color: Palette.white70) { + let icon = SKSpriteNode(texture: tex) + icon.size = tex.size() + pauseButton.addChild(icon) + } + addChild(pauseButton) + + banner.fontSize = 22 + banner.fontColor = Palette.red + banner.horizontalAlignmentMode = .center + banner.verticalAlignmentMode = .center + banner.position = CGPoint(x: fieldRect.midX, y: fieldRect.midY + fieldRect.height * 0.22) + banner.zPosition = 24 + banner.alpha = 0 + addChild(banner) + + let hintY = bottomInset + (max(fieldRect.minY - bottomInset, 60)) * 0.35 + addChild(caption("DRAG ANYWHERE TO FLY — CANNON AUTO-FIRES", x: size.width / 2, + y: max(bottomInset + 16, hintY), align: .center)) + } + + private func caption(_ text: String, x: CGFloat, y: CGFloat, align: SKLabelHorizontalAlignmentMode) -> SKLabelNode { + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = text + label.fontSize = 10 + label.fontColor = Palette.white40 + label.horizontalAlignmentMode = align + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + label.zPosition = 20 + return label + } + + // MARK: - Overlays + + private func bigLabel(_ text: String, fontNamed: String = "AvenirNext-Heavy", size fontSize: CGFloat, + color: UIColor, x: CGFloat, y: CGFloat, maxWidth: CGFloat? = nil) -> SKLabelNode { + let label = SKLabelNode(fontNamed: fontNamed) + label.text = text + label.fontSize = fontSize + label.fontColor = color + label.horizontalAlignmentMode = .center + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + if let maxWidth { + label.preferredMaxLayoutWidth = maxWidth + label.numberOfLines = 0 + } + return label + } + + private func buildReadyOverlay() { + readyOverlay.removeAllChildren() + readyOverlay.zPosition = 40 + addChild(readyOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.66), size: size) + dim.anchorPoint = .zero + readyOverlay.addChild(dim) + + let cx = size.width / 2 + let midY = size.height * 0.62 + readyOverlay.addChild(bigLabel("ZMERCURY", size: 36, color: .white, x: cx, y: midY + 64)) + readyOverlay.addChild(bigLabel("Skim the caves. Duck the fire. Survive the run.", + fontNamed: "AvenirNext-Medium", size: 14.5, + color: Palette.white70, x: cx, y: midY + 30)) + readyOverlay.addChild(bigLabel("Terrain touch drains shields fast — thread the gaps", + fontNamed: "AvenirNext-Medium", size: 13, + color: Palette.white40, x: cx, y: midY - 2, + maxWidth: size.width - 80)) + + let prompt = bigLabel("TAP TO LAUNCH", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint, + x: cx, y: midY - 64) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + readyOverlay.addChild(prompt) + } + + private func buildPausedOverlay() { + pausedOverlay.removeAllChildren() + pausedOverlay.zPosition = 40 + addChild(pausedOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.01, alpha: 0.80), size: size) + dim.anchorPoint = .zero + pausedOverlay.addChild(dim) + pausedOverlay.addChild(bigLabel("PAUSED", size: 30, color: .white, x: size.width / 2, y: size.height * 0.55)) + let hint = bigLabel("TAP TO RESUME", fontNamed: "Menlo-Bold", size: 15, color: Palette.mint, + x: size.width / 2, y: size.height * 0.55 - 44) + hint.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + pausedOverlay.addChild(hint) + } + + private func buildGameOverOverlay() { + gameOverOverlay.removeAllChildren() + gameOverOverlay.zPosition = 40 + addChild(gameOverOverlay) + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.72), size: size) + dim.anchorPoint = .zero + gameOverOverlay.addChild(dim) + let cx = size.width / 2 + let midY = size.height * 0.6 + gameOverOverlay.addChild(bigLabel("SHIELDS DOWN", size: 30, color: Palette.red, x: cx, y: midY + 36)) + gameOverDetail.fontSize = 15 + gameOverDetail.fontColor = .white + gameOverDetail.horizontalAlignmentMode = .center + gameOverDetail.position = CGPoint(x: cx, y: midY - 4) + gameOverOverlay.addChild(gameOverDetail) + gameOverRecord.fontSize = 14 + gameOverRecord.fontColor = Palette.amber + gameOverRecord.horizontalAlignmentMode = .center + gameOverRecord.position = CGPoint(x: cx, y: midY - 30) + gameOverOverlay.addChild(gameOverRecord) + let prompt = bigLabel("TAP TO FLY AGAIN", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint, + x: cx, y: midY - 70) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + gameOverOverlay.addChild(prompt) + } + + // MARK: - Game flow + + private func startGame() { + sim = MercurySim() + targetX = 42 + targetY = 60 + accumulator = 0 + fireCooldown = 0 + rebuildTerrainNodes() + state = .playing + applyStateVisibility() + renderFrame() + } + + private func applyStateVisibility() { + readyOverlay.isHidden = state != .ready + pausedOverlay.isHidden = state != .paused + gameOverOverlay.isHidden = state != .gameOver + pauseButton.isHidden = state != .playing + } + + private func endGame() { + // The ship goes out with a proper blast. + addChild(SceneArt.flash(size: size, color: Palette.amber, peak: 0.22, duration: 0.28)) + let fire = SceneArt.sparkBurst(color: Palette.amber, count: 30, speed: 220, lifetime: 0.7, scale: 0.26) + fire.position = playerNode.position + fire.zPosition = 7 + worldCrop.addChild(fire) + let core = SceneArt.glowSprite(color: Palette.red, radius: 46) + core.position = playerNode.position + core.zPosition = 6.5 + core.run(.sequence([.group([.scale(to: 2.4, duration: 0.3), .fadeOut(withDuration: 0.34)]), + .removeFromParent()])) + worldCrop.addChild(core) + worldCrop.run(SceneArt.shake(intensity: 10, duration: 0.45)) + + let record = HighScoreStore.shared.submit(id: "zmercury", score: sim.score) + gameOverDetail.text = "SECTOR \(sim.totalScroll / 512 + 1) · SCORE \(sim.score)" + gameOverRecord.text = record ? "★ NEW BEST ★" : "BEST \(HighScoreStore.shared.best("zmercury"))" + state = .gameOver + applyStateVisibility() + Haptics.error() + } + + // MARK: - Game loop + + override func update(_ currentTime: TimeInterval) { + if lastUpdate == 0 { lastUpdate = currentTime } + var dt = currentTime - lastUpdate + lastUpdate = currentTime + if dt > 0.25 { dt = 0.25 } + guard state == .playing else { return } + #if DEBUG + if demoFreeze { return } + #endif + + let interval = 1.0 / MercurySim.fps + accumulator += dt + var steps = 0 + while accumulator >= interval, steps < 5 { + accumulator -= interval + steps += 1 + stepSim(currentTime) + if state != .playing { break } + } + renderFrame() + } + + /// Advances the sim + terrain node bookkeeping without effects (for demos). + private func stepSimRaw() { + var input = MercurySim.Input() + if fireCooldown > 0 { fireCooldown -= 1 } + if fireCooldown == 0 { input.fire = true; fireCooldown = 6 } + sim.tick(input) + for event in sim.events { + switch event { + case .columnDrawn(let col): spawnTerrainColumn(mapColumn: col) + case .terrainCleared: rebuildTerrainNodes() + default: break + } + } + updateTerrainNodes() + } + + private func stepSim(_ now: TimeInterval) { + var input = MercurySim.Input() + let dx = targetX - sim.playerX + let dy = targetY - sim.playerY + if dx != 0 { input.dx = dx > 0 ? 1 : -1 } + if dy != 0 { input.dy = dy > 0 ? 1 : -1 } + if fireCooldown > 0 { fireCooldown -= 1 } + if fireCooldown == 0 { + input.fire = true + fireCooldown = 6 // ~5 shots/sec at 28.6 fps + } + sim.tick(input) + + for event in sim.events { + switch event { + case .columnDrawn(let col): + spawnTerrainColumn(mapColumn: col) + case .terrainCleared: + rebuildTerrainNodes() + case .waveStarted(let type): + if type == 7 { showBanner("WARSHIP CONTACT") } + case .enemyKilled: + Haptics.impact(.light, intensity: 0.5) + case .playerHit: + // Terrain scrape can hit 4× a frame — throttle the buzz. + if now - lastHitHaptic > 0.12 { + lastHitHaptic = now + Haptics.impact(.medium, intensity: 0.7) + let sparks = SceneArt.sparkBurst(color: Palette.amber, count: 8, + speed: 90, lifetime: 0.35, scale: 0.15) + sparks.position = playerNode.position + sparks.zPosition = 6 + worldCrop.addChild(sparks) + worldCrop.run(SceneArt.shake(intensity: 4, duration: 0.18)) + } + flashPlayer() + case .gameOver: + endGame() + } + } + updateTerrainNodes() + } + + private func showBanner(_ text: String) { + banner.removeAllActions() + banner.text = text + banner.alpha = 0 + banner.run(.sequence([ + .fadeIn(withDuration: 0.2), + .wait(forDuration: 1.2), + .fadeOut(withDuration: 0.5), + ])) + Haptics.warning() + } + + private func flashPlayer() { + playerNode.removeAction(forKey: "hitflash") + playerNode.run(.sequence([ + .colorize(with: .red, colorBlendFactor: 0.8, duration: 0.05), + .wait(forDuration: 0.06), + .colorize(withColorBlendFactor: 0, duration: 0.12), + ]), withKey: "hitflash") + } + + // MARK: - Rendering + + private func renderFrame() { + place(playerNode, img: .player, x: sim.playerX, y: sim.playerY) + playerNode.isHidden = state == .gameOver + playerTrail.particleBirthRate = state == .playing ? 90 : 0 + let bank = max(-1, min(1, CGFloat(targetY - sim.playerY) / 5)) + playerNode.zRotation = playerNode.zRotation * 0.8 + (bank * -0.09) * 0.2 + + for (i, node) in enemyNodes.enumerated() { + let e = sim.enemies[i] + if e.type != 0 { + place(node, img: e.img, x: e.x, y: e.y) + // First explosion frame → pop sparks at the kill site. + if e.img == .x1, lastEnemyImgs[i] != .x1, lastEnemyImgs[i] != nil { + let sparks = SceneArt.sparkBurst(color: Palette.amber, count: 12, + speed: 130, lifetime: 0.45, scale: 0.18) + sparks.position = node.position + sparks.zPosition = 6 + worldCrop.addChild(sparks) + } + lastEnemyImgs[i] = e.img + } else { + node.isHidden = true + lastEnemyImgs[i] = nil + } + } + + for (i, node) in pbNodes.enumerated() { + let b = sim.playerBullets[i] + if b.active { place(node, img: .bullet1, x: b.x, y: b.y) } + else { node.isHidden = true } + } + for (i, node) in ebNodes.enumerated() { + let b = sim.enemyBullets[i] + if b.active { place(node, img: b.img, x: b.x, y: b.y) } + else { node.isHidden = true } + } + + let s = max(0, sim.shield) + shieldValue.text = "\(s)" + shieldValue.fontColor = s > 50 ? Palette.mint : (s > 20 ? Palette.amber : Palette.red) + sectorValue.text = "\(sim.totalScroll / 512 + 1)" + scoreValue.text = "\(sim.score)" + } + + // MARK: - Touch handling + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + for touch in touches { + let p = touch.location(in: self) + switch state { + case .ready: + Haptics.selection() + startGame() + case .paused: + Haptics.selection() + state = .playing + applyStateVisibility() + case .gameOver: + Haptics.selection() + state = .ready + applyStateVisibility() + case .playing: + let dp = hypot(p.x - pauseButton.position.x, p.y - pauseButton.position.y) + if dp <= 30 { + Haptics.selection() + state = .paused + moveTouch = nil + applyStateVisibility() + continue + } + moveTouch = touch + touchAnchor = p + shipAnchor = CGPoint(x: CGFloat(sim.playerX), y: CGFloat(sim.playerY)) + targetX = sim.playerX + targetY = sim.playerY + } + } + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard state == .playing, let moveTouch, touches.contains(moveTouch) else { return } + let p = moveTouch.location(in: self) + // Finger travel maps ~1.35× on-screen ship travel (per-axis). + targetX = Int(shipAnchor.x + (p.x - touchAnchor.x) * 1.35 / unit) + targetY = Int(shipAnchor.y - (p.y - touchAnchor.y) * 1.35 / unitY) + targetX = min(max(targetX, 8), 56) + targetY = min(max(targetY, 32), 84) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil } + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil } + } +} diff --git a/Arcade85/Games/ZMercury/MercurySim.swift b/Arcade85/Games/ZMercury/MercurySim.swift new file mode 100644 index 0000000..88c5c05 --- /dev/null +++ b/Arcade85/Games/ZMercury/MercurySim.swift @@ -0,0 +1,451 @@ +import Foundation + +/// A faithful port of ZMercury 0.34 (Patrick Davidson, 2004). Reproduces the +/// original's main-loop order, terrain scroller, wave table, enemy behaviors, +/// BCD scoring and collision rules. The game is fully deterministic — the +/// original uses no randomness at all; waves come from the looping map and +/// fire cadence from per-slot stagger. +/// +/// Field: 136 sim units wide (128 visible + an 8-px staging column at the +/// right where new terrain is drawn), y grows downward with the visible +/// window at y 35…92. The player roams x 8…56, y 32…84 with an 8×12 hitbox. +/// Terrain scrolls left 1 px/frame; a new 8-px tile column enters every 8 +/// frames from a 64-column looping map. Frame rate: the original waits 7 +/// ticks of the ~200 Hz interrupt per frame ≈ 28.6 fps. +struct MercurySim { + typealias D = MercuryData + + static let fps = 200.0 / 7.0 + static let fieldWidth = 136 + static let visibleLeft = 8 // screen shows sprite-space x 8…135 + static let visibleWidth = 128 + static let minY = 35 // first visible row + static let maxY = 93 // bullets/sprites clipped at y ≥ 93 + static let visibleHeight = 58 + static let terrainTop = 32 // terrain buffer covers y 32…95 + + // MARK: - Entities + + struct Enemy { + var type = 0 // 0 none · 1 exploding · 4 basic · 7 phoenix + // 10 spin-sine · 13 base turret · 16 spin-T · 19 boss + var x = 0 + var w = 0 + var y = 0 + var h = 0 + var dmg = 0 // hit points; enemy survives at exactly 0 + var img = D.Img.enemy1 + var counter = 0 + var phase = 0 + var anim = 0 // animation/explosion frame index + } + + struct PlayerBullet { var active = false; var x = 0; var y = 0 } // 8×4, dmg 3 + struct EnemyBullet { + var active = false + var x = 0 + var y = 0 + var vy = 0 // -1 / 0 / +1, aimed at spawn + var img = D.Img.ebullet1 + } // 4×4, dmg 5, moves left 4 px/frame + + enum Event: Equatable { + case columnDrawn(mapColumn: Int) + case terrainCleared + case waveStarted(Int) + case enemyKilled(x: Int, y: Int) + case playerHit + case gameOver + } + + struct Input { + var dx = 0 + var dy = 0 + var fire = false // one shot per press + init(dx: Int = 0, dy: Int = 0, fire: Bool = false) { + self.dx = dx; self.dy = dy; self.fire = fire + } + } + + // MARK: - State + + private(set) var enemies = [Enemy](repeating: Enemy(), count: 12) + private(set) var playerBullets = [PlayerBullet](repeating: PlayerBullet(), count: 13) + private(set) var enemyBullets = [EnemyBullet](repeating: EnemyBullet(), count: 12) + + private(set) var playerX = 42 + private(set) var playerY = 60 + private(set) var shield = 100 // BCD "0100" in the original + private(set) var score = 0 // 6-digit BCD, capped + private(set) var gameTimer = 0 + private(set) var gameStatus = 0 // 0 scroll · 1 open space until wave cleared + private(set) var enemiesLeft = 0 // last frame's live count (incl. explosions) + private(set) var over = false + private(set) var events: [Event] = [] + + // Terrain: 64 rows × 17 bytes, original polarity (bit set = open sky). + // Byte 16 (px 128…135) is the off-screen staging column. + private(set) var terrain = [[UInt8]](repeating: [UInt8](repeating: 0xFF, count: 17), count: 64) + private(set) var terrainCount = 3 // bit phase within the current column + private(set) var terrainX = -1 // map column last entered (wraps 0…63) + private(set) var totalScroll = 0 // total pixels scrolled (for renderers) + + init() {} + + /// True if the terrain pixel at sprite-space (x, y) is solid rock. The + /// original offsets the terrain buffer one byte against sprite space + /// (`add hl,bc / dec hl`): terrain byte 0 covers sprite x 8…15, and the + /// staging column (byte 16) sits at sprite x 136…143, off the right edge. + func solid(x: Int, y: Int) -> Bool { + let row = y - Self.terrainTop + let byte = (x >> 3) - 1 + guard row >= 0, row < 64, byte >= 0, byte < 17 else { return false } + return terrain[row][byte] & UInt8(0x80 >> (x & 7)) == 0 + } + + // MARK: - Main loop (original call order) + + mutating func tick(_ input: Input) { + guard !over else { return } + events.removeAll(keepingCapacity: true) + + gameTimer &+= 1 + scrollTerrain() + movePlayer(input) + handlePlayerBullets() + processEnemies() + processEnemyBullets() + hitEnemy() + + if shield < 0 { + over = true + events.append(.gameOver) + } + } + + // MARK: - Terrain scroller + + private mutating func scrollTerrain() { + for r in 0..<64 { + var row = terrain[r] + for i in 0..<16 { row[i] = (row[i] << 1) | (row[i + 1] >> 7) } + row[16] = (row[16] << 1) | 1 + terrain[r] = row + } + totalScroll += 1 + + terrainCount += 1 + guard terrainCount == 8 else { return } + terrainCount = 0 + + if gameStatus != 0 { + if enemiesLeft != 0 { + // Wave gate: wipe the terrain and hold the map position. + for r in 0..<64 { terrain[r] = [UInt8](repeating: 0xFF, count: 17) } + events.append(.terrainCleared) + return + } + gameStatus = 0 + } + + terrainX = (terrainX + 1) % 64 + + for tileRow in 0..<8 { + let tile = D.tiles[D.map[tileRow][terrainX]] + for k in 0..<8 { terrain[tileRow * 8 + k][16] = tile[k] } + } + events.append(.columnDrawn(mapColumn: terrainX)) + + let wave = D.map[8][terrainX] + if wave != 0 { + events.append(.waveStarted(wave)) + enemyEntry(wave) + } + } + + // MARK: - Wave entry (entry.asm) + + private mutating func enemyEntry(_ wave: Int) { + func install(count: Int, xStep: Int, yStep: Int, phaseStep: Int, _ template: Enemy) { + var t = template + for _ in 0.. 0, playerY + 1 != 96 - 12 + 1 { playerY += 1 } + if input.dx < 0, playerX - 1 != 7 { playerX -= 1 } + if input.dx > 0, playerX + 1 != 57 { playerX += 1 } + if input.dy < 0, playerY - 1 != 31 { playerY -= 1 } + + if input.fire { playerShoot() } + + // Terrain collision: each of the ship's four corners drains 1 shield. + for (cx, cy) in [(playerX, playerY), (playerX, playerY + 11), + (playerX + 7, playerY), (playerX + 7, playerY + 11)] + where solid(x: cx, y: cy) { + hitPlayer() + } + } + + private mutating func hitPlayer() { + shield -= 1 + events.append(.playerHit) + } + + private mutating func playerShoot() { + guard let slot = playerBullets.firstIndex(where: { !$0.active }) else { return } + playerBullets[slot] = PlayerBullet(active: true, x: playerX + 2, y: playerY + 4) + } + + private mutating func handlePlayerBullets() { + for b in 0..<13 where playerBullets[b].active { + if playerBullets[b].x + 4 >= 136 { + playerBullets[b].active = false + } else { + playerBullets[b].x += 4 + } + } + } + + // MARK: - Enemies (enemies.asm) + + private mutating func processEnemies() { + enemiesLeft = 0 + for i in 0..<12 { + guard enemies[i].type != 0 else { continue } + enemiesLeft += 1 + processEnemy(i) + guard enemies[i].type > 1 else { continue } + if aabb(enemies[i].x, enemies[i].w, enemies[i].y, enemies[i].h, + playerX, 8, playerY, 12) { + hitPlayer() + damageEnemy(i, 1) + } + } + } + + private mutating func processEnemy(_ i: Int) { + switch enemies[i].type { + case 1: // exploding: advance every other frame + if gameTimer & 1 == 0 { + enemies[i].anim += 1 + if enemies[i].anim >= 8 { + enemies[i].type = 0 + addScore(20) + } else { + enemies[i].img = D.explosionFrames[enemies[i].anim] + } + } + case 4: // basic: left 1.5 px/frame, straight shots + guard moveLeftOneAndAHalf(i) else { return } + if fireDue(i, mask: 63) { + fireBullet(from: i, yOff: 2, img: .ebullet1, aimed: false) + } + case 7: // phoenix: left 1.5, aimed shots + guard moveLeftOneAndAHalf(i) else { return } + if fireDue(i, mask: 63) { + fireBullet(from: i, yOff: 4, img: .ebullet2, aimed: true) + } + case 10: // spin-sine: approach then bob on the sine + enemies[i].anim = (enemies[i].anim + 1) % 5 + enemies[i].img = D.spinFrames[enemies[i].anim] + if enemies[i].counter != 0 { + enemies[i].counter -= 1 + enemies[i].x -= 1 + } + enemies[i].phase += 1 + enemies[i].y = D.sines[enemies[i].phase & 127] + if fireDue(i, mask: 63) { + fireBullet(from: i, yOff: 2, img: .ebullet1, aimed: true) + } + case 13: // base turret: rides the terrain + enemies[i].x -= 1 + if enemies[i].x == 0 { enemies[i].type = 0; return } + if fireDue(i, mask: 31) { + fireBullet(from: i, yOff: 2, img: .ebullet1, aimed: true) + } + case 16: // spin-T: fast sine crosser + enemies[i].anim = (enemies[i].anim + 1) % 4 + enemies[i].img = D.spintFrames[enemies[i].anim] + enemies[i].phase += 3 + enemies[i].x -= 1 + if enemies[i].x == 0 { enemies[i].type = 0; return } + enemies[i].y = D.sines[enemies[i].phase & 127] / 2 + 30 + if fireDue(i, mask: 63) { + fireBullet(from: i, yOff: 2, img: .ebullet1, aimed: true) + } + case 19: // boss: approach x=128, triangle sweep + if enemies[i].x != 128 { enemies[i].x -= 1 } + var a = gameTimer & 255 + if a & 32 != 0 { a = ~a & 255 } + enemies[i].y = (a & 31) + 40 + enemies[i].counter += 1 + if enemies[i].counter == 0 { + enemies[i].counter = -44 + fireBullet(from: i, yOff: 1, img: .ebullet2, aimed: false) + fireBullet(from: i, yOff: 13, img: .ebullet2, aimed: false) + } + default: + break + } + } + + /// Left 1 px/frame plus an extra on odd frames; false once despawned. + private mutating func moveLeftOneAndAHalf(_ i: Int) -> Bool { + enemies[i].x -= 1 + if enemies[i].x == 0 { enemies[i].type = 0; return false } + if gameTimer & 1 == 1 { + enemies[i].x -= 1 + if enemies[i].x == 0 { enemies[i].type = 0; return false } + } + return true + } + + /// The original staggers fire by the enemy slot's memory address; slots + /// are 10 bytes apart, so (timer + slot*10) keeps the same relative phases. + private func fireDue(_ i: Int, mask: Int) -> Bool { + (gameTimer + i * 10) & mask == 0 + } + + private mutating func damageEnemy(_ i: Int, _ amount: Int) { + enemies[i].dmg -= amount + guard enemies[i].dmg < 0 else { return } // survives at exactly 0 + enemies[i].type = 1 + enemies[i].img = .x1 + enemies[i].anim = 0 + addScore(1) + events.append(.enemyKilled(x: enemies[i].x, y: enemies[i].y)) + } + + private mutating func addScore(_ n: Int) { + score = min(999999, score + n) + } + + // MARK: - Enemy fire (eshoot.asm) + + private mutating func fireBullet(from i: Int, yOff: Int, img: D.Img, aimed: Bool) { + guard let slot = enemyBullets.firstIndex(where: { !$0.active }) else { return } + let bx = enemies[i].x + let by = enemies[i].y + yOff + var vy = 0 + if aimed { + let dy = by - playerY - 5 + let dx = bx - playerX + if dx >= 0 { // only aim while right of the player + let q = dx >> 2 + if dy < 0 { + if q + dy < 0 { vy = 1 } // player far below: angle down + } else if q < dy { + vy = -1 // player far above: angle up + } + } + } + enemyBullets[slot] = EnemyBullet(active: true, x: bx, y: by, vy: vy, img: img) + } + + // MARK: - Enemy bullets (ebullets.asm) + + private mutating func processEnemyBullets() { + for b in 0..<12 where enemyBullets[b].active { + if enemyBullets[b].x <= 4 { // reached the left edge + enemyBullets[b].active = false + continue + } + enemyBullets[b].x -= 4 + enemyBullets[b].y += enemyBullets[b].vy + let y = enemyBullets[b].y + if y < Self.minY - 10 || y >= Self.maxY { + enemyBullets[b].active = false + continue + } + if aabb(enemyBullets[b].x, 4, y, 4, playerX, 8, playerY, 12) { + enemyBullets[b].active = false + for _ in 0..<5 { hitPlayer() } // dmg 5, applied per point + } + } + } + + // MARK: - Player bullets vs enemies (hitenemy.asm) + + private mutating func hitEnemy() { + for b in 0..<13 where playerBullets[b].active { + let bx = playerBullets[b].x + let by = playerBullets[b].y + for i in 0..<12 where enemies[i].type >= 2 { + if aabb(enemies[i].x, enemies[i].w, enemies[i].y, enemies[i].h, bx, 8, by, 4) { + playerBullets[b].active = false + damageEnemy(i, 3) + } + // No break: one bullet can damage several overlapping enemies. + } + } + } + + /// The original's exclusive AABB overlap test (collide.asm). + private func aabb(_ x1: Int, _ w1: Int, _ y1: Int, _ h1: Int, + _ x2: Int, _ w2: Int, _ y2: Int, _ h2: Int) -> Bool { + x1 - x2 < w2 && x2 - x1 < w1 && y1 - y2 < h2 && y2 - y1 < h1 + } + + #if DEBUG + mutating func debugSetShield(_ n: Int) { shield = n } + mutating func debugPlacePlayer(x: Int, y: Int) { playerX = x; playerY = y } + mutating func debugSetScore(_ n: Int) { score = n } + /// Position the map so column `col` is the next one drawn (8 ticks away). + mutating func debugWarpToColumn(_ col: Int) { + terrainX = (col + 63) % 64 + terrainCount = 7 + } + mutating func debugSpawnEnemy(_ e: Enemy, slot: Int) { enemies[slot] = e } + mutating func debugClearEnemies() { + enemies = [Enemy](repeating: Enemy(), count: 12) + enemiesLeft = 0 + } + #endif +} diff --git a/Arcade85/Games/ZTetris/TetrisScene.swift b/Arcade85/Games/ZTetris/TetrisScene.swift new file mode 100644 index 0000000..3b2b4cd --- /dev/null +++ b/Arcade85/Games/ZTetris/TetrisScene.swift @@ -0,0 +1,898 @@ +import SpriteKit +import UIKit + +/// HD remake of ZTetris. `TetrisSim` is the faithful ASM port; this scene draws +/// the 10×16 well in crisp neon, adds the original's per-level background +/// patterns, and drives play with touch buttons (DAS auto-repeat on the move +/// keys, held soft drop, tap hard drop / rotates). +final class TetrisScene: SKScene { + + // MARK: State + private enum GameState { case ready, playing, paused, gameOver } + private var state: GameState = .ready + + private var sim = TetrisSim() + private var chosenLevel = 0 // 0…9 + private var chosenHigh = 0 // 0…5 + + // MARK: Timing + private var lastUpdate: TimeInterval = 0 + private var gravityNext: TimeInterval = 0 + + // DAS (delayed auto-shift) for held ◄/►, and held soft drop. + private let dasDelay: TimeInterval = 0.167 + private let dasRepeat: TimeInterval = 0.050 + private let softRepeat: TimeInterval = 0.050 + private var dasDirection = 0 // -1 left, +1 right, 0 none + private var dasNext: TimeInterval = 0 + private var softHeld = false + private var softNext: TimeInterval = 0 + + // MARK: Layout + private var cellSize: CGFloat = 30 + private var boardOrigin = CGPoint.zero // bottom-left of the visible well + private var topInset: CGFloat = 0 + private var controlBarHeight: CGFloat = 190 + private var boardFrameRect = CGRect.zero + + // MARK: Nodes + private var cellSprites: [[SKSpriteNode]] = [] // [row(visible, 0=bottom)][col] + private var pieceSprites: [SKSpriteNode] = [] + private var ghostSprites: [SKSpriteNode] = [] + private var nextSprites: [SKSpriteNode] = [] + private var blockTexture: SKTexture? + private var patternStrips: [SKSpriteNode] = [] + private var appliedPattern = -1 + + // HUD + private let scoreValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let levelValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let linesValue = SKLabelNode(fontNamed: "Menlo-Bold") + private let pauseButton = SKShapeNode(circleOfRadius: 16) + + // Controls + private let btnLeft = ControlButton(symbol: "arrowtriangle.left.fill") + private let btnRight = ControlButton(symbol: "arrowtriangle.right.fill") + private let btnHard = ControlButton(symbol: "arrow.down.to.line") + private let btnSoft = ControlButton(symbol: "chevron.down") + private let btnCW = ControlButton(symbol: "arrow.clockwise") + private let btnCCW = ControlButton(symbol: "arrow.counterclockwise") + private var activeTouches = Set() + private var lastLeft = false, lastRight = false, lastSoft = false + private var lastHard = false, lastCW = false, lastCCW = false + + // Overlays + private let readyOverlay = SKNode() + private let pausedOverlay = SKNode() + private let gameOverOverlay = SKNode() + private let levelValueLabel = SKLabelNode(fontNamed: "Menlo-Bold") + private let highValueLabel = SKLabelNode(fontNamed: "Menlo-Bold") + private var stepperRects: [String: CGRect] = [:] + private let gameOverDetail = SKLabelNode(fontNamed: "Menlo-Bold") + private let gameOverRecord = SKLabelNode(fontNamed: "Menlo-Bold") + + private var builtSize: CGSize = .zero + #if DEBUG + private var demoFreeze = false + #endif + + // Piece palette (J, L, O, T, S, Z, I) + trash gray. HD modernization: the + // original well was monochrome; colors follow the familiar convention. + static let pieceColors: [UIColor] = [ + UIColor(red: 0.36, green: 0.55, blue: 1.00, alpha: 1), // J blue + UIColor(red: 1.00, green: 0.62, blue: 0.25, alpha: 1), // L orange + UIColor(red: 0.96, green: 0.82, blue: 0.25, alpha: 1), // O yellow + UIColor(red: 0.71, green: 0.50, blue: 1.00, alpha: 1), // T purple + UIColor(red: 0.42, green: 0.94, blue: 0.60, alpha: 1), // S green + UIColor(red: 1.00, green: 0.35, blue: 0.40, alpha: 1), // Z red + UIColor(red: 0.36, green: 0.85, blue: 1.00, alpha: 1), // I cyan + UIColor(white: 0.62, alpha: 1), // trash + ] + + /// The original's 16 per-level 8×8 background patterns (`Pattern` table in + /// ZTETRIS.ASM), used here to texture the side rails beside the well. + static let levelPatterns: [[UInt8]] = [ + [0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55], + [0x88, 0xFF, 0x22, 0xFF, 0x88, 0xFF, 0x22, 0xFF], + [0xFF, 0x99, 0x99, 0xFF, 0xFF, 0x99, 0x99, 0xFF], + [0x50, 0xD7, 0x14, 0xF7, 0x00, 0xF7, 0x14, 0xD7], + [0xEA, 0xAA, 0xAE, 0x00, 0x57, 0x55, 0x75, 0x00], + [0xFE, 0xAA, 0xAA, 0xAA, 0x28, 0xAA, 0xFE, 0x00], + [0x55, 0x55, 0xAA, 0xAA, 0x55, 0x55, 0xAA, 0xAA], + [0x80, 0xFE, 0x02, 0xFB, 0x08, 0xEF, 0x20, 0xBF], + [0xEE, 0xEE, 0xEE, 0x00, 0x77, 0x77, 0x77, 0x00], + [0x66, 0xCC, 0x99, 0x33, 0x66, 0xCC, 0x99, 0x33], + [0xCC, 0x33, 0xCC, 0x33, 0xCC, 0x33, 0xCC, 0x33], + [0xFE, 0x82, 0xBA, 0xAA, 0xBA, 0x82, 0xFE, 0x00], + [0xCC, 0xCC, 0x33, 0x33, 0xCC, 0xCC, 0x33, 0x33], + [0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA], + [0x7C, 0xFE, 0x7C, 0x00, 0x7C, 0xFE, 0x7C, 0x00], + [0xFF, 0xEF, 0x47, 0x12, 0xB8, 0xFD, 0xFF, 0xFF], + ] + + // MARK: - Lifecycle + + override func didMove(to view: SKView) { + anchorPoint = .zero + scaleMode = .resizeFill + layoutScene() + #if DEBUG + let env = ProcessInfo.processInfo.environment + if env["ARCADE_AUTOPLAY"] == "1" { startFromReady() } + if env["ARCADE_DEMO"] == "1" { + chosenLevel = 3 + startFromReady() + buildDemoStack() + demoFreeze = true + refreshBoard() + refreshPiece() + updateHUD() + } + #endif + } + + override func didChangeSize(_ oldSize: CGSize) { + guard view != nil, size != builtSize, size.width > 0 else { return } + layoutScene() + } + + // MARK: - Layout + + private func layoutScene() { + builtSize = size + removeAllChildren() + backgroundColor = Palette.space1 + + let insets = view?.safeAreaInsets ?? .zero + topInset = insets.top + let bottomInset = insets.bottom + + let bg = SKSpriteNode(texture: SceneArt.verticalGradient(size: size, top: Palette.space0, bottom: Palette.space1)) + bg.anchorPoint = .zero + bg.zPosition = -10 + addChild(bg) + let stars = SceneArt.starfield(size: size, count: 60, seed: 0x7E7215) + stars.zPosition = -9 + addChild(stars) + + controlBarHeight = max(bottomInset + 172, size.height * 0.235) + let hudHeight: CGFloat = 64 + let boardTop = size.height - topInset - hudHeight + let boardBottom = controlBarHeight + 12 + let availableH = boardTop - boardBottom + let availableW = size.width - 24 + cellSize = min(availableH / CGFloat(TetrisSim.visibleRows), availableW / CGFloat(TetrisSim.cols)) + let boardW = cellSize * CGFloat(TetrisSim.cols) + let boardH = cellSize * CGFloat(TetrisSim.visibleRows) + boardOrigin = CGPoint(x: (size.width - boardW) / 2, y: boardBottom + (availableH - boardH) / 2) + boardFrameRect = CGRect(x: boardOrigin.x, y: boardOrigin.y, width: boardW, height: boardH) + + // Frame + let frame = SKShapeNode(rect: boardFrameRect.insetBy(dx: -5, dy: -5), cornerRadius: 12) + frame.strokeColor = Palette.mint.withAlphaComponent(0.30) + frame.lineWidth = 1.5 + frame.glowWidth = 1 + frame.fillColor = UIColor(white: 0, alpha: 0.25) + frame.zPosition = -1 + addChild(frame) + + buildPatternStrips() + buildBoardNodes() + buildHUD() + buildControls(bottomInset: bottomInset) + buildReadyOverlay() + buildPausedOverlay() + buildGameOverOverlay() + + appliedPattern = -1 + applyLevelPattern() + refreshBoard() + refreshPiece() + refreshNext() + updateHUD() + applyStateVisibility() + } + + private func makeBlockTexture() -> SKTexture { + let px: CGFloat = 64 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: px, height: px)) + let image = renderer.image { ctx in + let cg = ctx.cgContext + let rect = CGRect(x: 2, y: 2, width: px - 4, height: px - 4) + let path = UIBezierPath(roundedRect: rect, cornerRadius: 10) + UIColor(white: 0.92, alpha: 1).setFill() + path.fill() + // top-edge highlight + bottom shade for a subtle bevel + cg.saveGState() + path.addClip() + UIColor.white.setFill() + UIBezierPath(rect: CGRect(x: 0, y: 0, width: px, height: px * 0.30)).fill() + UIColor(white: 0.72, alpha: 1).setFill() + UIBezierPath(rect: CGRect(x: 0, y: px * 0.72, width: px, height: px * 0.28)).fill() + cg.restoreGState() + UIColor(white: 0.45, alpha: 0.9).setStroke() + path.lineWidth = 3 + path.stroke() + } + return SKTexture(image: image) + } + + private func buildBoardNodes() { + let texture = makeBlockTexture() + blockTexture = texture + + cellSprites = (0.. [SKSpriteNode] { + (0.. 12 else { return } + for side in 0..<2 { + let node = SKSpriteNode(color: .clear, size: CGSize(width: stripW - 8, height: boardFrameRect.height)) + node.position = CGPoint( + x: side == 0 ? (boardFrameRect.minX - 5) / 2 : size.width - (boardFrameRect.minX - 5) / 2, + y: boardFrameRect.midY) + node.zPosition = -2 + addChild(node) + patternStrips.append(node) + } + } + + private func applyLevelPattern() { + let index = min(state == .ready ? chosenLevel : sim.level, 15) + guard index != appliedPattern, !patternStrips.isEmpty else { return } + appliedPattern = index + let texture = Self.patternTexture(Self.levelPatterns[index], + size: patternStrips[0].size, + dotSize: 3.2, + color: Palette.mint.withAlphaComponent(0.16)) + patternStrips.forEach { $0.texture = texture } + } + + /// Renders an 8×8 1-bit pattern tiled over `size` (MSB = leftmost pixel). + static func patternTexture(_ bytes: [UInt8], size: CGSize, dotSize: CGFloat, color: UIColor) -> SKTexture { + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { _ in + color.setFill() + let tile = dotSize * 8 + var ty: CGFloat = 0 + while ty < size.height { + var tx: CGFloat = 0 + while tx < size.width { + for (row, byte) in bytes.enumerated() { + for bit in 0..<8 where (byte & (0x80 >> UInt8(bit))) != 0 { + UIBezierPath(rect: CGRect(x: tx + CGFloat(bit) * dotSize, + y: ty + CGFloat(row) * dotSize, + width: dotSize, height: dotSize)).fill() + } + } + tx += tile + } + ty += tile + } + } + return SKTexture(image: image) + } + + // MARK: - Coordinates + + /// visibleRow 0 = bottom of the well (absolute y = 2). + private func cellCenter(col: Int, visibleRow: Int) -> CGPoint { + CGPoint(x: boardOrigin.x + (CGFloat(col) + 0.5) * cellSize, + y: boardOrigin.y + (CGFloat(visibleRow) + 0.5) * cellSize) + } + + private func centerFor(absX: Int, absY: Int) -> CGPoint { + cellCenter(col: absX - TetrisSim.playfieldMinX, visibleRow: absY - TetrisSim.bottomRowY) + } + + // MARK: - HUD + + private func buildHUD() { + let hudY = size.height - topInset - 28 + + addChild(caption("LEVEL", x: 74, y: hudY + 14, align: .left)) + levelValue.fontSize = 17 + levelValue.fontColor = Palette.violet + levelValue.horizontalAlignmentMode = .left + levelValue.verticalAlignmentMode = .center + levelValue.position = CGPoint(x: 74, y: hudY - 5) + levelValue.zPosition = 20 + addChild(levelValue) + + addChild(caption("LINES", x: 134, y: hudY + 14, align: .left)) + linesValue.fontSize = 17 + linesValue.fontColor = .white + linesValue.horizontalAlignmentMode = .left + linesValue.verticalAlignmentMode = .center + linesValue.position = CGPoint(x: 134, y: hudY - 5) + linesValue.zPosition = 20 + addChild(linesValue) + + addChild(caption("SCORE", x: size.width * 0.56, y: hudY + 14, align: .center)) + scoreValue.fontSize = 21 + scoreValue.fontColor = .white + scoreValue.horizontalAlignmentMode = .center + scoreValue.verticalAlignmentMode = .center + scoreValue.position = CGPoint(x: size.width * 0.56, y: hudY - 5) + scoreValue.zPosition = 20 + addChild(scoreValue) + + // NEXT preview box at the right + addChild(caption("NEXT", x: size.width - 44, y: hudY + 14, align: .center)) + let box = SKShapeNode(rectOf: CGSize(width: 66, height: 36), cornerRadius: 9) + box.fillColor = UIColor(white: 1, alpha: 0.06) + box.strokeColor = UIColor(white: 1, alpha: 0.14) + box.lineWidth = 1 + box.position = CGPoint(x: size.width - 44, y: hudY - 6) + box.zPosition = 20 + addChild(box) + + nextSprites.forEach { $0.removeFromParent() } + nextSprites = (0..<4).map { _ in + let node = SKSpriteNode(texture: blockTexture) + node.size = CGSize(width: 13, height: 13) + node.colorBlendFactor = 1 + node.zPosition = 21 + addChild(node) + return node + } + + // Pause toggle at the board frame's top-right corner + pauseButton.fillColor = UIColor(white: 1, alpha: 0.08) + pauseButton.strokeColor = UIColor(white: 1, alpha: 0.25) + pauseButton.lineWidth = 1 + pauseButton.position = CGPoint(x: boardFrameRect.maxX - 6, y: boardFrameRect.maxY - 6) + pauseButton.zPosition = 22 + pauseButton.removeAllChildren() + if let tex = SceneArt.symbolTexture("pause.fill", pointSize: 13, color: Palette.white70) { + let icon = SKSpriteNode(texture: tex) + icon.size = tex.size() + pauseButton.addChild(icon) + } + addChild(pauseButton) + } + + private func caption(_ text: String, x: CGFloat, y: CGFloat, align: SKLabelHorizontalAlignmentMode) -> SKLabelNode { + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = text + label.fontSize = 10 + label.fontColor = Palette.white40 + label.horizontalAlignmentMode = align + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + label.zPosition = 20 + return label + } + + // MARK: - Controls + + private func buildControls(bottomInset: CGFloat) { + let cy = bottomInset + (controlBarHeight - bottomInset) * 0.42 + + btnLeft.configure(radius: 37, tint: Palette.cyan) + btnRight.configure(radius: 37, tint: Palette.cyan) + btnHard.configure(radius: 40, tint: Palette.amber) + btnSoft.configure(radius: 24, tint: Palette.amber) + btnCW.configure(radius: 37, tint: Palette.mint) + btnCCW.configure(radius: 27, tint: Palette.mint) + + btnLeft.position = CGPoint(x: size.width * 0.12, y: cy) + btnRight.position = CGPoint(x: size.width * 0.315, y: cy) + btnHard.position = CGPoint(x: size.width * 0.5, y: cy) + btnSoft.position = CGPoint(x: size.width * 0.5, y: cy + 74) + btnCCW.position = CGPoint(x: size.width * 0.675, y: cy + 8) + btnCW.position = CGPoint(x: size.width * 0.87, y: cy) + [btnLeft, btnRight, btnHard, btnSoft, btnCW, btnCCW].forEach { $0.zPosition = 20; addChild($0) } + } + + // MARK: - Overlays + + private func buildReadyOverlay() { + readyOverlay.removeAllChildren() + readyOverlay.zPosition = 40 + addChild(readyOverlay) + + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.66), size: size) + dim.anchorPoint = .zero + readyOverlay.addChild(dim) + + let cx = size.width / 2 + let midY = size.height * 0.62 + readyOverlay.addChild(bigLabel("ZTETRIS", size: 34, color: .white, x: cx, y: midY + 78)) + readyOverlay.addChild(bigLabel("Stack. Clear. Don't top out.", + fontNamed: "AvenirNext-Medium", size: 15, + color: Palette.white70, x: cx, y: midY + 44)) + + buildStepper(title: "START LEVEL", value: levelValueLabel, valueText: "\(chosenLevel)", + y: midY - 14, keyMinus: "levelMinus", keyPlus: "levelPlus") + buildStepper(title: "START HEIGHT", value: highValueLabel, valueText: "\(chosenHigh)", + y: midY - 66, keyMinus: "highMinus", keyPlus: "highPlus") + + let prompt = bigLabel("TAP TO START", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint, + x: cx, y: midY - 126) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + readyOverlay.addChild(prompt) + + readyOverlay.addChild(bigLabel("◄ ► move ⟳ ⟲ rotate ⌄ soft ⤓ drop", + fontNamed: "Menlo", size: 11.5, color: Palette.white40, + x: cx, y: max(controlBarHeight + 22, midY - 162))) + } + + private func buildStepper(title: String, value: SKLabelNode, valueText: String, y: CGFloat, + keyMinus: String, keyPlus: String) { + let cx = size.width / 2 + readyOverlay.addChild(bigLabel(title, fontNamed: "Menlo-Bold", size: 12, color: Palette.white70, + x: cx, y: y + 25)) + let minusX = cx - 78, plusX = cx + 78 + let btnSize = CGSize(width: 44, height: 40) + readyOverlay.addChild(stepperButton("−", center: CGPoint(x: minusX, y: y))) + readyOverlay.addChild(stepperButton("+", center: CGPoint(x: plusX, y: y))) + stepperRects[keyMinus] = CGRect(x: minusX - 22, y: y - 20, width: btnSize.width, height: btnSize.height) + stepperRects[keyPlus] = CGRect(x: plusX - 22, y: y - 20, width: btnSize.width, height: btnSize.height) + + value.text = valueText + value.fontSize = 26 + value.fontColor = .white + value.horizontalAlignmentMode = .center + value.verticalAlignmentMode = .center + value.position = CGPoint(x: cx, y: y) + readyOverlay.addChild(value) + } + + private func stepperButton(_ glyph: String, center: CGPoint) -> SKNode { + let node = SKShapeNode(rectOf: CGSize(width: 44, height: 40), cornerRadius: 12) + node.fillColor = UIColor(white: 1, alpha: 0.10) + node.strokeColor = Palette.mint.withAlphaComponent(0.5) + node.lineWidth = 1 + node.position = center + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = glyph + label.fontSize = 24 + label.fontColor = Palette.mint + label.verticalAlignmentMode = .center + label.horizontalAlignmentMode = .center + node.addChild(label) + return node + } + + private func buildPausedOverlay() { + pausedOverlay.removeAllChildren() + pausedOverlay.zPosition = 40 + addChild(pausedOverlay) + // Opaque enough to hide the well — the original cleared the screen on pause. + let dim = SKSpriteNode(color: UIColor(white: 0.01, alpha: 0.93), size: size) + dim.anchorPoint = .zero + pausedOverlay.addChild(dim) + pausedOverlay.addChild(bigLabel("PAUSED", size: 30, color: .white, + x: size.width / 2, y: size.height * 0.55)) + let hint = bigLabel("TAP TO RESUME", fontNamed: "Menlo-Bold", size: 15, color: Palette.mint, + x: size.width / 2, y: size.height * 0.55 - 44) + hint.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + pausedOverlay.addChild(hint) + } + + private func buildGameOverOverlay() { + gameOverOverlay.removeAllChildren() + gameOverOverlay.zPosition = 40 + addChild(gameOverOverlay) + + let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.70), size: size) + dim.anchorPoint = .zero + gameOverOverlay.addChild(dim) + + let cx = size.width / 2 + let midY = size.height * 0.6 + let title = bigLabel("GAME OVER", size: 34, color: Palette.red, x: cx, y: midY + 40) + gameOverOverlay.addChild(title) + + gameOverDetail.fontSize = 15 + gameOverDetail.fontColor = .white + gameOverDetail.horizontalAlignmentMode = .center + gameOverDetail.position = CGPoint(x: cx, y: midY) + gameOverOverlay.addChild(gameOverDetail) + + gameOverRecord.fontSize = 14 + gameOverRecord.fontColor = Palette.amber + gameOverRecord.horizontalAlignmentMode = .center + gameOverRecord.position = CGPoint(x: cx, y: midY - 26) + gameOverOverlay.addChild(gameOverRecord) + + let prompt = bigLabel("TAP TO PLAY AGAIN", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint, + x: cx, y: midY - 66) + prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)]))) + gameOverOverlay.addChild(prompt) + } + + private func bigLabel(_ text: String, fontNamed: String = "AvenirNext-Heavy", size fontSize: CGFloat, + color: UIColor, x: CGFloat, y: CGFloat) -> SKLabelNode { + let label = SKLabelNode(fontNamed: fontNamed) + label.text = text + label.fontSize = fontSize + label.fontColor = color + label.horizontalAlignmentMode = .center + label.verticalAlignmentMode = .center + label.position = CGPoint(x: x, y: y) + return label + } + + // MARK: - Rendering + + private func refreshBoard() { + for row in 0..= dasNext { + let moved = dasDirection < 0 ? sim.moveLeft() : sim.moveRight() + if moved { refreshPiece(); Haptics.impact(.light, intensity: 0.3) } + dasNext = currentTime + dasRepeat + } + + // Held soft drop — also resets gravity like the original's Down key. + if softHeld, currentTime >= softNext { + softNext = currentTime + softRepeat + gravityNext = currentTime + sim.gravityInterval + handle(step: sim.softDropStep(), at: currentTime) + } + + // Gravity + if currentTime >= gravityNext { + gravityNext = currentTime + sim.gravityInterval + handle(step: sim.gravityStep(), at: currentTime) + } + } + + private func handle(step: TetrisSim.Step, at time: TimeInterval) { + switch step { + case .fell: + refreshPiece() + case .locked(let lock): + didLock(lock, at: time) + } + } + + private func didLock(_ lock: TetrisSim.Lock, at time: TimeInterval) { + refreshBoard() + refreshPiece() + refreshNext() + updateHUD() + + if lock.clearedYs.isEmpty { + Haptics.impact(.light, intensity: 0.6) + } else { + Haptics.success() + flashRows(lock.clearedYs) + popScore("+\(lock.scoreGained)") + gravityNext = time + sim.gravityInterval + 0.26 // brief beat for the flash + } + if lock.leveledUp { + Haptics.impact(.medium) + applyLevelPattern() + popScore("LEVEL \(sim.level)", color: Palette.violet, dy: 40) + } + if lock.gameOver { endGame() } + } + + private func flashRows(_ absYs: [Int]) { + for y in absYs where y <= TetrisSim.topVisibleY { + let flash = SKSpriteNode(color: .white, + size: CGSize(width: boardFrameRect.width, height: cellSize)) + flash.position = CGPoint(x: boardFrameRect.midX, + y: boardOrigin.y + (CGFloat(y - TetrisSim.bottomRowY) + 0.5) * cellSize) + flash.zPosition = 4 + flash.alpha = 0.85 + addChild(flash) + flash.run(.sequence([.fadeOut(withDuration: 0.26), .removeFromParent()])) + } + } + + private func popScore(_ text: String, color: UIColor = Palette.amber, dy: CGFloat = 0) { + let label = SKLabelNode(fontNamed: "Menlo-Bold") + label.text = text + label.fontSize = 20 + label.fontColor = color + label.position = CGPoint(x: boardFrameRect.midX, y: boardFrameRect.midY + dy) + label.zPosition = 30 + label.setScale(0.6) + addChild(label) + label.run(.sequence([ + .group([.scale(to: 1.0, duration: 0.14), + .moveBy(x: 0, y: 26, duration: 0.7), + .sequence([.wait(forDuration: 0.35), .fadeOut(withDuration: 0.35)])]), + .removeFromParent(), + ])) + } + + // MARK: - State transitions + + private func startFromReady() { + sim = TetrisSim(startLevel: chosenLevel, high: chosenHigh) + appliedPattern = -1 + state = .playing + gravityNext = lastUpdate + sim.gravityInterval + dasDirection = 0 + softHeld = false + applyLevelPattern() + refreshBoard() + refreshPiece() + refreshNext() + updateHUD() + applyStateVisibility() + } + + private func endGame() { + let final = sim.score + let record = HighScoreStore.shared.submit(id: "ztetris", score: final) + state = .gameOver + gameOverDetail.text = "SCORE \(final) LINES \(sim.lines) LEVEL \(sim.level)" + gameOverRecord.text = record ? "★ NEW BEST ★" : "BEST \(HighScoreStore.shared.best("ztetris"))" + Haptics.error() + applyStateVisibility() + } + + private func applyStateVisibility() { + readyOverlay.isHidden = state != .ready + pausedOverlay.isHidden = state != .paused + gameOverOverlay.isHidden = state != .gameOver + pauseButton.isHidden = state != .playing + let controlsActive = state == .playing + [btnLeft, btnRight, btnHard, btnSoft, btnCW, btnCCW].forEach { $0.alpha = controlsActive ? 1 : 0.28 } + refreshPiece() + } + + // MARK: - Touch handling + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + for touch in touches { + let p = touch.location(in: self) + switch state { + case .ready: + if handleStepperTap(at: p) { continue } + Haptics.selection() + startFromReady() + case .paused: + Haptics.selection() + state = .playing + gravityNext = lastUpdate + sim.gravityInterval + applyStateVisibility() + case .gameOver: + Haptics.selection() + state = .ready + applyStateVisibility() + case .playing: + let dp = hypot(p.x - pauseButton.position.x, p.y - pauseButton.position.y) + if dp <= 30 { + Haptics.selection() + state = .paused + dasDirection = 0; softHeld = false + applyStateVisibility() + continue + } + activeTouches.insert(touch) + } + } + recomputeControls() + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + recomputeControls() + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + touches.forEach { activeTouches.remove($0) } + recomputeControls() + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + touches.forEach { activeTouches.remove($0) } + recomputeControls() + } + + private func handleStepperTap(at p: CGPoint) -> Bool { + for (key, rect) in stepperRects where rect.contains(p) { + switch key { + case "levelMinus": chosenLevel = max(0, chosenLevel - 1) + case "levelPlus": chosenLevel = min(TetrisSim.maxStartLevel, chosenLevel + 1) + case "highMinus": chosenHigh = max(0, chosenHigh - 1) + case "highPlus": chosenHigh = min(TetrisSim.maxHigh, chosenHigh + 1) + default: break + } + levelValueLabel.text = "\(chosenLevel)" + highValueLabel.text = "\(chosenHigh)" + Haptics.selection() + return true + } + return false + } + + private func recomputeControls() { + var left = false, right = false, soft = false, hard = false, cw = false, ccw = false + if state == .playing { + for touch in activeTouches { + let p = touch.location(in: self) + if btnLeft.covers(p) { left = true } + if btnRight.covers(p) { right = true } + if btnSoft.covers(p) { soft = true } + if btnHard.covers(p) { hard = true } + if btnCW.covers(p) { cw = true } + if btnCCW.covers(p) { ccw = true } + } + } + + // Move: immediate step on the rising edge, then DAS repeat in update(). + if left && !lastLeft { + dasDirection = -1 + dasNext = lastUpdate + dasDelay + if sim.moveLeft() { refreshPiece(); Haptics.impact(.light, intensity: 0.4) } + } + if right && !lastRight { + dasDirection = 1 + dasNext = lastUpdate + dasDelay + if sim.moveRight() { refreshPiece(); Haptics.impact(.light, intensity: 0.4) } + } + // Releases: fall back to a still-held direction (re-armed), or stop. + if dasDirection == -1 && !left { + dasDirection = right ? 1 : 0 + dasNext = lastUpdate + dasDelay + } else if dasDirection == 1 && !right { + dasDirection = left ? -1 : 0 + dasNext = lastUpdate + dasDelay + } + + // Rotations and hard drop: single action per press. + if cw && !lastCW, sim.rotate() { refreshPiece(); Haptics.impact(.light, intensity: 0.5) } + if ccw && !lastCCW, sim.rotateBack() { refreshPiece(); Haptics.impact(.light, intensity: 0.5) } + if hard && !lastHard, state == .playing { + Haptics.impact(.medium, intensity: 0.8) + handle(step: sim.hardDrop(), at: lastUpdate) + gravityNext = lastUpdate + sim.gravityInterval + } + + // Soft drop: immediate step on press, repeats in update(). + if soft && !lastSoft { + softNext = lastUpdate + softRepeat + gravityNext = lastUpdate + sim.gravityInterval + handle(step: sim.softDropStep(), at: lastUpdate) + } + softHeld = soft + + lastLeft = left; lastRight = right; lastSoft = soft + lastHard = hard; lastCW = cw; lastCCW = ccw + + btnLeft.setPressed(left) + btnRight.setPressed(right) + btnSoft.setPressed(soft) + btnHard.setPressed(hard) + btnCW.setPressed(cw) + btnCCW.setPressed(ccw) + } + + // MARK: - Demo (screenshots) + + #if DEBUG + private func buildDemoStack() { + // A believable mid-game stack across the bottom rows. + let rows: [[Int]] = [ + [1, 1, 5, 5, 0, 3, 3, 2, 2, 8], + [4, 1, 5, 5, 6, 3, 3, 0, 2, 8], + [4, 4, 0, 7, 6, 6, 0, 2, 2, 8], + [4, 0, 0, 7, 0, 6, 0, 0, 8, 8], + ] + for (i, row) in rows.enumerated() { // rows[0] = top of stack + for (col, value) in row.enumerated() where value != 0 { + sim.debugSetCell(col: col, y: TetrisSim.bottomRowY + (rows.count - 1 - i), kind: value) + } + } + sim.debugPose(kind: 3, rot: 0, x: 6, y: 9) // T piece mid-fall + } + #endif +} diff --git a/Arcade85/Games/ZTetris/TetrisSim.swift b/Arcade85/Games/ZTetris/TetrisSim.swift new file mode 100644 index 0000000..1852f4a --- /dev/null +++ b/Arcade85/Games/ZTetris/TetrisSim.swift @@ -0,0 +1,327 @@ +import Foundation + +/// A faithful port of ZTetris 3.0 (Jimmy Mårdell, 1998) game logic from +/// `ZTETRIS.ASM`. The well, piece data, movement rules, scoring and level +/// behavior reproduce the original exactly; the SpriteKit scene renders this in +/// HD and drives it with touch controls. +/// +/// Original coordinate system (kept verbatim): `y` increases *upward* and +/// pieces fall by decrementing y. The board is a 16-wide bit-grid where columns +/// 0–2 and 13–15 are walls and column 3…12 is the 10-wide playfield; row y=1 is +/// the floor. Pieces spawn at (x=6, y=16); rows 2…17 are visible (18/19 are +/// headroom above the visible well). Each piece rotation is stored as four +/// (dy,dx) offsets packed into one 16-bit word — ported bit-for-bit from the +/// ASM's `BitData` table and decoded the same way `Uncrunch` does. +struct TetrisSim { + + // MARK: - Original data tables (verbatim from ZTETRIS.ASM) + + /// 7 pieces × 4 rotations. Each word packs 4 blocks × (dy,dx), 2 bits each, + /// least-significant pair first — exactly the `BitData` table. + static let bitData: [UInt16] = [ + 0b0001_0101_1001_1000, 0b0110_0101_0000_0100, 0b0010_0001_0101_1001, 0b0110_1010_0101_0100, // J + 0b0001_0101_1001_0000, 0b0010_0110_0101_0100, 0b0001_0101_1001_1010, 0b0110_0101_0100_1000, // L + 0b0101_1001_0100_1000, 0b0101_1001_0100_1000, 0b0101_1001_0100_1000, 0b0101_1001_0100_1000, // O + 0b0001_0101_1001_0100, 0b0001_0110_0101_0100, 0b0001_0110_0101_1001, 0b0110_0101_0100_1001, // T + 0b0000_0101_0100_1001, 0b0010_0001_0101_0100, 0b0000_0101_0100_1001, 0b0010_0001_0101_0100, // S + 0b0001_0101_0100_1000, 0b0001_0000_0110_0101, 0b0001_0101_0100_1000, 0b0001_0000_0110_0101, // Z + 0b0001_0101_1001_1101, 0b0111_0110_0101_0100, 0b0001_0101_1001_1101, 0b0111_0110_0101_0100, // I + ] + + /// Gravity delay per level 0–20, in main-loop iterations (`levelcnts`). + /// One iteration = 4 HALTs ≈ 20 ms on the TI-85, so seconds = count × 0.02. + static let levelCounts = [40, 36, 32, 28, 25, 22, 19, 17, 15, 13, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1] + + /// Base score for clearing 1–4 lines (`Scoring`), multiplied by (level+1). + static let lineScores = [4, 10, 30, 120] + + /// Piece kind order as stored in `BitData` (index 0…6). + enum Kind: Int, CaseIterable { case j = 0, l, o, t, s, z, i } + + // MARK: - Geometry constants (original values) + + static let gridWidth = 16 // bit-grid width incl. walls + static let gridHeight = 20 // y = 0…19 + static let playfieldMinX = 3 // playfield columns 3…12 + static let playfieldMaxX = 12 + static let floorY = 1 // solid floor row + static let bottomRowY = 2 // lowest playable row + static let topVisibleY = 17 // rows 2…17 are drawn (16 rows) + static let headroomY = 19 // rows 18/19 exist but are off-screen + static let spawnX = 6, spawnY = 16 // `ld hl,$0610` in CreateNew + static let cols = 10, visibleRows = 16 + static let maxStartLevel = 9 // level select 0…9 + static let maxHigh = 5 // "start high" option 0…5 (rows = high×2) + + /// Cell contents: 0 = empty, 1…7 = locked piece (Kind+1), 8 = trash line. + static let trashCell = 8 + + struct Piece { + var kind: Int // 0…6 + var rot: Int // 0…3 + var x: Int + var y: Int + } + + enum Step { + case fell + case locked(Lock) + } + + /// Everything the renderer needs to react to a piece locking down. + struct Lock { + var clearedYs: [Int] // absolute y of rows removed (before shifting) + var scoreGained: Int // displayed-score delta from this lock + var leveledUp: Bool + var gameOver: Bool + } + + // MARK: - State + + private(set) var grid: [[Int]] // [y][x] + private(set) var current: Piece + private(set) var nextKind: Int + private(set) var score = 0 // displayed score (`Score`) + private(set) var scoreUnits = 0 // hidden tenths (`scoreU`) + private(set) var lines = 0 + private(set) var level: Int + private(set) var startLevel: Int + private(set) var high: Int + private(set) var over = false + + private var rng: TetrisRNG + + // MARK: - Setup + + init(startLevel: Int = 0, high: Int = 0, seed: UInt64? = nil) { + let clampedLevel = min(max(startLevel, 0), Self.maxStartLevel) + let clampedHigh = min(max(high, 0), Self.maxHigh) + self.startLevel = clampedLevel + self.high = clampedHigh + self.level = clampedLevel + self.rng = TetrisRNG(seed: seed ?? UInt64.random(in: 1...UInt64.max)) + + // Empty grid with walls: columns 0–2 / 13–15 and rows 0–1 are solid. + var g = [[Int]](repeating: [Int](repeating: 0, count: Self.gridWidth), count: Self.gridHeight) + for y in 0.. Self.playfieldMaxX { + g[y][x] = Self.trashCell + 1 // wall sentinel (never drawn) + } + } + for x in 0.. 0 { addTrashRows(count: clampedHigh * 2) } + } + + /// Scrambled trash rows as in `AddTrashRow`: each row starts full and gets + /// 5 randomly-placed holes punched (duplicates allowed, so 1–5 holes). + private mutating func addTrashRows(count: Int) { + for i in 0.. [(dx: Int, dy: Int)] { + var word = bitData[kind * 4 + (rot & 3)] + var result: [(dx: Int, dy: Int)] = [] + for _ in 0..<4 { + let dy = Int(word & 3); word >>= 2 + let dx = Int(word & 3); word >>= 2 + result.append((dx: dx, dy: dy)) + } + return result + } + + static func absBlocks(_ p: Piece) -> [(x: Int, y: Int)] { + blocks(kind: p.kind, rot: p.rot).map { (x: p.x + $0.dx, y: p.y + $0.dy) } + } + + var currentBlocks: [(x: Int, y: Int)] { Self.absBlocks(current) } + + /// Where the current piece would come to rest (for the ghost outline). + var ghostPiece: Piece { + var p = current + while fits(applying: { $0.y -= 1 }, to: p) { p.y -= 1 } + return p + } + + /// `TestNewB`: the piece fits iff all four blocks land on empty cells. + private func fits(_ p: Piece) -> Bool { + for b in Self.absBlocks(p) { + guard b.x >= 0, b.x < Self.gridWidth, b.y >= 0, b.y < Self.gridHeight else { return false } + if grid[b.y][b.x] != 0 { return false } + } + return true + } + + private func fits(applying change: (inout Piece) -> Void, to piece: Piece) -> Bool { + var p = piece + change(&p) + return fits(p) + } + + // MARK: - Player moves (fail silently, like the original's revert) + + @discardableResult + mutating func moveLeft() -> Bool { tryChange { $0.x -= 1 } } + + @discardableResult + mutating func moveRight() -> Bool { tryChange { $0.x += 1 } } + + @discardableResult + mutating func rotate() -> Bool { tryChange { $0.rot = ($0.rot + 1) & 3 } } // 2nd/Up key + + @discardableResult + mutating func rotateBack() -> Bool { tryChange { $0.rot = ($0.rot + 3) & 3 } } // Alpha key + + private mutating func tryChange(_ change: (inout Piece) -> Void) -> Bool { + guard !over else { return false } + var p = current + change(&p) + guard fits(p) else { return false } + current = p + return true + } + + // MARK: - Falling + + /// One gravity step (`FallDown`): move down or lock. + mutating func gravityStep() -> Step { + guard !over else { return .fell } + if fits(applying: { $0.y -= 1 }, to: current) { + current.y -= 1 + return .fell + } + return .locked(lockPiece()) + } + + /// Down key (`MoveDown`): +1 hidden unit, then a gravity step. The unit is + /// granted even on the step that locks — exactly as in the original. + mutating func softDropStep() -> Step { + guard !over else { return .fell } + scoreUnits += 1 + return gravityStep() + } + + /// Drop key (`Drop`): +1 unit per attempted step (including the final + /// blocked one), then lock immediately. + mutating func hardDrop() -> Step { + guard !over else { return .fell } + while true { + scoreUnits += 1 + if fits(applying: { $0.y -= 1 }, to: current) { + current.y -= 1 + } else { + return .locked(lockPiece()) + } + } + } + + // MARK: - Locking (`BotReached` → `NewB`) + + private mutating func lockPiece() -> Lock { + let scoreBefore = score + + for b in currentBlocks where b.y < Self.gridHeight { + grid[b.y][b.x] = current.kind + 1 + } + + // scoreU rollover happens on every new piece (`NewB`). + score += scoreUnits / 10 + scoreUnits %= 10 + + // Scan for full rows (`CheckLines`/`RepScan`), remove and shift down. + var cleared: [Int] = [] + var y = Self.bottomRowY + while y <= Self.headroomY - 1 { + let full = (Self.playfieldMinX...Self.playfieldMaxX).allSatisfy { grid[y][$0] != 0 } + if full { + cleared.append(y + cleared.count) // report original position + for yy in y..<(Self.gridHeight - 1) { + for x in Self.playfieldMinX...Self.playfieldMaxX { + grid[yy][x] = grid[yy + 1][x] + } + } + for x in Self.playfieldMinX...Self.playfieldMaxX { + grid[Self.gridHeight - 1][x] = 0 + } + lines += 1 + // do not advance y: the shifted-down row needs rescanning + } else { + y += 1 + } + } + + if !cleared.isEmpty { + score += Self.lineScores[cleared.count - 1] * (level + 1) + } + + // Level = lines DIV 10, only ever increasing (capped at 20 per the doc). + var leveled = false + let target = min(lines / 10, 20) + if target > level { + level = target + leveled = true + } + + // Spawn the next piece (`CreateNew`); if it doesn't fit, game over. + current = Piece(kind: nextKind, rot: 0, x: Self.spawnX, y: Self.spawnY) + nextKind = Int(rng.next() % 7) + if !fits(current) { over = true } + + return Lock(clearedYs: cleared, + scoreGained: score - scoreBefore, + leveledUp: leveled, + gameOver: over) + } + + // MARK: - Derived + + /// Seconds per gravity row at the current level (levelcnts × ~20 ms). + var gravityInterval: TimeInterval { 0.02 * Double(Self.levelCounts[min(level, 20)]) } + + /// Cell lookup in playfield coordinates (col 0…9, absolute y). + func cell(col: Int, y: Int) -> Int { + grid[y][col + Self.playfieldMinX] + } + + #if DEBUG + // Test/screenshot hooks — never in Release builds. + mutating func debugSetCell(col: Int, y: Int, kind: Int) { + grid[y][col + Self.playfieldMinX] = kind + } + mutating func debugSetLines(_ n: Int) { lines = n } + mutating func debugPose(kind: Int, rot: Int, x: Int, y: Int) { + current = Piece(kind: kind, rot: rot, x: x, y: y) + } + #endif +} + +/// SplitMix64 — deterministic, seedable stand-in for the original's LFSR +/// (`PRandom`); piece selection is uniform 0…6 in both. +struct TetrisRNG { + private var state: UInt64 + init(seed: UInt64) { state = seed == 0 ? 0xBAD_5EED : seed } + mutating func next() -> UInt64 { + state &+= 0x9E3779B97F4A7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + return z ^ (z >> 31) + } +} diff --git a/Arcade85/Shell/GameCatalog.swift b/Arcade85/Shell/GameCatalog.swift new file mode 100644 index 0000000..820502e --- /dev/null +++ b/Arcade85/Shell/GameCatalog.swift @@ -0,0 +1,63 @@ +import SwiftUI +import SpriteKit + +/// Metadata for one title in the collection. +struct GameInfo: Identifiable { + let id: String + let title: String + let tagline: String + let origin: String // author · year of the original TI-85 program + let symbol: String // SF Symbol shown on the home card + let tint: Color + let playable: Bool // false → routes to the "coming soon" preview scene + var scoreIsMoney = false // BEST renders as dollars (Dope Wars nets) +} + +/// The five TI-85 classics being remade, plus a factory that builds the +/// SpriteKit scene for a given id. +enum GameCatalog { + static let games: [GameInfo] = [ + GameInfo(id: "lander", title: "Lunar Lander", tagline: "Cut your descent and set her down gently on the pad.", + origin: "Istar · 1997", symbol: "moon.stars.fill", tint: Theme.accent, playable: true), + // Galaxian's slot: swapped for a trading game (too much overlap with + // Phoenix). The Galaxian port stays in Games/Galaxian, dormant — + // restore by swapping this entry back. Dope Wars runs the NerdWars + // TI-85 engine re-skinned back to Dell's classic 1984 setup. + GameInfo(id: "dopewars", title: "Dope Wars", tagline: "Buy low, sell high, dodge Hardass, square the loan shark.", + origin: "J.E. Dell · 1984", symbol: "dollarsign.circle.fill", tint: Theme.accent2, playable: true, + scoreIsMoney: true), + GameInfo(id: "phoenix", title: "Phoenix", tagline: "Blast through waves, bank credits, upgrade your ship.", + origin: "P. Davidson · 2008", symbol: "flame.fill", tint: Theme.warning, playable: true), + GameInfo(id: "ztetris", title: "ZTetris", tagline: "Stack the falling blocks, clear lines, chase the count.", + origin: "J. Mårdell · 1998", symbol: "square.grid.3x3.fill", tint: Theme.violet, playable: true), + GameInfo(id: "zmercury", title: "ZMercury", tagline: "Skim the terrain and outshoot everything that moves.", + origin: "P. Davidson · 2004", symbol: "bolt.fill", tint: Theme.danger, playable: true), + ] + + static func game(id: String) -> GameInfo { + games.first { $0.id == id } ?? games[0] + } + + @MainActor + static func makeScene(id: String, size: CGSize) -> SKScene { + let scene: SKScene + switch id { + case "lander": + scene = LanderScene(size: size) + case "galaxian": // dormant, no catalog entry (see above) + scene = GalaxianScene(size: size) + case "dopewars": + scene = DopeWarsScene(size: size) + case "phoenix": + scene = PhoenixScene(size: size) + case "ztetris": + scene = TetrisScene(size: size) + case "zmercury": + scene = MercuryScene(size: size) + default: + scene = ComingSoonScene(size: size, info: game(id: id)) + } + scene.scaleMode = .resizeFill + return scene + } +} diff --git a/Arcade85/Shell/GameHostView.swift b/Arcade85/Shell/GameHostView.swift new file mode 100644 index 0000000..3e8adb6 --- /dev/null +++ b/Arcade85/Shell/GameHostView.swift @@ -0,0 +1,47 @@ +import SwiftUI +import SpriteKit + +/// Full-screen host for a game's SpriteKit scene, with a floating back button. +/// The scene is built once at the measured size and owns its own HUD. +struct GameHostView: View { + let info: GameInfo + @Environment(\.dismiss) private var dismiss + @State private var scene: SKScene? + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .topLeading) { + Color.black.ignoresSafeArea() + + if let scene { + SpriteView(scene: scene, + options: [.ignoresSiblingOrder]) + .ignoresSafeArea() + } + + Button { + Haptics.selection() + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(.white.opacity(0.9)) + .frame(width: 38, height: 38) + .background(.ultraThinMaterial, in: Circle()) + .overlay(Circle().stroke(.white.opacity(0.15), lineWidth: 1)) + } + .padding(.top, 6) + .padding(.leading, 14) + } + .onAppear { + if scene == nil { + let size = geo.size == .zero ? UIScreen.main.bounds.size : geo.size + scene = GameCatalog.makeScene(id: info.id, size: size) + } + } + } + .background(Color.black.ignoresSafeArea()) + .statusBarHidden(true) + .persistentSystemOverlays(.hidden) + } +} diff --git a/Arcade85/Shell/Haptics.swift b/Arcade85/Shell/Haptics.swift new file mode 100644 index 0000000..341312c --- /dev/null +++ b/Arcade85/Shell/Haptics.swift @@ -0,0 +1,15 @@ +import UIKit + +/// Thin wrapper over UIKit feedback generators so games can add tactile feel +/// without repeating boilerplate. All calls are cheap and safe to fire often. +enum Haptics { + static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle, intensity: CGFloat = 1.0) { + let generator = UIImpactFeedbackGenerator(style: style) + generator.impactOccurred(intensity: intensity) + } + + static func success() { UINotificationFeedbackGenerator().notificationOccurred(.success) } + static func warning() { UINotificationFeedbackGenerator().notificationOccurred(.warning) } + static func error() { UINotificationFeedbackGenerator().notificationOccurred(.error) } + static func selection() { UISelectionFeedbackGenerator().selectionChanged() } +} diff --git a/Arcade85/Shell/HighScoreStore.swift b/Arcade85/Shell/HighScoreStore.swift new file mode 100644 index 0000000..b9102a7 --- /dev/null +++ b/Arcade85/Shell/HighScoreStore.swift @@ -0,0 +1,43 @@ +import Foundation +import Combine + +/// Persists a best score per game id in UserDefaults. Shared across the shell +/// (to show "BEST" on cards) and the SpriteKit scenes (to submit results). +final class HighScoreStore: ObservableObject { + static let shared = HighScoreStore() + + private let storageKey = "arcade85.highscores.v1" + @Published private(set) var scores: [String: Int] = [:] + + private init() { load() } + + func best(_ id: String) -> Int { scores[id] ?? 0 } + + /// Records `score` for `id` if it beats the stored best. Returns true on a new record. + @discardableResult + func submit(id: String, score: Int) -> Bool { + guard score > (scores[id] ?? 0) else { return false } + scores[id] = score + save() + return true + } + + /// Clears the stored best for one game (used when a game's scoring scale changes). + func reset(_ id: String) { + guard scores[id] != nil else { return } + scores[id] = nil + save() + } + + private func load() { + guard let data = UserDefaults.standard.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode([String: Int].self, from: data) + else { return } + scores = decoded + } + + private func save() { + guard let data = try? JSONEncoder().encode(scores) else { return } + UserDefaults.standard.set(data, forKey: storageKey) + } +} diff --git a/Arcade85/Shell/RootView.swift b/Arcade85/Shell/RootView.swift new file mode 100644 index 0000000..a125e52 --- /dev/null +++ b/Arcade85/Shell/RootView.swift @@ -0,0 +1,200 @@ +import SwiftUI + +/// Home screen: a starfield backdrop, the "ARCADE 85" wordmark, and a stack of +/// game cards. Tapping a card presents the game full screen. +struct RootView: View { + @StateObject private var scores = HighScoreStore.shared + @State private var active: GameInfo? + + var body: some View { + ZStack { + LinearGradient(colors: [Theme.backgroundHi, Theme.background], + startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + StarfieldView() + .ignoresSafeArea() + .allowsHitTesting(false) + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + .padding(.top, 12) + .padding(.bottom, 6) + ForEach(GameCatalog.games) { game in + GameCard(info: game, best: scores.best(game.id)) { + Haptics.selection() + active = game + } + } + footer + } + .padding(.horizontal, 20) + .padding(.bottom, 32) + } + } + .fullScreenCover(item: $active) { game in + GameHostView(info: game) + } + .onAppear { + #if DEBUG + if let id = ProcessInfo.processInfo.environment["ARCADE_DIRECT_GAME"], + let info = GameCatalog.games.first(where: { $0.id == id }) { + active = info + } + #endif + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + (Text("ARCADE ").foregroundStyle(.white) + Text("85").foregroundStyle(Theme.accent)) + .font(Theme.title(40)) + .kerning(1) + Text("Five TI-85 classics, remade for touch.") + .font(Theme.rounded(15, .medium)) + .foregroundStyle(.white.opacity(0.55)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var footer: some View { + Text("Faithful HD remakes, ported from the original calculator sources.") + .font(Theme.rounded(12, .regular)) + .foregroundStyle(.white.opacity(0.35)) + .padding(.top, 8) + } +} + +/// One tappable game entry on the home screen. +struct GameCard: View { + let info: GameInfo + let best: Int + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(info.tint.opacity(0.16)) + Image(systemName: info.symbol) + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(info.tint) + } + .frame(width: 66, height: 66) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Text(info.title) + .font(Theme.title(19)) + .foregroundStyle(.white) + if !info.playable { + Text("SOON") + .font(Theme.mono(9, .bold)) + .padding(.horizontal, 6).padding(.vertical, 3) + .background(Capsule().fill(.white.opacity(0.10))) + .foregroundStyle(.white.opacity(0.6)) + } + } + Text(info.tagline) + .font(Theme.rounded(12.5, .regular)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + Text(info.origin) + .font(Theme.mono(10, .medium)) + .foregroundStyle(.white.opacity(0.35)) + .padding(.top, 1) + } + + Spacer(minLength: 4) + + VStack(alignment: .trailing, spacing: 2) { + if info.playable { + Text("BEST") + .font(Theme.mono(8, .bold)) + .foregroundStyle(.white.opacity(0.35)) + Text(info.scoreIsMoney ? Self.compactMoney(best) : "\(best)") + .font(Theme.mono(15, .bold)) + .foregroundStyle(info.tint) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white.opacity(0.30)) + } + .frame(height: 66) + } + .padding(13) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Theme.panel.opacity(0.85)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(info.tint.opacity(0.22), lineWidth: 1) + ) + ) + } + .buttonStyle(CardButtonStyle()) + } + + /// "$318,240" up to six figures, then "$13.4M" — card space is tight. + private static func compactMoney(_ n: Int) -> String { + switch n { + case 1_000_000_000...: return String(format: "$%.1fB", Double(n) / 1_000_000_000) + case 1_000_000...: return String(format: "$%.1fM", Double(n) / 1_000_000) + default: + let f = NumberFormatter() + f.numberStyle = .decimal + return "$" + (f.string(from: NSNumber(value: n)) ?? "\(n)") + } + } +} + +/// Subtle press-scale for the cards. +private struct CardButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.975 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +/// A static field of stars rendered with Canvas. Positions are deterministic so +/// the sky doesn't shuffle on every redraw. +struct StarfieldView: View { + private let stars: [Star] = { + var rng = SeededGenerator(seed: 0x5EED_1985) + return (0..<90).map { _ in + Star(x: .random(in: 0...1, using: &rng), + y: .random(in: 0...1, using: &rng), + r: .random(in: 0.5...1.7, using: &rng), + a: .random(in: 0.10...0.85, using: &rng)) + } + }() + + var body: some View { + Canvas { context, size in + for s in stars { + let rect = CGRect(x: s.x * size.width, y: s.y * size.height, + width: s.r * 2, height: s.r * 2) + context.fill(Path(ellipseIn: rect), with: .color(.white.opacity(s.a))) + } + } + } + + struct Star { let x, y, r, a: CGFloat } +} + +/// Tiny deterministic RNG so the starfield (and any other decorative layout) is stable. +struct SeededGenerator: RandomNumberGenerator { + private var state: UInt64 + init(seed: UInt64) { state = seed == 0 ? 0xDEAD_BEEF : seed } + mutating func next() -> UInt64 { + state &+= 0x9E3779B97F4A7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + return z ^ (z >> 31) + } +} diff --git a/Arcade85/Shell/Theme.swift b/Arcade85/Shell/Theme.swift new file mode 100644 index 0000000..d476355 --- /dev/null +++ b/Arcade85/Shell/Theme.swift @@ -0,0 +1,35 @@ +import SwiftUI + +/// Shared visual language for the collection: a dark, neon-retro palette that +/// every game and the shell draw from so the app reads as one product. +enum Theme { + // SwiftUI colors (shell UI) + static let background = Color(red: 0.020, green: 0.027, blue: 0.059) + static let backgroundHi = Color(red: 0.043, green: 0.071, blue: 0.149) + static let panel = Color(red: 0.071, green: 0.102, blue: 0.165) + static let accent = Color(red: 0.42, green: 0.94, blue: 0.80) // neon mint + static let accent2 = Color(red: 0.36, green: 0.72, blue: 1.00) // cyan + static let warning = Color(red: 0.96, green: 0.78, blue: 0.23) // amber + static let danger = Color(red: 1.00, green: 0.35, blue: 0.40) // red + static let violet = Color(red: 0.71, green: 0.50, blue: 1.00) + + static func title(_ size: CGFloat) -> Font { .system(size: size, weight: .heavy, design: .rounded) } + static func rounded(_ size: CGFloat, _ weight: Font.Weight = .semibold) -> Font { .system(size: size, weight: weight, design: .rounded) } + static func mono(_ size: CGFloat, _ weight: Font.Weight = .semibold) -> Font { .system(size: size, weight: weight, design: .monospaced) } +} + +/// UIColor / SKColor mirrors of the palette, for use inside SpriteKit scenes. +enum Palette { + static let space0 = UIColor(red: 0.043, green: 0.071, blue: 0.149, alpha: 1) // upper sky + static let space1 = UIColor(red: 0.016, green: 0.024, blue: 0.055, alpha: 1) // lower sky + static let mint = UIColor(red: 0.42, green: 0.94, blue: 0.80, alpha: 1) + static let mintDim = UIColor(red: 0.42, green: 0.94, blue: 0.80, alpha: 0.35) + static let cyan = UIColor(red: 0.36, green: 0.72, blue: 1.00, alpha: 1) + static let amber = UIColor(red: 0.96, green: 0.78, blue: 0.23, alpha: 1) + static let red = UIColor(red: 1.00, green: 0.35, blue: 0.40, alpha: 1) + static let violet = UIColor(red: 0.71, green: 0.50, blue: 1.00, alpha: 1) + static let terrain = UIColor(red: 0.055, green: 0.086, blue: 0.114, alpha: 1) + static let ink = UIColor(red: 0.055, green: 0.086, blue: 0.129, alpha: 1) + static let white70 = UIColor(white: 1, alpha: 0.7) + static let white40 = UIColor(white: 1, alpha: 0.4) +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f43ba73 --- /dev/null +++ b/README.md @@ -0,0 +1,416 @@ +# Arcade 85 + +A 5-in-1 iOS collection of faithful **HD remakes** of five classic TI-85 +calculator games. Native **SwiftUI + SpriteKit**. + +The games (from the original sources in this repo's sibling folders): + +| Game | Original | Status | +|------|----------|--------| +| **Lunar Lander** | Tiger/Istar, 1997 (`lander/LANDER.C`) | ✅ Playable | +| **ZTetris** | J. Mårdell, 1998 (`zetris/ZTETRIS.ASM`) | ✅ Playable | +| **Phoenix** | P. Davidson, 2008 (`phx4.0ti85/`) | ✅ Playable | +| **Dope Wars** | J.E. Dell, 1984 · engine via J. Gannon's NerdWars, 2000 | ✅ Playable | +| **ZMercury** | P. Davidson, 2004 (`zmercury-ti85/`) | ✅ Playable | +| **Galaxian** | P. Davidson, 1998 (`galaxian-ti85/`) | 💤 Ported, dormant | + +All five collection slots are playable. Galaxian was fully ported, then swapped +out of the line-up for a trading game (too much overlap with Phoenix); its code +stays in the project and swapping one `GameCatalog.swift` entry restores it. +The trading slot first shipped as NerdWars (the TI-85's Drugwars clone), then +was restored to the classic **Dope Wars** setup it secretly was all along — +see the Dope Wars section. + +## Running it + +Requires Xcode 16+ (developed against Xcode 26). + +```sh +open Arcade85/Arcade85.xcodeproj +# pick an iPhone simulator, then Cmd-R +``` + +Or from the command line: + +```sh +cd Arcade85 +xcodebuild -scheme Arcade85 -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17' build +``` + +There is no third-party dependency and no code-signing needed for the simulator. +The project uses Xcode "synchronized file groups", so **new files added under +`Arcade85/Arcade85/` are picked up automatically** — no `.pbxproj` editing. + +## Project layout + +``` +Arcade85/Arcade85/ + Arcade85App.swift @main entry point + Shell/ the collection UI, shared across every game + Theme.swift palette + fonts (SwiftUI + SpriteKit mirrors) + Haptics.swift feedback helpers + HighScoreStore.swift per-game best scores (UserDefaults) + GameCatalog.swift game metadata + SKScene factory + RootView.swift home screen (starfield + game cards) + GameHostView.swift full-screen SpriteView host + back button + Games/ + SceneArt.swift procedural gradients, starfields, symbol + sprite textures + ComingSoonScene.swift placeholder scene for the four unbuilt games + Lander/ + LanderSim.swift faithful port of LANDER.C (pure logic) + LanderScene.swift HD rendering, multi-touch thrusters, game flow + ZTetris/ + TetrisSim.swift faithful port of ZTETRIS.ASM (pure logic) + TetrisScene.swift HD rendering, DAS touch controls, game flow + Phoenix/ + PhoenixData.swift sprites (images.asm) + campaign (levels.asm), verbatim + PhoenixSim.swift faithful port of the Z80 game logic + PhoenixScene.swift HD rendering, drag-to-fly control, shop, flow + Galaxian/ (dormant — swapped out of the catalog for Dope Wars) + GalaxianData.swift sprites, formation patterns + 20-level table, verbatim + GalaxianSim.swift faithful port of GALAXIAN.ASM + GalaxianScene.swift HD rendering, drag-to-fly + laser button, flow + DopeWars/ + DopeWarsSim.swift the NerdWars engine restored to Dell's classic setup + DopeWarsScene.swift night-city trading board, event cards, cop chase, flow + ZMercury/ + MercuryData.swift terrain tiles, map, sine table + sprites, verbatim + MercurySim.swift faithful port of the multi-file Z80 sources + MercuryScene.swift HD terrain scroller, parallax, drag-to-fly, flow + Assets.xcassets/ app icon (procedurally generated) + accent color +``` + +## Design approach + +Each game is split into a **pure simulation** and a **SpriteKit scene** that +renders and drives it. The simulation reproduces the original's mechanics +exactly; the scene is where the HD glass goes (neon vector art, particles, +haptics, touch controls). This keeps the remakes honest — the feel comes from +the original numbers, not an approximation. + +### Lunar Lander (the reference implementation) + +`LanderSim` ports `LANDER.C` line-for-line in the original integer space: + +- the Dape/Istar landscape generator (random walk + erosion smoothing passes), +- the 10-wide landing pad carved 4 below the surrounding terrain, +- gravity, the ±2 thruster impulses, horizontal friction, velocity clamping, +- the 8-pixel-footprint landing/crash test (must be flat under all 8 columns + with descent speed ≥ −3), and +- the original scoring: `((90 − maxFuel) × fuel / maxFuel) × maxVel`. + +The sim ticks at the original's real pace — LANDER.C ends every loop pass with +`delay(20)` ≈ 200 ms (the same `delay()` whose 200-unit calls are the ~2 s +message pauses), i.e. **~5 loops/sec** — with rendering interpolated between +ticks so motion stays smooth. Taps shorter than one tick are latched so they +still burn for exactly one loop, like one keypress in the original. + +Deliberate HD liberties (everything else is the original's integer rules, +which hold verbatim on the Moon setting): + +- The field is **128×96** instead of 128×64 — extra sky above the same 0…32 + terrain for a phone-shaped playfield and a longer descent. Every rule is + height-relative, so nothing else changes. +- **Soft walls.** In the original the screen edge was fatal; on touch that + turned a mistimed start tap into an instant "lost in space". Now the top and + sides stop the lander (velocity zeroed) instead of ending the run, the + lander spawns with 7 px of ceiling headroom, and a tap that starts a run is + consumed by the transition — it can never double as a first-tick burn. + Crashing into terrain is the only way to die. +- A **GRAVITY** setting — the remake's third difficulty knob, added because + the original's self-braking lateral friction leaves little to fight: + - **MOON** — the original: pull 1/tick, drift damped every tick; + - **MARS** — pull 1,1,1,2 (avg 1.25), damping every *other* tick; + - **EARTH** — pull 1,2 alternating (avg 1.5), **no damping** — Newtonian + drift you must counter-burn, landing scores **×3** (Mars ×2). + The patterns are phase tables over the tick counter, so the sim stays + integer-exact. + +The scene renders the actual TI-85 8×8 lander bitmap as crisp merged neon +pixels and adds on-screen thruster pads (with true multi-touch so you can fire +lateral + main thrust together), a live V-SPEED / FUEL / SCORE HUD, and +ready / landed / game-over flow. The original's adjustable **FUEL (30–80)** +lives on the ready screen next to **MAX SPEED (4–7)** — the original allowed +speed 3, but there terminal velocity *equals* the safe-touchdown limit and the +game cannot be lost, so the floor is 4. The classic's own defaults (40/7) are +its hardest speed setting, so the remake defaults to a gentler **60 fuel / +speed 4 / Moon**, remembers the player's last choice, and teaches the rule on +screen (`V-SPEED · SAFE ≤3` plus stepper hints). The score formula already +pays more for harder settings; the gravity multiplier extends that to the new +worlds. A 20-assertion standalone harness pins the spawn headroom, wall +clamps, all three pull/damping patterns, the score multipliers, and that +speed 4 remains losable. + +### ZTetris + +`TetrisSim` ports `ZTETRIS.ASM` mechanically — several of its tables are +embedded **verbatim**: + +- the 28-word `BitData` piece table (7 pieces × 4 rotations, four (dy,dx) + offsets packed 2 bits each), decoded exactly like the ASM's `Uncrunch`; +- the well geometry: 10 columns, 16 visible rows + 2 rows of hidden headroom, + spawn at the original (6,16), no wall kicks, moves fail silently; +- `levelcnts` gravity (level 0–20), converted at the TI-85's real interrupt + timing: one main-loop iteration = 4 HALTs ≈ 20 ms, so level 0 = 0.8 s/row; +- scoring: line clears `4/10/30/120 × (level+1)`, plus the hidden units + counter (`scoreU`) — +1 per Down/Drop step *including the blocked one* — + carried into the displayed score ÷10 on each new piece, exactly as `NewB` + does it; +- level = lines ÷ 10 (only increasing, capped at 20), start level 0–9; +- "start high" trash (0–5 → ×2 rows), each row punched with 5 random gaps; +- the 16 per-level background patterns, tiled on the rails beside the well. + +The scene adds the HD glass: colored beveled blocks, a ghost landing outline, +next-piece preview, DAS auto-repeat on held ◄ ► (0.167 s delay, 50 ms repeat), +held soft drop, tap hard drop, both rotation directions, pause, and line-clear +flash/score-pop feedback. The sim has a standalone test harness (68 assertions) +covering piece decode, drop scoring, clears, leveling, trash rows and top-out. + +### Phoenix + +The deepest port: the entire Phoenix 4.0 campaign and economy, extracted from +the Z80 sources file by file: + +- **Campaign** (`levels.asm`): all 31 combat waves + 9 shop stops, with every + power/movement/weapon/fire-rate value and the four pattern paths (circle, + oval, figure-eight, huge oval) transcribed literally; `L_GOTO` chains inlined. +- **Enemy AI** (`emove.asm`): the synchronized rectangle formation wobble + (byte-timer, odd-tick moves, 17-tick phases, −60 entry descent), bosses + (descend then 83/90-px sweeps), bouncers, pattern followers driven by the + original's bit-reversed-timer fractional velocities (`Div_A_16`), rampagers + with the exact trigger columns, and the 4-path swoop system. +- **Weapons**: player F1–F5 with the original damage/speed/offsets (including + F4's alternating sides and F5's arcing swing shots), enemy weapons from + straight drops to the 7-angle-bucket aimed big/huge shots. +- **Economy**: money as dmg-0 "bonus bullets" dropped every 4–11 kills, $ value + by difficulty (100/50/25), cash cap 9900, and the shop's 7 items with the + original prices and purchase rules (shield cap 16, one-time weapons that + auto-select, companion refill). +- **Companion** (`helper.asm`): position-queue follower, 10-damage shots every + 8 frames, its own 16-point shield and explosion. +- **Scoring** (`score16.asm`): shields×1000 + time bonus (19000 counting down + every other frame) + difficulty/speed bonus + cash, awarded on finishing. +- **Sprites** (`images.asm`): all 44 bitmaps rendered as neon pixel art, + including the 16-wide bosses and the 8-frame explosion. + +Touch layer: drag anywhere to fly (original ±2/±1-px-per-frame speed limits +still govern the ship), always-on autofire at the original 10/4-frame cadence, +tap weapon selector, pause, and full title → shop → victory/game-over flow at +the original 25/29/35 fps speed options. Sim verified by a 172-assertion +standalone harness (progress additions covered by a follow-up suite). + +**Progress scoring (remake addition).** The original only scored a *finished* +campaign (`score16.asm`), which on the phone meant the high score sat at 0 +until you beat all 31 waves. The sim now tracks `wavesCleared` and +`totalEarned` (lifetime $, unspent or not), and every run posts +`waves × 400 + earnings` — a death shows `WAVE n/31 · SCORE`, and a victory +adds the untouched score16 tally (shields/time/bonus/cash) on top, itemised +as a PROGRESS line in the classic victory breakdown. + +The 2:1 TI-85 window is drawn **full-bleed** with a **2× vertical stretch on +entity positions** while the sprites themselves keep a mild 1.25× arcade-monitor +stretch — the battle fills the phone instead of sitting in a letterboxed strip, +and the pixel art stays crisp. Sim geometry and collisions are untouched — the +visible window itself can't widen, because the original parks and spawns +entities just outside the y 32…96 band. + +### Galaxian + +`GalaxianSim` ports `GALAXIAN.ASM` (v3.0) at its ~35 fps frame rate: + +- **Formation choreography**: each slot's pattern byte is *both* entry time + and formation depth — an enemy triggers when `timer & 127 == 128 - depth`, + descends 1 px/frame, and the 128-frame timer wrap locks it into formation + exactly at its row. The whole formation then sways ±32 px on timer bit 6. +- **Swoops**: the original's 8-stage dive state machine (types 5–12: up/out, + arc over, dive at double speed), started by the R-register roll + (`R < 19/128` on every 4th frame), aimed away from the screen edge, and + rejoining the entry queue at the bottom for −1 point. +- **20 levels** from `levels_jump_table`: plain waves, the ring formation, + mixed weak/strong waves (13 power-1 + 7 of the level type), bouncing cross + trainers (exactly half of which can flee, −50), aim-shooting "operator" + ships, patrolling 10-hit bosses, and two 98-hit superbosses that deploy + drones mid-fight (−200 each). +- **Weapons**: one cannon bullet per press from a 10-slot pool (+10 per hit, + +100 per kill), and 9 lasers that wipe an entire column but never hurt a + boss. Enemy bullets come in 5 drift types picked by your position. +- **Scoring** exactly as documented: +200 per level, +25 per boss hit, +1000 + per boss kill, −16×16 per death, +5000 victory. + +The scene adds drag-to-fly (4-way, original 1 px/frame speed), always-on +autofire, a big LASER button with the remaining count, wave banners, and the +9-lives / 9-lasers HUD. Sim verified by a 129-assertion standalone harness. + +### ZMercury + +`MercurySim` ports the multi-file 2004 sources — a fully deterministic +horizontal cave-shooter (the original contains *no* randomness at all): + +- **Terrain scroller**: 1 px/frame leftward; every 8 frames a new 8-px tile + column enters from a 64-column looping map (9th map row = wave triggers). + The buffer's polarity quirk (bits set = open sky) and the one-byte offset + between sprite space and terrain space are both preserved. +- **Waves**: straight flyers, aimed-fire phoenixes, sine-bobbing spinners, + turret bases mounted on their map pad tiles (ceiling *and* floor), spin-T + sine crossers, an open-space gate that halts the map until the field is + clear, and a 50-hit boss that holds the right edge firing bullet pairs. +- **Damage model**: 100 shield points; every ship corner touching rock costs + 1 per frame, enemy bullets cost 5, rams cost 1 (and hurt the enemy back). + Kills pay +1 immediately and +20 when the explosion finishes — the + original's BCD scoring, faithfully odd. +- **Aimed fire**: the shallow-cone rule — bullets angle up/down only when + the vertical offset beats deltaX/4 — plus per-slot fire stagger. + +The scene renders the terrain from prebuilt map-column textures that slide +with the sim's scroll counter — full-bleed, positions/terrain stretched 1.9× +vertically (sprites 1.35×), every rock pixel facing open air lit as a glowing +rim over a dark rock mass — plus drifting star layers, a slower stalactite +silhouette band for cave depth, and the original ~28.6 fps (`timer ≥ 7` +against the ~200 Hz interrupt). Sim verified by an 81-assertion standalone +harness. + +### Dope Wars + +The genre swap for the fifth slot — and a restoration story. The engine is the +verbatim port of **NerdWars** (J. Gannon, 2000), the TI-85's only +Drugwars-style game, decoded from its `.85p`. Reading the BASIC revealed that +Gannon had kept John E. Dell's original **Drug Wars (1984)** item variables +`C,H,A,W,S,L` — Cocaine, Heroin, Acid, Weed, Speed, Ludes — and merely +rescaled the price bands and renamed the nouns. `DopeWarsSim` reverses the +re-skin: Dell's classic setup on Gannon's proven engine. + +- **The month**: $2,000 cash, the classic **$5,500** debt to the loan shark + growing 10%/day, a 6%/day bank (shark and bank live in the Bronx), a + 100-pocket trench coat, and 30 subway rides between six boroughs (Bronx, + Ghetto, Central Park, Manhattan, Coney Island, Brooklyn); each ride rerolls + the six prices plus one 0…20 event roll. +- **Market** (Dell's iconic bands): Cocaine 15000–30000 · Heroin 5000–14000 · + Acid 1000–4500 · Weed 300–900 · Speed 90–250 · Ludes 10–60, bent by events: + cheap heroin at 850–1999, "Cops made a big bust!" heroin at 18000–42999, + $80,000+ cocaine manias, $2 pharmacy-raid ludes, weed and acid floods, a + subway mugging (keep 2/3), $200-coat and $400-gun offers (Baretta / + .38 Special / Saturday Night Special), free drugs on a dead dude — and the + weed on the sidewalk that always ends in one last wild trip. +- **Officer Hardass**: carrying 50+ units can trigger a chase — run (50/50; + escaping rerolls the day, possibly straight into another event) or fight + with a gun; every fight round draws return fire (+3 wounds, 50 = death), + and dropping the whole squad loots Hardass's body ($750–1999) with a + $1,000 doctor offer if you can afford it. +- **Scoring**: your **final net worth in dollars** (cash + bank − debt) is + the high score — "$13.4M" beats "$2.1M", the way a trading game should + brag. (Dell's `√(net ÷ 31.5)` 0–100 rating is still computed in the sim + for the curious; the old 0–100 stored best migrates to $0 once.) +- **The market grows with you (remake extension).** Below $100k net worth + the game is exactly the classic. Cross **$100k** and the ludes slot + retires — any stock is bought out at yesterday's price — and **METH** + moves in at $2,000–16,000, the widest swing on the board. Cross **$1M** + and speed retires for **CHINA WHITE** at $40,000–160,000, the whale + product that dwarfs even a cocaine mania. Shifts are one-way, announced + as street-word cards on arrival, and bring their own events on formerly + idle rolls of the 0…20 table: a Jersey meth-lab explosion spikes local + product to $30–48k (the tier-2 face of the old $2-ludes roll), a superlab + dump floors meth at $400–999, and the cartel flooding the docks drops + China White to $8–20k. The dead-dude loot names the tier's product — + yes, you can find china white on a dead dude in the subway. +- **The streets got real (round-5 extensions).** Each borough now prices its + own way — multipliers on the daily band rolls (Bronx neutral; event prices + are absolute news and ignore the bias): heroin and speed run cheap in the + Ghetto, acid and weed in the Park, everything's sleepy-cheap on Coney, + Manhattan's rich buyers pay up to +30%, Brooklyn's fences pay a little + over — so routing (buy in the Park, sell in Manhattan) is a real strategy, + and the subway sheet captions each borough's economy. Some days **word on + the street** names a product and a borough for tomorrow — ride there to + find out if the tip was good (×3 spike or ÷3 glut) or street garbage + (about 1 in 4). And the debt finally has teeth: run a **$15,000+ tab past + day 10** and you get one warning before the shark's two leg-breakers start + jumping you — the classic chase engine with his collection money as loot, + amber sodium strobe, and a nastier ending ("THE SHARK COLLECTED") if they + put you down. Rumors and shark muscle roll on a separate RNG stream, so + classic price/event sequences per seed stay byte-identical. +- **Month lengths**: 30 / 60 / 90 days or **endless** on the ready screen — + endless has no clock and posts no score (interest caps keep marathon runs + from overflowing). Amount sheets have a MAX chip, and tapping the number + opens an in-game keypad to type any amount directly (DONE trims to the + limit). +- **Engine quirks kept**: the dead-dude loot roll can never grant cocaine + (`int(rand*5)` is 0…4 over the cheap-to-dear order); the shark fronts at + most $5,000 *per visit* but unlimited visits; the final ride ends the month + with no fresh roll. One NerdWars quirk was *dropped* with the re-theme: + trading matched items **by price** (`If F==C …`), which could fill/empty + several pockets at once — no longer authentic to the classic fiction, so + trading is by item, the way Dell's original worked (the harness pins the + new behaviour with a regression test). + +The scene wraps it in a night-city board — skyline silhouettes with lit +windows, sodium-lamp glow, gradient market rows with day-over-day ▲▼ movement +arrows and reroll flashes (arrows sit out a tier-shift day, since yesterday's +prices belong to retired products) — plus quantity-chip trade sheets, +SUBWAY / SHARK / BANK / END actions, the original's Pause screens as +tap-through event cards, and a RUN / FIGHT chase overlay under red/blue +strobe light. The ready screen states the rules as a plain bulleted list and +carries the 30/60/90/∞ month picker. Sim verified by a 133-assertion +standalone harness covering the classic engine end-to-end plus every +extension: tier mechanics (bands, liquidation, one-way + double promotion, +tier events and their tier-1 no-ops, tier-aware loot naming), borough bias +math and event-absolute pricing, rumor spawn/payout/garbage/missed/final-day +gating, the shark warning → chase → loot/death paths, and month-length / +endless / interest-cap behaviour. + +## The HD art pass + +A collection-wide visual up-level (round 3 of feedback: "still a little too +simple… as if they are straight ports"). Shared toolkit in `SceneArt.swift`: +radial glow textures, gradient panel textures, programmatic particle emitters +(spark bursts, thrust/trail flames, ambient dust), vignettes, screen-shake and +flash actions. Per game, all render-layer only — every sim stays verbatim: + +- **Lander**: full-bleed field; terrain rebuilt as gradient rock with speckle + grain and a lit crust hugging the skyline, over a distant ridge silhouette; + particle exhaust jets + engine glow instead of triangle flames; pulsing pad + light-cone and sequenced approach beacons; touchdown dust ring; crash + flash + spark burst + tumbling debris + screen shake; nebula blooms, + two-depth starfield, drifting dust, vignette. +- **Phoenix**: full-bleed field (2× position / 1.25× sprite stretch); sprites + shaded top-lit so they read as bodies, not flat clusters; engine trail and + banking tilt on the ship; particle explosions; screen shake + red flash on + hits; WAVE / BOSS banners; two scrolling star layers over static far stars. +- **ZMercury**: full-bleed canyon (1.9× / 1.35×); terrain columns re-rendered + with glowing rim pixels on every air-facing edge over dark rock; a slow + stalactite/stalagmite silhouette band behind the real walls; engine trail, + scrape sparks + shake, kill sparks synced to the original explosion frames, + and a proper going-out blast. +- **Dope Wars**: built HD from the start (see its section). +- **ZTetris**: untouched — it set the bar. + +## Porting notes + +Tip from the ZTetris port: the 1990s sources use extended-ASCII + CRLF, which +makes `grep` treat them as binary — strip with +`tr -d '\r' < FILE | tr -c '[:print:]\n\t' '?'` first, then the ASM tables +(pieces, speeds, scoring) can be read and ported directly. + +NerdWars (the Dope Wars engine) arrived as a `.85p` Graph Link file rather +than source — but that format's ASCII mode stores TI-85 BASIC nearly as plain +text, so a ~20-line decoder mapping the few special bytes (0xD6 newline, +0x1C the STO→ arrow, 0x19 ≥, 0x1A negate, 0x10 √) recovers the entire +listing. + +## Notes + +- Portrait-only, status bar hidden, targets iOS 17+. +- The app icon is generated by `scripts`-free CoreGraphics (see the git history + / the 1024px asset in `Assets.xcassets`). +- `#if DEBUG` launch hooks exist for screenshots/tests and are stripped from + Release builds: + - `ARCADE_DIRECT_GAME=` — open straight into a game. + - `ARCADE_AUTOPLAY=1` — start the run immediately (any game). + - `ARCADE_DEMO=1` — freeze a staged mid-game pose (any game). + - `ARCADE_DEMO_VIEW=buy|chase|tier3|over|ride|keypad|sharkchase|endless` — + with Dope Wars' demo, pose the buy sheet, cop chase, tier-3 board + (Meth + China White), net-worth game-over, borough-captioned subway + sheet, amount keypad, leg-breaker chase, or an endless-mode board. + - `ARCADE_SHOP=1` — open Phoenix's shop stocked with cash. + - `ARCADE_GAMEOVER=1` — pose Phoenix's death card mid-campaign (progress score). + + e.g. `SIMCTL_CHILD_ARCADE_DIRECT_GAME=lander xcrun simctl launch booted com.albanese.arcade85`