Initial commit: Arcade85 (5-in-1 TI-85 HD remakes) + AGENTS.md runbook

This commit is contained in:
2026-09-16 21:52:32 -04:00
commit b7b209b70c
33 changed files with 11568 additions and 0 deletions
+22
View File
@@ -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
+149
View File
@@ -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.
+321
View File
@@ -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>
+12
View File
@@ -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
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+69
View File
@@ -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
+628
View File
@@ -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
}
+229
View File
@@ -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),
]
}
+679
View File
@@ -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 }
}
}
+628
View File
@@ -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
+214
View File
@@ -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
}
+390
View File
@@ -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
+975
View File
@@ -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
}
+295
View File
@@ -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)
}
}
+142
View File
@@ -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,
]
}
+813
View File
@@ -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 }
}
}
+451
View File
@@ -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
}
+898
View File
@@ -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
}
+327
View File
@@ -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)
}
}
+63
View File
@@ -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
}
}
+47
View File
@@ -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)
}
}
+15
View File
@@ -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() }
}
+43
View File
@@ -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)
}
}
+200
View File
@@ -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)
}
}
+35
View File
@@ -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)
}
+416
View File
@@ -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`