Initial commit: Arcade85 (5-in-1 TI-85 HD remakes) + AGENTS.md runbook
This commit is contained in:
+22
@@ -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
|
||||
@@ -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 <UDID> "$APP"
|
||||
xcrun devicectl device process launch --device <UDID> 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/<Name>/<Name>Scene.swift` + `<Name>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.
|
||||
@@ -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 = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
A00000000000000000000003 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A00000000000000000000005 /* Arcade85.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2660"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000004"
|
||||
BuildableName = "Arcade85.app"
|
||||
BlueprintName = "Arcade85"
|
||||
ReferencedContainer = "container:Arcade85.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000004"
|
||||
BuildableName = "Arcade85.app"
|
||||
BlueprintName = "Arcade85"
|
||||
ReferencedContainer = "container:Arcade85.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000004"
|
||||
BuildableName = "Arcade85.app"
|
||||
BlueprintName = "Arcade85"
|
||||
ReferencedContainer = "container:Arcade85.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct Arcade85App: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.preferredColorScheme(.dark)
|
||||
.statusBarHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 709 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -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..<sprite.w where (bits & (UInt32(0x8000_0000) >> 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<UITouch>, 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<UITouch>, 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<UITouch>, with event: UIEvent?) {
|
||||
if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil }
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil }
|
||||
}
|
||||
}
|
||||
@@ -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..<count {
|
||||
enemies[i] = Enemy(type: 1, pwr: lev.enemyPwr, x: 110, y: 110)
|
||||
}
|
||||
}
|
||||
|
||||
switch lev.kind {
|
||||
case .standard:
|
||||
fillFormation(20)
|
||||
enemiesRemaining = 20
|
||||
case .boss(_, let pwr):
|
||||
fillFormation(20)
|
||||
enemiesRemaining = 21
|
||||
bossStatus = 1; bossPwr = pwr; bossX = 30; bossY = 0
|
||||
case .weak:
|
||||
fillFormation(20)
|
||||
for i in 0...12 { enemies[i].pwr = 1 }
|
||||
enemiesRemaining = 20
|
||||
case .cross, .operators:
|
||||
// 10 bouncers in even slots; the odd slot after each carries
|
||||
// (vx, vy) in its pwr/x fields, exactly like the original layout.
|
||||
for i in stride(from: 0, to: 20, by: 2) {
|
||||
enemies[i] = Enemy(type: 13, pwr: lev.enemyPwr, x: (randR() & 63) + 10, y: 0)
|
||||
enemies[i + 1] = Enemy(type: 0, pwr: 1, x: 1, y: 0)
|
||||
}
|
||||
enemiesRemaining = 10
|
||||
case .superBoss:
|
||||
fillFormation(8)
|
||||
enemiesRemaining = 9
|
||||
bossStatus = 5; bossPwr = 98; bossX = 2; bossY = 0
|
||||
}
|
||||
events.append(.levelLoaded(level))
|
||||
}
|
||||
|
||||
// MARK: - Player weapons
|
||||
|
||||
private mutating func youShoot(_ input: Input) {
|
||||
guard yourStatus == 0, input.fire else { return }
|
||||
guard let slot = playerBullets.firstIndex(where: { !$0.active }) else { return }
|
||||
playerBullets[slot] = PlayerBullet(active: true, x: playerX + 2, y: playerY - 4)
|
||||
}
|
||||
|
||||
private mutating func youShootLaser(_ input: Input) {
|
||||
guard input.laser, lasers > 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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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..<Self.fieldWidth {
|
||||
var k = s[i - 1] + (rand() & 0x0F) - (rand() & 0x0F)
|
||||
if k > 32 { k = 32 }
|
||||
if k < 0 { k = 0 }
|
||||
s[i] = k
|
||||
}
|
||||
|
||||
// Erosion: repeated weighted smoothing passes.
|
||||
let passes = (rand() & 0x0F) + 2
|
||||
for _ in 0..<passes {
|
||||
for i in 1..<(Self.fieldWidth - 1) {
|
||||
s[i] = (s[i - 1] + s[i] + s[i + 1] + s[i]) >> 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..<min(start + Self.padWidth, Self.fieldWidth) { s[i] = h }
|
||||
|
||||
surface = s
|
||||
padStart = start
|
||||
padHeight = h
|
||||
|
||||
resetLander()
|
||||
}
|
||||
|
||||
/// Drops a fresh lander at the top of the field with full tanks, keeping the
|
||||
/// current terrain. Used to (re)start a descent on the previewed landscape.
|
||||
mutating func resetLander() {
|
||||
x = 32 + (rand() & 0x3F) // 32…95
|
||||
y = Self.fieldHeight - 8 // high, with headroom below the ceiling
|
||||
vx = 0
|
||||
vy = 0
|
||||
fuel = maxFuel
|
||||
outcome = .flying
|
||||
gravPhase = 0
|
||||
}
|
||||
|
||||
/// Applies a difficulty change from the ready screen without regenerating terrain.
|
||||
mutating func setDifficulty(maxFuel: Int, maxVel: Int, gravity: Gravity) {
|
||||
self.maxFuel = maxFuel
|
||||
self.maxVel = maxVel
|
||||
self.gravity = gravity
|
||||
self.fuel = maxFuel
|
||||
}
|
||||
|
||||
// MARK: - Simulation
|
||||
|
||||
/// Advances exactly one integer tick with the given thruster inputs and
|
||||
/// returns the resulting outcome. Rules are identical to the original loop.
|
||||
@discardableResult
|
||||
mutating func tick(left: Bool, up: Bool, right: Bool) -> 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
|
||||
}
|
||||
@@ -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"),
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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..<n {
|
||||
cx = (cx + spacing) & 0xFF
|
||||
install(cx, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Per-frame input
|
||||
|
||||
struct Input {
|
||||
var dx = 0 // -1, 0, +1 (thrust direction; sim applies ±2)
|
||||
var dy = 0 // -1, 0, +1 (±1 per frame)
|
||||
var fire = false
|
||||
var selectWeapon: Int? = nil // 0…4 tap on weapon selector
|
||||
}
|
||||
|
||||
// MARK: - Main loop (main16.asm order)
|
||||
|
||||
mutating func tick(_ input: Input) {
|
||||
guard !over, !finished else { return }
|
||||
events.removeAll(keepingCapacity: true)
|
||||
|
||||
frameInit()
|
||||
hitEnemies()
|
||||
|
||||
if enemiesLeft == 0 {
|
||||
loadLevel()
|
||||
if finished || over { return }
|
||||
}
|
||||
|
||||
doPlayer(input)
|
||||
doCompanion()
|
||||
doEnemies()
|
||||
movePlayerBullets()
|
||||
moveEnemyBullets()
|
||||
hitPlayer()
|
||||
}
|
||||
|
||||
private mutating func frameInit() {
|
||||
gameTimer &+= 1
|
||||
if gameTimer & 1 == 1, timeScore > 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..<Self.maxPlayerBullets {
|
||||
switch playerBullets[i].type {
|
||||
case 0: continue
|
||||
case 3, 6: // enhanced / standard: up 2
|
||||
bulletUp(i, dy: -2)
|
||||
case 9, 12: // double: up 4
|
||||
bulletUp(i, dy: -4)
|
||||
case 21: // triple center: up 5
|
||||
bulletUp(i, dy: -5)
|
||||
case 15: // triple, fired left: drifts left
|
||||
playerBullets[i].x -= 2
|
||||
if playerBullets[i].x <= 2 { playerBullets[i].type = 0; continue }
|
||||
bulletUp(i, dy: -4)
|
||||
case 18: // triple, fired right: drifts right
|
||||
playerBullets[i].x += 2
|
||||
if playerBullets[i].x >= 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..<Self.maxPlayerBullets where playerBullets[bi].type != 0 {
|
||||
let b = playerBullets[bi]
|
||||
for ei in 0..<Self.maxEnemies {
|
||||
let e = enemies[ei]
|
||||
guard e.pwr > 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..<Self.maxEnemies where enemies[i].pwr != 0 {
|
||||
enemyMove(i, swoopFlags: &swoopUsedThisFrame)
|
||||
enemyShoot(i)
|
||||
enemyAnimate(i)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Movement types ---
|
||||
|
||||
private mutating func enemyMove(_ i: Int, swoopFlags: inout Int) {
|
||||
switch enemies[i].moveType {
|
||||
case D.Move.standard: standardSwing(i)
|
||||
case D.Move.none: break
|
||||
case D.Move.boss: bossMove(i)
|
||||
case D.Move.bounce: bounceMove(i)
|
||||
case D.Move.patternStart: patternInit(i)
|
||||
case D.Move.patternWait: patternWait(i)
|
||||
case D.Move.patternMain: patternMove(i)
|
||||
case D.Move.rampage: rampageMove(i)
|
||||
case D.Move.rampageWait:
|
||||
if enemiesLeft >= 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..<Self.maxEnemyBullets {
|
||||
let type = enemyBullets[i].type
|
||||
switch type {
|
||||
case 0: continue
|
||||
case -5: // impact explosion effect
|
||||
if gameTimer & 1 == 1 {
|
||||
enemyBullets[i].data += 1
|
||||
if enemyBullets[i].data >= 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..<Self.maxEnemyBullets {
|
||||
let b = enemyBullets[i]
|
||||
guard b.type > 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
|
||||
}
|
||||
@@ -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..<count {
|
||||
let r = CGFloat.random(in: 0.6...1.8, using: &rng)
|
||||
let dot = SKShapeNode(circleOfRadius: r)
|
||||
dot.fillColor = UIColor(white: 1, alpha: CGFloat.random(in: 0.12...0.9, using: &rng))
|
||||
dot.strokeColor = .clear
|
||||
dot.position = CGPoint(x: CGFloat.random(in: 0...size.width, using: &rng),
|
||||
y: CGFloat.random(in: 0...size.height, using: &rng))
|
||||
if i % 6 == 0 {
|
||||
let twinkle = SKAction.sequence([
|
||||
.fadeAlpha(to: 0.15, duration: .random(in: 0.8...1.8, using: &rng)),
|
||||
.fadeAlpha(to: 0.9, duration: .random(in: 0.8...1.8, using: &rng)),
|
||||
])
|
||||
dot.run(.repeatForever(twinkle))
|
||||
}
|
||||
node.addChild(dot)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/// Renders an SF Symbol into a texture so scenes can show iconography.
|
||||
static func symbolTexture(_ name: String, pointSize: CGFloat, color: UIColor) -> 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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<UITouch>, 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<UITouch>, 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<UITouch>, with event: UIEvent?) {
|
||||
if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil }
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil }
|
||||
}
|
||||
}
|
||||
@@ -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..<count {
|
||||
if let slot = enemies.firstIndex(where: { $0.type == 0 }) {
|
||||
enemies[slot] = t
|
||||
}
|
||||
t.x += xStep
|
||||
t.y += yStep
|
||||
t.phase += phaseStep
|
||||
}
|
||||
}
|
||||
|
||||
switch wave {
|
||||
case 1: // simple wave
|
||||
install(count: 3, xStep: 0, yStep: 9, phaseStep: 0,
|
||||
Enemy(type: 4, x: 136, w: 8, y: 50, h: 8, dmg: 5, img: .enemy1))
|
||||
case 2: // phoenixes
|
||||
install(count: 3, xStep: 8, yStep: 15, phaseStep: 0,
|
||||
Enemy(type: 7, x: 135, w: 8, y: 50, h: 12, dmg: 10, img: .enemy2))
|
||||
case 3: // spinning sines
|
||||
install(count: 4, xStep: 10, yStep: 15, phaseStep: 10,
|
||||
Enemy(type: 10, x: 138, w: 8, y: 1, h: 8, dmg: 15, img: .spin1,
|
||||
counter: 40, phase: 138))
|
||||
case 4: // open space until the field is clear
|
||||
gameStatus = 1
|
||||
case 5: // base turret on the pad tile in this column
|
||||
for tileRow in stride(from: 7, through: 0, by: -1) where D.map[tileRow][terrainX] == 10 {
|
||||
let y = (tileRow + 1) * 8 + Self.minY - 11
|
||||
if let slot = enemies.firstIndex(where: { $0.type == 0 }) {
|
||||
enemies[slot] = Enemy(type: 13, x: 137, w: 8, y: y, h: 8, dmg: 45, img: .base)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 6: // partial spinning sines
|
||||
install(count: 2, xStep: 10, yStep: 15, phaseStep: 10,
|
||||
Enemy(type: 10, x: 138, w: 8, y: 1, h: 8, dmg: 15, img: .spin1,
|
||||
counter: 40, phase: 138))
|
||||
case 7: // boss + escort of spinning Ts
|
||||
install(count: 1, xStep: 0, yStep: 0, phaseStep: 0,
|
||||
Enemy(type: 19, x: 138, w: 40, y: 8, h: 16, dmg: 50, img: .boss, counter: -44))
|
||||
install(count: 3, xStep: 10, yStep: 15, phaseStep: 10,
|
||||
Enemy(type: 16, x: 138, w: 8, y: 1, h: 8, dmg: 18, img: .spint1,
|
||||
counter: 40, phase: 138))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player (player.asm)
|
||||
|
||||
private mutating func movePlayer(_ input: Input) {
|
||||
// Original key order: down, left, right, up — each edge-blocked.
|
||||
if input.dy > 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
|
||||
}
|
||||
@@ -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<UITouch>()
|
||||
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..<TetrisSim.visibleRows).map { row in
|
||||
(0..<TetrisSim.cols).map { col in
|
||||
let node = SKSpriteNode(texture: texture)
|
||||
node.size = CGSize(width: cellSize, height: cellSize)
|
||||
node.position = cellCenter(col: col, visibleRow: row)
|
||||
node.colorBlendFactor = 1
|
||||
node.zPosition = 1
|
||||
node.isHidden = true
|
||||
addChild(node)
|
||||
return node
|
||||
}
|
||||
}
|
||||
|
||||
func pieceLayer(count: Int, z: CGFloat, alpha: CGFloat) -> [SKSpriteNode] {
|
||||
(0..<count).map { _ in
|
||||
let node = SKSpriteNode(texture: texture)
|
||||
node.size = CGSize(width: cellSize, height: cellSize)
|
||||
node.colorBlendFactor = 1
|
||||
node.zPosition = z
|
||||
node.alpha = alpha
|
||||
node.isHidden = true
|
||||
addChild(node)
|
||||
return node
|
||||
}
|
||||
}
|
||||
ghostSprites = pieceLayer(count: 4, z: 2, alpha: 0.22)
|
||||
pieceSprites = pieceLayer(count: 4, z: 3, alpha: 1)
|
||||
}
|
||||
|
||||
/// The original's level pattern, tiled up the rails on both sides of the well.
|
||||
private func buildPatternStrips() {
|
||||
patternStrips.forEach { $0.removeFromParent() }
|
||||
patternStrips = []
|
||||
let stripW = boardFrameRect.minX - 10
|
||||
guard stripW > 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..<TetrisSim.visibleRows {
|
||||
for col in 0..<TetrisSim.cols {
|
||||
let value = sim.cell(col: col, y: row + TetrisSim.bottomRowY)
|
||||
let node = cellSprites[row][col]
|
||||
if value == 0 {
|
||||
node.isHidden = true
|
||||
} else {
|
||||
node.isHidden = false
|
||||
node.color = Self.pieceColors[min(value - 1, 7)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPiece() {
|
||||
let playing = state == .playing || state == .paused
|
||||
guard playing, !sim.over else {
|
||||
pieceSprites.forEach { $0.isHidden = true }
|
||||
ghostSprites.forEach { $0.isHidden = true }
|
||||
return
|
||||
}
|
||||
let color = Self.pieceColors[sim.current.kind]
|
||||
let ghost = TetrisSim.absBlocks(sim.ghostPiece)
|
||||
for (i, b) in sim.currentBlocks.enumerated() {
|
||||
let node = pieceSprites[i]
|
||||
let visible = b.y <= TetrisSim.topVisibleY
|
||||
node.isHidden = !visible
|
||||
if visible {
|
||||
node.color = color
|
||||
node.position = centerFor(absX: b.x, absY: b.y)
|
||||
}
|
||||
}
|
||||
for (i, b) in ghost.enumerated() {
|
||||
let node = ghostSprites[i]
|
||||
let visible = b.y <= TetrisSim.topVisibleY
|
||||
node.isHidden = !visible
|
||||
if visible {
|
||||
node.color = color
|
||||
node.position = centerFor(absX: b.x, absY: b.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshNext() {
|
||||
let blocks = TetrisSim.blocks(kind: sim.nextKind, rot: 0)
|
||||
let minX = blocks.map(\.dx).min() ?? 0
|
||||
let maxX = blocks.map(\.dx).max() ?? 0
|
||||
let minY = blocks.map(\.dy).min() ?? 0
|
||||
let maxY = blocks.map(\.dy).max() ?? 0
|
||||
let mini: CGFloat = 13
|
||||
let boxCenter = CGPoint(x: size.width - 44, y: size.height - topInset - 34)
|
||||
let w = CGFloat(maxX - minX + 1) * mini
|
||||
let h = CGFloat(maxY - minY + 1) * mini
|
||||
for (i, b) in blocks.enumerated() {
|
||||
let node = nextSprites[i]
|
||||
node.color = Self.pieceColors[sim.nextKind]
|
||||
node.position = CGPoint(
|
||||
x: boxCenter.x - w / 2 + (CGFloat(b.dx - minX) + 0.5) * mini,
|
||||
y: boxCenter.y - h / 2 + (CGFloat(b.dy - minY) + 0.5) * mini)
|
||||
node.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
private func updateHUD() {
|
||||
scoreValue.text = "\(sim.score)"
|
||||
levelValue.text = "\(sim.level)"
|
||||
linesValue.text = "\(sim.lines)"
|
||||
}
|
||||
|
||||
// MARK: - Game loop
|
||||
|
||||
override func update(_ currentTime: TimeInterval) {
|
||||
if lastUpdate == 0 {
|
||||
lastUpdate = currentTime
|
||||
gravityNext = currentTime + sim.gravityInterval
|
||||
}
|
||||
lastUpdate = currentTime
|
||||
guard state == .playing, !sim.over else { return }
|
||||
#if DEBUG
|
||||
if demoFreeze { return }
|
||||
#endif
|
||||
|
||||
// Held-move auto repeat (DAS)
|
||||
if dasDirection != 0, currentTime >= 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<UITouch>, 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<UITouch>, with event: UIEvent?) {
|
||||
recomputeControls()
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
touches.forEach { activeTouches.remove($0) }
|
||||
recomputeControls()
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, 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
|
||||
}
|
||||
@@ -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.gridHeight {
|
||||
for x in 0..<Self.gridWidth where x < Self.playfieldMinX || x > Self.playfieldMaxX {
|
||||
g[y][x] = Self.trashCell + 1 // wall sentinel (never drawn)
|
||||
}
|
||||
}
|
||||
for x in 0..<Self.gridWidth { g[0][x] = Self.trashCell + 1; g[1][x] = Self.trashCell + 1 }
|
||||
grid = g
|
||||
|
||||
current = Piece(kind: 0, rot: 0, x: Self.spawnX, y: Self.spawnY)
|
||||
nextKind = 0
|
||||
current.kind = Int(rng.next() % 7)
|
||||
nextKind = Int(rng.next() % 7)
|
||||
|
||||
// "Start high": high×2 trash rows, always scrambled (AddLines path).
|
||||
if clampedHigh > 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..<count {
|
||||
let y = Self.bottomRowY + i
|
||||
for x in Self.playfieldMinX...Self.playfieldMaxX { grid[y][x] = Self.trashCell }
|
||||
for _ in 0..<5 {
|
||||
let gap = Int(rng.next() % 10)
|
||||
grid[y][Self.playfieldMinX + gap] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Piece geometry
|
||||
|
||||
/// Decodes one rotation word into 4 (dx,dy) offsets — the `Uncrunch` logic.
|
||||
static func blocks(kind: Int, rot: Int) -> [(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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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=<id>` — 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`
|
||||
Reference in New Issue
Block a user