Initial commit: Arcade85 (5-in-1 TI-85 HD remakes) + AGENTS.md runbook
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Xcode / build
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
|
xcuserdata/
|
||||||
|
*.moved-aside
|
||||||
|
*.hmap
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
|
# Secrets / signing — never commit
|
||||||
|
*.p8
|
||||||
|
*.env
|
||||||
|
.env
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Arcade85
|
||||||
|
|
||||||
|
A 5-in-1 iOS collection of faithful **HD remakes** of five classic TI-85 calculator
|
||||||
|
games. Native **SwiftUI + SpriteKit**, one shared shell, each game a
|
||||||
|
`Scene` (SpriteKit) + `Sim` (pure Swift engine) pair.
|
||||||
|
|
||||||
|
This file is the operational runbook for an agent building/deploying Arcade85 on the
|
||||||
|
Mac Studio **without the human in the loop**. Read it top to bottom before touching
|
||||||
|
signing, git, or the build. It follows the same pattern as `~/dev/KyleCode/AGENTS.md`
|
||||||
|
(same Gitea, same `~/dev/.env` token, same signing identity).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Repo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
Arcade85/
|
||||||
|
Arcade85.xcodeproj/ # hand-maintained (NOT xcodegen) — edit via Xcode/pbxproj
|
||||||
|
Arcade85/
|
||||||
|
Arcade85App.swift # @main
|
||||||
|
Shell/ # app chrome: RootView, GameHostView, GameCatalog,
|
||||||
|
# Theme, Haptics, HighScoreStore
|
||||||
|
Games/
|
||||||
|
SceneArt.swift # shared procedural texture/sprite helpers
|
||||||
|
DopeWars/ DopeWarsScene.swift + DopeWarsSim.swift
|
||||||
|
Lander/ LanderScene.swift + LanderSim.swift
|
||||||
|
Phoenix/ PhoenixScene.swift + PhoenixSim.swift + PhoenixData.swift
|
||||||
|
ZTetris/ TetrisScene.swift + TetrisSim.swift
|
||||||
|
ZMercury/ MercuryScene.swift + MercurySim.swift + MercuryData.swift
|
||||||
|
Galaxian/ (ported, dormant — see GameCatalog note)
|
||||||
|
ComingSoonScene.swift
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Convention:** every game is a `*Scene.swift` (SpriteKit, all UI/input) paired with a
|
||||||
|
`*Sim.swift` (pure Swift state machine — no UIKit/SpriteKit imports). The Sim is the
|
||||||
|
engine and is where mechanics live; the Scene only renders and routes touches. Keep
|
||||||
|
them separate. Dope Wars' `DopeWarsSim` is the reference for a well-factored Sim
|
||||||
|
(seeded RNG, a separate `extRng` stream for non-classic extensions, `#if DEBUG` debug
|
||||||
|
hooks, a `messages: [String]` queue the Scene drains one-per-tap).
|
||||||
|
|
||||||
|
The original TI-85 sources this was remade from live in sibling folders under
|
||||||
|
`~/dev/TI/` (`lander/`, `zetris/`, `phx4.0ti85/`, `zmercury-ti85/`, `galaxian-ti85/`).
|
||||||
|
They are **not** part of this repo.
|
||||||
|
|
||||||
|
## 2. Signing
|
||||||
|
|
||||||
|
- **Team:** `TG8X9PP8VT` (Kyle Albanese). **Identity:** "Apple Development: Kyle
|
||||||
|
Albanese (5SRT78F8BG)". **Bundle ID:** `com.albanese.arcade85`.
|
||||||
|
- **Automatic signing** (`CODE_SIGN_STYLE = Automatic`), `GENERATE_INFOPLIST_FILE = YES`
|
||||||
|
(no Info.plist file exists — don't add one).
|
||||||
|
- The current pbxproj does **not** pin `DEVELOPMENT_TEAM`; the identity in the login
|
||||||
|
keychain is used. If a device build ever fails to sign, set
|
||||||
|
`DEVELOPMENT_TEAM = TG8X9PP8VT` on the target (don't switch to manual profiles).
|
||||||
|
|
||||||
|
### Environment prerequisites (why this works)
|
||||||
|
|
||||||
|
Commands run as the user's **own launchd job in `gui/501` (Aqua session)**, so the
|
||||||
|
login keychain is readable by `xcodebuild`/`codesign`. Verify before a device build
|
||||||
|
(do NOT change anything if it fails — stop and tell the user):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
launchctl managername # must print: Aqua
|
||||||
|
security find-identity -v -p codesigning /Users/fung/Library/Keychains/login.keychain-db
|
||||||
|
# must list: "Apple Development: Kyle Albanese (5SRT78F8BG)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hard rules:** never revoke/recreate the Apple Development cert, don't touch keychain
|
||||||
|
settings, keep automatic signing, never commit `*.p8` or `*.env` secrets.
|
||||||
|
|
||||||
|
## 3. Build
|
||||||
|
|
||||||
|
Run from `/Users/fung/dev/TI/Arcade85`.
|
||||||
|
|
||||||
|
### Simulator (no signing — default for quick checks)
|
||||||
|
```bash
|
||||||
|
xcodebuild -project Arcade85.xcodeproj -scheme Arcade85 \
|
||||||
|
-destination 'generic/platform=iOS Simulator' -configuration Debug \
|
||||||
|
-derivedDataPath build/dd build CODE_SIGNING_ALLOWED=NO
|
||||||
|
```
|
||||||
|
Success: `** BUILD SUCCEEDED **`. (Verified 2026-09-16.)
|
||||||
|
|
||||||
|
### Device (real iPhone, auto-signed)
|
||||||
|
```bash
|
||||||
|
xcodebuild -project Arcade85.xcodeproj -scheme Arcade85 \
|
||||||
|
-destination 'generic/platform=iOS' -derivedDataPath build/dd \
|
||||||
|
-allowProvisioningUpdates build
|
||||||
|
```
|
||||||
|
Install/launch a paired device (list with `xcrun devicectl list devices`):
|
||||||
|
```bash
|
||||||
|
APP=build/dd/Build/Products/Debug-iphoneos/Arcade85.app
|
||||||
|
xcrun devicectl device install app --device <UDID> "$APP"
|
||||||
|
xcrun devicectl device process launch --device <UDID> com.albanese.arcade85
|
||||||
|
```
|
||||||
|
|
||||||
|
**There are no automated tests in this project.** A passing build is the check. For
|
||||||
|
gameplay, run in the simulator and play, or use the Dope Wars debug env vars below.
|
||||||
|
|
||||||
|
### Dope Wars debug env vars (DEBUG builds only)
|
||||||
|
`DopeWarsScene.didMove(to:)` reads `ProcessInfo` env vars for screenshot/demo poses:
|
||||||
|
`ARCADE_AUTOPLAY=1` (auto-start a run), `ARCADE_DEMO=1` (deterministic day-12 demo),
|
||||||
|
`ARCADE_DEMO_VIEW=buy|chase|tier3|over|ride|keypad|sharkchase|endless`. The
|
||||||
|
`DopeWarsSim` also exposes `debug*` mutators (wallet, debt, price, tier, held, forced
|
||||||
|
RNG rolls) gated behind `#if DEBUG`.
|
||||||
|
|
||||||
|
## 4. Git & Gitea
|
||||||
|
|
||||||
|
Remote: Gitea `https://git.kwalb.com/kyle/Arcade85` (branch `main`). No git
|
||||||
|
credentials in the keychain — use the API token from `~/dev/.env` inline, and let the
|
||||||
|
credential prompt fail to store (harmless). **The repo is created on first push:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(sed -n 's/^GITEA_API=//p' ~/dev/.env)
|
||||||
|
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Arcade85","private":false,"default_branch":"main"}' \
|
||||||
|
https://git.kwalb.com/api/v1/user/repos
|
||||||
|
# then:
|
||||||
|
git push "https://kyle:$TOKEN@git.kwalb.com/kyle/Arcade85.git" main
|
||||||
|
```
|
||||||
|
|
||||||
|
API: `curl -H "Authorization: token $TOKEN" https://git.kwalb.com/api/v1/repos/kyle/Arcade85/…`
|
||||||
|
|
||||||
|
**Only commit/push when asked.** Stage only intended files. Never stage `*.p8` or
|
||||||
|
`*.env`.
|
||||||
|
|
||||||
|
### `.env` — do not `source` it
|
||||||
|
`~/dev/.env` holds `GITEA_URL`, `GITEA_API` (token), `GITEA_USERNAME`, `GITEA_EMAIL`
|
||||||
|
plus other service keys. **Do not `source` it** — a later line is a bare
|
||||||
|
`https://claude.ai/magic-link…` URL (not `key=value`) which breaks `source` in zsh.
|
||||||
|
Read values with sed:
|
||||||
|
```bash
|
||||||
|
TOKEN=$(sed -n 's/^GITEA_API=//p' ~/dev/.env)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. `.gitignore` (already in place — don't remove)
|
||||||
|
`build/`, `DerivedData/`, `*.xcuserstate`, `xcuserdata/`, `*.moved-aside`, `*.hmap`,
|
||||||
|
`*.ipa`, `*.dSYM.zip`, `*.dSYM`, `.DS_Store`, `.build/`, `.swiftpm/`.
|
||||||
|
|
||||||
|
Add `*.p8` and `*.env` to this ignore list if/when they could appear here (none do
|
||||||
|
today, but the rule is: never commit signing keys or secret files).
|
||||||
|
|
||||||
|
## 6. Adding a game
|
||||||
|
|
||||||
|
1. Create `Games/<Name>/<Name>Scene.swift` + `<Name>Sim.swift` (mirror Dope Wars).
|
||||||
|
2. Add both files to the Xcode target in `Arcade85.xcodeproj/project.pbxproj`
|
||||||
|
(hand-maintained — there is **no** `project.yml`/xcodegen step; edit the pbxproj or
|
||||||
|
use Xcode's "Add Files to Arcade85").
|
||||||
|
3. Register the slot in `Shell/GameCatalog.swift`.
|
||||||
|
4. Build (simulator) to verify.
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
A00000000000000000000005 /* Arcade85.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Arcade85.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
A0000000000000000000000F /* Arcade85 */ = {isa = PBXFileSystemSynchronizedRootGroup; path = Arcade85; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
A00000000000000000000007 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
A00000000000000000000002 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A0000000000000000000000F /* Arcade85 */,
|
||||||
|
A00000000000000000000003 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A00000000000000000000003 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A00000000000000000000005 /* Arcade85.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
A00000000000000000000004 /* Arcade85 */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = A0000000000000000000000A /* Build configuration list for PBXNativeTarget "Arcade85" */;
|
||||||
|
buildPhases = (
|
||||||
|
A00000000000000000000006 /* Sources */,
|
||||||
|
A00000000000000000000007 /* Frameworks */,
|
||||||
|
A00000000000000000000008 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
A0000000000000000000000F /* Arcade85 */,
|
||||||
|
);
|
||||||
|
name = Arcade85;
|
||||||
|
productName = Arcade85;
|
||||||
|
productReference = A00000000000000000000005 /* Arcade85.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
A00000000000000000000001 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2660;
|
||||||
|
LastUpgradeCheck = 2660;
|
||||||
|
TargetAttributes = {
|
||||||
|
A00000000000000000000004 = {
|
||||||
|
CreatedOnToolsVersion = 26.6;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = A00000000000000000000009 /* Build configuration list for PBXProject "Arcade85" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = A00000000000000000000002;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = A00000000000000000000003 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
A00000000000000000000004 /* Arcade85 */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
A00000000000000000000008 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
A00000000000000000000006 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
A0000000000000000000000B /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
A0000000000000000000000C /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
A0000000000000000000000D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Arcade 85";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIRequiresFullScreen = YES;
|
||||||
|
INFOPLIST_KEY_UIStatusBarHidden = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.albanese.arcade85;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
A0000000000000000000000E /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Arcade 85";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIRequiresFullScreen = YES;
|
||||||
|
INFOPLIST_KEY_UIStatusBarHidden = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.albanese.arcade85;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
A00000000000000000000009 /* Build configuration list for PBXProject "Arcade85" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
A0000000000000000000000B /* Debug */,
|
||||||
|
A0000000000000000000000C /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
A0000000000000000000000A /* Build configuration list for PBXNativeTarget "Arcade85" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
A0000000000000000000000D /* Debug */,
|
||||||
|
A0000000000000000000000E /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = A00000000000000000000001 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2660"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A00000000000000000000004"
|
||||||
|
BuildableName = "Arcade85.app"
|
||||||
|
BlueprintName = "Arcade85"
|
||||||
|
ReferencedContainer = "container:Arcade85.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A00000000000000000000004"
|
||||||
|
BuildableName = "Arcade85.app"
|
||||||
|
BlueprintName = "Arcade85"
|
||||||
|
ReferencedContainer = "container:Arcade85.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A00000000000000000000004"
|
||||||
|
BuildableName = "Arcade85.app"
|
||||||
|
BlueprintName = "Arcade85"
|
||||||
|
ReferencedContainer = "container:Arcade85.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct Arcade85App: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView()
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
.statusBarHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.706",
|
||||||
|
"green" : "0.937",
|
||||||
|
"red" : "0.361"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 709 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import SpriteKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Placeholder scene shown for the four titles that aren't built yet. Keeps the
|
||||||
|
/// shell fully navigable and previews each game's identity.
|
||||||
|
final class ComingSoonScene: SKScene {
|
||||||
|
private let info: GameInfo
|
||||||
|
|
||||||
|
init(size: CGSize, info: GameInfo) {
|
||||||
|
self.info = info
|
||||||
|
super.init(size: size)
|
||||||
|
self.scaleMode = .resizeFill
|
||||||
|
self.anchorPoint = CGPoint(x: 0, y: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been used") }
|
||||||
|
|
||||||
|
override func didMove(to view: SKView) {
|
||||||
|
backgroundColor = Palette.space1
|
||||||
|
removeAllChildren()
|
||||||
|
|
||||||
|
let bg = SKSpriteNode(texture: SceneArt.verticalGradient(size: size, top: Palette.space0, bottom: Palette.space1))
|
||||||
|
bg.anchorPoint = CGPoint(x: 0, y: 0)
|
||||||
|
bg.position = .zero
|
||||||
|
bg.zPosition = -10
|
||||||
|
addChild(bg)
|
||||||
|
|
||||||
|
addChild(SceneArt.starfield(size: size, count: 70, seed: UInt64(abs(info.id.hashValue) & 0xFFFFFF)))
|
||||||
|
|
||||||
|
let tint = UIColor(info.tint)
|
||||||
|
let center = CGPoint(x: size.width / 2, y: size.height * 0.58)
|
||||||
|
|
||||||
|
if let tex = SceneArt.symbolTexture(info.symbol, pointSize: 120, color: tint) {
|
||||||
|
let icon = SKSpriteNode(texture: tex)
|
||||||
|
icon.position = center
|
||||||
|
icon.setScale(0.6)
|
||||||
|
addChild(icon)
|
||||||
|
let pulse = SKAction.sequence([
|
||||||
|
.group([.scale(to: 0.66, duration: 1.4), .fadeAlpha(to: 1.0, duration: 1.4)]),
|
||||||
|
.group([.scale(to: 0.60, duration: 1.4), .fadeAlpha(to: 0.7, duration: 1.4)]),
|
||||||
|
])
|
||||||
|
icon.run(.repeatForever(pulse))
|
||||||
|
}
|
||||||
|
|
||||||
|
addLabel(info.title, font: "AvenirNext-Heavy", size: 34, color: .white, y: center.y - 120)
|
||||||
|
addLabel("HD REMAKE · COMING SOON", font: "Menlo-Bold", size: 13, color: tint, y: center.y - 156)
|
||||||
|
addLabel(info.tagline, font: "AvenirNext-Medium", size: 15, color: Palette.white70,
|
||||||
|
y: center.y - 196, maxWidth: size.width - 80)
|
||||||
|
addLabel("Original: \(info.origin)", font: "Menlo", size: 12, color: Palette.white40, y: size.height * 0.16)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func addLabel(_ text: String, font: String, size fontSize: CGFloat, color: UIColor,
|
||||||
|
y: CGFloat, maxWidth: CGFloat? = nil) -> SKLabelNode {
|
||||||
|
let label = SKLabelNode(fontNamed: font)
|
||||||
|
label.text = text
|
||||||
|
label.fontSize = fontSize
|
||||||
|
label.fontColor = color
|
||||||
|
label.horizontalAlignmentMode = .center
|
||||||
|
label.verticalAlignmentMode = .center
|
||||||
|
label.position = CGPoint(x: size.width / 2, y: y)
|
||||||
|
if let maxWidth {
|
||||||
|
label.preferredMaxLayoutWidth = maxWidth
|
||||||
|
label.numberOfLines = 0
|
||||||
|
}
|
||||||
|
addChild(label)
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,628 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The classic "Drug Wars" (John E. Dell, 1984) — one month on the New York
|
||||||
|
/// streets: $2,000 in cash, $5,500 owed to the loan shark, a 100-pocket trench
|
||||||
|
/// coat, and the subway between six boroughs. Buy low, sell high, and settle up.
|
||||||
|
///
|
||||||
|
/// The engine is the verbatim TI-85 port of Joey Gannon's NerdWars (2000),
|
||||||
|
/// which turned out to be Dell's game with the serial numbers filed off — its
|
||||||
|
/// BASIC kept Dell's item variables C,H,A,W,S,L (Cocaine, Heroin, Acid, Weed,
|
||||||
|
/// Speed, Ludes) and merely rescaled the price bands. This restores the classic
|
||||||
|
/// setup: Dell's six drugs at their iconic prices, the Bronx as home base
|
||||||
|
/// (loan shark + bank), Officer Hardass on your tail, $400 guns and $200
|
||||||
|
/// trench coats. Interest (debt 10%/day, bank 6%/day), the 0…20 daily event
|
||||||
|
/// roll, chase/punch mechanics, and √(net ÷ 31.5) scoring are untouched.
|
||||||
|
///
|
||||||
|
/// Quirks kept from the source engine:
|
||||||
|
/// - The dead-dude giveaway rolls int(rand*5) = 0…4 over the cheap-to-dear
|
||||||
|
/// item order, so you can never find free cocaine.
|
||||||
|
/// - Smoking the weed you find on the sidewalk (event 14) is always fatal.
|
||||||
|
/// - Day-31 arrival ends the month with no final price roll.
|
||||||
|
/// Quirk dropped with the re-theme: NerdWars matched trade items BY PRICE
|
||||||
|
/// (`If F==C …`), a bug that could fill or empty several pockets at once.
|
||||||
|
/// Trading here is by item, the way Dell's original worked.
|
||||||
|
///
|
||||||
|
/// Remake extension — the market grows with you. Below $100k net worth the
|
||||||
|
/// game is exactly the classic. Cross $100k and word gets around: the ludes
|
||||||
|
/// slot retires (any stock is bought out at yesterday's price) and METH moves
|
||||||
|
/// in at $2,000–16,000 — the widest swing on the board. Cross $1M and the
|
||||||
|
/// speed slot retires for CHINA WHITE at $40,000–160,000, the whale product
|
||||||
|
/// that dwarfs even a cocaine spike. Tier shifts are one-way, checked on
|
||||||
|
/// arrival, and bring their own events (meth lab bust/superlab glut, cartel
|
||||||
|
/// dock flood) on formerly idle rolls of the 0…20 event table.
|
||||||
|
///
|
||||||
|
/// Remake extensions, round 5 — the streets get real:
|
||||||
|
/// - **Borough economies**: each borough multiplies the daily band rolls
|
||||||
|
/// (the Bronx is the neutral reference; event prices are absolute news and
|
||||||
|
/// ignore the bias). Heroin runs cheap in the Ghetto, acid and weed in the
|
||||||
|
/// Park, everything's sleepy-cheap on Coney, Manhattan's buyers pay up,
|
||||||
|
/// Brooklyn's fences pay a little over.
|
||||||
|
/// - **Word on the street**: some days a tip names a product and a borough
|
||||||
|
/// for tomorrow — ride there and find out if it was good (×3 spike, ÷3
|
||||||
|
/// glut) or street garbage (~1 in 4). Rumors and shark muscle roll on a
|
||||||
|
/// SEPARATE rng stream, so classic price/event sequences per seed are
|
||||||
|
/// untouched.
|
||||||
|
/// - **The shark collects**: run a tab of $15,000+ past day 10 and you get
|
||||||
|
/// one warning; after that his leg-breakers find you — the classic chase
|
||||||
|
/// engine with the shark's collection money as the prize and a nastier
|
||||||
|
/// ending if they put you down.
|
||||||
|
/// - **Month lengths**: 30/60/90 days, or endless (no clock, no leaderboard;
|
||||||
|
/// debt and bank interest cap at $999,999,999,999 so long runs stay sane).
|
||||||
|
struct DopeWarsSim {
|
||||||
|
|
||||||
|
// MARK: Items & locations
|
||||||
|
|
||||||
|
/// Dell's C,H,A,W,S,L — priciest to cheapest.
|
||||||
|
enum Item: Int, CaseIterable {
|
||||||
|
case cocaine = 0, heroin, acid, weed, speed, ludes
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
switch self {
|
||||||
|
case .cocaine: return "Cocaine"
|
||||||
|
case .heroin: return "Heroin"
|
||||||
|
case .acid: return "Acid"
|
||||||
|
case .weed: return "Weed"
|
||||||
|
case .speed: return "Speed"
|
||||||
|
case .ludes: return "Ludes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Lowercase form used by the dead-dude loot message.
|
||||||
|
var lootName: String { name.lowercased() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subway map. Mechanically the original only distinguishes the Bronx
|
||||||
|
/// (loan shark + bank) from everywhere else, but the boroughs are the game.
|
||||||
|
enum Location: Int, CaseIterable {
|
||||||
|
case bronx = 0, ghetto, park, manhattan, coney, brooklyn
|
||||||
|
var name: String {
|
||||||
|
["Bronx", "Ghetto", "Central Park", "Manhattan", "Coney Island", "Brooklyn"][rawValue]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Interaction model
|
||||||
|
|
||||||
|
enum Offer: Equatable {
|
||||||
|
case coat // +10 pockets for $200
|
||||||
|
case gun(String) // $400, takes 5 pockets
|
||||||
|
case weed // smoking it is fatal
|
||||||
|
case doctor // $1000 clears all wounds
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EndCause: Equatable { case monthOver, quit, gunnedDown, badTrip, sharkCollected }
|
||||||
|
|
||||||
|
enum Phase: Equatable { case trading, offer(Offer), chase, gameOver }
|
||||||
|
|
||||||
|
/// A next-day tip: a product, a borough, and whether the street was right.
|
||||||
|
struct Rumor: Equatable {
|
||||||
|
let slot: Int
|
||||||
|
let location: Location
|
||||||
|
let dayDue: Int
|
||||||
|
let isSpike: Bool
|
||||||
|
let isTrue: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Percent price multipliers per borough (rows follow `Location` order),
|
||||||
|
/// applied to the daily band rolls. The Bronx is the neutral reference
|
||||||
|
/// market; event prices are absolute news and ignore the bias.
|
||||||
|
static let boroughBias: [[Int]] = [
|
||||||
|
[100, 100, 100, 100, 100, 100], // Bronx — home turf
|
||||||
|
[ 95, 75, 90, 100, 85, 90], // Ghetto — heroin & speed country
|
||||||
|
[105, 110, 75, 80, 100, 95], // Central Park — acid & weed run cheap
|
||||||
|
[130, 115, 110, 115, 110, 110], // Manhattan — rich buyers pay up
|
||||||
|
[ 90, 95, 95, 85, 90, 80], // Coney Island — sleepy boardwalk prices
|
||||||
|
[110, 105, 105, 105, 105, 105], // Brooklyn — fences pay over market
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: State (original BASIC variable in comments)
|
||||||
|
|
||||||
|
private(set) var wallet = 2000 // Z
|
||||||
|
private(set) var debt = 5500 // Y — the classic's $5,500
|
||||||
|
private(set) var bank = 0 // V
|
||||||
|
private(set) var day = 1 // B (reaching 31 ends the game)
|
||||||
|
private(set) var coatTotal = 100 // T
|
||||||
|
private(set) var coatFree = 100 // K = T − ΣMNOPQR
|
||||||
|
private(set) var guns = 0 // I
|
||||||
|
private(set) var wounds = 0 // J (50 = death)
|
||||||
|
private(set) var location: Location = .bronx // home flag: 2 home / 1 away
|
||||||
|
private(set) var price = [Int](repeating: 0, count: 6) // C H A W S L
|
||||||
|
private(set) var held = [Int](repeating: 0, count: 6) // M N O P Q R
|
||||||
|
private(set) var chaseCops = 0 // D deputies; −1 ⇔ all down
|
||||||
|
private(set) var phase: Phase = .trading
|
||||||
|
/// Market tier — 1 classic, 2 (net ≥ $100k) meth replaces ludes,
|
||||||
|
/// 3 (net ≥ $1M) China White replaces speed. One-way per run.
|
||||||
|
private(set) var tier = 1
|
||||||
|
/// Round-5 extension state.
|
||||||
|
private(set) var rumor: Rumor?
|
||||||
|
private(set) var sharkChase = false // current chase is leg-breakers
|
||||||
|
private var sharkWarned = false
|
||||||
|
private var lastSharkDay = 0
|
||||||
|
/// Days in the month; `Int.max` = endless (never ends on its own).
|
||||||
|
let monthLen: Int
|
||||||
|
/// Interstitial texts, shown one per tap like the original's Pause screens.
|
||||||
|
private(set) var messages: [String] = []
|
||||||
|
private(set) var finalScore = 0
|
||||||
|
private(set) var finalNet = 0
|
||||||
|
private(set) var endCause: EndCause?
|
||||||
|
|
||||||
|
var inBronx: Bool { location == .bronx }
|
||||||
|
var totalHeld: Int { held.reduce(0, +) }
|
||||||
|
var netWorth: Int { bank + wallet - debt }
|
||||||
|
|
||||||
|
/// What a market slot is called at the current tier: the ludes slot deals
|
||||||
|
/// meth from tier 2, the speed slot deals China White from tier 3.
|
||||||
|
func displayName(_ item: Item) -> String {
|
||||||
|
switch item {
|
||||||
|
case .ludes where tier >= 2: return "Meth"
|
||||||
|
case .speed where tier >= 3: return "China White"
|
||||||
|
default: return item.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func lootName(_ item: Item) -> String { displayName(item).lowercased() }
|
||||||
|
|
||||||
|
var offerPrompt: String? {
|
||||||
|
guard case .offer(let offer) = phase else { return nil }
|
||||||
|
switch offer {
|
||||||
|
case .coat: return "Will you buy a new trench coat with more pockets for $200?"
|
||||||
|
case .gun(let name): return "Will you buy \(name)?"
|
||||||
|
case .weed: return "There's some weed on the sidewalk that smells like good stuff. Will you smoke it?"
|
||||||
|
case .doctor: return "Will you pay $1000 for a doctor to sew you up?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rng: SeededGenerator
|
||||||
|
/// Separate stream for the round-5 extras (rumors, shark muscle) so the
|
||||||
|
/// classic price/event sequence per seed stays byte-identical.
|
||||||
|
private var extRng: SeededGenerator
|
||||||
|
|
||||||
|
init(seed: UInt64? = nil, monthLen: Int = 30) {
|
||||||
|
let s = seed ?? UInt64.random(in: 1...UInt64.max)
|
||||||
|
rng = SeededGenerator(seed: s)
|
||||||
|
extRng = SeededGenerator(seed: s ^ 0x5EED_0FF5)
|
||||||
|
self.monthLen = monthLen <= 0 ? Int.max : monthLen
|
||||||
|
rollDay() // G8 runs before the first menu
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TI-85 `rand`: uniform in [0, 1).
|
||||||
|
private mutating func rand() -> Double {
|
||||||
|
#if DEBUG
|
||||||
|
if !debugForcedRolls.isEmpty { return debugForcedRolls.removeFirst() }
|
||||||
|
#endif
|
||||||
|
return Double(rng.next() >> 11) * 0x1.0p-53
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension-stream uniform in [0, 1) — see `extRng`.
|
||||||
|
private mutating func extRand() -> Double {
|
||||||
|
#if DEBUG
|
||||||
|
if !debugExtForcedRolls.isEmpty { return debugExtForcedRolls.removeFirst() }
|
||||||
|
#endif
|
||||||
|
return Double(extRng.next() >> 11) * 0x1.0p-53
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day roll (Lbl G8)
|
||||||
|
|
||||||
|
/// Fresh prices at the tier's bands, then the day's 0…20 event roll
|
||||||
|
/// (most values do nothing). Roll shapes mirror the BASIC's ×100/×10
|
||||||
|
/// quantisation for the cheaper drugs. One rand() per slot at every tier,
|
||||||
|
/// so classic (tier 1) runs stay roll-for-roll identical to the original.
|
||||||
|
private mutating func rollDay() {
|
||||||
|
checkMarketShift()
|
||||||
|
price[Item.cocaine.rawValue] = Int((rand() * 15000 + 15000).rounded()) // 15000…30000
|
||||||
|
price[Item.heroin.rawValue] = Int((rand() * 9000 + 5000).rounded()) // 5000…14000
|
||||||
|
price[Item.acid.rawValue] = Int((rand() * 35 + 10).rounded()) * 100 // 1000…4500
|
||||||
|
price[Item.weed.rawValue] = Int((rand() * 60 + 30).rounded()) * 10 // 300…900
|
||||||
|
price[Item.speed.rawValue] = tier >= 3
|
||||||
|
? Int((rand() * 1200 + 400).rounded()) * 100 // CW: 40000…160000
|
||||||
|
: Int((rand() * 16 + 9).rounded()) * 10 // 90…250
|
||||||
|
price[Item.ludes.rawValue] = tier >= 2
|
||||||
|
? Int((rand() * 140 + 20).rounded()) * 100 // meth: 2000…16000
|
||||||
|
: Int((rand() * 5 + 1).rounded()) * 10 // 10…60
|
||||||
|
let bias = Self.boroughBias[location.rawValue]
|
||||||
|
for i in 0..<6 { price[i] = max(1, price[i] * bias[i] / 100) }
|
||||||
|
resolveRumor()
|
||||||
|
applyEvent(Int((rand() * 20).rounded()))
|
||||||
|
if phase == .trading { sharkPressure() }
|
||||||
|
if phase == .trading { maybeSpawnRumor() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Word on the street (remake extension)
|
||||||
|
|
||||||
|
/// A due rumor pays out (or doesn't) only if you actually rode to the
|
||||||
|
/// named borough; ride anywhere else and it just expires unverified.
|
||||||
|
private mutating func resolveRumor() {
|
||||||
|
guard let r = rumor, day >= r.dayDue else { return }
|
||||||
|
rumor = nil
|
||||||
|
guard day == r.dayDue, location == r.location else { return }
|
||||||
|
let name = displayName(Item(rawValue: r.slot)!)
|
||||||
|
if !r.isTrue {
|
||||||
|
messages.append("The word was garbage. \(name) is trading like any other day.")
|
||||||
|
} else if r.isSpike {
|
||||||
|
price[r.slot] *= 3
|
||||||
|
messages.append("The word was good — \(name) is going crazy here today!!")
|
||||||
|
} else {
|
||||||
|
price[r.slot] = max(1, price[r.slot] / 3)
|
||||||
|
messages.append("The word was good — \(name) is practically free here today!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func maybeSpawnRumor() {
|
||||||
|
guard rumor == nil, day < monthLen else { return }
|
||||||
|
guard extRand() < 0.35 else { return }
|
||||||
|
let slot = min(5, Int(extRand() * 6))
|
||||||
|
let loc = Location(rawValue: min(5, Int(extRand() * 6)))!
|
||||||
|
let spike = extRand() < 0.5
|
||||||
|
let isTrue = extRand() < 0.75
|
||||||
|
rumor = Rumor(slot: slot, location: loc, dayDue: day + 1, isSpike: spike, isTrue: isTrue)
|
||||||
|
let name = displayName(Item(rawValue: slot)!).lowercased()
|
||||||
|
messages.append(spike
|
||||||
|
? "Word on the street: \(name) is about to go scarce in \(loc.name) — tomorrow it pays."
|
||||||
|
: "Word on the street: a big load of \(name) hits \(loc.name) tomorrow — prices'll tank.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The shark collects (remake extension)
|
||||||
|
|
||||||
|
/// Run a $15k+ tab past day 10 and the shark sends people: one warning,
|
||||||
|
/// then a 30%-per-arrival chance his two leg-breakers jump you — the
|
||||||
|
/// classic chase engine with nastier stakes.
|
||||||
|
private mutating func sharkPressure() {
|
||||||
|
guard debt >= 15_000, day >= 10, day != lastSharkDay else { return }
|
||||||
|
if !sharkWarned {
|
||||||
|
sharkWarned = true
|
||||||
|
lastSharkDay = day
|
||||||
|
messages.append("A kid runs up: \"The shark says your tab's a problem. Square it, or his boys will.\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard extRand() < 0.30 else { return }
|
||||||
|
lastSharkDay = day
|
||||||
|
sharkChase = true
|
||||||
|
chaseCops = 1 // two goons; D counts down to −1
|
||||||
|
messages.append("Two of the loan shark's leg-breakers step out of a doorway!!")
|
||||||
|
phase = .chase
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-way tier promotion, checked as a day begins. Retiring a slot buys
|
||||||
|
/// out any stock at yesterday's price — the collector always pays.
|
||||||
|
private mutating func checkMarketShift() {
|
||||||
|
if tier < 2, netWorth >= 100_000 {
|
||||||
|
tier = 2
|
||||||
|
messages.append("Word on the street: you're a heavy roller now. Nobody wants ludes anymore — but a new product called METH is moving, and it moves big.")
|
||||||
|
liquidate(.ludes, calledAs: "ludes")
|
||||||
|
}
|
||||||
|
if tier < 3, netWorth >= 1_000_000 {
|
||||||
|
tier = 3
|
||||||
|
messages.append("Word is out: you're a millionaire. Speed is small-time — the cartel wants a buyer for CHINA WHITE. Pure, scarce, priced like it.")
|
||||||
|
liquidate(.speed, calledAs: "speed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func liquidate(_ item: Item, calledAs name: String) {
|
||||||
|
let n = held[item.rawValue]
|
||||||
|
guard n > 0 else { return }
|
||||||
|
let payout = price[item.rawValue] * n
|
||||||
|
wallet += payout
|
||||||
|
held[item.rawValue] = 0
|
||||||
|
recomputeFree()
|
||||||
|
messages.append("A collector bought out your \(n) units of \(name) for $\(payout). That market is done.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func applyEvent(_ d: Int) {
|
||||||
|
switch d {
|
||||||
|
case 1:
|
||||||
|
if tier >= 2 {
|
||||||
|
// The ludes slot deals meth now — its crash event becomes a spike.
|
||||||
|
messages.append("A meth lab blew up in Jersey! Local product is suddenly precious!!")
|
||||||
|
price[Item.ludes.rawValue] = Int(30000 + rand() * 18000) // 30000…47999
|
||||||
|
} else {
|
||||||
|
messages.append("Rival dealers raided a pharmacy and are selling cheap ludes!!")
|
||||||
|
price[Item.ludes.rawValue] = 2
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
messages.append("The market has been flooded with cheap home-grown weed!")
|
||||||
|
price[Item.weed.rawValue] = 122
|
||||||
|
case 3:
|
||||||
|
messages.append("The market has been flooded with cheap heroin!")
|
||||||
|
price[Item.heroin.rawValue] = Int(850 + rand() * 1150) // 850…1999
|
||||||
|
case 4, 5:
|
||||||
|
messages.append("Cops made a big bust! Heroin prices are outrageous!")
|
||||||
|
price[Item.heroin.rawValue] = Int(18000 + rand() * 25000) // 18000…42999
|
||||||
|
case 6, 7:
|
||||||
|
messages.append("Addicts are buying cocaine at outrageous prices!")
|
||||||
|
price[Item.cocaine.rawValue] = Int(80000 + rand() * 60000) // 80000…139999
|
||||||
|
case 8:
|
||||||
|
messages.append("You were mugged in the subway!")
|
||||||
|
wallet = (wallet / 3) * 2 // int(Z/3) then ×2 — keep ~2/3
|
||||||
|
case 9, 10, 11:
|
||||||
|
guard totalHeld >= 50 else { return } // big carriers draw the heat
|
||||||
|
chaseCops = d == 9 ? 1 : (d == 10 ? 3 : 4)
|
||||||
|
messages.append("Officer Hardass and \(chaseCops) of his deputies are chasing you!!")
|
||||||
|
phase = .chase
|
||||||
|
case 12, 13:
|
||||||
|
guard wallet >= 500, coatFree >= 5 else { return }
|
||||||
|
let names = ["a $400 Baretta", "a $400 .38 Special", "a $400 Saturday Night Special"]
|
||||||
|
phase = .offer(.gun(names[Int((rand() * 2).rounded())]))
|
||||||
|
case 14:
|
||||||
|
phase = .offer(.weed)
|
||||||
|
case 15:
|
||||||
|
guard wallet >= 300 else { return }
|
||||||
|
phase = .offer(.coat)
|
||||||
|
case 16:
|
||||||
|
guard coatFree >= 8 else { return }
|
||||||
|
let f = Int(rand() * 7 + 1) // 1…7 units
|
||||||
|
let items: [Item] = [.ludes, .speed, .weed, .acid, .heroin, .cocaine]
|
||||||
|
let item = items[Int(rand() * 5)] // 0…4 — cocaine unreachable
|
||||||
|
held[item.rawValue] += f
|
||||||
|
recomputeFree()
|
||||||
|
messages.append("You found \(f) units of \(lootName(item)) on a dead dude in the subway!")
|
||||||
|
case 17:
|
||||||
|
messages.append("The market has been flooded with cheap home-made acid!")
|
||||||
|
price[Item.acid.rawValue] = Int(250 + rand() * 550) // 250…799
|
||||||
|
case 18:
|
||||||
|
guard tier >= 3 else { return } // idle roll in the classic game
|
||||||
|
messages.append("The cartel flooded the docks! China White is going for nothing!!")
|
||||||
|
price[Item.speed.rawValue] = Int(8000 + rand() * 12000) // 8000…19999
|
||||||
|
case 19:
|
||||||
|
guard tier >= 2 else { return } // idle roll in the classic game
|
||||||
|
messages.append("A superlab upstate dumped its stock — meth is dirt cheap!!")
|
||||||
|
price[Item.ludes.rawValue] = Int(400 + rand() * 600) // 400…999
|
||||||
|
default:
|
||||||
|
break // 0, 20 and gated rolls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func recomputeFree() { // (T−M−N−O−P−Q−R)→K
|
||||||
|
coatFree = coatTotal - totalHeld
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Trading
|
||||||
|
|
||||||
|
/// int(Z/F): how many the wallet covers at today's price.
|
||||||
|
func affordable(_ item: Item) -> Int {
|
||||||
|
let f = price[item.rawValue]
|
||||||
|
return f > 0 ? wallet / f : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func sellable(_ item: Item) -> Int {
|
||||||
|
held[item.rawValue]
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func buy(_ item: Item, count: Int) -> Bool {
|
||||||
|
guard phase == .trading, messages.isEmpty, count >= 0 else { return false }
|
||||||
|
guard count <= coatFree, count <= affordable(item) else { return false }
|
||||||
|
wallet -= price[item.rawValue] * count
|
||||||
|
held[item.rawValue] += count
|
||||||
|
recomputeFree()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func sell(_ item: Item, count: Int) -> Bool {
|
||||||
|
guard phase == .trading, messages.isEmpty, count >= 0 else { return false }
|
||||||
|
guard count <= sellable(item) else { return false }
|
||||||
|
held[item.rawValue] -= count
|
||||||
|
wallet += price[item.rawValue] * count
|
||||||
|
recomputeFree()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The subway (Lbl G5/S)
|
||||||
|
|
||||||
|
/// Rides to a borough: day advances, debt grows 10%, bank grows 6%
|
||||||
|
/// (int-truncated exactly like the calculator's decimal arithmetic), then
|
||||||
|
/// either the month ends or a fresh day is rolled. Returns false for
|
||||||
|
/// "You're already in the Bronx!".
|
||||||
|
@discardableResult
|
||||||
|
mutating func ride(to destination: Location) -> Bool {
|
||||||
|
guard phase == .trading, messages.isEmpty else { return false }
|
||||||
|
if destination == .bronx && inBronx {
|
||||||
|
messages.append("You're already in the Bronx!")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
location = destination
|
||||||
|
day += 1
|
||||||
|
// int(Y*1.1) / int(V*1.06), capped so endless runs can't overflow.
|
||||||
|
debt = min(debt * 11 / 10, 999_999_999_999)
|
||||||
|
bank = min(bank * 106 / 100, 999_999_999_999)
|
||||||
|
if day > monthLen { endGame(.monthOver) } else { rollDay() }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loan shark (Lbl G6) & bank (Lbl G7) — Bronx only
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func repay(_ amount: Int) -> Bool {
|
||||||
|
guard phase == .trading, messages.isEmpty, inBronx else { return false }
|
||||||
|
guard amount >= 0, amount <= wallet, amount <= debt else { return false }
|
||||||
|
debt -= amount
|
||||||
|
wallet -= amount
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shark fronts at most $5,000 per visit.
|
||||||
|
@discardableResult
|
||||||
|
mutating func borrow(_ amount: Int) -> Bool {
|
||||||
|
guard phase == .trading, messages.isEmpty, inBronx else { return false }
|
||||||
|
guard amount >= 0, amount <= 5000 else { return false }
|
||||||
|
debt += amount
|
||||||
|
wallet += amount
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func deposit(_ amount: Int) -> Bool {
|
||||||
|
guard phase == .trading, messages.isEmpty, inBronx else { return false }
|
||||||
|
guard amount >= 0, amount <= wallet else { return false }
|
||||||
|
bank += amount
|
||||||
|
wallet -= amount
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func withdraw(_ amount: Int) -> Bool {
|
||||||
|
guard phase == .trading, messages.isEmpty, inBronx else { return false }
|
||||||
|
guard amount >= 0, amount <= bank else { return false }
|
||||||
|
bank -= amount
|
||||||
|
wallet += amount
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Offers
|
||||||
|
|
||||||
|
mutating func answerOffer(yes: Bool) {
|
||||||
|
guard case .offer(let offer) = phase, messages.isEmpty else { return }
|
||||||
|
phase = .trading
|
||||||
|
guard yes else { return } // every "No" path just continues
|
||||||
|
switch offer {
|
||||||
|
case .coat:
|
||||||
|
coatTotal += 10 // (T+10)→T
|
||||||
|
recomputeFree()
|
||||||
|
wallet -= 200
|
||||||
|
case .gun:
|
||||||
|
guns += 1 // (I+1)→I
|
||||||
|
wallet -= 400
|
||||||
|
coatTotal -= 5 // a gun costs 5 pockets
|
||||||
|
recomputeFree()
|
||||||
|
case .weed:
|
||||||
|
messages.append("You hallucinated for three days on the wildest trip of your life.")
|
||||||
|
messages.append("Then you wandered onto the tracks at 42nd Street. Game over, dealer.")
|
||||||
|
endGame(.badTrip)
|
||||||
|
case .doctor:
|
||||||
|
wallet -= 1000
|
||||||
|
wounds = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The chase (Lbl M/K/L/O/N)
|
||||||
|
|
||||||
|
/// 50/50: lose them in an alley (fresh G8 roll — new prices, possibly a
|
||||||
|
/// new event) or fail and take a fire round.
|
||||||
|
mutating func chaseRun() {
|
||||||
|
guard phase == .chase, messages.isEmpty else { return }
|
||||||
|
if Int(rand().rounded()) == 0 {
|
||||||
|
messages.append("You lost them in an alley!")
|
||||||
|
sharkChase = false
|
||||||
|
phase = .trading
|
||||||
|
rollDay()
|
||||||
|
} else {
|
||||||
|
messages.append("You can't shake them!")
|
||||||
|
fireRound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Needs a gun; 50/50 miss or drop one deputy. Either way (unless the last
|
||||||
|
/// one falls) they answer with a fire round, exactly like the original's
|
||||||
|
/// fall-through to Lbl O.
|
||||||
|
mutating func chaseFight() {
|
||||||
|
guard phase == .chase, messages.isEmpty else { return }
|
||||||
|
guard guns > 0 else {
|
||||||
|
messages.append("You don't have any guns! You have to run!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Int(rand().rounded()) == 0 {
|
||||||
|
messages.append("You missed!!!")
|
||||||
|
} else {
|
||||||
|
chaseCops -= 1
|
||||||
|
messages.append("You dropped one!!!")
|
||||||
|
if chaseCops == -1 { // If D==(−1) Goto N
|
||||||
|
chaseVictory()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fireRound()
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func fireRound() {
|
||||||
|
if Int(rand().rounded()) == 0 {
|
||||||
|
messages.append("They're firing at you! They missed!!!")
|
||||||
|
} else {
|
||||||
|
wounds += 3
|
||||||
|
messages.append("They're firing at you! You've been hit!")
|
||||||
|
if wounds >= 50 {
|
||||||
|
if sharkChase {
|
||||||
|
messages.append("The shark collected — in blood.")
|
||||||
|
endGame(.sharkCollected)
|
||||||
|
} else {
|
||||||
|
messages.append("They wasted you, man!!!")
|
||||||
|
endGame(.gunnedDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func chaseVictory() {
|
||||||
|
if sharkChase {
|
||||||
|
sharkChase = false
|
||||||
|
let loot = Int(rand() * 500 + 250) // 250…749 collection money
|
||||||
|
wallet += loot
|
||||||
|
messages.append("You took them both down!!!")
|
||||||
|
messages.append("You found $\(loot) of the shark's collection money. He won't forget this.")
|
||||||
|
phase = wallet >= 1200 ? .offer(.doctor) : .trading
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let loot = Int(rand() * 1250 + 750) // 750…1999
|
||||||
|
wallet += loot
|
||||||
|
messages.append("You took them all down!!!")
|
||||||
|
messages.append("You found $\(loot) on Officer Hardass's body.")
|
||||||
|
phase = wallet >= 1200 ? .offer(.doctor) : .trading
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ending (Lbl C)
|
||||||
|
|
||||||
|
mutating func quit() {
|
||||||
|
guard phase == .trading, messages.isEmpty else { return }
|
||||||
|
endGame(.quit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score = √((bank + wallet − debt) / 31.5), floored at 0 and capped at
|
||||||
|
/// 100 — so a perfect 100 takes a net worth of $315,000.
|
||||||
|
private mutating func endGame(_ cause: EndCause) {
|
||||||
|
let net = bank + wallet - debt
|
||||||
|
finalNet = net
|
||||||
|
finalScore = net < 0 ? 0 : min(100, Int((Double(net) / 31.5).squareRoot().rounded()))
|
||||||
|
endCause = cause
|
||||||
|
phase = .gameOver
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message queue (the original's Pause screens)
|
||||||
|
|
||||||
|
mutating func popMessage() -> String? {
|
||||||
|
messages.isEmpty ? nil : messages.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug hooks
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Values consumed by `rand()` before the real generator — lets tests force
|
||||||
|
/// specific 50/50 outcomes and price rolls.
|
||||||
|
var debugForcedRolls: [Double] = []
|
||||||
|
/// Same, for the extension stream (rumor spawn, shark pressure chance).
|
||||||
|
var debugExtForcedRolls: [Double] = []
|
||||||
|
|
||||||
|
mutating func debugSetRumor(slot: Int, location: Location, dayDue: Int,
|
||||||
|
isSpike: Bool, isTrue: Bool) {
|
||||||
|
rumor = Rumor(slot: slot, location: location, dayDue: dayDue,
|
||||||
|
isSpike: isSpike, isTrue: isTrue)
|
||||||
|
}
|
||||||
|
mutating func debugTriggerShark() {
|
||||||
|
sharkChase = true
|
||||||
|
chaseCops = 1
|
||||||
|
messages.append("Two of the loan shark's leg-breakers step out of a doorway!!")
|
||||||
|
phase = .chase
|
||||||
|
}
|
||||||
|
mutating func debugClearRumor() { rumor = nil }
|
||||||
|
|
||||||
|
mutating func debugSetWallet(_ v: Int) { wallet = v }
|
||||||
|
mutating func debugSetDebt(_ v: Int) { debt = v }
|
||||||
|
mutating func debugSetBank(_ v: Int) { bank = v }
|
||||||
|
mutating func debugSetDay(_ v: Int) { day = v }
|
||||||
|
mutating func debugSetGuns(_ v: Int) { guns = v }
|
||||||
|
mutating func debugSetWounds(_ v: Int) { wounds = v }
|
||||||
|
mutating func debugSetCoatTotal(_ v: Int) { coatTotal = v; recomputeFree() }
|
||||||
|
mutating func debugSetLocation(_ l: Location) { location = l }
|
||||||
|
mutating func debugSetPrice(_ item: Item, _ v: Int) { price[item.rawValue] = v }
|
||||||
|
mutating func debugSetTier(_ t: Int) { tier = min(3, max(1, t)) }
|
||||||
|
mutating func debugSetHeld(_ item: Item, _ v: Int) { held[item.rawValue] = v; recomputeFree() }
|
||||||
|
mutating func debugApplyEvent(_ d: Int) { applyEvent(d) }
|
||||||
|
mutating func debugRollDay() { rollDay() }
|
||||||
|
mutating func debugClearMessages() { messages.removeAll() }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Static data for Galaxian, transcribed verbatim from `GALAXIAN.ASM`
|
||||||
|
/// (Patrick Davidson, v3.0, 1998): every sprite bitmap, the six formation
|
||||||
|
/// patterns, and the 20-level campaign table.
|
||||||
|
enum GalaxianData {
|
||||||
|
|
||||||
|
// MARK: - Sprites
|
||||||
|
|
||||||
|
/// A 1-bit sprite. `rows[i]` holds row `i` with bit 31 as the leftmost
|
||||||
|
/// pixel (the ASM's row bytes packed big-endian, as drawn by `drw_spr`).
|
||||||
|
struct Sprite {
|
||||||
|
let w: Int
|
||||||
|
let h: Int
|
||||||
|
let rows: [UInt32]
|
||||||
|
|
||||||
|
init(_ w: Int, _ h: Int, _ byteRows: [[UInt8]]) {
|
||||||
|
self.w = w
|
||||||
|
self.h = h
|
||||||
|
self.rows = byteRows.map { bytes in
|
||||||
|
var v: UInt32 = 0
|
||||||
|
for (i, b) in bytes.enumerated() { v |= UInt32(b) << (24 - 8 * i) }
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Img: Hashable {
|
||||||
|
case enemy1, enemy2, enemy3, enemy4, cross, operatorShip
|
||||||
|
case boss1, boss2, boss3
|
||||||
|
case ship, playerBullet, enemyBullet
|
||||||
|
case x1, x2, x3, x4, x5, x6, x7, x8 // explosion frames
|
||||||
|
}
|
||||||
|
|
||||||
|
static let explosionFrames: [Img] = [.x1, .x2, .x3, .x4, .x5, .x6, .x7, .x8]
|
||||||
|
|
||||||
|
static let sprites: [Img: Sprite] = [
|
||||||
|
// enemy_warped (the "cross" trainer)
|
||||||
|
.cross: Sprite(7, 7, [
|
||||||
|
[0b1100_0110], [0b1110_1110], [0b0111_1100], [0b0011_1000],
|
||||||
|
[0b0111_1100], [0b1110_1110], [0b1100_0110],
|
||||||
|
]),
|
||||||
|
.enemy1: Sprite(6, 6, [
|
||||||
|
[0b1000_0100], [0b1000_0100], [0b1111_1100], [0b1111_1100],
|
||||||
|
[0b0111_1000], [0b0011_0000],
|
||||||
|
]),
|
||||||
|
.enemy2: Sprite(6, 7, [
|
||||||
|
[0b1000_0100], [0b1100_1100], [0b1111_1100], [0b1000_0100],
|
||||||
|
[0b1000_0100], [0b0100_1000], [0b0011_0000],
|
||||||
|
]),
|
||||||
|
.enemy3: Sprite(6, 5, [
|
||||||
|
[0b1000_0100], [0b1111_1100], [0b1000_0100], [0b0100_1000],
|
||||||
|
[0b0011_0000],
|
||||||
|
]),
|
||||||
|
.enemy4: Sprite(7, 5, [
|
||||||
|
[0b1000_0010], [0b1100_0110], [0b1010_1010], [0b1001_0010],
|
||||||
|
[0b1000_0010],
|
||||||
|
]),
|
||||||
|
.operatorShip: Sprite(7, 7, [
|
||||||
|
[0b0111_1100], [0b1111_1110], [0b1100_0110], [0b1100_0110],
|
||||||
|
[0b1100_0110], [0b1111_1110], [0b0111_1100],
|
||||||
|
]),
|
||||||
|
.boss1: Sprite(16, 8, [
|
||||||
|
[0b1000_0000, 0b0000_0001], [0b1100_0000, 0b0000_0011],
|
||||||
|
[0b1011_1111, 0b1111_1101], [0b1000_0001, 0b1000_0001],
|
||||||
|
[0b1000_0001, 0b1000_0001], [0b0100_0001, 0b1000_0010],
|
||||||
|
[0b0010_0001, 0b1000_0100], [0b0001_1111, 0b1111_1000],
|
||||||
|
]),
|
||||||
|
.boss2: Sprite(16, 8, [
|
||||||
|
[0b1111_1111, 0b1111_1111], [0b1110_0000, 0b0000_0111],
|
||||||
|
[0b1110_0000, 0b0000_0111], [0b1110_0000, 0b0000_0111],
|
||||||
|
[0b1110_0000, 0b0000_0111], [0b1111_0000, 0b0000_1111],
|
||||||
|
[0b1110_1000, 0b0001_0111], [0b0100_0111, 0b1110_0010],
|
||||||
|
]),
|
||||||
|
.boss3: Sprite(31, 8, [
|
||||||
|
[0b1111_1111, 0b1111_1111, 0b1111_1111, 0b1111_1110],
|
||||||
|
[0b1010_1000, 0b0000_0000, 0b0000_0000, 0b0010_1010],
|
||||||
|
[0b1010_1001, 0b0101_0101, 0b0101_0101, 0b0010_1010],
|
||||||
|
[0b1010_1000, 0b0000_0000, 0b0000_0000, 0b0010_1010],
|
||||||
|
[0b1010_1000, 0b0000_0111, 0b1100_0000, 0b0010_1010],
|
||||||
|
[0b1000_1111, 0b1100_0010, 0b1000_0111, 0b1110_0010],
|
||||||
|
[0b0101_0000, 0b0010_0001, 0b0000_1000, 0b0001_0100],
|
||||||
|
[0b0010_0000, 0b0001_1111, 0b1111_0000, 0b0000_1000],
|
||||||
|
]),
|
||||||
|
.enemyBullet: Sprite(3, 3, [
|
||||||
|
[0b0100_0000], [0b1110_0000], [0b0100_0000],
|
||||||
|
]),
|
||||||
|
.playerBullet: Sprite(3, 5, [
|
||||||
|
[0b0100_0000], [0b1110_0000], [0b1110_0000], [0b1110_0000],
|
||||||
|
[0b1110_0000],
|
||||||
|
]),
|
||||||
|
.ship: Sprite(7, 7, [
|
||||||
|
[0b0001_0000], [0b0011_1000], [0b1011_1010], [0b1011_1010],
|
||||||
|
[0b1011_1010], [0b1111_1110], [0b1111_1110],
|
||||||
|
]),
|
||||||
|
.x1: Sprite(8, 6, [
|
||||||
|
[0b0000_0000], [0b0001_1100], [0b0011_1110], [0b0101_0110],
|
||||||
|
[0b0011_1000], [0b0000_0000],
|
||||||
|
]),
|
||||||
|
.x2: Sprite(8, 6, [
|
||||||
|
[0b0011_0000], [0b0100_1110], [0b1011_1110], [0b0100_1111],
|
||||||
|
[0b0011_1000], [0b0001_1010],
|
||||||
|
]),
|
||||||
|
.x3: Sprite(8, 6, [
|
||||||
|
[0b1111_0011], [0b0100_1110], [0b1011_0101], [0b0100_0101],
|
||||||
|
[0b0011_1110], [0b1101_1010],
|
||||||
|
]),
|
||||||
|
.x4: Sprite(8, 6, [
|
||||||
|
[0b1111_0011], [0b0100_1110], [0b1011_0101], [0b0100_0101],
|
||||||
|
[0b0011_1110], [0b1101_1010],
|
||||||
|
]),
|
||||||
|
.x5: Sprite(8, 6, [
|
||||||
|
[0b0100_0001], [0b0010_0110], [0b0001_0101], [0b0100_0100],
|
||||||
|
[0b0001_0010], [0b1001_1010],
|
||||||
|
]),
|
||||||
|
.x6: Sprite(8, 6, [
|
||||||
|
[0b0100_0010], [0b0010_0000], [0b0000_0001], [0b0100_0100],
|
||||||
|
[0b0010_0010], [0b1000_1010],
|
||||||
|
]),
|
||||||
|
.x7: Sprite(8, 6, [
|
||||||
|
[0b0000_1000], [0b1100_0010], [0b0000_0000], [0b0010_0000],
|
||||||
|
[0b0000_0001], [0b0011_0000],
|
||||||
|
]),
|
||||||
|
.x8: Sprite(8, 6, [
|
||||||
|
[0b0000_0100], [0b0000_0000], [0b0100_0000], [0b0000_0000],
|
||||||
|
[0b0000_0001], [0b0010_0100],
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Formation patterns
|
||||||
|
|
||||||
|
/// 20 (x, depth) pairs per pattern. The depth byte does double duty in the
|
||||||
|
/// original: an enemy triggers its entry when `timer & 127 == 128 - depth`
|
||||||
|
/// and descends 1 px/frame until the timer wraps — arriving at row `depth`
|
||||||
|
/// exactly on the wrap.
|
||||||
|
typealias Pattern = [(x: Int, y: Int)]
|
||||||
|
|
||||||
|
static let pattern1: Pattern = [
|
||||||
|
(10, 16), (20, 16), (30, 16), (40, 16), (50, 16),
|
||||||
|
(5, 24), (15, 24), (25, 24), (35, 24), (45, 24),
|
||||||
|
(50, 32), (10, 32), (20, 32), (30, 32), (40, 32),
|
||||||
|
(5, 40), (15, 40), (25, 40), (35, 40), (45, 40),
|
||||||
|
]
|
||||||
|
|
||||||
|
static let pattern2: Pattern = [
|
||||||
|
(1, 16), (14, 16), (27, 16), (40, 16), (53, 16),
|
||||||
|
(1, 24), (14, 24), (27, 24), (40, 24), (53, 24),
|
||||||
|
(1, 32), (14, 32), (27, 32), (40, 32), (53, 32),
|
||||||
|
(1, 40), (14, 40), (27, 40), (40, 40), (53, 40),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Shared by levels 3/7/11/16 (the boss levels): formation sits lower.
|
||||||
|
static let patternBoss: Pattern = [
|
||||||
|
(1, 26), (14, 26), (27, 26), (40, 26), (53, 26),
|
||||||
|
(1, 34), (14, 34), (27, 34), (40, 34), (53, 34),
|
||||||
|
(1, 42), (14, 42), (27, 42), (40, 42), (53, 42),
|
||||||
|
(1, 50), (14, 50), (27, 50), (40, 50), (53, 50),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Shared by the mixed-type levels 4/8/12 (7 + 6 + 7 layout).
|
||||||
|
static let patternWeak: Pattern = [
|
||||||
|
(5, 18), (13, 18), (21, 18), (29, 18), (37, 18), (45, 18), (53, 18),
|
||||||
|
(9, 28), (17, 28), (25, 28), (33, 28), (41, 28), (49, 28),
|
||||||
|
(5, 38), (13, 38), (21, 38), (29, 38), (37, 38), (45, 38), (53, 38),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Level 6's ring of enemies.
|
||||||
|
static let patternRing: Pattern = [
|
||||||
|
(53, 28), (52, 32), (48, 35), (42, 38), (35, 39), (27, 40), (19, 39),
|
||||||
|
(12, 38), (6, 35), (2, 32), (1, 28), (2, 24), (6, 21), (12, 18),
|
||||||
|
(19, 17), (27, 16), (35, 17), (42, 18), (48, 21), (52, 24),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Levels 10/14: four columns of five.
|
||||||
|
static let patternColumns: Pattern = [
|
||||||
|
(1, 16), (1, 22), (1, 28), (1, 34), (1, 40),
|
||||||
|
(19, 16), (19, 22), (19, 28), (19, 34), (19, 40),
|
||||||
|
(37, 16), (37, 22), (37, 28), (37, 34), (37, 40),
|
||||||
|
(55, 16), (55, 22), (55, 28), (55, 34), (55, 40),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Levels
|
||||||
|
|
||||||
|
enum LevelKind {
|
||||||
|
/// 20 formation enemies of one type.
|
||||||
|
case standard
|
||||||
|
/// 20 formation enemies + a patrolling boss (21 to clear).
|
||||||
|
case boss(img: Img, pwr: Int)
|
||||||
|
/// 13 weak (power-1, enemy-1 art, slots 0…12) + 7 of the level type.
|
||||||
|
case weak
|
||||||
|
/// 10 bouncing cross trainers (half of them can flee).
|
||||||
|
case cross
|
||||||
|
/// 10 bouncing operator ships that fire aimed shots.
|
||||||
|
case operators
|
||||||
|
/// Sweeping superboss (98 hits) + 8 formation enemies + drone deploys.
|
||||||
|
case superBoss
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Level {
|
||||||
|
let kind: LevelKind
|
||||||
|
let enemyImg: Img
|
||||||
|
let enemyPwr: Int
|
||||||
|
let pattern: Pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
/// levels[0] is level 1. Mirrors `levels_jump_table` exactly.
|
||||||
|
static let levels: [Level] = [
|
||||||
|
Level(kind: .standard, enemyImg: .enemy1, enemyPwr: 1, pattern: pattern1),
|
||||||
|
Level(kind: .standard, enemyImg: .enemy1, enemyPwr: 1, pattern: pattern2),
|
||||||
|
Level(kind: .boss(img: .boss1, pwr: 10), enemyImg: .enemy1, enemyPwr: 1, pattern: patternBoss),
|
||||||
|
Level(kind: .weak, enemyImg: .enemy2, enemyPwr: 2, pattern: patternWeak),
|
||||||
|
Level(kind: .cross, enemyImg: .cross, enemyPwr: 5, pattern: pattern1),
|
||||||
|
Level(kind: .standard, enemyImg: .enemy2, enemyPwr: 2, pattern: patternRing),
|
||||||
|
Level(kind: .boss(img: .boss2, pwr: 10), enemyImg: .enemy2, enemyPwr: 2, pattern: patternBoss),
|
||||||
|
Level(kind: .weak, enemyImg: .enemy3, enemyPwr: 3, pattern: patternWeak),
|
||||||
|
Level(kind: .cross, enemyImg: .cross, enemyPwr: 5, pattern: pattern1),
|
||||||
|
Level(kind: .standard, enemyImg: .enemy3, enemyPwr: 3, pattern: patternColumns),
|
||||||
|
Level(kind: .boss(img: .boss2, pwr: 10), enemyImg: .enemy3, enemyPwr: 3, pattern: patternBoss),
|
||||||
|
Level(kind: .weak, enemyImg: .enemy4, enemyPwr: 4, pattern: patternWeak),
|
||||||
|
Level(kind: .cross, enemyImg: .cross, enemyPwr: 5, pattern: pattern1),
|
||||||
|
Level(kind: .standard, enemyImg: .enemy4, enemyPwr: 4, pattern: patternColumns),
|
||||||
|
Level(kind: .operators, enemyImg: .operatorShip, enemyPwr: 8, pattern: pattern1),
|
||||||
|
Level(kind: .boss(img: .boss2, pwr: 10), enemyImg: .enemy4, enemyPwr: 4, pattern: patternBoss),
|
||||||
|
Level(kind: .operators, enemyImg: .operatorShip, enemyPwr: 8, pattern: pattern1),
|
||||||
|
Level(kind: .superBoss, enemyImg: .enemy1, enemyPwr: 1, pattern: pattern1),
|
||||||
|
Level(kind: .operators, enemyImg: .operatorShip, enemyPwr: 8, pattern: pattern1),
|
||||||
|
Level(kind: .superBoss, enemyImg: .enemy4, enemyPwr: 4, pattern: pattern1),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
import SpriteKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// HD remake of Galaxian 3.0. `GalaxianSim` is the faithful Z80 port; this
|
||||||
|
/// scene renders the original sprites as neon pixel art, drives the ship with
|
||||||
|
/// drag-anywhere touch + autofire, puts the limited lasers on a button, and
|
||||||
|
/// wraps the 20-level campaign in ready / game-over / victory flow.
|
||||||
|
final class GalaxianScene: SKScene {
|
||||||
|
|
||||||
|
typealias D = GalaxianData
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
private enum GameState { case ready, playing, paused, gameOver, victory }
|
||||||
|
private var state: GameState = .ready
|
||||||
|
|
||||||
|
private var sim = GalaxianSim()
|
||||||
|
|
||||||
|
// MARK: Timing
|
||||||
|
private var lastUpdate: TimeInterval = 0
|
||||||
|
private var accumulator: TimeInterval = 0
|
||||||
|
private var fireCooldown = 0
|
||||||
|
|
||||||
|
// MARK: Layout
|
||||||
|
private var unit: CGFloat = 4 // scene points per sim unit, horizontal
|
||||||
|
private var unitY: CGFloat = 5 // vertical — unit × 1.25 (taller pixels)
|
||||||
|
private var fieldRect = CGRect.zero // visible window: 96 × 64 sim units (y 16…79)
|
||||||
|
private var topInset: CGFloat = 0
|
||||||
|
|
||||||
|
// MARK: Touch control
|
||||||
|
private var moveTouch: UITouch?
|
||||||
|
private var touchAnchor = CGPoint.zero
|
||||||
|
private var shipAnchor = CGPoint.zero
|
||||||
|
private var targetX = 70
|
||||||
|
private var targetY = 70
|
||||||
|
private var pendingLaser = false
|
||||||
|
|
||||||
|
// MARK: Nodes
|
||||||
|
private let worldCrop = SKCropNode()
|
||||||
|
private var playerNode = SKSpriteNode()
|
||||||
|
private var bossNode = SKSpriteNode()
|
||||||
|
private var bossBoomNode = SKSpriteNode()
|
||||||
|
private var enemyNodes: [SKSpriteNode] = []
|
||||||
|
private var pbNodes: [SKSpriteNode] = []
|
||||||
|
private var ebNodes: [SKSpriteNode] = []
|
||||||
|
private var textureCache: [String: SKTexture] = [:]
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
private let scoreValue = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let waveValue = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let livesValue = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let laserButton = ControlButton(symbol: "bolt.fill")
|
||||||
|
private let laserCount = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let pauseButton = SKShapeNode(circleOfRadius: 16)
|
||||||
|
private let waveBanner = SKLabelNode(fontNamed: "AvenirNext-Heavy")
|
||||||
|
|
||||||
|
// Overlays
|
||||||
|
private let readyOverlay = SKNode()
|
||||||
|
private let pausedOverlay = SKNode()
|
||||||
|
private let gameOverOverlay = SKNode()
|
||||||
|
private let victoryOverlay = SKNode()
|
||||||
|
private let gameOverDetail = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let gameOverRecord = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let victoryDetail = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let victoryRecord = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
|
||||||
|
private var builtSize: CGSize = .zero
|
||||||
|
#if DEBUG
|
||||||
|
private var demoFreeze = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: Sprite tints
|
||||||
|
|
||||||
|
static func tint(for img: D.Img) -> UIColor {
|
||||||
|
switch img {
|
||||||
|
case .enemy1: return Palette.mint
|
||||||
|
case .enemy2: return Palette.cyan
|
||||||
|
case .enemy3: return UIColor(red: 0.55, green: 0.80, blue: 1.0, alpha: 1)
|
||||||
|
case .enemy4: return Palette.violet
|
||||||
|
case .cross: return UIColor(red: 1.0, green: 0.45, blue: 0.85, alpha: 1)
|
||||||
|
case .operatorShip: return UIColor(red: 1.0, green: 0.62, blue: 0.25, alpha: 1)
|
||||||
|
case .boss1, .boss2: return Palette.red
|
||||||
|
case .boss3: return UIColor(red: 1.0, green: 0.55, blue: 0.30, alpha: 1)
|
||||||
|
case .ship: return .white
|
||||||
|
case .playerBullet: return Palette.mint
|
||||||
|
case .enemyBullet: return Palette.amber
|
||||||
|
case .x1, .x2, .x3, .x4, .x5, .x6, .x7, .x8: return Palette.amber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
override func didMove(to view: SKView) {
|
||||||
|
anchorPoint = .zero
|
||||||
|
scaleMode = .resizeFill
|
||||||
|
layoutScene()
|
||||||
|
#if DEBUG
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
if env["ARCADE_AUTOPLAY"] == "1" { startGame() }
|
||||||
|
if env["ARCADE_DEMO"] == "1" {
|
||||||
|
startGame()
|
||||||
|
sim.debugSetLevel(6) // the ring formation
|
||||||
|
for _ in 0..<300 { sim.tick(GalaxianSim.Input()) }
|
||||||
|
sim.debugPlacePlayer(x: 44, y: 70)
|
||||||
|
demoFreeze = true
|
||||||
|
renderFrame()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didChangeSize(_ oldSize: CGSize) {
|
||||||
|
guard view != nil, size != builtSize, size.width > 0 else { return }
|
||||||
|
layoutScene()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Textures
|
||||||
|
|
||||||
|
private func texture(for img: D.Img) -> SKTexture {
|
||||||
|
let key = "\(img)"
|
||||||
|
if let t = textureCache[key] { return t }
|
||||||
|
let sprite = D.sprites[img]!
|
||||||
|
let cell: CGFloat = 10
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: CGFloat(sprite.w) * cell,
|
||||||
|
height: CGFloat(sprite.h) * cell))
|
||||||
|
let color = Self.tint(for: img)
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
cg.setShadow(offset: .zero, blur: cell * 0.6, color: color.withAlphaComponent(0.85).cgColor)
|
||||||
|
for pass in 0..<2 {
|
||||||
|
if pass == 1 { cg.setShadow(offset: .zero, blur: 0, color: nil) }
|
||||||
|
for (row, bits) in sprite.rows.enumerated() {
|
||||||
|
for col in 0..<sprite.w where (bits & (UInt32(0x8000_0000) >> UInt32(col))) != 0 {
|
||||||
|
let rect = CGRect(x: CGFloat(col) * cell, y: CGFloat(row) * cell,
|
||||||
|
width: cell, height: cell)
|
||||||
|
color.setFill()
|
||||||
|
UIBezierPath(roundedRect: rect.insetBy(dx: cell * 0.05, dy: cell * 0.05),
|
||||||
|
cornerRadius: cell * 0.18).fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let t = SKTexture(image: image)
|
||||||
|
textureCache[key] = t
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout
|
||||||
|
|
||||||
|
private func layoutScene() {
|
||||||
|
builtSize = size
|
||||||
|
removeAllChildren()
|
||||||
|
textureCache.removeAll()
|
||||||
|
backgroundColor = Palette.space1
|
||||||
|
|
||||||
|
let insets = view?.safeAreaInsets ?? .zero
|
||||||
|
topInset = insets.top
|
||||||
|
let bottomInset = insets.bottom
|
||||||
|
|
||||||
|
let bg = SKSpriteNode(texture: SceneArt.verticalGradient(size: size, top: Palette.space0, bottom: Palette.space1))
|
||||||
|
bg.anchorPoint = .zero
|
||||||
|
bg.zPosition = -10
|
||||||
|
addChild(bg)
|
||||||
|
let stars = SceneArt.starfield(size: size, count: 90, seed: 0x6A1AC5)
|
||||||
|
stars.zPosition = -9
|
||||||
|
addChild(stars)
|
||||||
|
|
||||||
|
// Field: 96 × 64 visible sim units (rows 16…79 of the back buffer),
|
||||||
|
// width-fit, drawn with 1.25× tall pixels for a fuller portrait field.
|
||||||
|
let margin: CGFloat = 14
|
||||||
|
unit = (size.width - margin * 2) / 96
|
||||||
|
unitY = unit * 1.25
|
||||||
|
let fieldH = unitY * 64
|
||||||
|
let hudBottom = size.height - topInset - 92
|
||||||
|
fieldRect = CGRect(x: margin, y: hudBottom - fieldH, width: unit * 96, height: fieldH)
|
||||||
|
|
||||||
|
let frame = SKShapeNode(rect: fieldRect.insetBy(dx: -6, dy: -6), cornerRadius: 14)
|
||||||
|
frame.strokeColor = Palette.mint.withAlphaComponent(0.30)
|
||||||
|
frame.lineWidth = 1.5
|
||||||
|
frame.glowWidth = 1
|
||||||
|
frame.fillColor = UIColor(white: 0, alpha: 0.30)
|
||||||
|
frame.zPosition = -1
|
||||||
|
addChild(frame)
|
||||||
|
|
||||||
|
worldCrop.removeAllChildren()
|
||||||
|
let mask = SKSpriteNode(color: .white, size: fieldRect.size)
|
||||||
|
mask.position = CGPoint(x: fieldRect.midX, y: fieldRect.midY)
|
||||||
|
worldCrop.maskNode = mask
|
||||||
|
worldCrop.zPosition = 0
|
||||||
|
addChild(worldCrop)
|
||||||
|
|
||||||
|
buildEntityNodes()
|
||||||
|
buildHUD(bottomInset: bottomInset)
|
||||||
|
buildReadyOverlay()
|
||||||
|
buildPausedOverlay()
|
||||||
|
buildGameOverOverlay()
|
||||||
|
buildVictoryOverlay()
|
||||||
|
|
||||||
|
applyStateVisibility()
|
||||||
|
renderFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildEntityNodes() {
|
||||||
|
func makeNode() -> SKSpriteNode {
|
||||||
|
let n = SKSpriteNode()
|
||||||
|
n.isHidden = true
|
||||||
|
worldCrop.addChild(n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
enemyNodes = (0..<20).map { _ in let n = makeNode(); n.zPosition = 4; return n }
|
||||||
|
pbNodes = (0..<10).map { _ in let n = makeNode(); n.zPosition = 3; return n }
|
||||||
|
ebNodes = (0..<10).map { _ in let n = makeNode(); n.zPosition = 3; return n }
|
||||||
|
bossNode = makeNode()
|
||||||
|
bossNode.zPosition = 4
|
||||||
|
bossBoomNode = makeNode()
|
||||||
|
bossBoomNode.zPosition = 5
|
||||||
|
playerNode = makeNode()
|
||||||
|
playerNode.zPosition = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sim → scene coordinates (sim y grows downward; visible window starts at 16).
|
||||||
|
private func point(x: Int, y: Int, w: Int, h: Int) -> CGPoint {
|
||||||
|
CGPoint(x: fieldRect.minX + (CGFloat(x) + CGFloat(w) / 2) * unit,
|
||||||
|
y: fieldRect.maxY - (CGFloat(y) - CGFloat(GalaxianSim.visibleTop) + CGFloat(h) / 2) * unitY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func place(_ node: SKSpriteNode, img: D.Img, x: Int, y: Int) {
|
||||||
|
let sprite = D.sprites[img]!
|
||||||
|
node.texture = texture(for: img)
|
||||||
|
node.size = CGSize(width: CGFloat(sprite.w) * unit, height: CGFloat(sprite.h) * unitY)
|
||||||
|
node.position = point(x: x, y: y, w: sprite.w, h: sprite.h)
|
||||||
|
node.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HUD
|
||||||
|
|
||||||
|
private func buildHUD(bottomInset: CGFloat) {
|
||||||
|
let hudY = size.height - topInset - 26
|
||||||
|
|
||||||
|
addChild(caption("LIVES", x: 74, y: hudY + 12, align: .left))
|
||||||
|
livesValue.fontSize = 17
|
||||||
|
livesValue.fontColor = .white
|
||||||
|
livesValue.horizontalAlignmentMode = .left
|
||||||
|
livesValue.verticalAlignmentMode = .center
|
||||||
|
livesValue.position = CGPoint(x: 74, y: hudY - 8)
|
||||||
|
livesValue.zPosition = 20
|
||||||
|
addChild(livesValue)
|
||||||
|
|
||||||
|
addChild(caption("WAVE", x: size.width / 2, y: hudY + 12, align: .center))
|
||||||
|
waveValue.fontSize = 17
|
||||||
|
waveValue.fontColor = .white
|
||||||
|
waveValue.horizontalAlignmentMode = .center
|
||||||
|
waveValue.verticalAlignmentMode = .center
|
||||||
|
waveValue.position = CGPoint(x: size.width / 2, y: hudY - 8)
|
||||||
|
waveValue.zPosition = 20
|
||||||
|
addChild(waveValue)
|
||||||
|
|
||||||
|
addChild(caption("SCORE", x: size.width - 20, y: hudY + 12, align: .right))
|
||||||
|
scoreValue.fontSize = 17
|
||||||
|
scoreValue.fontColor = .white
|
||||||
|
scoreValue.horizontalAlignmentMode = .right
|
||||||
|
scoreValue.verticalAlignmentMode = .center
|
||||||
|
scoreValue.position = CGPoint(x: size.width - 20, y: hudY - 8)
|
||||||
|
scoreValue.zPosition = 20
|
||||||
|
addChild(scoreValue)
|
||||||
|
|
||||||
|
// Laser button in the thumb zone, count badge underneath the bolt.
|
||||||
|
let ly = max(bottomInset + 96, fieldRect.minY - 116)
|
||||||
|
laserButton.configure(radius: 40, tint: Palette.cyan)
|
||||||
|
laserButton.position = CGPoint(x: size.width - 66, y: ly)
|
||||||
|
laserButton.zPosition = 20
|
||||||
|
addChild(laserButton)
|
||||||
|
laserCount.fontSize = 13
|
||||||
|
laserCount.fontColor = Palette.cyan
|
||||||
|
laserCount.horizontalAlignmentMode = .center
|
||||||
|
laserCount.verticalAlignmentMode = .center
|
||||||
|
laserCount.position = CGPoint(x: size.width - 66, y: ly - 58)
|
||||||
|
laserCount.zPosition = 20
|
||||||
|
addChild(laserCount)
|
||||||
|
|
||||||
|
pauseButton.fillColor = UIColor(white: 1, alpha: 0.08)
|
||||||
|
pauseButton.strokeColor = UIColor(white: 1, alpha: 0.25)
|
||||||
|
pauseButton.lineWidth = 1
|
||||||
|
pauseButton.position = CGPoint(x: fieldRect.maxX - 6, y: fieldRect.maxY - 6)
|
||||||
|
pauseButton.zPosition = 22
|
||||||
|
pauseButton.removeAllChildren()
|
||||||
|
if let tex = SceneArt.symbolTexture("pause.fill", pointSize: 13, color: Palette.white70) {
|
||||||
|
let icon = SKSpriteNode(texture: tex)
|
||||||
|
icon.size = tex.size()
|
||||||
|
pauseButton.addChild(icon)
|
||||||
|
}
|
||||||
|
addChild(pauseButton)
|
||||||
|
|
||||||
|
// Wave banner (fades in/out on level entry).
|
||||||
|
waveBanner.fontSize = 24
|
||||||
|
waveBanner.fontColor = Palette.mint
|
||||||
|
waveBanner.horizontalAlignmentMode = .center
|
||||||
|
waveBanner.verticalAlignmentMode = .center
|
||||||
|
waveBanner.position = CGPoint(x: fieldRect.midX, y: fieldRect.midY)
|
||||||
|
waveBanner.zPosition = 24
|
||||||
|
waveBanner.alpha = 0
|
||||||
|
addChild(waveBanner)
|
||||||
|
|
||||||
|
let hintY = bottomInset + (max(fieldRect.minY - bottomInset, 60)) * 0.30
|
||||||
|
addChild(caption("DRAG ANYWHERE TO FLY — CANNON AUTO-FIRES", x: size.width / 2,
|
||||||
|
y: max(bottomInset + 16, hintY), align: .center))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func caption(_ text: String, x: CGFloat, y: CGFloat, align: SKLabelHorizontalAlignmentMode) -> SKLabelNode {
|
||||||
|
let label = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
label.text = text
|
||||||
|
label.fontSize = 10
|
||||||
|
label.fontColor = Palette.white40
|
||||||
|
label.horizontalAlignmentMode = align
|
||||||
|
label.verticalAlignmentMode = .center
|
||||||
|
label.position = CGPoint(x: x, y: y)
|
||||||
|
label.zPosition = 20
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Overlays
|
||||||
|
|
||||||
|
private func bigLabel(_ text: String, fontNamed: String = "AvenirNext-Heavy", size fontSize: CGFloat,
|
||||||
|
color: UIColor, x: CGFloat, y: CGFloat, maxWidth: CGFloat? = nil) -> SKLabelNode {
|
||||||
|
let label = SKLabelNode(fontNamed: fontNamed)
|
||||||
|
label.text = text
|
||||||
|
label.fontSize = fontSize
|
||||||
|
label.fontColor = color
|
||||||
|
label.horizontalAlignmentMode = .center
|
||||||
|
label.verticalAlignmentMode = .center
|
||||||
|
label.position = CGPoint(x: x, y: y)
|
||||||
|
if let maxWidth {
|
||||||
|
label.preferredMaxLayoutWidth = maxWidth
|
||||||
|
label.numberOfLines = 0
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildReadyOverlay() {
|
||||||
|
readyOverlay.removeAllChildren()
|
||||||
|
readyOverlay.zPosition = 40
|
||||||
|
addChild(readyOverlay)
|
||||||
|
let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.66), size: size)
|
||||||
|
dim.anchorPoint = .zero
|
||||||
|
readyOverlay.addChild(dim)
|
||||||
|
|
||||||
|
let cx = size.width / 2
|
||||||
|
let midY = size.height * 0.62
|
||||||
|
readyOverlay.addChild(bigLabel("GALAXIAN", size: 36, color: .white, x: cx, y: midY + 64))
|
||||||
|
readyOverlay.addChild(bigLabel("20 waves. 9 lives. 9 lasers. Hold the line.",
|
||||||
|
fontNamed: "AvenirNext-Medium", size: 14.5,
|
||||||
|
color: Palette.white70, x: cx, y: midY + 30))
|
||||||
|
readyOverlay.addChild(bigLabel("Cannon auto-fires — the LASER button wipes a column",
|
||||||
|
fontNamed: "AvenirNext-Medium", size: 13,
|
||||||
|
color: Palette.white40, x: cx, y: midY - 2,
|
||||||
|
maxWidth: size.width - 80))
|
||||||
|
|
||||||
|
let prompt = bigLabel("TAP TO LAUNCH", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint,
|
||||||
|
x: cx, y: midY - 64)
|
||||||
|
prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)])))
|
||||||
|
readyOverlay.addChild(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildPausedOverlay() {
|
||||||
|
pausedOverlay.removeAllChildren()
|
||||||
|
pausedOverlay.zPosition = 40
|
||||||
|
addChild(pausedOverlay)
|
||||||
|
let dim = SKSpriteNode(color: UIColor(white: 0.01, alpha: 0.80), size: size)
|
||||||
|
dim.anchorPoint = .zero
|
||||||
|
pausedOverlay.addChild(dim)
|
||||||
|
pausedOverlay.addChild(bigLabel("PAUSED", size: 30, color: .white, x: size.width / 2, y: size.height * 0.55))
|
||||||
|
let hint = bigLabel("TAP TO RESUME", fontNamed: "Menlo-Bold", size: 15, color: Palette.mint,
|
||||||
|
x: size.width / 2, y: size.height * 0.55 - 44)
|
||||||
|
hint.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)])))
|
||||||
|
pausedOverlay.addChild(hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildGameOverOverlay() {
|
||||||
|
gameOverOverlay.removeAllChildren()
|
||||||
|
gameOverOverlay.zPosition = 40
|
||||||
|
addChild(gameOverOverlay)
|
||||||
|
let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.72), size: size)
|
||||||
|
dim.anchorPoint = .zero
|
||||||
|
gameOverOverlay.addChild(dim)
|
||||||
|
let cx = size.width / 2
|
||||||
|
let midY = size.height * 0.6
|
||||||
|
gameOverOverlay.addChild(bigLabel("FLEET DESTROYED", size: 30, color: Palette.red, x: cx, y: midY + 36))
|
||||||
|
gameOverDetail.fontSize = 15
|
||||||
|
gameOverDetail.fontColor = .white
|
||||||
|
gameOverDetail.horizontalAlignmentMode = .center
|
||||||
|
gameOverDetail.position = CGPoint(x: cx, y: midY - 4)
|
||||||
|
gameOverOverlay.addChild(gameOverDetail)
|
||||||
|
gameOverRecord.fontSize = 14
|
||||||
|
gameOverRecord.fontColor = Palette.amber
|
||||||
|
gameOverRecord.horizontalAlignmentMode = .center
|
||||||
|
gameOverRecord.position = CGPoint(x: cx, y: midY - 30)
|
||||||
|
gameOverOverlay.addChild(gameOverRecord)
|
||||||
|
let prompt = bigLabel("TAP TO FLY AGAIN", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint,
|
||||||
|
x: cx, y: midY - 70)
|
||||||
|
prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)])))
|
||||||
|
gameOverOverlay.addChild(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildVictoryOverlay() {
|
||||||
|
victoryOverlay.removeAllChildren()
|
||||||
|
victoryOverlay.zPosition = 40
|
||||||
|
addChild(victoryOverlay)
|
||||||
|
let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.82), size: size)
|
||||||
|
dim.anchorPoint = .zero
|
||||||
|
victoryOverlay.addChild(dim)
|
||||||
|
let cx = size.width / 2
|
||||||
|
let midY = size.height * 0.60
|
||||||
|
victoryOverlay.addChild(bigLabel("INVASION REPELLED", size: 28, color: Palette.mint, x: cx, y: midY + 48))
|
||||||
|
victoryDetail.fontSize = 17
|
||||||
|
victoryDetail.fontColor = Palette.amber
|
||||||
|
victoryDetail.horizontalAlignmentMode = .center
|
||||||
|
victoryDetail.position = CGPoint(x: cx, y: midY)
|
||||||
|
victoryOverlay.addChild(victoryDetail)
|
||||||
|
victoryRecord.fontSize = 14
|
||||||
|
victoryRecord.fontColor = Palette.amber
|
||||||
|
victoryRecord.horizontalAlignmentMode = .center
|
||||||
|
victoryRecord.position = CGPoint(x: cx, y: midY - 28)
|
||||||
|
victoryOverlay.addChild(victoryRecord)
|
||||||
|
let prompt = bigLabel("TAP TO RETURN", fontNamed: "Menlo-Bold", size: 15, color: Palette.mint,
|
||||||
|
x: cx, y: midY - 68)
|
||||||
|
prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)])))
|
||||||
|
victoryOverlay.addChild(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game flow
|
||||||
|
|
||||||
|
private func startGame() {
|
||||||
|
sim = GalaxianSim()
|
||||||
|
targetX = 70
|
||||||
|
targetY = 70
|
||||||
|
accumulator = 0
|
||||||
|
fireCooldown = 0
|
||||||
|
pendingLaser = false
|
||||||
|
state = .playing
|
||||||
|
applyStateVisibility()
|
||||||
|
renderFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyStateVisibility() {
|
||||||
|
readyOverlay.isHidden = state != .ready
|
||||||
|
pausedOverlay.isHidden = state != .paused
|
||||||
|
gameOverOverlay.isHidden = state != .gameOver
|
||||||
|
victoryOverlay.isHidden = state != .victory
|
||||||
|
pauseButton.isHidden = state != .playing
|
||||||
|
laserButton.alpha = state == .playing ? 1 : 0.25
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endGame(won: Bool) {
|
||||||
|
let record = HighScoreStore.shared.submit(id: "galaxian", score: sim.score)
|
||||||
|
if won {
|
||||||
|
victoryDetail.text = "SCORE \(sim.score)"
|
||||||
|
victoryRecord.text = record ? "★ NEW BEST ★" : "BEST \(HighScoreStore.shared.best("galaxian"))"
|
||||||
|
state = .victory
|
||||||
|
Haptics.success()
|
||||||
|
} else {
|
||||||
|
gameOverDetail.text = "WAVE \(max(1, sim.level))/20 · SCORE \(sim.score)"
|
||||||
|
gameOverRecord.text = record ? "★ NEW BEST ★" : "BEST \(HighScoreStore.shared.best("galaxian"))"
|
||||||
|
state = .gameOver
|
||||||
|
Haptics.error()
|
||||||
|
}
|
||||||
|
applyStateVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game loop
|
||||||
|
|
||||||
|
override func update(_ currentTime: TimeInterval) {
|
||||||
|
if lastUpdate == 0 { lastUpdate = currentTime }
|
||||||
|
var dt = currentTime - lastUpdate
|
||||||
|
lastUpdate = currentTime
|
||||||
|
if dt > 0.25 { dt = 0.25 }
|
||||||
|
guard state == .playing else { return }
|
||||||
|
#if DEBUG
|
||||||
|
if demoFreeze { return }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let interval = 1.0 / GalaxianSim.fps
|
||||||
|
accumulator += dt
|
||||||
|
var steps = 0
|
||||||
|
while accumulator >= interval, steps < 5 {
|
||||||
|
accumulator -= interval
|
||||||
|
steps += 1
|
||||||
|
stepSim()
|
||||||
|
if state != .playing { break }
|
||||||
|
}
|
||||||
|
renderFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stepSim() {
|
||||||
|
var input = GalaxianSim.Input()
|
||||||
|
let dx = targetX - sim.playerX
|
||||||
|
let dy = targetY - sim.playerY
|
||||||
|
if dx != 0 { input.dx = dx > 0 ? 1 : -1 }
|
||||||
|
if dy != 0 { input.dy = dy > 0 ? 1 : -1 }
|
||||||
|
if fireCooldown > 0 { fireCooldown -= 1 }
|
||||||
|
if fireCooldown == 0 {
|
||||||
|
input.fire = true
|
||||||
|
fireCooldown = 6 // ~6 shots/sec at 35 fps
|
||||||
|
}
|
||||||
|
if pendingLaser {
|
||||||
|
input.laser = true
|
||||||
|
pendingLaser = false
|
||||||
|
}
|
||||||
|
sim.tick(input)
|
||||||
|
|
||||||
|
for event in sim.events {
|
||||||
|
switch event {
|
||||||
|
case .levelLoaded(let n):
|
||||||
|
showWaveBanner(n)
|
||||||
|
case .enemyDestroyed:
|
||||||
|
Haptics.impact(.light, intensity: 0.5)
|
||||||
|
case .bossDestroyed:
|
||||||
|
Haptics.success()
|
||||||
|
case .playerHit:
|
||||||
|
Haptics.impact(.heavy, intensity: 0.9)
|
||||||
|
case .laserFired(let x):
|
||||||
|
fireLaserBeam(atColumn: x)
|
||||||
|
Haptics.impact(.medium, intensity: 0.8)
|
||||||
|
case .droneDeployed:
|
||||||
|
Haptics.warning()
|
||||||
|
case .crossFled:
|
||||||
|
break
|
||||||
|
case .gameOver:
|
||||||
|
endGame(won: false)
|
||||||
|
case .victory:
|
||||||
|
endGame(won: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showWaveBanner(_ n: Int) {
|
||||||
|
waveBanner.removeAllActions()
|
||||||
|
waveBanner.text = "WAVE \(n) / 20"
|
||||||
|
waveBanner.alpha = 0
|
||||||
|
waveBanner.run(.sequence([
|
||||||
|
.fadeIn(withDuration: 0.2),
|
||||||
|
.wait(forDuration: 1.0),
|
||||||
|
.fadeOut(withDuration: 0.5),
|
||||||
|
]))
|
||||||
|
Haptics.selection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rendering
|
||||||
|
|
||||||
|
private func renderFrame() {
|
||||||
|
// Player: normal, invulnerable shimmer, or explosion frames.
|
||||||
|
if sim.over && !sim.won {
|
||||||
|
playerNode.isHidden = true
|
||||||
|
} else if let frame = sim.playerExplosionFrame {
|
||||||
|
place(playerNode, img: D.explosionFrames[frame], x: sim.playerX, y: sim.playerY)
|
||||||
|
playerNode.alpha = 1
|
||||||
|
} else {
|
||||||
|
place(playerNode, img: .ship, x: sim.playerX, y: sim.playerY)
|
||||||
|
playerNode.alpha = sim.playerInvulnerable ? 0.45 : 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enemies (formation, swoopers, bouncers, explosions).
|
||||||
|
for (i, node) in enemyNodes.enumerated() {
|
||||||
|
if let d = sim.enemyDisplay(i) {
|
||||||
|
place(node, img: d.img, x: d.x, y: d.y)
|
||||||
|
} else {
|
||||||
|
node.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss: patrolling sprite or twin explosions.
|
||||||
|
if sim.bossStatus > 0, let img = sim.bossImg {
|
||||||
|
place(bossNode, img: img, x: sim.bossX, y: sim.bossY)
|
||||||
|
bossBoomNode.isHidden = true
|
||||||
|
} else if sim.bossStatus < 0 {
|
||||||
|
let f = D.explosionFrames[min(sim.bossPwr >> 1, 7)]
|
||||||
|
place(bossNode, img: f, x: sim.bossX, y: sim.bossY)
|
||||||
|
place(bossBoomNode, img: f, x: sim.bossX + 8, y: sim.bossY + 4)
|
||||||
|
} else {
|
||||||
|
bossNode.isHidden = true
|
||||||
|
bossBoomNode.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bullets.
|
||||||
|
for (i, node) in pbNodes.enumerated() {
|
||||||
|
let b = sim.playerBullets[i]
|
||||||
|
if b.active { place(node, img: .playerBullet, x: b.x, y: b.y) }
|
||||||
|
else { node.isHidden = true }
|
||||||
|
}
|
||||||
|
for (i, node) in ebNodes.enumerated() {
|
||||||
|
let b = sim.enemyBullets[i]
|
||||||
|
if b.type != 0 { place(node, img: .enemyBullet, x: b.x, y: b.y) }
|
||||||
|
else { node.isHidden = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// HUD.
|
||||||
|
scoreValue.text = "\(sim.score)"
|
||||||
|
waveValue.text = "\(max(1, sim.level))/20"
|
||||||
|
livesValue.text = "\(max(0, sim.lives))"
|
||||||
|
laserCount.text = "LASER ×\(sim.lasers)"
|
||||||
|
laserButton.setPressed(false)
|
||||||
|
laserButton.alpha = state != .playing ? 0.25 : (sim.lasers > 0 ? 1.0 : 0.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Effects
|
||||||
|
|
||||||
|
private func fireLaserBeam(atColumn x: Int) {
|
||||||
|
let bx = fieldRect.minX + (CGFloat(x) + 0.5) * unit
|
||||||
|
let path = CGMutablePath()
|
||||||
|
path.move(to: CGPoint(x: bx, y: fieldRect.minY))
|
||||||
|
path.addLine(to: CGPoint(x: bx, y: fieldRect.maxY))
|
||||||
|
let beam = SKShapeNode(path: path)
|
||||||
|
beam.strokeColor = Palette.cyan
|
||||||
|
beam.lineWidth = 2.5
|
||||||
|
beam.glowWidth = 6
|
||||||
|
beam.zPosition = 8
|
||||||
|
worldCrop.addChild(beam)
|
||||||
|
beam.run(.sequence([.fadeOut(withDuration: 0.28), .removeFromParent()]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Touch handling
|
||||||
|
|
||||||
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
for touch in touches {
|
||||||
|
let p = touch.location(in: self)
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
Haptics.selection()
|
||||||
|
startGame()
|
||||||
|
case .paused:
|
||||||
|
Haptics.selection()
|
||||||
|
state = .playing
|
||||||
|
applyStateVisibility()
|
||||||
|
case .gameOver, .victory:
|
||||||
|
Haptics.selection()
|
||||||
|
state = .ready
|
||||||
|
applyStateVisibility()
|
||||||
|
case .playing:
|
||||||
|
let dp = hypot(p.x - pauseButton.position.x, p.y - pauseButton.position.y)
|
||||||
|
if dp <= 30 {
|
||||||
|
Haptics.selection()
|
||||||
|
state = .paused
|
||||||
|
moveTouch = nil
|
||||||
|
applyStateVisibility()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if laserButton.covers(p) {
|
||||||
|
if sim.lasers > 0 {
|
||||||
|
pendingLaser = true
|
||||||
|
laserButton.setPressed(true)
|
||||||
|
} else {
|
||||||
|
Haptics.warning()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moveTouch = touch
|
||||||
|
touchAnchor = p
|
||||||
|
shipAnchor = CGPoint(x: CGFloat(sim.playerX), y: CGFloat(sim.playerY))
|
||||||
|
targetX = sim.playerX
|
||||||
|
targetY = sim.playerY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
guard state == .playing, let moveTouch, touches.contains(moveTouch) else { return }
|
||||||
|
let p = moveTouch.location(in: self)
|
||||||
|
// Finger travel maps ~1.35× on-screen ship travel (per-axis).
|
||||||
|
targetX = Int(shipAnchor.x + (p.x - touchAnchor.x) * 1.35 / unit)
|
||||||
|
targetY = Int(shipAnchor.y - (p.y - touchAnchor.y) * 1.35 / unitY)
|
||||||
|
targetX = min(max(targetX, 1), 88)
|
||||||
|
targetY = min(max(targetY, 61), 72)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A faithful port of Galaxian 3.0 (Patrick Davidson, 1998) from
|
||||||
|
/// `GALAXIAN.ASM`. Runs the original's exact main-loop order, enemy state
|
||||||
|
/// machine, timers, random rolls, hitboxes and scoring at ~35 fps.
|
||||||
|
///
|
||||||
|
/// Coordinates: the original composes into a 96-wide back buffer whose rows
|
||||||
|
/// 16…79 are copied to the LCD — so x is 0…95, y grows downward, and the
|
||||||
|
/// visible window is y 16…79. Enemies stage above (y < 16) and bullets die
|
||||||
|
/// just below (y = 81). The player roams x 1…88, y 61…72.
|
||||||
|
///
|
||||||
|
/// Enemy slots are 4 "bytes" (type, pwr, x, y) exactly as in TEXT_MEM — and
|
||||||
|
/// on cross/operator levels the bounce velocities live in the *following*
|
||||||
|
/// (empty) slot's pwr/x fields, faithfully mirroring the original's layout.
|
||||||
|
struct GalaxianSim {
|
||||||
|
typealias D = GalaxianData
|
||||||
|
|
||||||
|
static let fps = 35.0 // per the doc: "approximately 35 fps"
|
||||||
|
static let fieldWidth = 96
|
||||||
|
static let visibleTop = 16 // first LCD row of the back buffer
|
||||||
|
static let visibleHeight = 64
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
struct Enemy {
|
||||||
|
var type = 0 // 0 empty · 1 waiting · 2 descending
|
||||||
|
// 3 formation · 4 exploding
|
||||||
|
// 5…12 swoop stages · 13 warp/bounce
|
||||||
|
var pwr = 0
|
||||||
|
var x = 0
|
||||||
|
var y = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlayerBullet { var active = false; var x = 0; var y = 0 }
|
||||||
|
struct EnemyBullet { var type = 0; var x = 0; var y = 0 } // 1 straight · 2/4 left · 3/5 right
|
||||||
|
|
||||||
|
enum Event: Equatable {
|
||||||
|
case levelLoaded(Int)
|
||||||
|
case enemyDestroyed(x: Int, y: Int)
|
||||||
|
case bossDestroyed
|
||||||
|
case playerHit
|
||||||
|
case laserFired(x: Int)
|
||||||
|
case droneDeployed
|
||||||
|
case crossFled
|
||||||
|
case gameOver
|
||||||
|
case victory
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Input {
|
||||||
|
var dx = 0 // -1 / 0 / +1
|
||||||
|
var dy = 0 // -1 up / +1 down
|
||||||
|
var fire = false // one cannon shot (per press)
|
||||||
|
var laser = false // one laser (per press)
|
||||||
|
init(dx: Int = 0, dy: Int = 0, fire: Bool = false, laser: Bool = false) {
|
||||||
|
self.dx = dx; self.dy = dy; self.fire = fire; self.laser = laser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var enemies = [Enemy](repeating: Enemy(), count: 20)
|
||||||
|
private(set) var playerBullets = [PlayerBullet](repeating: PlayerBullet(), count: 10)
|
||||||
|
private(set) var enemyBullets = [EnemyBullet](repeating: EnemyBullet(), count: 10)
|
||||||
|
|
||||||
|
private(set) var playerX = 70
|
||||||
|
private(set) var playerY = 70
|
||||||
|
private(set) var yourStatus = 0 // 0 normal · 16…31 exploding · 1…15 respawn shield
|
||||||
|
private(set) var lives = 9
|
||||||
|
private(set) var lasers = 9
|
||||||
|
private(set) var score = 0
|
||||||
|
private(set) var level = 0 // 1…20 once loaded
|
||||||
|
private(set) var enemiesRemaining = 0
|
||||||
|
private(set) var timer: UInt8 = 0
|
||||||
|
|
||||||
|
private(set) var bossStatus = 0 // 0 none · 1…4 patrol · 5/6 super sweep · -1 exploding
|
||||||
|
private(set) var bossPwr = 0 // hit points; explosion frame while exploding
|
||||||
|
private(set) var bossX = 0
|
||||||
|
private(set) var bossY = 0
|
||||||
|
|
||||||
|
private(set) var over = false
|
||||||
|
private(set) var won = false
|
||||||
|
private(set) var events: [Event] = []
|
||||||
|
|
||||||
|
private var rng: SeededGenerator
|
||||||
|
|
||||||
|
init(seed: UInt64? = nil) {
|
||||||
|
rng = SeededGenerator(seed: seed ?? UInt64.random(in: 1...UInt64.max))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Level config helpers
|
||||||
|
|
||||||
|
var currentLevel: D.Level { D.levels[max(0, min(level, 20) - 1)] }
|
||||||
|
|
||||||
|
private var isWeakLevel: Bool { if case .weak = currentLevel.kind { return true }; return false }
|
||||||
|
private var isWarpLevel: Bool {
|
||||||
|
switch currentLevel.kind { case .cross, .operators: return true; default: return false }
|
||||||
|
}
|
||||||
|
private var isOperatorLevel: Bool { if case .operators = currentLevel.kind { return true }; return false }
|
||||||
|
private var isSuperBoss: Bool { if case .superBoss = currentLevel.kind { return true }; return false }
|
||||||
|
|
||||||
|
/// R-register read: pseudo-random 0…127.
|
||||||
|
private mutating func randR() -> Int { Int(rng.next() & 0x7F) }
|
||||||
|
/// One `rra`-on-R carry test: ~50/50.
|
||||||
|
private mutating func rngBit() -> Bool { rng.next() & 1 == 1 }
|
||||||
|
|
||||||
|
private mutating func subScore(_ n: Int) { score = max(0, score - n) }
|
||||||
|
|
||||||
|
// MARK: - Main loop (original call order)
|
||||||
|
|
||||||
|
mutating func tick(_ input: Input) {
|
||||||
|
guard !over else { return }
|
||||||
|
events.removeAll(keepingCapacity: true)
|
||||||
|
|
||||||
|
if enemiesRemaining == 0 { nextLevel() }
|
||||||
|
if over { return }
|
||||||
|
timer &+= 1
|
||||||
|
|
||||||
|
youShoot(input)
|
||||||
|
youShootLaser(input)
|
||||||
|
yourShip(input)
|
||||||
|
if over { return }
|
||||||
|
moveYourBullets()
|
||||||
|
enemySwoop()
|
||||||
|
handleEnemies()
|
||||||
|
enemiesHitYou()
|
||||||
|
boss()
|
||||||
|
bossShootPeriodic()
|
||||||
|
enemiesShootYou()
|
||||||
|
moveEnemyBullets()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Level loading (Next_Level)
|
||||||
|
|
||||||
|
private mutating func nextLevel() {
|
||||||
|
enemies = [Enemy](repeating: Enemy(), count: 20)
|
||||||
|
timer = 0
|
||||||
|
score += 200
|
||||||
|
level += 1
|
||||||
|
if level == 21 {
|
||||||
|
score += 5000
|
||||||
|
won = true
|
||||||
|
over = true
|
||||||
|
events.append(.victory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bossStatus = 0
|
||||||
|
let lev = currentLevel
|
||||||
|
|
||||||
|
func fillFormation(_ count: Int) {
|
||||||
|
for i in 0..<count {
|
||||||
|
enemies[i] = Enemy(type: 1, pwr: lev.enemyPwr, x: 110, y: 110)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch lev.kind {
|
||||||
|
case .standard:
|
||||||
|
fillFormation(20)
|
||||||
|
enemiesRemaining = 20
|
||||||
|
case .boss(_, let pwr):
|
||||||
|
fillFormation(20)
|
||||||
|
enemiesRemaining = 21
|
||||||
|
bossStatus = 1; bossPwr = pwr; bossX = 30; bossY = 0
|
||||||
|
case .weak:
|
||||||
|
fillFormation(20)
|
||||||
|
for i in 0...12 { enemies[i].pwr = 1 }
|
||||||
|
enemiesRemaining = 20
|
||||||
|
case .cross, .operators:
|
||||||
|
// 10 bouncers in even slots; the odd slot after each carries
|
||||||
|
// (vx, vy) in its pwr/x fields, exactly like the original layout.
|
||||||
|
for i in stride(from: 0, to: 20, by: 2) {
|
||||||
|
enemies[i] = Enemy(type: 13, pwr: lev.enemyPwr, x: (randR() & 63) + 10, y: 0)
|
||||||
|
enemies[i + 1] = Enemy(type: 0, pwr: 1, x: 1, y: 0)
|
||||||
|
}
|
||||||
|
enemiesRemaining = 10
|
||||||
|
case .superBoss:
|
||||||
|
fillFormation(8)
|
||||||
|
enemiesRemaining = 9
|
||||||
|
bossStatus = 5; bossPwr = 98; bossX = 2; bossY = 0
|
||||||
|
}
|
||||||
|
events.append(.levelLoaded(level))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Player weapons
|
||||||
|
|
||||||
|
private mutating func youShoot(_ input: Input) {
|
||||||
|
guard yourStatus == 0, input.fire else { return }
|
||||||
|
guard let slot = playerBullets.firstIndex(where: { !$0.active }) else { return }
|
||||||
|
playerBullets[slot] = PlayerBullet(active: true, x: playerX + 2, y: playerY - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func youShootLaser(_ input: Input) {
|
||||||
|
guard input.laser, lasers > 0 else { return }
|
||||||
|
lasers -= 1
|
||||||
|
let lx = playerX + 3
|
||||||
|
events.append(.laserFired(x: lx))
|
||||||
|
for i in 0..<20 {
|
||||||
|
let t = enemies[i].type
|
||||||
|
if t == 0 || t == 1 || t == 4 { continue }
|
||||||
|
let dx = lx - enemies[i].x
|
||||||
|
if dx >= 0 && dx <= 7 { // beam crosses the 8-px sprite
|
||||||
|
enemies[i].pwr = 0
|
||||||
|
enemies[i].type = 4
|
||||||
|
events.append(.enemyDestroyed(x: enemies[i].x, y: enemies[i].y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Player ship (Your_Ship)
|
||||||
|
|
||||||
|
private mutating func yourShip(_ input: Input) {
|
||||||
|
if yourStatus & 16 != 0 { // exploding: -16 points a frame
|
||||||
|
subScore(16)
|
||||||
|
yourStatus -= 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if yourStatus != 0 {
|
||||||
|
yourStatus -= 1
|
||||||
|
if lives == 0 {
|
||||||
|
over = true
|
||||||
|
events.append(.gameOver)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Original key order: down, left, right, up — each edge-blocked.
|
||||||
|
if input.dy > 0, playerY + 1 != 73 { playerY += 1 }
|
||||||
|
if input.dx < 0, playerX - 1 != 0 { playerX -= 1 }
|
||||||
|
if input.dx > 0, playerX + 1 != 89 { playerX += 1 }
|
||||||
|
if input.dy < 0, playerY - 1 != 60 { playerY -= 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func damageYou() {
|
||||||
|
yourStatus = 31
|
||||||
|
lives -= 1
|
||||||
|
events.append(.playerHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Player bullets (Move_Your_Bullets)
|
||||||
|
|
||||||
|
private mutating func moveYourBullets() {
|
||||||
|
for b in 0..<10 where playerBullets[b].active {
|
||||||
|
playerBullets[b].y -= 1
|
||||||
|
if playerBullets[b].y == 0 { playerBullets[b].active = false; continue }
|
||||||
|
playerBullets[b].y -= 1
|
||||||
|
if playerBullets[b].y == 0 { playerBullets[b].active = false; continue }
|
||||||
|
|
||||||
|
let bx = playerBullets[b].x
|
||||||
|
let by = playerBullets[b].y
|
||||||
|
|
||||||
|
// Enemies. Original quirk kept: every overlapped enemy is hit
|
||||||
|
// (+10 each), stopping only when one is destroyed.
|
||||||
|
var killed = false
|
||||||
|
for i in 0..<20 {
|
||||||
|
let t = enemies[i].type
|
||||||
|
if t == 0 || t == 1 || t == 4 { continue }
|
||||||
|
let dx = enemies[i].x - bx + 5
|
||||||
|
if dx < 0 || dx >= 8 { continue }
|
||||||
|
let dy = enemies[i].y - by + 5
|
||||||
|
if dy < 0 || dy >= 10 { continue }
|
||||||
|
|
||||||
|
playerBullets[b].active = false
|
||||||
|
score += 10
|
||||||
|
enemies[i].pwr -= 1
|
||||||
|
if enemies[i].pwr == 0 {
|
||||||
|
enemies[i].type = 4 // pwr doubles as explosion frame
|
||||||
|
events.append(.enemyDestroyed(x: enemies[i].x, y: enemies[i].y))
|
||||||
|
killed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if killed { continue }
|
||||||
|
|
||||||
|
// Boss (SMC-widened hitbox for the superboss).
|
||||||
|
if bossStatus > 0 {
|
||||||
|
let k1 = isSuperBoss ? 31 : 15
|
||||||
|
let k2 = isSuperBoss ? 34 : 18
|
||||||
|
let dx = bossX - bx + k1
|
||||||
|
let dy = bossY - by + 8
|
||||||
|
if dx >= 0 && dx < k2 && dy >= 0 && dy < 13 {
|
||||||
|
playerBullets[b].active = false
|
||||||
|
score += 25
|
||||||
|
bossPwr -= 1
|
||||||
|
if bossPwr == 0 { bossStatus = -1 } // pwr now counts the explosion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Swoop trigger (Enemy_Swoop)
|
||||||
|
|
||||||
|
private mutating func enemySwoop() {
|
||||||
|
guard timer & 3 == 2 else { return }
|
||||||
|
let r = randR()
|
||||||
|
guard r < 19 else { return }
|
||||||
|
guard enemies[r].type == 3 else { return }
|
||||||
|
var t = 5 + (rngBit() ? 1 : 0)
|
||||||
|
let x = enemies[r].x
|
||||||
|
if x & 64 != 0 { t = 6 } // right side: swoop left
|
||||||
|
else if x & 32 == 0 { t = 5 } // left side: swoop right
|
||||||
|
enemies[r].type = t // middle keeps the random pick
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enemy state machine (Handle_Enemies)
|
||||||
|
|
||||||
|
private mutating func handleEnemies() {
|
||||||
|
for i in 0..<20 {
|
||||||
|
switch enemies[i].type {
|
||||||
|
case 1: enemyWaiting(i)
|
||||||
|
case 2: enemyDescending(i)
|
||||||
|
case 3: enemyFormation(i)
|
||||||
|
case 4: enemyExploding(i)
|
||||||
|
case 5: swoopStage(i, dx: 1, dy: -1, next: 7)
|
||||||
|
case 6: swoopStage(i, dx: -1, dy: -1, next: 8)
|
||||||
|
case 7: swoopStage(i, dx: 1, dy: 0, next: 9)
|
||||||
|
case 8: swoopStage(i, dx: -1, dy: 0, next: 10)
|
||||||
|
case 9: swoopStage(i, dx: 1, dy: 1, next: 11)
|
||||||
|
case 10: swoopStage(i, dx: -1, dy: 1, next: 11)
|
||||||
|
case 11: swoopStage(i, dx: 0, dy: 1, next: 12)
|
||||||
|
case 12: enemyDiving(i)
|
||||||
|
case 13: enemyWarp(i)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 1: enters when the masked timer matches its pattern byte. The
|
||||||
|
/// pattern byte is both entry time and formation depth: the enemy descends
|
||||||
|
/// 1 px/frame and the timer wraps exactly `depth` frames later.
|
||||||
|
private mutating func enemyWaiting(_ i: Int) {
|
||||||
|
let pair = currentLevel.pattern[19 - i] // original indexes coords from the end
|
||||||
|
guard (Int(timer) & 127) == 128 - pair.y else { return }
|
||||||
|
enemies[i].type = 2
|
||||||
|
enemies[i].x = pair.x
|
||||||
|
enemies[i].y = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 2: descend until the 128-frame wrap, then join the formation.
|
||||||
|
private mutating func enemyDescending(_ i: Int) {
|
||||||
|
if timer == 0 || timer == 128 {
|
||||||
|
enemies[i].type = 3
|
||||||
|
enemyFormation(i) // original falls through
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enemies[i].y += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 3: formation sway — even frames only, direction from timer bit 6.
|
||||||
|
private mutating func enemyFormation(_ i: Int) {
|
||||||
|
guard timer & 1 == 0 else { return }
|
||||||
|
enemies[i].x += 1
|
||||||
|
if timer & 64 != 0 { enemies[i].x -= 2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 4: 16-frame explosion, then +100 and the slot frees up.
|
||||||
|
private mutating func enemyExploding(_ i: Int) {
|
||||||
|
if enemies[i].pwr == 15 {
|
||||||
|
enemies[i].type = 0
|
||||||
|
enemiesRemaining -= 1
|
||||||
|
score += 100
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enemies[i].pwr += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Types 5…11: constant-velocity swoop stages, advancing every 4th frame.
|
||||||
|
private mutating func swoopStage(_ i: Int, dx: Int, dy: Int, next: Int) {
|
||||||
|
enemies[i].x += dx
|
||||||
|
enemies[i].y += dy
|
||||||
|
if timer & 3 == 0 { enemies[i].type = next }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 12: fast dive off the bottom, then rejoin the entry queue (-1 pt).
|
||||||
|
private mutating func enemyDiving(_ i: Int) {
|
||||||
|
enemies[i].y += 2
|
||||||
|
if enemies[i].y > 78 {
|
||||||
|
enemies[i].type = 1
|
||||||
|
subScore(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 13: cross/operator bounce box. Velocities live in the next slot
|
||||||
|
/// (pwr = vx, x = vy). Half of the crosses can flee off the top (-50).
|
||||||
|
private mutating func enemyWarp(_ i: Int) {
|
||||||
|
var vx = enemies[i + 1].pwr
|
||||||
|
var vy = enemies[i + 1].x
|
||||||
|
|
||||||
|
let nx = enemies[i].x + vx
|
||||||
|
if nx == 1 { vx = 1 }
|
||||||
|
if nx == 23 { vx = rngBit() ? 1 : -1 }
|
||||||
|
if nx == 45 { vx = rngBit() ? 1 : -1 }
|
||||||
|
if nx == 87 { vx = -1 }
|
||||||
|
enemies[i].x = nx
|
||||||
|
enemies[i + 1].pwr = vx
|
||||||
|
|
||||||
|
var ny = enemies[i].y + vy
|
||||||
|
if ny == 0 { // escaped off the top
|
||||||
|
enemies[i].type = 0
|
||||||
|
enemiesRemaining -= 1
|
||||||
|
subScore(50)
|
||||||
|
events.append(.crossFled)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ny == 15 { vy = 1 }
|
||||||
|
if ny == 27 { vy = rngBit() ? 1 : -1 }
|
||||||
|
if ny == 39 {
|
||||||
|
vy = -1
|
||||||
|
if !rngBit(), !rngBit(), // 1-in-4 roll
|
||||||
|
!isOperatorLevel, (40 - 2 * i) & 8 != 0 {
|
||||||
|
vy = -2 // flee: rises 2/frame to exactly 0
|
||||||
|
ny -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enemies[i].y = ny
|
||||||
|
enemies[i + 1].x = vy
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enemies ram you (Enemies_Hit_You)
|
||||||
|
|
||||||
|
private mutating func enemiesHitYou() {
|
||||||
|
guard yourStatus == 0 else { return }
|
||||||
|
for i in 0..<20 {
|
||||||
|
guard enemies[i].type != 0 else { continue }
|
||||||
|
let dx = enemies[i].x - playerX + 6
|
||||||
|
if dx < 0 || dx >= 12 { continue }
|
||||||
|
let dy = enemies[i].y - playerY + 6
|
||||||
|
if dy < 0 || dy >= 12 { continue }
|
||||||
|
enemies[i].pwr = 0
|
||||||
|
enemies[i].type = 4
|
||||||
|
events.append(.enemyDestroyed(x: enemies[i].x, y: enemies[i].y))
|
||||||
|
damageYou()
|
||||||
|
return // one ram per frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Boss (Boss)
|
||||||
|
|
||||||
|
private mutating func boss() {
|
||||||
|
switch bossStatus {
|
||||||
|
case 0:
|
||||||
|
return
|
||||||
|
case ..<0: // exploding
|
||||||
|
if bossPwr == 15 {
|
||||||
|
bossStatus = 0
|
||||||
|
enemiesRemaining -= 1
|
||||||
|
score += 1000
|
||||||
|
events.append(.bossDestroyed)
|
||||||
|
} else {
|
||||||
|
bossPwr += 1
|
||||||
|
}
|
||||||
|
case 1: // patrol: down to y 20
|
||||||
|
bossY += 1
|
||||||
|
if bossY == 20 { bossStatus = 2 }
|
||||||
|
case 2: // right to x 80
|
||||||
|
bossX += 1
|
||||||
|
if bossX == 80 { bossStatus = 3 }
|
||||||
|
case 3: // up to y 16
|
||||||
|
bossY -= 1
|
||||||
|
if bossY == 16 { bossStatus = 4 }
|
||||||
|
case 4: // left to x 0
|
||||||
|
bossX -= 1
|
||||||
|
if bossX == 0 { bossStatus = 1 }
|
||||||
|
case 5: // superboss: sweep left to 0
|
||||||
|
bossX -= 1
|
||||||
|
if bossX == 0 { bossStatus = 6 }
|
||||||
|
deployEnemies()
|
||||||
|
case 6: // descend to y 16, sweep right to 65
|
||||||
|
if bossY != 16 {
|
||||||
|
bossY += 1
|
||||||
|
} else {
|
||||||
|
bossX += 1
|
||||||
|
if bossX == 65 { bossStatus = 5 }
|
||||||
|
}
|
||||||
|
deployEnemies()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Superboss drone factory: ~7/128 chance a frame, into slots 0…6 (-200).
|
||||||
|
private mutating func deployEnemies() {
|
||||||
|
let r = randR()
|
||||||
|
guard r < 7 else { return }
|
||||||
|
guard enemies[r].type == 0 else { return }
|
||||||
|
enemies[r] = Enemy(type: 11, pwr: currentLevel.enemyPwr, x: bossX + 12, y: 24)
|
||||||
|
enemiesRemaining += 1
|
||||||
|
subScore(200)
|
||||||
|
events.append(.droneDeployed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Firing (Boss_Shoot / Enemies_Shoot_You)
|
||||||
|
|
||||||
|
/// The original's 5-zone aim: picks a straight/drifting bullet type from
|
||||||
|
/// the player's horizontal offset against the muzzle x.
|
||||||
|
private mutating func spawnAimedBullet(fromX d: Int, y e: Int) {
|
||||||
|
guard let slot = enemyBullets.firstIndex(where: { $0.type == 0 }) else { return }
|
||||||
|
var t = 1
|
||||||
|
let a = playerX - d + 18
|
||||||
|
if a < 0 {
|
||||||
|
t = 2
|
||||||
|
if a + 18 < 0 { t = 4 }
|
||||||
|
} else if a - 54 >= 0 {
|
||||||
|
t = 3
|
||||||
|
if a - 72 >= 0 { t = 5 }
|
||||||
|
}
|
||||||
|
enemyBullets[slot] = EnemyBullet(type: t, x: d, y: e)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func spawnStraightBullet(x: Int, y: Int) {
|
||||||
|
guard let slot = enemyBullets.firstIndex(where: { $0.type == 0 }) else { return }
|
||||||
|
enemyBullets[slot] = EnemyBullet(type: 1, x: x, y: y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func bossShootPeriodic() {
|
||||||
|
guard timer & 63 == 0, bossStatus > 0 else { return }
|
||||||
|
let e = bossY + 5
|
||||||
|
if isSuperBoss {
|
||||||
|
for off in [0, 10, 20, 30] { spawnAimedBullet(fromX: bossX + off, y: e) }
|
||||||
|
} else {
|
||||||
|
spawnAimedBullet(fromX: bossX, y: e)
|
||||||
|
spawnAimedBullet(fromX: bossX + 13, y: e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func enemiesShootYou() {
|
||||||
|
let r = randR()
|
||||||
|
guard r < 19 else { return }
|
||||||
|
let t = enemies[r].type
|
||||||
|
guard t != 0, t != 1 else { return }
|
||||||
|
let d = enemies[r].x + 2
|
||||||
|
let e = enemies[r].y + 5
|
||||||
|
if isOperatorLevel {
|
||||||
|
spawnAimedBullet(fromX: d, y: e) // operators aim like bosses
|
||||||
|
} else {
|
||||||
|
spawnStraightBullet(x: d, y: e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enemy bullets (Move_Enemy_Bullets)
|
||||||
|
|
||||||
|
private mutating func moveEnemyBullets() {
|
||||||
|
for b in 0..<10 {
|
||||||
|
let t = enemyBullets[b].type
|
||||||
|
guard t != 0 else { continue }
|
||||||
|
|
||||||
|
switch t {
|
||||||
|
case 2:
|
||||||
|
if timer & 1 == 0 {
|
||||||
|
enemyBullets[b].x -= 1
|
||||||
|
if enemyBullets[b].x == 0 { enemyBullets[b].type = 0; continue }
|
||||||
|
}
|
||||||
|
case 4:
|
||||||
|
enemyBullets[b].x -= 1
|
||||||
|
if enemyBullets[b].x == 0 { enemyBullets[b].type = 0; continue }
|
||||||
|
case 3:
|
||||||
|
if timer & 1 == 0 {
|
||||||
|
enemyBullets[b].x += 1
|
||||||
|
if enemyBullets[b].x == 95 { enemyBullets[b].type = 0; continue }
|
||||||
|
}
|
||||||
|
case 5:
|
||||||
|
enemyBullets[b].x += 1
|
||||||
|
if enemyBullets[b].x == 95 { enemyBullets[b].type = 0; continue }
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if enemyBullets[b].y == 81 { enemyBullets[b].type = 0; continue }
|
||||||
|
enemyBullets[b].y += 1
|
||||||
|
|
||||||
|
if yourStatus == 0 {
|
||||||
|
let dx = playerX - enemyBullets[b].x + 6
|
||||||
|
let dy = playerY - enemyBullets[b].y + 6
|
||||||
|
if dx >= 0 && dx < 9 && dy >= 0 && dy < 9 {
|
||||||
|
damageYou()
|
||||||
|
enemyBullets[b].type = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Display helpers
|
||||||
|
|
||||||
|
/// What to draw for enemy slot `i`, or nil if nothing.
|
||||||
|
func enemyDisplay(_ i: Int) -> (img: D.Img, x: Int, y: Int)? {
|
||||||
|
let e = enemies[i]
|
||||||
|
switch e.type {
|
||||||
|
case 0, 1:
|
||||||
|
return nil
|
||||||
|
case 4:
|
||||||
|
return (D.explosionFrames[min(e.pwr >> 1, 7)], e.x, e.y)
|
||||||
|
default:
|
||||||
|
let img: D.Img = (isWeakLevel && i <= 12) ? .enemy1 : currentLevel.enemyImg
|
||||||
|
return (img, e.x, e.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bossImg: D.Img? {
|
||||||
|
guard bossStatus != 0 else { return nil }
|
||||||
|
if case .boss(let img, _) = currentLevel.kind { return img }
|
||||||
|
if isSuperBoss { return .boss3 }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Player explosion frame (0…7) while exploding, else nil.
|
||||||
|
var playerExplosionFrame: Int? {
|
||||||
|
guard yourStatus & 16 != 0 else { return nil }
|
||||||
|
return ((((yourStatus - 1) & 14) ^ 14) >> 1)
|
||||||
|
}
|
||||||
|
var playerInvulnerable: Bool { yourStatus != 0 }
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
mutating func debugSetLevel(_ n: Int) { // next tick loads level n
|
||||||
|
level = n - 1
|
||||||
|
enemiesRemaining = 0
|
||||||
|
enemies = [Enemy](repeating: Enemy(), count: 20)
|
||||||
|
bossStatus = 0
|
||||||
|
}
|
||||||
|
mutating func debugPlacePlayer(x: Int, y: Int) { playerX = x; playerY = y }
|
||||||
|
mutating func debugSetLives(_ n: Int) { lives = n }
|
||||||
|
mutating func debugSetLasers(_ n: Int) { lasers = n }
|
||||||
|
mutating func debugSetScore(_ n: Int) { score = n }
|
||||||
|
mutating func debugSetTimer(_ t: UInt8) { timer = t }
|
||||||
|
mutating func debugSetEnemy(_ i: Int, type: Int, pwr: Int, x: Int, y: Int) {
|
||||||
|
enemies[i] = Enemy(type: type, pwr: pwr, x: x, y: y)
|
||||||
|
}
|
||||||
|
mutating func debugSetRemaining(_ n: Int) { enemiesRemaining = n }
|
||||||
|
mutating func debugSetBoss(status: Int, pwr: Int, x: Int, y: Int) {
|
||||||
|
bossStatus = status; bossPwr = pwr; bossX = x; bossY = y
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A faithful port of the TI-85 "Lunar Lander" (Tiger/Istar, 1997) game logic
|
||||||
|
/// from `LANDER.C`. Physics, terrain generation, landing tolerances and scoring
|
||||||
|
/// are the original's integer rules verbatim on the Moon setting; the SpriteKit
|
||||||
|
/// scene scales the field up for HD rendering.
|
||||||
|
///
|
||||||
|
/// Coordinate convention (from the original): `y` is height above the floor,
|
||||||
|
/// larger = higher; gravity decrements `vy` each tick; `x` is the left edge of
|
||||||
|
/// the 8-wide lander. `surface[i]` is the terrain height (0…32) at column `i`.
|
||||||
|
///
|
||||||
|
/// HD-remake liberties (everything else is the original's integer rules):
|
||||||
|
/// - The field is 128×96 instead of 128×64 — extra sky above the same 0…32
|
||||||
|
/// terrain, giving a phone-shaped playfield and room to fly the descent.
|
||||||
|
/// - The field edges are soft walls: drifting into the top or sides stops the
|
||||||
|
/// lander instead of ending the run (the original's screen edge was fatal,
|
||||||
|
/// which on touch turned a mistimed start tap into an instant loss).
|
||||||
|
/// - A gravity setting (Moon/Mars/Earth). Moon is the original: pull 1/tick,
|
||||||
|
/// side drift auto-damped every tick. Heavier worlds pull harder AND keep
|
||||||
|
/// more of your sideways momentum, so you must burn to counter-correct —
|
||||||
|
/// and multiply the landing score ×2/×3.
|
||||||
|
struct LanderSim {
|
||||||
|
static let fieldWidth = 128
|
||||||
|
static let fieldHeight = 96 // original: 64 (see header note)
|
||||||
|
static let landerSize = 8 // sprite is 8 px wide/tall in the original
|
||||||
|
static let padWidth = 10
|
||||||
|
|
||||||
|
/// The gravity/handling worlds. `pull(_:)`/`damps(_:)` are phase patterns
|
||||||
|
/// over the tick counter so everything stays integer-exact:
|
||||||
|
/// - moon: pull 1 every tick, damping every tick (the original)
|
||||||
|
/// - mars: pull 2 every 4th, damping every other tick (avg 1.25)
|
||||||
|
/// - earth: pull 2 every other, no damping — Newtonian (avg 1.5)
|
||||||
|
enum Gravity: Int, CaseIterable {
|
||||||
|
case moon = 0, mars, earth
|
||||||
|
var name: String { ["MOON", "MARS", "EARTH"][rawValue] }
|
||||||
|
var scoreMultiplier: Int { rawValue + 1 }
|
||||||
|
func pull(_ phase: Int) -> Int {
|
||||||
|
switch self {
|
||||||
|
case .moon: return 1
|
||||||
|
case .mars: return phase % 4 == 3 ? 2 : 1
|
||||||
|
case .earth: return phase % 2 == 1 ? 2 : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func damps(_ phase: Int) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .moon: return true
|
||||||
|
case .mars: return phase % 2 == 0
|
||||||
|
case .earth: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Difficulty — fuel/speed are the original's main-menu settings (fuel
|
||||||
|
// 30…80, speed now 4…7: at 3 terminal velocity equals the safe landing
|
||||||
|
// speed and the game can't be lost). Gravity is the remake's third knob.
|
||||||
|
var maxFuel: Int
|
||||||
|
var maxVel: Int
|
||||||
|
var gravity: Gravity = .moon
|
||||||
|
|
||||||
|
// Terrain
|
||||||
|
private(set) var surface = [Int](repeating: 0, count: fieldWidth)
|
||||||
|
private(set) var padStart = 0
|
||||||
|
private(set) var padHeight = 0
|
||||||
|
|
||||||
|
// Lander state
|
||||||
|
private(set) var x = 0
|
||||||
|
private(set) var y = 0
|
||||||
|
private(set) var vx = 0
|
||||||
|
private(set) var vy = 0
|
||||||
|
private(set) var fuel = 0
|
||||||
|
|
||||||
|
enum Outcome: Equatable { case flying, landed, crashed }
|
||||||
|
private(set) var outcome: Outcome = .flying
|
||||||
|
private(set) var lastLandingScore = 0
|
||||||
|
private var gravPhase = 0
|
||||||
|
|
||||||
|
private var rng: SeededGenerator
|
||||||
|
|
||||||
|
init(maxFuel: Int = 40, maxVel: Int = 7, gravity: Gravity = .moon, seed: UInt64? = nil) {
|
||||||
|
self.maxFuel = maxFuel
|
||||||
|
self.maxVel = maxVel
|
||||||
|
self.gravity = gravity
|
||||||
|
self.rng = SeededGenerator(seed: seed ?? UInt64.random(in: 1...UInt64.max))
|
||||||
|
newTerrain()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 15-bit pseudo-random value, mirroring the original C `rand()` range.
|
||||||
|
private mutating func rand() -> Int { Int(rng.next() & 0x7FFF) }
|
||||||
|
|
||||||
|
// MARK: - Terrain
|
||||||
|
|
||||||
|
/// Generates a fresh landscape + landing pad and resets the lander. This is
|
||||||
|
/// the "landscape generator" from LANDER.C (© Dape/Istar) plus pad carving.
|
||||||
|
mutating func newTerrain() {
|
||||||
|
var s = [Int](repeating: 0, count: Self.fieldWidth)
|
||||||
|
|
||||||
|
s[0] = (rand() & 0x1F) + (rand() & 0x1F)
|
||||||
|
if s[0] > 32 { s[0] = 32 }
|
||||||
|
for i in 1..<Self.fieldWidth {
|
||||||
|
var k = s[i - 1] + (rand() & 0x0F) - (rand() & 0x0F)
|
||||||
|
if k > 32 { k = 32 }
|
||||||
|
if k < 0 { k = 0 }
|
||||||
|
s[i] = k
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erosion: repeated weighted smoothing passes.
|
||||||
|
let passes = (rand() & 0x0F) + 2
|
||||||
|
for _ in 0..<passes {
|
||||||
|
for i in 1..<(Self.fieldWidth - 1) {
|
||||||
|
s[i] = (s[i - 1] + s[i] + s[i + 1] + s[i]) >> 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carve a flat landing pad, dropped 4 below the surrounding terrain.
|
||||||
|
let start = 32 + (rand() & 0x3F) // 32…95
|
||||||
|
var h = s[start] - 4
|
||||||
|
if h < 0 { h = 0 }
|
||||||
|
for i in start..<min(start + Self.padWidth, Self.fieldWidth) { s[i] = h }
|
||||||
|
|
||||||
|
surface = s
|
||||||
|
padStart = start
|
||||||
|
padHeight = h
|
||||||
|
|
||||||
|
resetLander()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops a fresh lander at the top of the field with full tanks, keeping the
|
||||||
|
/// current terrain. Used to (re)start a descent on the previewed landscape.
|
||||||
|
mutating func resetLander() {
|
||||||
|
x = 32 + (rand() & 0x3F) // 32…95
|
||||||
|
y = Self.fieldHeight - 8 // high, with headroom below the ceiling
|
||||||
|
vx = 0
|
||||||
|
vy = 0
|
||||||
|
fuel = maxFuel
|
||||||
|
outcome = .flying
|
||||||
|
gravPhase = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a difficulty change from the ready screen without regenerating terrain.
|
||||||
|
mutating func setDifficulty(maxFuel: Int, maxVel: Int, gravity: Gravity) {
|
||||||
|
self.maxFuel = maxFuel
|
||||||
|
self.maxVel = maxVel
|
||||||
|
self.gravity = gravity
|
||||||
|
self.fuel = maxFuel
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Simulation
|
||||||
|
|
||||||
|
/// Advances exactly one integer tick with the given thruster inputs and
|
||||||
|
/// returns the resulting outcome. Rules are identical to the original loop.
|
||||||
|
@discardableResult
|
||||||
|
mutating func tick(left: Bool, up: Bool, right: Bool) -> Outcome {
|
||||||
|
guard outcome == .flying else { return outcome }
|
||||||
|
|
||||||
|
vy -= gravity.pull(gravPhase) // gravity (original: −1 every tick)
|
||||||
|
if left, fuel > 0 { vx -= 2; fuel -= 1 }
|
||||||
|
if right, fuel > 0 { vx += 2; fuel -= 1 }
|
||||||
|
if up, fuel > 0 { vy += 2; fuel -= 1 }
|
||||||
|
|
||||||
|
if gravity.damps(gravPhase) { // horizontal damping (Moon: every tick)
|
||||||
|
if vx > 0 { vx -= 1 } else if vx < 0 { vx += 1 }
|
||||||
|
}
|
||||||
|
gravPhase += 1
|
||||||
|
|
||||||
|
vx = clamp(vx, -maxVel, maxVel)
|
||||||
|
vy = clamp(vy, -maxVel, maxVel)
|
||||||
|
|
||||||
|
x += vx
|
||||||
|
y += vy
|
||||||
|
|
||||||
|
// Soft walls: the field edge stops the lander, it doesn't end the run.
|
||||||
|
if y > Self.fieldHeight - 1 { y = Self.fieldHeight - 1; if vy > 0 { vy = 0 } }
|
||||||
|
if x < 0 { x = 0; if vx < 0 { vx = 0 } }
|
||||||
|
if x > Self.fieldWidth - Self.landerSize {
|
||||||
|
x = Self.fieldWidth - Self.landerSize
|
||||||
|
if vx > 0 { vx = 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Landing / crash test across the 8-pixel footprint.
|
||||||
|
let l = surface[x]
|
||||||
|
var flat = 0
|
||||||
|
for i in x..<(x + Self.landerSize) {
|
||||||
|
let sfc = surface[i]
|
||||||
|
if sfc > y - 9 && sfc == l && vy >= -3 {
|
||||||
|
flat += 1
|
||||||
|
} else if (sfc > y - 9 && vy < -3) || (sfc > y - 4) {
|
||||||
|
outcome = .crashed
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if flat > 6 {
|
||||||
|
outcome = .landed
|
||||||
|
lastLandingScore = ((90 - maxFuel) * fuel / maxFuel) * maxVel * gravity.scoreMultiplier
|
||||||
|
}
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived values for the HUD
|
||||||
|
|
||||||
|
/// Positive when descending; this is the value the original crash test gates on.
|
||||||
|
var descentRate: Int { -vy }
|
||||||
|
/// A landing at the current vertical speed would be survivable (vy >= -3).
|
||||||
|
var isSafeDescent: Bool { vy >= -3 }
|
||||||
|
var fuelFraction: Double { maxFuel > 0 ? Double(fuel) / Double(maxFuel) : 0 }
|
||||||
|
|
||||||
|
private func clamp(_ v: Int, _ lo: Int, _ hi: Int) -> Int { min(max(v, lo), hi) }
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Places the lander at a fixed state for screenshots/tests.
|
||||||
|
mutating func debugPose(x: Int, y: Int, vx: Int = 0, vy: Int = -2) {
|
||||||
|
self.x = x; self.y = y; self.vx = vx; self.vy = vy; self.outcome = .flying
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Data tables for Phoenix 4.0 (Patrick Davidson, 2008), transcribed from
|
||||||
|
/// `images.asm` and `levels.asm`. Sprites are stored as 16-bit rows with bit 15
|
||||||
|
/// = leftmost pixel; multi-part wide sprites from the original are already
|
||||||
|
/// merged. The campaign is the original's level list, command for command
|
||||||
|
/// (L_GOTO chains are inlined, which is exactly what the interpreter did).
|
||||||
|
enum PhoenixData {
|
||||||
|
|
||||||
|
// MARK: - Sprites
|
||||||
|
|
||||||
|
struct Sprite {
|
||||||
|
let w: Int
|
||||||
|
let h: Int
|
||||||
|
let rows: [UInt16] // bit 15 = leftmost pixel of the sprite
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Img: Hashable {
|
||||||
|
case enemy1, enemy2, enemy2a, enemy4, silo // silo = img_operator
|
||||||
|
case bounce, boss, boss2, swoop
|
||||||
|
case spin1a, spin1b, spin1c, spin1d
|
||||||
|
case spin2a, spin2b, spin2c, spin2d
|
||||||
|
case enemy3a, enemy3b, enemy3c, enemy3d
|
||||||
|
case playerShip, playerShipDamaged, companion
|
||||||
|
case pb0, pb1, pb2l, pb2r, pb3, pb5, pbQuad
|
||||||
|
case eb1, eb2, eb3, eb4, money
|
||||||
|
case x1, x2, x3, x4, x5, x6, x7, x8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animated image sequences: (frame image, duration in game frames).
|
||||||
|
/// All original sequences loop from the start; the explosion instead ends
|
||||||
|
/// with a kill marker, which the sim special-cases.
|
||||||
|
enum Anim {
|
||||||
|
case spin1, spin2, enemy3, swoop
|
||||||
|
var frames: [(img: Img, time: Int)] {
|
||||||
|
switch self {
|
||||||
|
case .spin1: return [(.spin1a, 2), (.spin1b, 2), (.spin1c, 2), (.spin1d, 2)]
|
||||||
|
case .spin2: return [(.spin2a, 2), (.spin2b, 2), (.spin2c, 2), (.spin2d, 2)]
|
||||||
|
case .enemy3: return [(.enemy3a, 4), (.enemy3b, 4), (.enemy3c, 4), (.enemy3d, 4)]
|
||||||
|
case .swoop: return [(.swoop, 2), (.swoop, 2)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let explosionFrames: [Img] = [.x1, .x2, .x3, .x4, .x5, .x6, .x7, .x8]
|
||||||
|
|
||||||
|
private static func s8(_ w: Int, _ h: Int, _ rows: [UInt8]) -> Sprite {
|
||||||
|
Sprite(w: w, h: h, rows: rows.map { UInt16($0) << 8 })
|
||||||
|
}
|
||||||
|
|
||||||
|
static let sprites: [Img: Sprite] = [
|
||||||
|
// Standard enemies
|
||||||
|
.enemy4: s8(7, 9, [0b10000010, 0b11000110, 0b11111110, 0b10000010, 0b10000010,
|
||||||
|
0b10000010, 0b11111110, 0b01111100, 0b00111000]),
|
||||||
|
.enemy1: s8(6, 6, [0b10000100, 0b10000100, 0b11111100, 0b11111100, 0b01111000, 0b00110000]),
|
||||||
|
.enemy2: s8(8, 5, [0b10000001, 0b11011011, 0b11111111, 0b11011011, 0b10000001]),
|
||||||
|
.enemy2a: s8(5, 5, [0b10001000, 0b11111000, 0b10001000, 0b01010000, 0b00100000]),
|
||||||
|
.silo: s8(8, 8, [0b01111110, 0b11111111, 0b11000011, 0b11000011, 0b11000011,
|
||||||
|
0b11000011, 0b11111111, 0b01111110]),
|
||||||
|
// Spinners (4-frame rotations)
|
||||||
|
.spin1a: s8(8, 8, [0b00100000, 0b00111100, 0b01111111, 0b01100110, 0b01100110,
|
||||||
|
0b11111110, 0b00111100, 0b00000100]),
|
||||||
|
.spin1b: s8(8, 8, [0b00001000, 0b00111100, 0b01111110, 0b11100110, 0b01100111,
|
||||||
|
0b01111110, 0b00111100, 0b00010000]),
|
||||||
|
.spin1c: s8(8, 8, [0b00000010, 0b10111100, 0b01111110, 0b01100110, 0b01100110,
|
||||||
|
0b01111110, 0b00111101, 0b01000000]),
|
||||||
|
.spin1d: s8(8, 8, [0b01000000, 0b01111111, 0b01111110, 0b01100110, 0b01100110,
|
||||||
|
0b01111110, 0b11111110, 0b00000010]),
|
||||||
|
.spin2a: s8(8, 8, [0b00100000, 0b00111100, 0b01111111, 0b01111110, 0b01111110,
|
||||||
|
0b11111110, 0b00111100, 0b00000100]),
|
||||||
|
.spin2b: s8(8, 8, [0b00001000, 0b00111100, 0b01111110, 0b11111110, 0b01111111,
|
||||||
|
0b01111110, 0b00111100, 0b00010000]),
|
||||||
|
.spin2c: s8(8, 8, [0b00000010, 0b10111100, 0b01111110, 0b01111110, 0b01111110,
|
||||||
|
0b01111110, 0b00111101, 0b01000000]),
|
||||||
|
.spin2d: s8(8, 8, [0b01000000, 0b01111111, 0b01111110, 0b01111110, 0b01111110,
|
||||||
|
0b01111110, 0b11111110, 0b00000010]),
|
||||||
|
// Bouncers & ring enemies
|
||||||
|
.bounce: s8(8, 9, [0b00011000, 0b00011000, 0b00011000, 0b00100100, 0b01000010,
|
||||||
|
0b10000001, 0b10011001, 0b10100101, 0b11000011]),
|
||||||
|
.enemy3a: s8(8, 8, [0b00111100, 0b01000010, 0b10000001, 0b11111111, 0b11111111,
|
||||||
|
0b10000001, 0b01000010, 0b00111100]),
|
||||||
|
.enemy3b: s8(8, 8, [0b00111100, 0b01000110, 0b10001111, 0b10011101, 0b10111001,
|
||||||
|
0b11110001, 0b01100010, 0b00111100]),
|
||||||
|
// Note: row 4 of enemy3c assembles from the original's 7-digit literal
|
||||||
|
// (%1011001); the shipped byte is $59, kept faithfully.
|
||||||
|
.enemy3c: s8(8, 8, [0b00111100, 0b01011010, 0b10011001, 0b01011001, 0b10011001,
|
||||||
|
0b10011001, 0b01011010, 0b00111100]),
|
||||||
|
.enemy3d: s8(8, 8, [0b00111100, 0b01100010, 0b11110001, 0b10111001, 0b10011101,
|
||||||
|
0b10001111, 0b01000110, 0b00111100]),
|
||||||
|
// Bosses (16 wide, merged from the two 8-px sections)
|
||||||
|
.boss2: Sprite(w: 16, h: 12, rows: [
|
||||||
|
0b11111000_00011111, 0b10001111_11110001, 0b10001000_00010001, 0b10001000_00010001,
|
||||||
|
0b10001000_00010001, 0b10001000_00010001, 0b10001000_00010001, 0b10001111_11110001,
|
||||||
|
0b11111000_00011111, 0b11111000_00011111, 0b01110000_00001110, 0b00100000_00000100,
|
||||||
|
]),
|
||||||
|
.boss: Sprite(w: 16, h: 12, rows: [
|
||||||
|
0b10000000_00000001, 0b11000000_00000011, 0b11100000_00000111, 0b11111111_11111111,
|
||||||
|
0b11000111_11100011, 0b11000011_11000011, 0b11000001_10000011, 0b11000001_10000011,
|
||||||
|
0b11100001_10000111, 0b01110001_10001110, 0b00111111_11111100, 0b00011111_11111000,
|
||||||
|
]),
|
||||||
|
// Swooper (11 wide, merged from 8+3 sections)
|
||||||
|
.swoop: Sprite(w: 11, h: 8, rows: [
|
||||||
|
0b11100000_11100000, 0b10101110_10100000, 0b11101110_11100000, 0b10111111_10100000,
|
||||||
|
0b11111111_11100000, 0b10101110_10100000, 0b11101110_11100000, 0b10100100_10100000,
|
||||||
|
]),
|
||||||
|
// Player craft
|
||||||
|
.playerShip: s8(8, 6, [0b10000001, 0b10000001, 0b10011001, 0b10100101, 0b11000011, 0b10000001]),
|
||||||
|
.playerShipDamaged: s8(8, 6, [0b10000000, 0b00000001, 0b10010000, 0b00000101, 0b11000000, 0b00000001]),
|
||||||
|
.companion: s8(7, 7, [0b00010000, 0b00111000, 0b10111010, 0b10111010, 0b10111010,
|
||||||
|
0b11111110, 0b11111110]),
|
||||||
|
// Player bullets
|
||||||
|
.pb0: s8(2, 6, [0b11000000, 0b11000000, 0b11000000, 0b11000000, 0b11000000, 0b11000000]),
|
||||||
|
.pb1: s8(4, 8, [0b01100000, 0b11110000, 0b10100000, 0b01010000, 0b10100000,
|
||||||
|
0b01010000, 0b10100000, 0b01010000]),
|
||||||
|
.pb2l: s8(3, 7, [0b00100000, 0b01100000, 0b11100000, 0b11100000, 0b11100000, 0b11000000, 0b10000000]),
|
||||||
|
.pb2r: s8(3, 7, [0b10000000, 0b11000000, 0b11100000, 0b11100000, 0b11100000, 0b01100000, 0b00100000]),
|
||||||
|
.pb3: s8(4, 4, [0b01100000, 0b11110000, 0b11110000, 0b01100000]),
|
||||||
|
.pb5: s8(5, 7, [0b00100000, 0b01110000, 0b11111000, 0b11111000, 0b11111000, 0b11011000, 0b10001000]),
|
||||||
|
.pbQuad: s8(3, 5, [0b01000000, 0b11100000, 0b11100000, 0b11100000, 0b11100000]),
|
||||||
|
// Enemy bullets & drops
|
||||||
|
.eb1: s8(2, 2, [0b11000000, 0b11000000]),
|
||||||
|
.eb3: s8(3, 3, [0b01000000, 0b11100000, 0b01000000]),
|
||||||
|
.eb2: s8(3, 7, [0b10100000, 0b11100000, 0b11100000, 0b01000000, 0b01000000, 0b11100000, 0b01000000]),
|
||||||
|
.money: s8(5, 7, [0b00100000, 0b11111000, 0b10100000, 0b11111000, 0b00101000, 0b11111000, 0b00100000]),
|
||||||
|
.eb4: s8(5, 5, [0b01110000, 0b11111000, 0b11111000, 0b11111000, 0b01110000]),
|
||||||
|
// Explosion frames
|
||||||
|
.x1: s8(8, 6, [0b00000000, 0b00011100, 0b00111110, 0b01010110, 0b00111000, 0b00000000]),
|
||||||
|
.x2: s8(8, 6, [0b00110000, 0b01001110, 0b10111110, 0b01001111, 0b00111000, 0b00011010]),
|
||||||
|
.x3: s8(8, 6, [0b11110011, 0b01001110, 0b10110101, 0b01000101, 0b00111110, 0b11011010]),
|
||||||
|
.x4: s8(8, 6, [0b11110011, 0b01001110, 0b10110101, 0b01000101, 0b00111110, 0b11011010]),
|
||||||
|
.x5: s8(8, 6, [0b01000001, 0b00100110, 0b00010101, 0b01000100, 0b00010010, 0b10011010]),
|
||||||
|
.x6: s8(8, 6, [0b01000010, 0b00100000, 0b00000001, 0b01000100, 0b00100010, 0b10001010]),
|
||||||
|
.x7: s8(8, 6, [0b00001000, 0b11000010, 0b00000000, 0b00100000, 0b00000001, 0b00110000]),
|
||||||
|
.x8: s8(8, 6, [0b00000100, 0b00000000, 0b01000000, 0b00000000, 0b00000001, 0b00100100]),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Movement / firing / weapon constants (phoenixz.i values)
|
||||||
|
|
||||||
|
enum Move {
|
||||||
|
static let standard = 0, none = 3, boss = 4, bounce = 7
|
||||||
|
static let patternStart = 10, patternWait = 13, patternMain = 16
|
||||||
|
static let rampage = 19, rampageWait = 22
|
||||||
|
static let swoopHoriz = 25, swoopDown = 28, swoopUp = 31, swoopWait = 34
|
||||||
|
static let rampageInit = 37
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Fire {
|
||||||
|
static let random = 0, none = 2, periodic = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Weapon {
|
||||||
|
static let normal = 0, double = 3, semiAim = 6, big = 9, huge = 12
|
||||||
|
static let arrow = 15, singleBig = 18, singleHuge = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swoop path table: (Y destination, X destination, X entry) per pattern.
|
||||||
|
static let swoopPaths: [(yDest: Int, xDest: Int, xEntry: Int)] = [
|
||||||
|
(59, 102, 14), (50, 30, 90), (41, 78, 42), (32, 54, 66),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Big/huge bullet velocity table indexed by angle code: (yv, xv) in
|
||||||
|
/// half-pixels per frame (`speed_table`).
|
||||||
|
static let bigBulletSpeeds: [(yv: Int, xv: Int)] = [
|
||||||
|
(1, -2), (2, -2), (2, -1), (2, 0), (2, 1), (2, 2), (1, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Patterns (velocities in 16ths of a pixel per frame)
|
||||||
|
|
||||||
|
struct Pattern {
|
||||||
|
let segments: [(time: Int, xv: Int, yv: Int)]
|
||||||
|
let loopStart: Int // segment index the terminating goto returns to
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PatternID: Hashable { case hugeOval, figureEight, circle, oval }
|
||||||
|
|
||||||
|
static let patterns: [PatternID: Pattern] = [
|
||||||
|
.hugeOval: Pattern(segments: [
|
||||||
|
(32, 0, 19),
|
||||||
|
(64, 20, 0), (10, 16, 0), (8, 12, -12), (28, 0, -12), (8, -12, -12), (10, -16, 0),
|
||||||
|
(64, -20, 0), (10, -16, 0), (8, -12, 12), (28, 0, 12), (8, 12, 12), (10, 16, 0),
|
||||||
|
], loopStart: 1),
|
||||||
|
.figureEight: Pattern(segments: [
|
||||||
|
(32, 0, 12),
|
||||||
|
(12, 12, 12), (16, 16, 0), (34, 16, -12), (16, 15, 0), (12, 12, 12), (8, 0, 14),
|
||||||
|
(12, -12, 12), (16, -15, 0), (34, -16, -12), (16, -16, 0), (12, -12, 12), (8, 0, 16),
|
||||||
|
], loopStart: 1),
|
||||||
|
.circle: Pattern(segments: [
|
||||||
|
(32, 0, 14),
|
||||||
|
(8, 12, 12), (16, 16, 0), (8, 12, -12), (16, 0, -16),
|
||||||
|
(8, -12, -12), (16, -16, 0), (8, -12, 12), (16, 0, 16),
|
||||||
|
], loopStart: 1),
|
||||||
|
.oval: Pattern(segments: [
|
||||||
|
(32, 0, 16),
|
||||||
|
(4, 12, 12), (76, 16, 0), (4, 12, -12), (6, 0, -16),
|
||||||
|
(4, -12, -12), (76, -16, 0), (4, -12, 12), (6, 0, 16),
|
||||||
|
], loopStart: 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Campaign (levels.asm, L_GOTO chains inlined)
|
||||||
|
|
||||||
|
enum ImageRef {
|
||||||
|
case still(Img)
|
||||||
|
case anim(Anim)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Cmd {
|
||||||
|
case setPower(Int)
|
||||||
|
case setMoveType(Int)
|
||||||
|
case setMoveData(Int, Int) // movedata bytes 0 and 1
|
||||||
|
case setMovePattern(PatternID) // word form of L_SET_MOVEDATA
|
||||||
|
case image(ImageRef)
|
||||||
|
case setFireType(Int)
|
||||||
|
case setFireRate(Int)
|
||||||
|
case setWeapon(Int)
|
||||||
|
case setFirePower(Int)
|
||||||
|
case installOne(x: Int, y: Int)
|
||||||
|
case installRow(x: Int, y: Int, n: Int, spacing: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LevelKind {
|
||||||
|
case combat(enemies: Int, commands: [Cmd])
|
||||||
|
case shop
|
||||||
|
case gameEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared command chunks (the original's labeled fragments reused by L_GOTO).
|
||||||
|
private static let lev1x: [Cmd] = [.installRow(x: 18, y: 10, n: 6, spacing: 15),
|
||||||
|
.installRow(x: 18, y: 0, n: 6, spacing: 15),
|
||||||
|
.installRow(x: 18, y: 20, n: 6, spacing: 15)]
|
||||||
|
private static let lev1y: [Cmd] = [.installRow(x: 18, y: 0, n: 6, spacing: 15),
|
||||||
|
.installRow(x: 18, y: 20, n: 6, spacing: 15)]
|
||||||
|
private static let lev1z: [Cmd] = [.installRow(x: 18, y: 20, n: 6, spacing: 15)]
|
||||||
|
private static let lev2x: [Cmd] = [.installRow(x: 20, y: 10, n: 6, spacing: 15)] + lev1y
|
||||||
|
private static let bossBlock: [Cmd] = [ // lev3y
|
||||||
|
.setMoveType(Move.boss), .setPower(61), .setMoveData(0, 32),
|
||||||
|
.image(.still(.boss)), .setFireType(Fire.periodic), .setFireRate(64),
|
||||||
|
.setWeapon(Weapon.double), .installOne(x: 90, y: 1),
|
||||||
|
]
|
||||||
|
private static let lev3x: [Cmd] = [.installRow(x: 18, y: 20, n: 6, spacing: 15)] + bossBlock
|
||||||
|
private static let boss2Block: [Cmd] = [ // lev12x
|
||||||
|
.setMoveType(Move.boss), .setPower(101), .setMoveData(0, 31),
|
||||||
|
.image(.still(.boss2)), .setFirePower(2), .setFireType(Fire.periodic),
|
||||||
|
.setFireRate(64), .setWeapon(Weapon.big), .installOne(x: 90, y: 1),
|
||||||
|
]
|
||||||
|
private static let lev13x: [Cmd] = [.setWeapon(Weapon.semiAim), .setMoveType(Move.rampageWait),
|
||||||
|
.setFireRate(8)] + lev1x
|
||||||
|
private static let lev14x: [Cmd] = [.setWeapon(Weapon.semiAim), .setMoveType(Move.rampageWait),
|
||||||
|
.setFireRate(8),
|
||||||
|
.installRow(x: 19, y: 10, n: 6, spacing: 15),
|
||||||
|
.installRow(x: 12, y: 0, n: 6, spacing: 15),
|
||||||
|
.installRow(x: 12, y: 20, n: 6, spacing: 15)]
|
||||||
|
private static let lev15x: [Cmd] = [.setWeapon(Weapon.semiAim), .setMoveType(Move.rampageWait),
|
||||||
|
.setFireRate(8),
|
||||||
|
.installRow(x: 16, y: 12, n: 7, spacing: 15),
|
||||||
|
.installRow(x: 22, y: 21, n: 6, spacing: 15)] + boss2Block
|
||||||
|
private static let levswx: [Cmd] = [.image(.anim(.swoop)), .setMoveType(Move.swoopWait),
|
||||||
|
.setFireRate(4), .setPower(14),
|
||||||
|
.installRow(x: 0, y: 0, n: 9, spacing: 0)]
|
||||||
|
private static let levox: [Cmd] = [.setMoveType(Move.rampageInit), .setFireRate(4),
|
||||||
|
.setPower(41), .image(.still(.silo)),
|
||||||
|
.setWeapon(Weapon.semiAim),
|
||||||
|
.installRow(x: 0, y: 0, n: 12, spacing: 0)]
|
||||||
|
private static let levl: LevelKind = .combat(enemies: 18, commands: [
|
||||||
|
.setPower(14), .image(.anim(.enemy3)), .setMoveType(Move.patternStart),
|
||||||
|
.setFireRate(4), .setWeapon(Weapon.semiAim), .setMovePattern(.hugeOval),
|
||||||
|
.installRow(x: 14, y: 18, n: 17, spacing: 15),
|
||||||
|
.setMoveType(Move.boss), .setPower(151), .setMoveData(0, 31),
|
||||||
|
.image(.still(.boss2)), .setFirePower(2), .setFireType(Fire.periodic),
|
||||||
|
.setFireRate(64), .setWeapon(Weapon.huge), .installOne(x: 90, y: 15),
|
||||||
|
])
|
||||||
|
|
||||||
|
/// The full built-in level table, in play order.
|
||||||
|
static let campaign: [LevelKind] = [
|
||||||
|
// Stage 1
|
||||||
|
.combat(enemies: 18, commands: lev1x), // lev1
|
||||||
|
.combat(enemies: 18, commands: lev2x), // lev2
|
||||||
|
.combat(enemies: 7, commands: lev3x), // lev3 (boss)
|
||||||
|
.shop,
|
||||||
|
// Stage 2
|
||||||
|
.combat(enemies: 18, commands: [.setPower(6), .image(.still(.enemy2))] + lev1x), // lev4
|
||||||
|
.combat(enemies: 18, commands: [.setPower(6), .image(.still(.enemy2))] + lev2x), // lev5
|
||||||
|
.combat(enemies: 6, commands: bounceLevel(n: 6)), // bounce6
|
||||||
|
.combat(enemies: 7, commands: [.setPower(6), .image(.still(.enemy2))] + lev3x), // lev6
|
||||||
|
.shop,
|
||||||
|
// Stage 3
|
||||||
|
.combat(enemies: 18, commands: [.setPower(6), .image(.still(.enemy2)), // lev4a
|
||||||
|
.installRow(x: 18, y: 0, n: 6, spacing: 15),
|
||||||
|
.setPower(8), .image(.still(.enemy2a)),
|
||||||
|
.installRow(x: 18, y: 10, n: 6, spacing: 15)] + lev1z),
|
||||||
|
.combat(enemies: 18, commands: [.setPower(8), .image(.still(.enemy2a))] + lev2x), // lev5a
|
||||||
|
.combat(enemies: 8, commands: bounceLevel(n: 8)), // bounce8
|
||||||
|
.combat(enemies: 13, commands: [.setPower(6), .image(.still(.enemy2)), // lev6a
|
||||||
|
.installRow(x: 18, y: 15, n: 6, spacing: 15),
|
||||||
|
.setPower(8), .image(.still(.enemy2a)),
|
||||||
|
.installRow(x: 18, y: 23, n: 6, spacing: 15)] + bossBlock),
|
||||||
|
.shop,
|
||||||
|
// Stage 4
|
||||||
|
.combat(enemies: 14, commands: [.setPower(14), .image(.anim(.enemy3)), // lev7
|
||||||
|
.setMoveType(Move.patternStart), .setFireRate(4),
|
||||||
|
.setWeapon(Weapon.semiAim), .setMovePattern(.circle),
|
||||||
|
.installRow(x: 14, y: 18, n: 7, spacing: 27),
|
||||||
|
.installRow(x: 29, y: 74, n: 7, spacing: 27)]),
|
||||||
|
.combat(enemies: 13, commands: [.setPower(14), .image(.anim(.enemy3)), // lev8
|
||||||
|
.setMoveType(Move.patternStart), .setFireRate(4),
|
||||||
|
.setWeapon(Weapon.semiAim), .setMovePattern(.figureEight),
|
||||||
|
.installRow(x: 14, y: 18, n: 13, spacing: 15)]),
|
||||||
|
.combat(enemies: 10, commands: bounceLevel(n: 10)), // bounce10
|
||||||
|
.combat(enemies: 14, commands: [.setPower(14), .image(.anim(.enemy3)), // lev9
|
||||||
|
.setMoveType(Move.patternStart), .setFireRate(4),
|
||||||
|
.setWeapon(Weapon.semiAim), .setMovePattern(.oval),
|
||||||
|
.installRow(x: 14, y: 18, n: 13, spacing: 15)] + bossBlock),
|
||||||
|
.shop,
|
||||||
|
// Stage 5
|
||||||
|
.combat(enemies: 18, commands: [.setPower(17), .image(.still(.enemy4)), // lev10
|
||||||
|
.setWeapon(Weapon.arrow)] + lev2x),
|
||||||
|
.combat(enemies: 18, commands: [.setPower(17), .image(.still(.enemy4)), // lev11
|
||||||
|
.setWeapon(Weapon.arrow)] + lev1x),
|
||||||
|
.combat(enemies: 13, commands: [.setPower(6), .image(.still(.enemy2)), // lev12
|
||||||
|
.installRow(x: 18, y: 12, n: 6, spacing: 15),
|
||||||
|
.installRow(x: 18, y: 21, n: 6, spacing: 15)] + boss2Block),
|
||||||
|
.shop,
|
||||||
|
// Stage 6
|
||||||
|
.combat(enemies: 18, commands: [.image(.anim(.spin1)), .setPower(21)] + lev13x), // lev13
|
||||||
|
.combat(enemies: 18, commands: [.image(.anim(.spin1)), .setPower(21)] + lev14x), // lev14
|
||||||
|
.combat(enemies: 14, commands: [.image(.anim(.spin1)), .setPower(21)] + lev15x), // lev15
|
||||||
|
.shop,
|
||||||
|
// Stage 7
|
||||||
|
.combat(enemies: 18, commands: [.image(.anim(.spin2)), .setPower(31)] + lev13x), // lev13a
|
||||||
|
.combat(enemies: 18, commands: [.image(.anim(.spin2)), .setPower(31)] + lev14x), // lev14a
|
||||||
|
.combat(enemies: 14, commands: [.image(.anim(.spin2)), .setPower(31)] + lev15x), // lev15a
|
||||||
|
.shop,
|
||||||
|
// Stage 8
|
||||||
|
.combat(enemies: 9, commands: [.setWeapon(Weapon.singleBig)] + levswx), // levsw1
|
||||||
|
levl, // levl
|
||||||
|
.shop,
|
||||||
|
// Stage 9
|
||||||
|
.combat(enemies: 8, commands: [.setMoveType(Move.rampageInit), .setFireRate(4), // levo1
|
||||||
|
.setPower(41), .image(.still(.silo)),
|
||||||
|
.setWeapon(Weapon.semiAim),
|
||||||
|
.installRow(x: 0, y: 0, n: 8, spacing: 0)]),
|
||||||
|
.combat(enemies: 12, commands: levox), // levo2
|
||||||
|
.combat(enemies: 13, commands: [.setMoveType(Move.boss), .setPower(151), // levo3
|
||||||
|
.setMoveData(0, 31), .image(.still(.boss2)),
|
||||||
|
.setFirePower(2), .setFireType(Fire.periodic),
|
||||||
|
.setFireRate(64), .setWeapon(Weapon.huge),
|
||||||
|
.installOne(x: 90, y: 1),
|
||||||
|
.setFirePower(1)] + levox),
|
||||||
|
.shop,
|
||||||
|
// Stage 10
|
||||||
|
.combat(enemies: 9, commands: [.setWeapon(Weapon.singleHuge)] + levswx), // levsw2
|
||||||
|
levl, // levl (again)
|
||||||
|
.gameEnd,
|
||||||
|
]
|
||||||
|
|
||||||
|
private static func bounceLevel(n: Int) -> [Cmd] {
|
||||||
|
[.image(.still(.bounce)), .setMoveType(Move.bounce), .setPower(36),
|
||||||
|
.setMoveData(3, 3), .setFireRate(4), .setWeapon(Weapon.semiAim),
|
||||||
|
.installRow(x: 14, y: 20, n: n, spacing: 10)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings tables (title16.asm)
|
||||||
|
|
||||||
|
/// Speed options: label, frames/sec (per the readme's TI-85 rates), bonus.
|
||||||
|
static let speedOptions: [(label: String, fps: Double, bonus: Int)] = [
|
||||||
|
("SLOW", 25, 0), ("MEDIUM", 29, 1000), ("FAST", 35, 5000),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Difficulty options: label, value of each $ drop, bonus.
|
||||||
|
static let difficultyOptions: [(label: String, moneyValue: Int, bonus: Int)] = [
|
||||||
|
("EASY", 100, 0), ("MEDIUM", 50, 5000), ("HARD", 25, 15000),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Shop (shop16.asm order and prices)
|
||||||
|
|
||||||
|
struct ShopItem {
|
||||||
|
let name: String
|
||||||
|
let price: Int
|
||||||
|
let detail: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shopItems: [ShopItem] = [
|
||||||
|
ShopItem(name: "Extra Shield", price: 100, detail: "+1 shield step (max 16)"),
|
||||||
|
ShopItem(name: "Weapon F2", price: 300, detail: "Twin cannons, faster shots"),
|
||||||
|
ShopItem(name: "Companion", price: 500, detail: "Escort ship, 5× firepower"),
|
||||||
|
ShopItem(name: "Weapon Plus", price: 750, detail: "Auto-fire + double F1 power"),
|
||||||
|
ShopItem(name: "Weapon F3", price: 1000, detail: "Triple spread"),
|
||||||
|
ShopItem(name: "Weapon F4", price: 1250, detail: "Alternating heavy shots"),
|
||||||
|
ShopItem(name: "Weapon F5", price: 2000, detail: "The most powerful of all"),
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,975 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A faithful port of Phoenix 4.0 (Patrick Davidson, 2008) game logic from the
|
||||||
|
/// Z80 sources. Runs in the original coordinate space: X 0…127, Y increases
|
||||||
|
/// downward, with the visible window being Y 32…95 (the region above is
|
||||||
|
/// off-screen staging that enemies descend from). All movement, firing,
|
||||||
|
/// collision, shop and scoring rules are ported routine-for-routine; see
|
||||||
|
/// PhoenixData for the campaign/sprite tables.
|
||||||
|
struct PhoenixSim {
|
||||||
|
|
||||||
|
typealias D = PhoenixData
|
||||||
|
|
||||||
|
// MARK: - Playfield constants
|
||||||
|
|
||||||
|
static let fieldW = 128
|
||||||
|
static let screenTop = 32 // first visible Y
|
||||||
|
static let screenBottom = 96 // bullets die at y >= 96
|
||||||
|
static let maxEnemies = 18
|
||||||
|
static let maxPlayerBullets = 16
|
||||||
|
static let maxEnemyBullets = 15
|
||||||
|
|
||||||
|
// MARK: - Entities
|
||||||
|
|
||||||
|
struct Enemy {
|
||||||
|
var pwr = 0 // 0 = empty slot, -1 = exploding
|
||||||
|
var moveType = D.Move.standard
|
||||||
|
var move0 = 0, move1 = 0, move2 = 0 // e_movedata (byte semantics where needed)
|
||||||
|
var pattern: D.PatternID? = nil // pattern pointer (word movedata)
|
||||||
|
var patternIndex = 0 // segment index within pattern
|
||||||
|
var x = 0, w = 0, y = 0, h = 0
|
||||||
|
var image: D.ImageRef = .still(.enemy1)
|
||||||
|
var animIndex = 0
|
||||||
|
var animCountdown = 0 // e_imageseq (0 = still image)
|
||||||
|
var exploding = false // pwr == -1 with explosion animation
|
||||||
|
var fireType = D.Fire.random
|
||||||
|
var fireRate = 2
|
||||||
|
var fireData = 0
|
||||||
|
var fireWeapon = D.Weapon.normal
|
||||||
|
var firePower = 1
|
||||||
|
|
||||||
|
var currentImg: D.Img {
|
||||||
|
if exploding { return D.explosionFrames[min(animIndex, 7)] }
|
||||||
|
switch image {
|
||||||
|
case .still(let img): return img
|
||||||
|
case .anim(let anim): return anim.frames[animIndex % anim.frames.count].img
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlayerBullet {
|
||||||
|
var type = 0 // 0 = empty; type codes from shoot.asm
|
||||||
|
var dmg = 0
|
||||||
|
var x = 0, w = 0, y = 0, h = 0
|
||||||
|
var data = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EnemyBullet {
|
||||||
|
var type = 0 // 0 = empty, -5 = impact explosion effect
|
||||||
|
var dmg = 0 // 0 => money drop
|
||||||
|
var x = 0, w = 0, y = 0, h = 0
|
||||||
|
var data = 0 // explosion phase for type -5
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game state
|
||||||
|
|
||||||
|
private(set) var enemies = [Enemy](repeating: Enemy(), count: maxEnemies)
|
||||||
|
private(set) var playerBullets = [PlayerBullet](repeating: PlayerBullet(), count: maxPlayerBullets)
|
||||||
|
private(set) var enemyBullets = [EnemyBullet](repeating: EnemyBullet(), count: maxEnemyBullets)
|
||||||
|
|
||||||
|
private(set) var playerX = 60
|
||||||
|
private(set) var playerY = 70
|
||||||
|
private(set) var playerPwr = 16 // shield, max 16
|
||||||
|
private(set) var cash = 0
|
||||||
|
private(set) var weaponUpgrade = false
|
||||||
|
private(set) var hasWeapon = [true, false, false, false, false] // F1…F5
|
||||||
|
private(set) var chosenWeapon = 0 // 0…4
|
||||||
|
private var fireCounter = 0
|
||||||
|
private var weapon4Side = false
|
||||||
|
private var weapon5Alt = false
|
||||||
|
|
||||||
|
private(set) var companionPwr = 0 // 0 none, -1 exploding
|
||||||
|
private(set) var companionX = 0
|
||||||
|
private(set) var companionY = 0
|
||||||
|
private(set) var companionExplodeFrame = 0
|
||||||
|
private var posQueue: [(y: Int, x: Int)] = []
|
||||||
|
|
||||||
|
private(set) var level = 0 // index of NEXT level to load
|
||||||
|
private(set) var enemiesLeft = 0
|
||||||
|
private(set) var gameTimer = 0
|
||||||
|
private(set) var timeScore = 19000
|
||||||
|
private(set) var bonusScore = 0
|
||||||
|
// Remake addition: run progress for the high-score system. The original
|
||||||
|
// only scored a finished campaign (score16.asm); these track how far a
|
||||||
|
// run got so a death still posts a score.
|
||||||
|
private(set) var wavesCleared = 0 // combat levels fully cleared
|
||||||
|
private(set) var totalEarned = 0 // lifetime $ collected (shop spending doesn't reduce it)
|
||||||
|
private(set) var moneyValue = 100 // per $ collected
|
||||||
|
private(set) var moneyCounter = 4 // kills until next $ drop
|
||||||
|
private(set) var over = false
|
||||||
|
private(set) var finished = false
|
||||||
|
|
||||||
|
let speedOption: Int
|
||||||
|
let difficultyOption: Int
|
||||||
|
var fps: Double { D.speedOptions[speedOption].fps }
|
||||||
|
|
||||||
|
private var rng: TetrisRNG
|
||||||
|
|
||||||
|
enum Event: Equatable {
|
||||||
|
case enteredShop
|
||||||
|
case levelLoaded(Int)
|
||||||
|
case gameOver
|
||||||
|
case gameFinished
|
||||||
|
case enemyDestroyed(x: Int, y: Int)
|
||||||
|
case playerHit
|
||||||
|
case moneyCollected
|
||||||
|
case bossLevel
|
||||||
|
}
|
||||||
|
private(set) var events: [Event] = []
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
init(speed: Int = 2, difficulty: Int = 0, seed: UInt64? = nil) {
|
||||||
|
self.speedOption = min(max(speed, 0), 2)
|
||||||
|
self.difficultyOption = min(max(difficulty, 0), 2)
|
||||||
|
self.rng = TetrisRNG(seed: seed ?? UInt64.random(in: 1...UInt64.max))
|
||||||
|
self.moneyValue = D.difficultyOptions[difficultyOption].moneyValue
|
||||||
|
self.bonusScore = D.difficultyOptions[difficultyOption].bonus + D.speedOptions[speedOption].bonus
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func rand() -> Int { Int(rng.next() & 0xFF) }
|
||||||
|
|
||||||
|
/// `Div_A_16`: divide by 16 with frame-averaged fractions — the timer's low
|
||||||
|
/// nibble is bit-reversed and added before the shift so x/16 rounds up on
|
||||||
|
/// exactly x of every 16 frames.
|
||||||
|
private func divA16(_ a: Int) -> Int {
|
||||||
|
let t = gameTimer & 15
|
||||||
|
let rev = ((t & 1) << 3) | ((t & 2) << 1) | ((t & 4) >> 1) | ((t & 8) >> 3)
|
||||||
|
return (a + rev) >> 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Level loading (init.asm)
|
||||||
|
|
||||||
|
/// Loads the next level. Returns without enemies for shop/game-end levels
|
||||||
|
/// (events signal what happened).
|
||||||
|
mutating func loadLevel() {
|
||||||
|
// The previous level just finished; if it was a combat wave, bank it.
|
||||||
|
if level > 0, case .combat = D.campaign[level - 1] { wavesCleared += 1 }
|
||||||
|
guard level < D.campaign.count else { finished = true; return }
|
||||||
|
let def = D.campaign[level]
|
||||||
|
level += 1
|
||||||
|
|
||||||
|
switch def {
|
||||||
|
case .shop:
|
||||||
|
// Original only opens the shop if you have money; either way the
|
||||||
|
// level is over immediately and the next one loads next frame.
|
||||||
|
if cash > 0 { events.append(.enteredShop) }
|
||||||
|
enemiesLeft = 0
|
||||||
|
return
|
||||||
|
case .gameEnd:
|
||||||
|
finished = true
|
||||||
|
events.append(.gameFinished)
|
||||||
|
return
|
||||||
|
case .combat(let count, let commands):
|
||||||
|
enemiesLeft = count
|
||||||
|
runLoader(commands)
|
||||||
|
events.append(.levelLoaded(level))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func runLoader(_ commands: [D.Cmd]) {
|
||||||
|
// default_data
|
||||||
|
var buf = Enemy()
|
||||||
|
buf.pwr = 4
|
||||||
|
buf.moveType = D.Move.standard
|
||||||
|
buf.move0 = 0; buf.move1 = -60; buf.move2 = 0
|
||||||
|
buf.x = 10; buf.y = 10
|
||||||
|
buf.image = .still(.enemy1)
|
||||||
|
buf.fireType = D.Fire.random
|
||||||
|
buf.fireRate = 2
|
||||||
|
buf.fireWeapon = D.Weapon.normal
|
||||||
|
buf.firePower = 1
|
||||||
|
|
||||||
|
var slot = 0
|
||||||
|
func install(_ x: Int, _ y: Int) {
|
||||||
|
guard slot < Self.maxEnemies else { return }
|
||||||
|
var e = buf
|
||||||
|
e.x = x & 0xFF
|
||||||
|
e.y = y
|
||||||
|
let img = e.currentImg
|
||||||
|
let sprite = D.sprites[img]!
|
||||||
|
e.w = sprite.w
|
||||||
|
e.h = sprite.h
|
||||||
|
// Animated images start at their first frame's countdown.
|
||||||
|
if case .anim(let anim) = e.image {
|
||||||
|
e.animIndex = 0
|
||||||
|
e.animCountdown = anim.frames[0].time
|
||||||
|
} else {
|
||||||
|
e.animCountdown = 0
|
||||||
|
}
|
||||||
|
enemies[slot] = e
|
||||||
|
slot += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for cmd in commands {
|
||||||
|
switch cmd {
|
||||||
|
case .setPower(let p): buf.pwr = p
|
||||||
|
case .setMoveType(let t): buf.moveType = t
|
||||||
|
case .setMoveData(let a, let b): buf.move0 = a; buf.move1 = b; buf.pattern = nil
|
||||||
|
case .setMovePattern(let p): buf.pattern = p; buf.patternIndex = 0
|
||||||
|
case .image(let ref): buf.image = ref; buf.animIndex = 0
|
||||||
|
case .setFireType(let t): buf.fireType = t
|
||||||
|
case .setFireRate(let r): buf.fireRate = r
|
||||||
|
case .setWeapon(let w): buf.fireWeapon = w
|
||||||
|
case .setFirePower(let p): buf.firePower = p
|
||||||
|
case .installOne(let x, let y):
|
||||||
|
install(x, y)
|
||||||
|
case .installRow(let x, let y, let n, let spacing):
|
||||||
|
install(x, y)
|
||||||
|
var cx = x
|
||||||
|
for _ in 1..<n {
|
||||||
|
cx = (cx + spacing) & 0xFF
|
||||||
|
install(cx, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-frame input
|
||||||
|
|
||||||
|
struct Input {
|
||||||
|
var dx = 0 // -1, 0, +1 (thrust direction; sim applies ±2)
|
||||||
|
var dy = 0 // -1, 0, +1 (±1 per frame)
|
||||||
|
var fire = false
|
||||||
|
var selectWeapon: Int? = nil // 0…4 tap on weapon selector
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main loop (main16.asm order)
|
||||||
|
|
||||||
|
mutating func tick(_ input: Input) {
|
||||||
|
guard !over, !finished else { return }
|
||||||
|
events.removeAll(keepingCapacity: true)
|
||||||
|
|
||||||
|
frameInit()
|
||||||
|
hitEnemies()
|
||||||
|
|
||||||
|
if enemiesLeft == 0 {
|
||||||
|
loadLevel()
|
||||||
|
if finished || over { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
doPlayer(input)
|
||||||
|
doCompanion()
|
||||||
|
doEnemies()
|
||||||
|
movePlayerBullets()
|
||||||
|
moveEnemyBullets()
|
||||||
|
hitPlayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func frameInit() {
|
||||||
|
gameTimer &+= 1
|
||||||
|
if gameTimer & 1 == 1, timeScore > 0 { timeScore -= 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Player (player16.asm + shoot.asm)
|
||||||
|
|
||||||
|
private mutating func doPlayer(_ input: Input) {
|
||||||
|
if let sel = input.selectWeapon, sel >= 0, sel < 5, hasWeapon[sel] {
|
||||||
|
chosenWeapon = sel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire: cooldown 10 frames base, 4 with the upgrade; releasing resets.
|
||||||
|
if !input.fire {
|
||||||
|
fireCounter = 0
|
||||||
|
} else {
|
||||||
|
if fireCounter == 0 {
|
||||||
|
fireCounter = weaponUpgrade ? 4 : 10
|
||||||
|
playerShoot()
|
||||||
|
} else {
|
||||||
|
fireCounter -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movement, exact bound checks from do_player (blocked at the limit).
|
||||||
|
if input.dy > 0 {
|
||||||
|
let ny = playerY + 1
|
||||||
|
if ny != 90 { playerY = ny }
|
||||||
|
} else if input.dy < 0 {
|
||||||
|
let ny = playerY - 1
|
||||||
|
if ny != 68 { playerY = ny }
|
||||||
|
}
|
||||||
|
if input.dx < 0 {
|
||||||
|
let nx = playerX - 2
|
||||||
|
if nx != 12 { playerX = nx }
|
||||||
|
} else if input.dx > 0 {
|
||||||
|
let nx = playerX + 2
|
||||||
|
if nx != 108 { playerX = nx }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func firePlayerBullet(type: Int, dmg: Int, w: Int, h: Int, xOff: Int) {
|
||||||
|
guard let i = playerBullets.firstIndex(where: { $0.type == 0 }) else { return }
|
||||||
|
playerBullets[i] = PlayerBullet(type: type, dmg: dmg,
|
||||||
|
x: playerX + xOff, w: w,
|
||||||
|
y: playerY - 3, h: h, data: 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func playerShoot() {
|
||||||
|
switch chosenWeapon {
|
||||||
|
case 0:
|
||||||
|
if weaponUpgrade {
|
||||||
|
firePlayerBullet(type: 3, dmg: 4, w: 4, h: 8, xOff: 4)
|
||||||
|
} else {
|
||||||
|
firePlayerBullet(type: 6, dmg: 2, w: 2, h: 6, xOff: 5)
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
firePlayerBullet(type: 9, dmg: 2, w: 3, h: 7, xOff: 1)
|
||||||
|
firePlayerBullet(type: 12, dmg: 2, w: 3, h: 7, xOff: 8)
|
||||||
|
case 2:
|
||||||
|
firePlayerBullet(type: 21, dmg: 4, w: 4, h: 4, xOff: 4)
|
||||||
|
firePlayerBullet(type: 15, dmg: 3, w: 4, h: 4, xOff: 2)
|
||||||
|
firePlayerBullet(type: 18, dmg: 3, w: 4, h: 4, xOff: 6)
|
||||||
|
case 3:
|
||||||
|
weapon4Side.toggle()
|
||||||
|
let side = weapon4Side ? 7 : 0
|
||||||
|
firePlayerBullet(type: 27, dmg: 8, w: 3, h: 5, xOff: side + 2)
|
||||||
|
firePlayerBullet(type: 24, dmg: 8, w: 3, h: 5, xOff: side)
|
||||||
|
case 4:
|
||||||
|
weapon5Alt.toggle()
|
||||||
|
firePlayerBullet(type: 33, dmg: 20, w: 5, h: 7, xOff: weapon5Alt ? 4 : 3)
|
||||||
|
firePlayerBullet(type: 36, dmg: 8, w: 4, h: 4, xOff: 0)
|
||||||
|
firePlayerBullet(type: 39, dmg: 8, w: 4, h: 4, xOff: 8)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Player bullets (bullets.asm)
|
||||||
|
|
||||||
|
private mutating func movePlayerBullets() {
|
||||||
|
for i in 0..<Self.maxPlayerBullets {
|
||||||
|
switch playerBullets[i].type {
|
||||||
|
case 0: continue
|
||||||
|
case 3, 6: // enhanced / standard: up 2
|
||||||
|
bulletUp(i, dy: -2)
|
||||||
|
case 9, 12: // double: up 4
|
||||||
|
bulletUp(i, dy: -4)
|
||||||
|
case 21: // triple center: up 5
|
||||||
|
bulletUp(i, dy: -5)
|
||||||
|
case 15: // triple, fired left: drifts left
|
||||||
|
playerBullets[i].x -= 2
|
||||||
|
if playerBullets[i].x <= 2 { playerBullets[i].type = 0; continue }
|
||||||
|
bulletUp(i, dy: -4)
|
||||||
|
case 18: // triple, fired right: drifts right
|
||||||
|
playerBullets[i].x += 2
|
||||||
|
if playerBullets[i].x >= 124 { playerBullets[i].type = 0; continue }
|
||||||
|
bulletUp(i, dy: -4)
|
||||||
|
case 24, 27: // quad: up 4, drifting ±0.5/frame
|
||||||
|
if gameTimer & 1 == 0 {
|
||||||
|
playerBullets[i].x += (playerBullets[i].type == 27) ? 1 : -1
|
||||||
|
if playerBullets[i].x <= 0 || playerBullets[i].x >= 124 {
|
||||||
|
playerBullets[i].type = 0; continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bulletUp(i, dy: -4)
|
||||||
|
case 30: // companion shot: straight up 4
|
||||||
|
bulletUp(i, dy: -4)
|
||||||
|
case 33: // super: up 5
|
||||||
|
bulletUp(i, dy: -5)
|
||||||
|
case 36, 39: // swing: sideways 1, arcing Y
|
||||||
|
playerBullets[i].x += (playerBullets[i].type == 36) ? 1 : -1
|
||||||
|
if playerBullets[i].x <= 0 || playerBullets[i].x >= 124 {
|
||||||
|
playerBullets[i].type = 0; continue
|
||||||
|
}
|
||||||
|
playerBullets[i].data -= 1
|
||||||
|
let dy = playerBullets[i].data >> 1 // arithmetic: 3,2,2,1…0,-1…
|
||||||
|
bulletUp(i, dy: dy)
|
||||||
|
default:
|
||||||
|
playerBullets[i].type = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func bulletUp(_ i: Int, dy: Int) {
|
||||||
|
playerBullets[i].y += dy
|
||||||
|
if playerBullets[i].y < 24 { playerBullets[i].type = 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bullets vs enemies (enemyhit.asm)
|
||||||
|
|
||||||
|
private mutating func hitEnemies() {
|
||||||
|
for bi in 0..<Self.maxPlayerBullets where playerBullets[bi].type != 0 {
|
||||||
|
let b = playerBullets[bi]
|
||||||
|
for ei in 0..<Self.maxEnemies {
|
||||||
|
let e = enemies[ei]
|
||||||
|
guard e.pwr > 0 else { continue }
|
||||||
|
guard rectsOverlap(x1: e.x, w1: e.w, y1: e.y, h1: e.h,
|
||||||
|
x2: b.x, w2: b.w, y2: b.y, h2: b.h) else { continue }
|
||||||
|
playerBullets[bi].type = 0
|
||||||
|
enemies[ei].pwr -= b.dmg
|
||||||
|
if enemies[ei].pwr <= 0 {
|
||||||
|
startExplosion(ei)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func startExplosion(_ ei: Int) {
|
||||||
|
enemies[ei].pwr = -1
|
||||||
|
enemies[ei].exploding = true
|
||||||
|
enemies[ei].moveType = D.Move.none
|
||||||
|
enemies[ei].animIndex = 0
|
||||||
|
enemies[ei].animCountdown = 2
|
||||||
|
enemies[ei].fireType = D.Fire.none
|
||||||
|
events.append(.enemyDestroyed(x: enemies[ei].x, y: enemies[ei].y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enemies (enemies.asm / emove.asm / eshoot.asm)
|
||||||
|
|
||||||
|
private mutating func doEnemies() {
|
||||||
|
var swoopUsedThisFrame = 0 // misc_flags, reset each frame
|
||||||
|
for i in 0..<Self.maxEnemies where enemies[i].pwr != 0 {
|
||||||
|
enemyMove(i, swoopFlags: &swoopUsedThisFrame)
|
||||||
|
enemyShoot(i)
|
||||||
|
enemyAnimate(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Movement types ---
|
||||||
|
|
||||||
|
private mutating func enemyMove(_ i: Int, swoopFlags: inout Int) {
|
||||||
|
switch enemies[i].moveType {
|
||||||
|
case D.Move.standard: standardSwing(i)
|
||||||
|
case D.Move.none: break
|
||||||
|
case D.Move.boss: bossMove(i)
|
||||||
|
case D.Move.bounce: bounceMove(i)
|
||||||
|
case D.Move.patternStart: patternInit(i)
|
||||||
|
case D.Move.patternWait: patternWait(i)
|
||||||
|
case D.Move.patternMain: patternMove(i)
|
||||||
|
case D.Move.rampage: rampageMove(i)
|
||||||
|
case D.Move.rampageWait:
|
||||||
|
if enemiesLeft >= 9 { standardSwing(i) }
|
||||||
|
else {
|
||||||
|
enemies[i].moveType = D.Move.rampage
|
||||||
|
enemies[i].move1 = -1
|
||||||
|
enemies[i].move2 = -1
|
||||||
|
rampageApply(i)
|
||||||
|
}
|
||||||
|
case D.Move.swoopHoriz: swoopHoriz(i)
|
||||||
|
case D.Move.swoopDown: swoopDown(i)
|
||||||
|
case D.Move.swoopUp:
|
||||||
|
enemies[i].y -= 1
|
||||||
|
if enemies[i].y == 0 { enemies[i].moveType = D.Move.swoopWait }
|
||||||
|
case D.Move.swoopWait: swoopWaitMove(i, flags: &swoopFlags)
|
||||||
|
case D.Move.rampageInit:
|
||||||
|
enemies[i].moveType = D.Move.rampage
|
||||||
|
enemies[i].move1 = 1
|
||||||
|
enemies[i].move2 = 1
|
||||||
|
enemies[i].x = (rand() & 63) + 28
|
||||||
|
enemies[i].y = rand() & 8
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EM_STANDARD: byte timer counts up (from -60 on entry), moves on odd
|
||||||
|
/// ticks, phase advances at 17 cycling down/right/up/left.
|
||||||
|
private mutating func standardSwing(_ i: Int) {
|
||||||
|
let phase = enemies[i].move0
|
||||||
|
enemies[i].move1 = (enemies[i].move1 + 1) & 0xFF
|
||||||
|
let timer = enemies[i].move1
|
||||||
|
guard timer & 1 == 1 else { return }
|
||||||
|
if timer == 17 {
|
||||||
|
enemies[i].move1 = 0
|
||||||
|
enemies[i].move0 = (phase + 4) & 12
|
||||||
|
}
|
||||||
|
switch phase {
|
||||||
|
case 0: enemies[i].y += 1
|
||||||
|
case 4: enemies[i].x += 1
|
||||||
|
case 8: enemies[i].y -= 1
|
||||||
|
default: enemies[i].x -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func bossMove(_ i: Int) {
|
||||||
|
if enemies[i].move0 == 0 {
|
||||||
|
enemies[i].move1 -= 1
|
||||||
|
if enemies[i].move1 == 0 {
|
||||||
|
enemies[i].move0 = -1
|
||||||
|
enemies[i].move1 = 83
|
||||||
|
} else {
|
||||||
|
enemies[i].y += 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
enemies[i].move1 -= 1
|
||||||
|
if enemies[i].move1 == 0 {
|
||||||
|
enemies[i].move0 = -enemies[i].move0
|
||||||
|
enemies[i].move1 = 90
|
||||||
|
}
|
||||||
|
enemies[i].x += enemies[i].move0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EM_BOUNCE: move0 = Y velocity code, move1 = X velocity code.
|
||||||
|
/// Bit 1 = direction (down/right), bit 0 = fast (every frame vs every other).
|
||||||
|
private mutating func bounceMove(_ i: Int) {
|
||||||
|
let yv = enemies[i].move0
|
||||||
|
let xv = enemies[i].move1
|
||||||
|
if (gameTimer | xv) & 1 == 1 {
|
||||||
|
if xv & 2 != 0 {
|
||||||
|
enemies[i].x += 1
|
||||||
|
if enemies[i].x == 107 { enemies[i].move1 = rand() & 1 }
|
||||||
|
} else {
|
||||||
|
enemies[i].x -= 1
|
||||||
|
if enemies[i].x == 13 { enemies[i].move1 = (rand() & 1) | 2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (gameTimer | yv) & 1 == 1 {
|
||||||
|
if yv & 2 != 0 {
|
||||||
|
enemies[i].y += 1
|
||||||
|
if enemies[i].y == 60 { enemies[i].move0 = rand() & 1 }
|
||||||
|
} else {
|
||||||
|
enemies[i].y -= 1
|
||||||
|
if enemies[i].y == 31 { enemies[i].move0 = (rand() & 1) | 2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EM_PATTERNSTART: X was installed as the entry delay, Y as the start X.
|
||||||
|
private mutating func patternInit(_ i: Int) {
|
||||||
|
enemies[i].moveType = D.Move.patternWait
|
||||||
|
let givenY = enemies[i].y
|
||||||
|
let givenX = enemies[i].x
|
||||||
|
enemies[i].y = 110 // parked off-screen while waiting
|
||||||
|
enemies[i].x = givenY
|
||||||
|
enemies[i].move2 = givenX & 0xFF // delay counter
|
||||||
|
if enemies[i].move2 == 0 { enemies[i].move2 = 256 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func patternWait(_ i: Int) {
|
||||||
|
enemies[i].move2 -= 1
|
||||||
|
if enemies[i].move2 == 0 {
|
||||||
|
enemies[i].move2 = -1 // original double-decrements
|
||||||
|
enemies[i].moveType = D.Move.patternMain
|
||||||
|
enemies[i].y = 24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func patternMove(_ i: Int) {
|
||||||
|
guard let pid = enemies[i].pattern, let pattern = D.patterns[pid] else { return }
|
||||||
|
var index = enemies[i].patternIndex
|
||||||
|
enemies[i].move2 += 1
|
||||||
|
if enemies[i].move2 == pattern.segments[index].time {
|
||||||
|
index += 1
|
||||||
|
if index >= pattern.segments.count { index = pattern.loopStart }
|
||||||
|
enemies[i].patternIndex = index
|
||||||
|
enemies[i].move2 = 0
|
||||||
|
}
|
||||||
|
let seg = pattern.segments[index]
|
||||||
|
enemies[i].x += divA16(seg.xv)
|
||||||
|
enemies[i].y += divA16(seg.yv)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func rampageApply(_ i: Int) { rampageMove(i) }
|
||||||
|
|
||||||
|
private mutating func rampageMove(_ i: Int) {
|
||||||
|
var xv = enemies[i].move1
|
||||||
|
var yv = enemies[i].move2
|
||||||
|
let nx = enemies[i].x + xv
|
||||||
|
enemies[i].x = nx
|
||||||
|
if nx < 16 { xv = 1 }
|
||||||
|
else if nx == 40 || nx == 71 || nx == 93 { xv = (rand() & 1) == 1 ? -1 : 1 }
|
||||||
|
else if nx >= 105 { xv = -1 }
|
||||||
|
let ny = enemies[i].y + yv
|
||||||
|
enemies[i].y = ny
|
||||||
|
if ny < 41 { yv = 1 }
|
||||||
|
else if ny == 51 { yv = (rand() & 1) == 1 ? -1 : 1 }
|
||||||
|
else if ny >= 59 { yv = -1 }
|
||||||
|
enemies[i].move1 = xv
|
||||||
|
enemies[i].move2 = yv
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func swoopWaitMove(_ i: Int, flags: inout Int) {
|
||||||
|
guard gameTimer & 15 == 0 else { return }
|
||||||
|
let pick = rand() & 3
|
||||||
|
let bit = 1 << pick
|
||||||
|
guard flags & bit == 0 else { return }
|
||||||
|
flags |= bit
|
||||||
|
let path = D.swoopPaths[pick]
|
||||||
|
enemies[i].moveType = D.Move.swoopDown
|
||||||
|
enemies[i].move1 = path.yDest
|
||||||
|
enemies[i].move2 = path.xDest
|
||||||
|
enemies[i].x = path.xEntry
|
||||||
|
enemies[i].y = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func swoopDown(_ i: Int) {
|
||||||
|
enemies[i].y += 1
|
||||||
|
if enemies[i].y == enemies[i].move1 {
|
||||||
|
enemies[i].move1 = enemies[i].move2 >= 64 ? 2 : -2
|
||||||
|
enemies[i].moveType = D.Move.swoopHoriz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func swoopHoriz(_ i: Int) {
|
||||||
|
enemies[i].x += enemies[i].move1
|
||||||
|
if enemies[i].x == enemies[i].move2 {
|
||||||
|
enemies[i].moveType = D.Move.swoopUp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Firing ---
|
||||||
|
|
||||||
|
private mutating func enemyShoot(_ i: Int) {
|
||||||
|
let e = enemies[i]
|
||||||
|
switch e.fireType {
|
||||||
|
case D.Fire.random:
|
||||||
|
guard rand() < e.fireRate else { return }
|
||||||
|
case D.Fire.periodic:
|
||||||
|
enemies[i].fireData += 1
|
||||||
|
guard enemies[i].fireData == e.fireRate else { return }
|
||||||
|
enemies[i].fireData = 0
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fireEnemyWeapon(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func fireEnemyWeapon(_ i: Int) {
|
||||||
|
let e = enemies[i]
|
||||||
|
switch e.fireWeapon {
|
||||||
|
case D.Weapon.normal:
|
||||||
|
spawnEnemyBullet(type: 3, from: e, dmg: e.firePower, w: 2, h: 2)
|
||||||
|
case D.Weapon.double:
|
||||||
|
fireAimed(from: e, xShift: 0)
|
||||||
|
fireAimed(from: e, xShift: 14)
|
||||||
|
case D.Weapon.semiAim:
|
||||||
|
fireSemiAim(i)
|
||||||
|
case D.Weapon.big:
|
||||||
|
fireLarge(i, huge: false, xShift: 0)
|
||||||
|
fireLarge(i, huge: false, xShift: 13)
|
||||||
|
case D.Weapon.huge:
|
||||||
|
fireLarge(i, huge: true, xShift: 0)
|
||||||
|
fireLarge(i, huge: true, xShift: 11)
|
||||||
|
case D.Weapon.arrow:
|
||||||
|
spawnEnemyBullet(type: 21, from: e, dmg: e.firePower, w: 3, h: 7)
|
||||||
|
case D.Weapon.singleBig:
|
||||||
|
fireLarge(i, huge: false, xShift: 2)
|
||||||
|
case D.Weapon.singleHuge:
|
||||||
|
fireLarge(i, huge: true, xShift: 1)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// W_DOUBLE component: straight, or diagonal when the player is >30 off.
|
||||||
|
private mutating func fireAimed(from e: Enemy, xShift: Int) {
|
||||||
|
var shifted = e
|
||||||
|
shifted.x += xShift
|
||||||
|
if playerX + 30 < shifted.x {
|
||||||
|
spawnEnemyBullet(type: 9, from: shifted, dmg: e.firePower, w: 2, h: 2)
|
||||||
|
} else if playerX - 30 > shifted.x {
|
||||||
|
spawnEnemyBullet(type: 12, from: shifted, dmg: e.firePower, w: 2, h: 2)
|
||||||
|
} else {
|
||||||
|
spawnEnemyBullet(type: 3, from: shifted, dmg: e.firePower, w: 2, h: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// W_SEMIAIM: straight down, or half-angle toward the player.
|
||||||
|
private mutating func fireSemiAim(_ i: Int) {
|
||||||
|
let e = enemies[i]
|
||||||
|
let dy = playerY - e.y
|
||||||
|
guard dy >= 0 else { return }
|
||||||
|
let b = (dy >> 2) + (dy >> 3) // (3/8) * dy
|
||||||
|
guard b != 0 else { return }
|
||||||
|
let dx = playerX - e.x
|
||||||
|
if abs(dx) < b {
|
||||||
|
spawnEnemyBullet(type: 3, from: e, dmg: e.firePower, w: 2, h: 2)
|
||||||
|
} else if dx < 0 {
|
||||||
|
spawnEnemyBullet(type: 15, from: e, dmg: e.firePower, w: 2, h: 2)
|
||||||
|
} else {
|
||||||
|
spawnEnemyBullet(type: 18, from: e, dmg: e.firePower, w: 2, h: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// W_BIG / W_HUGE: fully-aimed shot in one of 7 angle buckets.
|
||||||
|
private mutating func fireLarge(_ i: Int, huge: Bool, xShift: Int) {
|
||||||
|
var e = enemies[i]
|
||||||
|
e.x += xShift
|
||||||
|
let dy = playerY - e.y
|
||||||
|
guard dy >= 0 else { return }
|
||||||
|
let b = (dy >> 2) + (dy >> 3)
|
||||||
|
guard b != 0 else { return }
|
||||||
|
var dx = playerX - e.x
|
||||||
|
let left = dx < 0
|
||||||
|
if left { dx = -dx }
|
||||||
|
var c = 0
|
||||||
|
var rem = dx - b
|
||||||
|
while rem >= 0 && c < 3 { // get_angle buckets 0…3
|
||||||
|
c += 1
|
||||||
|
rem -= b
|
||||||
|
if c == 2 { rem -= b } // the doubled step in the original
|
||||||
|
}
|
||||||
|
let angle = left ? -min(c, 3) : min(c, 3)
|
||||||
|
let type = (huge ? 36 : 27) + angle // big 24…30, huge 33…39
|
||||||
|
spawnEnemyBullet(type: type, from: e, dmg: e.firePower,
|
||||||
|
w: huge ? 5 : 5, h: huge ? 5 : 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func spawnEnemyBullet(type: Int, from e: Enemy, dmg: Int, w: Int, h: Int) {
|
||||||
|
guard let i = enemyBullets.firstIndex(where: { $0.type == 0 }) else { return }
|
||||||
|
enemyBullets[i] = EnemyBullet(type: type, dmg: dmg,
|
||||||
|
x: e.x + 2, w: w, y: e.y + 7, h: h, data: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Animation & death (enemy_display / kill_enemy) ---
|
||||||
|
|
||||||
|
private mutating func enemyAnimate(_ i: Int) {
|
||||||
|
if enemies[i].exploding {
|
||||||
|
enemies[i].animCountdown -= 1
|
||||||
|
if enemies[i].animCountdown == 0 {
|
||||||
|
enemies[i].animIndex += 1
|
||||||
|
if enemies[i].animIndex >= D.explosionFrames.count {
|
||||||
|
killEnemy(i)
|
||||||
|
} else {
|
||||||
|
enemies[i].animCountdown = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard case .anim(let anim) = enemies[i].image, enemies[i].animCountdown > 0 else { return }
|
||||||
|
enemies[i].animCountdown -= 1
|
||||||
|
if enemies[i].animCountdown == 0 {
|
||||||
|
enemies[i].animIndex = (enemies[i].animIndex + 1) % anim.frames.count
|
||||||
|
enemies[i].animCountdown = anim.frames[enemies[i].animIndex].time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func killEnemy(_ i: Int) {
|
||||||
|
let x = enemies[i].x
|
||||||
|
let y = enemies[i].y
|
||||||
|
enemies[i] = Enemy() // free the slot
|
||||||
|
enemiesLeft -= 1
|
||||||
|
moneyCounter -= 1
|
||||||
|
if moneyCounter == 0 {
|
||||||
|
moneyCounter = (rand() & 7) + 4
|
||||||
|
// Drop money at the dead enemy's position (a dmg-0 bonus bullet).
|
||||||
|
if let bi = enemyBullets.firstIndex(where: { $0.type == 0 }) {
|
||||||
|
enemyBullets[bi] = EnemyBullet(type: 6, dmg: 0, x: x + 2, w: 5, y: y + 7, h: 7, data: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enemy bullets (ebullets.asm)
|
||||||
|
|
||||||
|
private mutating func moveEnemyBullets() {
|
||||||
|
for i in 0..<Self.maxEnemyBullets {
|
||||||
|
let type = enemyBullets[i].type
|
||||||
|
switch type {
|
||||||
|
case 0: continue
|
||||||
|
case -5: // impact explosion effect
|
||||||
|
if gameTimer & 1 == 1 {
|
||||||
|
enemyBullets[i].data += 1
|
||||||
|
if enemyBullets[i].data >= 8 { enemyBullets[i].type = 0 }
|
||||||
|
}
|
||||||
|
case 3: // straight down 1
|
||||||
|
ebDown(i, 1)
|
||||||
|
case 6: // money: falls every other frame
|
||||||
|
if gameTimer & 1 == 0 { ebDown(i, 1) }
|
||||||
|
case 9: // diagonal left
|
||||||
|
enemyBullets[i].x -= 1
|
||||||
|
if enemyBullets[i].x <= 0 { enemyBullets[i].type = 0; continue }
|
||||||
|
ebDown(i, 1)
|
||||||
|
case 12: // diagonal right
|
||||||
|
enemyBullets[i].x += 1
|
||||||
|
if enemyBullets[i].x >= 126 { enemyBullets[i].type = 0; continue }
|
||||||
|
ebDown(i, 1)
|
||||||
|
case 15: // half-left: alternate straight/diag
|
||||||
|
if gameTimer & 1 == 1 {
|
||||||
|
enemyBullets[i].x -= 1
|
||||||
|
if enemyBullets[i].x <= 0 { enemyBullets[i].type = 0; continue }
|
||||||
|
}
|
||||||
|
ebDown(i, 1)
|
||||||
|
case 18: // half-right
|
||||||
|
if gameTimer & 1 == 1 {
|
||||||
|
enemyBullets[i].x += 1
|
||||||
|
if enemyBullets[i].x >= 126 { enemyBullets[i].type = 0; continue }
|
||||||
|
}
|
||||||
|
ebDown(i, 1)
|
||||||
|
case 21: // arrow: straight down 1
|
||||||
|
ebDown(i, 1)
|
||||||
|
case 24...30: // big, angle-coded
|
||||||
|
ebLarge(i, index: type - 24)
|
||||||
|
case 33...39: // huge, angle-coded
|
||||||
|
ebLarge(i, index: type - 33)
|
||||||
|
default:
|
||||||
|
enemyBullets[i].type = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func ebDown(_ i: Int, _ dy: Int) {
|
||||||
|
enemyBullets[i].y += dy
|
||||||
|
if enemyBullets[i].y >= Self.screenBottom { enemyBullets[i].type = 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Big/huge bullets: velocities in half-pixels with timer-dithered halves.
|
||||||
|
private mutating func ebLarge(_ i: Int, index: Int) {
|
||||||
|
guard index >= 0, index < D.bigBulletSpeeds.count else { enemyBullets[i].type = 0; return }
|
||||||
|
let speeds = D.bigBulletSpeeds[index]
|
||||||
|
let dx = halfStep(speeds.xv)
|
||||||
|
let dy = halfStep(speeds.yv)
|
||||||
|
enemyBullets[i].x += dx
|
||||||
|
if enemyBullets[i].x < 0 || enemyBullets[i].x >= Self.fieldW {
|
||||||
|
enemyBullets[i].type = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ebDown(i, dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// div_by_2 with timer-based fraction: a/2, remainder applied on odd frames.
|
||||||
|
private func halfStep(_ v: Int) -> Int {
|
||||||
|
var out = v >> 1
|
||||||
|
if v & 1 != 0, gameTimer & 1 != 0 { out += 1 }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enemy bullets vs player/companion (hityou.asm)
|
||||||
|
|
||||||
|
private mutating func hitPlayer() {
|
||||||
|
scanHits(targetX: playerX, targetW: 8, targetY: playerY, targetH: 6, isCompanion: false)
|
||||||
|
if companionPwr > 0 {
|
||||||
|
scanHits(targetX: companionX, targetW: 7, targetY: companionY, targetH: 7, isCompanion: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func scanHits(targetX: Int, targetW: Int, targetY: Int, targetH: Int, isCompanion: Bool) {
|
||||||
|
for i in 0..<Self.maxEnemyBullets {
|
||||||
|
let b = enemyBullets[i]
|
||||||
|
guard b.type > 0 else { continue }
|
||||||
|
guard rectsOverlap(x1: targetX, w1: targetW, y1: targetY, h1: targetH,
|
||||||
|
x2: b.x, w2: b.w, y2: b.y, h2: b.h) else { continue }
|
||||||
|
if b.dmg == 0 {
|
||||||
|
// Money pickup
|
||||||
|
enemyBullets[i].type = 0
|
||||||
|
cash = min(cash + moneyValue, 9900)
|
||||||
|
totalEarned += moneyValue
|
||||||
|
events.append(.moneyCollected)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Convert the bullet into a small impact explosion.
|
||||||
|
enemyBullets[i].type = -5
|
||||||
|
enemyBullets[i].data = 0
|
||||||
|
enemyBullets[i].x -= 3
|
||||||
|
enemyBullets[i].y -= 2
|
||||||
|
if isCompanion {
|
||||||
|
companionPwr -= b.dmg
|
||||||
|
if companionPwr <= 0 {
|
||||||
|
companionPwr = -1
|
||||||
|
companionExplodeFrame = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playerPwr -= b.dmg
|
||||||
|
events.append(.playerHit)
|
||||||
|
if playerPwr < 0 {
|
||||||
|
over = true
|
||||||
|
events.append(.gameOver)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Companion (helper.asm)
|
||||||
|
|
||||||
|
private mutating func doCompanion() {
|
||||||
|
if companionPwr < 0 {
|
||||||
|
// Explosion animation, one frame every other tick.
|
||||||
|
if gameTimer & 1 == 0 {
|
||||||
|
companionExplodeFrame += 1
|
||||||
|
if companionExplodeFrame >= 8 { companionPwr = 0 }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard companionPwr > 0 else { return }
|
||||||
|
|
||||||
|
// Age the position queue; the player's live position feeds the tail.
|
||||||
|
posQueue.append((y: playerY, x: playerX))
|
||||||
|
if posQueue.count > 11 { posQueue.removeFirst(posQueue.count - 11) }
|
||||||
|
guard let oldest = posQueue.first else { return }
|
||||||
|
|
||||||
|
// Destination: just above the oldest position, offset to the side.
|
||||||
|
let destY = oldest.y - 1
|
||||||
|
let destX = oldest.x < 60 ? oldest.x + 11 : oldest.x - 10
|
||||||
|
|
||||||
|
if companionY < destY { companionY += 1 } else if companionY > destY { companionY -= 1 }
|
||||||
|
if companionX < destX { companionX += 1 } else if companionX > destX { companionX -= 1 }
|
||||||
|
|
||||||
|
// Fires a heavy shot every 8 frames.
|
||||||
|
if gameTimer & 7 == 0 {
|
||||||
|
if let i = playerBullets.firstIndex(where: { $0.type == 0 }) {
|
||||||
|
playerBullets[i] = PlayerBullet(type: 30, dmg: 10,
|
||||||
|
x: companionX + 2, w: 3,
|
||||||
|
y: companionY - 5, h: 5, data: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shop (shop16.asm)
|
||||||
|
|
||||||
|
enum ShopResult { case bought, insufficientFunds, alreadyOwned, shieldFull }
|
||||||
|
|
||||||
|
mutating func buy(itemIndex: Int) -> ShopResult {
|
||||||
|
let price = D.shopItems[itemIndex].price
|
||||||
|
switch itemIndex {
|
||||||
|
case 0: // extra shield
|
||||||
|
guard playerPwr < 16 else { return .shieldFull }
|
||||||
|
guard cash >= price else { return .insufficientFunds }
|
||||||
|
cash -= price
|
||||||
|
playerPwr += 1
|
||||||
|
return .bought
|
||||||
|
case 2: // companion
|
||||||
|
guard companionPwr != 16 else { return .alreadyOwned }
|
||||||
|
guard cash >= price else { return .insufficientFunds }
|
||||||
|
cash -= price
|
||||||
|
companionPwr = 16
|
||||||
|
companionY = 90
|
||||||
|
companionX = 60
|
||||||
|
posQueue.removeAll()
|
||||||
|
return .bought
|
||||||
|
case 3: // weapon upgrade
|
||||||
|
guard !weaponUpgrade else { return .alreadyOwned }
|
||||||
|
guard cash >= price else { return .insufficientFunds }
|
||||||
|
cash -= price
|
||||||
|
weaponUpgrade = true
|
||||||
|
return .bought
|
||||||
|
default: // weapons F2…F5 (items 1, 4, 5, 6 → weapons 1…4)
|
||||||
|
let weapon = [0: 0, 1: 1, 4: 2, 5: 3, 6: 4][itemIndex] ?? 0
|
||||||
|
guard !hasWeapon[weapon] else { return .alreadyOwned }
|
||||||
|
guard cash >= price else { return .insufficientFunds }
|
||||||
|
cash -= price
|
||||||
|
hasWeapon[weapon] = true
|
||||||
|
chosenWeapon = weapon
|
||||||
|
return .bought
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Final score (score16.asm)
|
||||||
|
|
||||||
|
var finalScore: Int {
|
||||||
|
max(0, playerPwr) * 1000 + timeScore + bonusScore + cash
|
||||||
|
}
|
||||||
|
|
||||||
|
var shieldScore: Int { max(0, playerPwr) * 1000 }
|
||||||
|
|
||||||
|
/// Remake addition: how far the run got, scoreable at any moment — waves
|
||||||
|
/// beaten plus everything ever earned. A death posts this; a victory posts
|
||||||
|
/// this plus the original score16 tally on top.
|
||||||
|
var progressScore: Int { wavesCleared * 400 + totalEarned }
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func rectsOverlap(x1: Int, w1: Int, y1: Int, h1: Int,
|
||||||
|
x2: Int, w2: Int, y2: Int, h2: Int) -> Bool {
|
||||||
|
x1 < x2 + w2 && x2 < x1 + w1 && y1 < y2 + h2 && y2 < y1 + h1
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
mutating func debugSetCash(_ amount: Int) { cash = amount }
|
||||||
|
mutating func debugSetLevel(_ n: Int) { level = n }
|
||||||
|
mutating func debugSetShield(_ n: Int) { playerPwr = n }
|
||||||
|
mutating func debugGiveWeapons() {
|
||||||
|
hasWeapon = [true, true, true, true, true]
|
||||||
|
weaponUpgrade = true
|
||||||
|
}
|
||||||
|
mutating func debugPlacePlayer(x: Int, y: Int) { playerX = x; playerY = y }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import SpriteKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Reusable procedural art for the SpriteKit scenes: gradients, starfields,
|
||||||
|
/// SF-symbol textures, and an HD rendering of the original TI-85 lander sprite.
|
||||||
|
enum SceneArt {
|
||||||
|
|
||||||
|
/// A vertical gradient texture (top color at the top of the node).
|
||||||
|
static func verticalGradient(size: CGSize, top: UIColor, bottom: UIColor) -> SKTexture {
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||||
|
let gradient = CGGradient(colorsSpace: colorSpace,
|
||||||
|
colors: [top.cgColor, bottom.cgColor] as CFArray,
|
||||||
|
locations: [0, 1])!
|
||||||
|
cg.drawLinearGradient(gradient,
|
||||||
|
start: CGPoint(x: 0, y: 0),
|
||||||
|
end: CGPoint(x: 0, y: size.height),
|
||||||
|
options: [])
|
||||||
|
}
|
||||||
|
return SKTexture(image: image)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node full of small stars scattered across `size`. A handful twinkle.
|
||||||
|
static func starfield(size: CGSize, count: Int, seed: UInt64) -> SKNode {
|
||||||
|
let node = SKNode()
|
||||||
|
var rng = SeededGenerator(seed: seed)
|
||||||
|
for i in 0..<count {
|
||||||
|
let r = CGFloat.random(in: 0.6...1.8, using: &rng)
|
||||||
|
let dot = SKShapeNode(circleOfRadius: r)
|
||||||
|
dot.fillColor = UIColor(white: 1, alpha: CGFloat.random(in: 0.12...0.9, using: &rng))
|
||||||
|
dot.strokeColor = .clear
|
||||||
|
dot.position = CGPoint(x: CGFloat.random(in: 0...size.width, using: &rng),
|
||||||
|
y: CGFloat.random(in: 0...size.height, using: &rng))
|
||||||
|
if i % 6 == 0 {
|
||||||
|
let twinkle = SKAction.sequence([
|
||||||
|
.fadeAlpha(to: 0.15, duration: .random(in: 0.8...1.8, using: &rng)),
|
||||||
|
.fadeAlpha(to: 0.9, duration: .random(in: 0.8...1.8, using: &rng)),
|
||||||
|
])
|
||||||
|
dot.run(.repeatForever(twinkle))
|
||||||
|
}
|
||||||
|
node.addChild(dot)
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders an SF Symbol into a texture so scenes can show iconography.
|
||||||
|
static func symbolTexture(_ name: String, pointSize: CGFloat, color: UIColor) -> SKTexture? {
|
||||||
|
let config = UIImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold)
|
||||||
|
guard let image = UIImage(systemName: name, withConfiguration: config)?
|
||||||
|
.withTintColor(color, renderingMode: .alwaysOriginal)
|
||||||
|
else { return nil }
|
||||||
|
return SKTexture(image: image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HD toolkit (shared by the per-game overhauls)
|
||||||
|
|
||||||
|
/// A soft radial falloff — the building block for glows and particles.
|
||||||
|
static func radialGlowTexture(color: UIColor, radius: CGFloat) -> SKTexture {
|
||||||
|
let side = radius * 2
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: side, height: side))
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
let colors = [color.withAlphaComponent(0.85).cgColor,
|
||||||
|
color.withAlphaComponent(0.25).cgColor,
|
||||||
|
color.withAlphaComponent(0).cgColor] as CFArray
|
||||||
|
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
colors: colors, locations: [0, 0.35, 1])!
|
||||||
|
cg.drawRadialGradient(gradient,
|
||||||
|
startCenter: CGPoint(x: radius, y: radius), startRadius: 0,
|
||||||
|
endCenter: CGPoint(x: radius, y: radius), endRadius: radius,
|
||||||
|
options: [])
|
||||||
|
}
|
||||||
|
return SKTexture(image: image)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An additive-blended glow sprite ready to drop behind any node.
|
||||||
|
static func glowSprite(color: UIColor, radius: CGFloat) -> SKSpriteNode {
|
||||||
|
let sprite = SKSpriteNode(texture: radialGlowTexture(color: color, radius: radius))
|
||||||
|
sprite.blendMode = .add
|
||||||
|
return sprite
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small soft dot used as the particle texture everywhere.
|
||||||
|
static let particleTexture: SKTexture = radialGlowTexture(color: .white, radius: 12)
|
||||||
|
|
||||||
|
/// Rounded panel with a vertical gradient fill, hairline stroke, and a
|
||||||
|
/// faint top highlight — the standard card/HUD surface.
|
||||||
|
static func panelTexture(size: CGSize, cornerRadius: CGFloat,
|
||||||
|
top: UIColor, bottom: UIColor,
|
||||||
|
stroke: UIColor = UIColor(white: 1, alpha: 0.10)) -> SKTexture {
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
let rect = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5)
|
||||||
|
let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius)
|
||||||
|
cg.saveGState()
|
||||||
|
path.addClip()
|
||||||
|
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
colors: [top.cgColor, bottom.cgColor] as CFArray,
|
||||||
|
locations: [0, 1])!
|
||||||
|
cg.drawLinearGradient(gradient, start: .zero,
|
||||||
|
end: CGPoint(x: 0, y: size.height), options: [])
|
||||||
|
// top highlight line (remember: UIKit origin is top-left here)
|
||||||
|
cg.setFillColor(UIColor(white: 1, alpha: 0.07).cgColor)
|
||||||
|
cg.fill(CGRect(x: cornerRadius * 0.6, y: 1, width: size.width - cornerRadius * 1.2, height: 1.5))
|
||||||
|
cg.restoreGState()
|
||||||
|
stroke.setStroke()
|
||||||
|
path.lineWidth = 1
|
||||||
|
path.stroke()
|
||||||
|
}
|
||||||
|
return SKTexture(image: image)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot spark burst (explosions, impacts). Self-removing.
|
||||||
|
static func sparkBurst(color: UIColor, count: Int = 26, speed: CGFloat = 190,
|
||||||
|
lifetime: CGFloat = 0.7, scale: CGFloat = 0.24) -> SKEmitterNode {
|
||||||
|
let e = SKEmitterNode()
|
||||||
|
e.particleTexture = particleTexture
|
||||||
|
e.particleBirthRate = CGFloat(count) * 40
|
||||||
|
e.numParticlesToEmit = count
|
||||||
|
e.particleLifetime = lifetime
|
||||||
|
e.particleLifetimeRange = lifetime * 0.6
|
||||||
|
e.emissionAngleRange = .pi * 2
|
||||||
|
e.particleSpeed = speed
|
||||||
|
e.particleSpeedRange = speed * 0.8
|
||||||
|
e.particleAlpha = 0.95
|
||||||
|
e.particleAlphaSpeed = -1.4 / lifetime
|
||||||
|
e.particleScale = scale
|
||||||
|
e.particleScaleRange = scale * 0.5
|
||||||
|
e.particleScaleSpeed = -scale / lifetime
|
||||||
|
e.particleColor = color
|
||||||
|
e.particleColorBlendFactor = 1
|
||||||
|
e.particleBlendMode = .add
|
||||||
|
e.yAcceleration = -60
|
||||||
|
e.run(.sequence([.wait(forDuration: Double(lifetime) * 2), .removeFromParent()]))
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Continuous engine/thruster flame pointing along `angle` (radians).
|
||||||
|
static func thrustEmitter(color: UIColor, angle: CGFloat, speed: CGFloat = 150,
|
||||||
|
birthRate: CGFloat = 130, scale: CGFloat = 0.30,
|
||||||
|
lifetime: CGFloat = 0.28) -> SKEmitterNode {
|
||||||
|
let e = SKEmitterNode()
|
||||||
|
e.particleTexture = particleTexture
|
||||||
|
e.particleBirthRate = birthRate
|
||||||
|
e.particleLifetime = lifetime
|
||||||
|
e.particleLifetimeRange = lifetime * 0.5
|
||||||
|
e.emissionAngle = angle
|
||||||
|
e.emissionAngleRange = 0.45
|
||||||
|
e.particleSpeed = speed
|
||||||
|
e.particleSpeedRange = speed * 0.4
|
||||||
|
e.particleAlpha = 0.9
|
||||||
|
e.particleAlphaSpeed = -3.0
|
||||||
|
e.particleScale = scale
|
||||||
|
e.particleScaleSpeed = -scale * 2.2
|
||||||
|
e.particleColor = color
|
||||||
|
e.particleColorBlendFactor = 1
|
||||||
|
e.particleBlendMode = .add
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lingering world-space trail behind a moving node. Set `targetNode`
|
||||||
|
/// to the scene (or a world layer) after adding, so the ribbon stays put.
|
||||||
|
static func trailEmitter(color: UIColor, scale: CGFloat = 0.20,
|
||||||
|
lifetime: CGFloat = 0.45, birthRate: CGFloat = 60) -> SKEmitterNode {
|
||||||
|
let e = SKEmitterNode()
|
||||||
|
e.particleTexture = particleTexture
|
||||||
|
e.particleBirthRate = birthRate
|
||||||
|
e.particleLifetime = lifetime
|
||||||
|
e.particleSpeed = 0
|
||||||
|
e.particleAlpha = 0.5
|
||||||
|
e.particleAlphaSpeed = -0.5 / lifetime - 0.6
|
||||||
|
e.particleScale = scale
|
||||||
|
e.particleScaleSpeed = -scale / lifetime
|
||||||
|
e.particleColor = color
|
||||||
|
e.particleColorBlendFactor = 1
|
||||||
|
e.particleBlendMode = .add
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slow ambient dust drifting through the playfield — cheap depth.
|
||||||
|
static func ambientDust(size: CGSize, color: UIColor = UIColor(white: 1, alpha: 1)) -> SKEmitterNode {
|
||||||
|
let e = SKEmitterNode()
|
||||||
|
e.particleTexture = particleTexture
|
||||||
|
e.particleBirthRate = 3
|
||||||
|
e.particleLifetime = 14
|
||||||
|
e.particleLifetimeRange = 6
|
||||||
|
e.particlePositionRange = CGVector(dx: size.width, dy: size.height)
|
||||||
|
e.particleSpeed = 10
|
||||||
|
e.particleSpeedRange = 8
|
||||||
|
e.emissionAngle = .pi / 2 * 3
|
||||||
|
e.emissionAngleRange = 0.6
|
||||||
|
e.particleAlpha = 0.0
|
||||||
|
e.particleAlphaSequence = SKKeyframeSequence(keyframeValues: [0.0, 0.28, 0.28, 0.0],
|
||||||
|
times: [0, 0.15, 0.75, 1])
|
||||||
|
e.particleScale = 0.10
|
||||||
|
e.particleScaleRange = 0.08
|
||||||
|
e.particleColor = color
|
||||||
|
e.particleColorBlendFactor = 1
|
||||||
|
e.particleBlendMode = .add
|
||||||
|
e.advanceSimulationTime(20) // pre-warm so it never starts empty
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Darkened corners that focus the eye on the playfield.
|
||||||
|
static func vignette(size: CGSize, alpha: CGFloat = 0.55) -> SKSpriteNode {
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
let colors = [UIColor.black.withAlphaComponent(0).cgColor,
|
||||||
|
UIColor.black.withAlphaComponent(0).cgColor,
|
||||||
|
UIColor.black.withAlphaComponent(alpha).cgColor] as CFArray
|
||||||
|
let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
colors: colors, locations: [0, 0.62, 1])!
|
||||||
|
let center = CGPoint(x: size.width / 2, y: size.height / 2)
|
||||||
|
cg.drawRadialGradient(gradient, startCenter: center, startRadius: 0,
|
||||||
|
endCenter: center,
|
||||||
|
endRadius: max(size.width, size.height) * 0.72,
|
||||||
|
options: [])
|
||||||
|
}
|
||||||
|
let sprite = SKSpriteNode(texture: SKTexture(image: image))
|
||||||
|
sprite.zPosition = 900
|
||||||
|
sprite.isUserInteractionEnabled = false
|
||||||
|
return sprite
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quick camera-less screen shake: run it on a world container node.
|
||||||
|
static func shake(intensity: CGFloat, duration: TimeInterval = 0.35) -> SKAction {
|
||||||
|
var actions: [SKAction] = []
|
||||||
|
var amp = intensity
|
||||||
|
while amp > 0.5 {
|
||||||
|
let dx = CGFloat.random(in: -amp...amp)
|
||||||
|
let dy = CGFloat.random(in: -amp...amp)
|
||||||
|
actions.append(.moveBy(x: dx, y: dy, duration: 0.02))
|
||||||
|
actions.append(.moveBy(x: -dx, y: -dy, duration: 0.02))
|
||||||
|
amp *= 0.72
|
||||||
|
}
|
||||||
|
let count = max(actions.count, 1)
|
||||||
|
let step = duration / Double(count)
|
||||||
|
return .sequence(actions.map { a in a.duration = step; return a })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Additive white flash overlay for hits/explosions. Self-removing.
|
||||||
|
static func flash(size: CGSize, color: UIColor = .white, peak: CGFloat = 0.32,
|
||||||
|
duration: TimeInterval = 0.22) -> SKSpriteNode {
|
||||||
|
let sprite = SKSpriteNode(color: color, size: size)
|
||||||
|
sprite.alpha = peak
|
||||||
|
sprite.blendMode = .add
|
||||||
|
sprite.zPosition = 850
|
||||||
|
sprite.run(.sequence([.fadeOut(withDuration: duration), .removeFromParent()]))
|
||||||
|
return sprite
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The original TI-85 Lunar Lander sprite (8×8, MSB = leftmost pixel),
|
||||||
|
/// taken verbatim from LANDER.C, rendered as crisp rounded neon pixels.
|
||||||
|
static let landerBitmap: [UInt8] = [
|
||||||
|
0b00011010,
|
||||||
|
0b00111111,
|
||||||
|
0b01001010,
|
||||||
|
0b11111110,
|
||||||
|
0b01111110,
|
||||||
|
0b00100100,
|
||||||
|
0b01000010,
|
||||||
|
0b11100111,
|
||||||
|
]
|
||||||
|
|
||||||
|
static func landerTexture(cell: CGFloat = 40, color: UIColor = Palette.mint) -> SKTexture {
|
||||||
|
let side = cell * 8
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: side, height: side))
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
// First pass: a soft glow behind the whole silhouette.
|
||||||
|
cg.setShadow(offset: .zero, blur: cell * 0.5, color: color.withAlphaComponent(0.9).cgColor)
|
||||||
|
for (row, bits) in landerBitmap.enumerated() {
|
||||||
|
for col in 0..<8 where (bits & (0x80 >> UInt8(col))) != 0 {
|
||||||
|
let rect = CGRect(x: CGFloat(col) * cell, y: CGFloat(row) * cell, width: cell, height: cell)
|
||||||
|
color.setFill()
|
||||||
|
UIBezierPath(rect: rect.insetBy(dx: cell * 0.06, dy: cell * 0.06)).fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Second pass: crisp merged pixels (no shadow) so the shape reads solid.
|
||||||
|
cg.setShadow(offset: .zero, blur: 0, color: nil)
|
||||||
|
for (row, bits) in landerBitmap.enumerated() {
|
||||||
|
for col in 0..<8 where (bits & (0x80 >> UInt8(col))) != 0 {
|
||||||
|
let rect = CGRect(x: CGFloat(col) * cell, y: CGFloat(row) * cell, width: cell, height: cell)
|
||||||
|
color.setFill()
|
||||||
|
UIBezierPath(roundedRect: rect.insetBy(dx: cell * 0.04, dy: cell * 0.04), cornerRadius: cell * 0.16).fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SKTexture(image: image)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Static data for ZMercury 0.34 (Patrick Davidson, 2004), transcribed from
|
||||||
|
/// the original sources: terrain tiles (`terrain.inc`), the looping level map
|
||||||
|
/// (`map0.inc`), the sine table (`sines.inc`) and every sprite (`images.asm`,
|
||||||
|
/// `sprites/`). Sprites in the original are stored as height + H mask rows +
|
||||||
|
/// H image rows and drawn `(bg | mask) ^ image`; the visible ink is the image
|
||||||
|
/// (XOR) half, which is what's transcribed here.
|
||||||
|
enum MercuryData {
|
||||||
|
|
||||||
|
// MARK: - Sprites (all ≤ 8 px wide; bit 7 = leftmost pixel)
|
||||||
|
|
||||||
|
struct Sprite {
|
||||||
|
let h: Int
|
||||||
|
let rows: [UInt8]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Img: Hashable {
|
||||||
|
case player, bullet1, ebullet1, ebullet2
|
||||||
|
case enemy1, enemy2, base, boss
|
||||||
|
case spin1, spin2, spin3, spin4, spin5
|
||||||
|
case spint1, spint2, spint3, spint4
|
||||||
|
case x1, x2, x3, x4, x5, x6, x7, x8
|
||||||
|
}
|
||||||
|
|
||||||
|
static let spinFrames: [Img] = [.spin1, .spin2, .spin3, .spin4, .spin5]
|
||||||
|
static let spintFrames: [Img] = [.spint1, .spint2, .spint3, .spint4]
|
||||||
|
static let explosionFrames: [Img] = [.x1, .x2, .x3, .x4, .x5, .x6, .x7, .x8]
|
||||||
|
|
||||||
|
static let sprites: [Img: Sprite] = [
|
||||||
|
.player: Sprite(h: 12, rows: [0xFF, 0xFE, 0xC0, 0xC0, 0xFE, 0xC3,
|
||||||
|
0xC3, 0xFE, 0xC0, 0xC0, 0xFE, 0xFF]),
|
||||||
|
.bullet1: Sprite(h: 4, rows: [0x7E, 0xFF, 0xFF, 0x7E]),
|
||||||
|
.ebullet1: Sprite(h: 4, rows: [0x60, 0xF0, 0xF0, 0x60]),
|
||||||
|
.ebullet2: Sprite(h: 2, rows: [0xFF, 0xFF]),
|
||||||
|
.enemy1: Sprite(h: 8, rows: [0x1F, 0x07, 0x7F, 0xFF, 0xFF, 0x7F, 0x07, 0x1F]),
|
||||||
|
.enemy2: Sprite(h: 10, rows: [0xFF, 0x07, 0x0E, 0x1C, 0x38, 0x38, 0x1C, 0x0E, 0x07, 0xFF]),
|
||||||
|
// The base dome: mask half is used — its image half is just the core,
|
||||||
|
// and on-calc the dome shape comes from the mask carving the terrain.
|
||||||
|
.base: Sprite(h: 8, rows: [0x3C, 0x7E, 0xFF, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C]),
|
||||||
|
.boss: Sprite(h: 16, rows: [0x7F, 0xC1, 0xC1, 0x7F, 0x07, 0x0F, 0x7F, 0xFF,
|
||||||
|
0xFF, 0x7F, 0x0F, 0x07, 0x7F, 0xC1, 0xC1, 0x7F]),
|
||||||
|
.spin1: Sprite(h: 8, rows: [0x81, 0x7E, 0x42, 0x42, 0x42, 0x42, 0x7E, 0x81]),
|
||||||
|
.spin2: Sprite(h: 8, rows: [0x20, 0x3C, 0x43, 0x42, 0x42, 0xC2, 0x3C, 0x04]),
|
||||||
|
.spin3: Sprite(h: 8, rows: [0x10, 0x3C, 0x42, 0x43, 0xC2, 0x42, 0x3C, 0x08]),
|
||||||
|
.spin4: Sprite(h: 8, rows: [0x08, 0x3C, 0x42, 0xC2, 0x43, 0x42, 0x3C, 0x10]),
|
||||||
|
.spin5: Sprite(h: 8, rows: [0x04, 0x3C, 0xC2, 0x42, 0x42, 0x43, 0x3C, 0x20]),
|
||||||
|
.spint1: Sprite(h: 8, rows: [0x20, 0x40, 0xB8, 0x24, 0x24, 0x1D, 0x02, 0x04]),
|
||||||
|
.spint2: Sprite(h: 8, rows: [0x3C, 0x10, 0x1C, 0x24, 0x24, 0x18, 0x08, 0x3C]),
|
||||||
|
.spint3: Sprite(h: 8, rows: [0x04, 0x02, 0x1D, 0x24, 0x24, 0xB8, 0x40, 0x20]),
|
||||||
|
.spint4: Sprite(h: 8, rows: [0x00, 0x00, 0x99, 0xA7, 0xE5, 0x9D, 0x00, 0x00]),
|
||||||
|
.x1: Sprite(h: 8, rows: [0b0000_1000, 0b0001_1100, 0b1011_1110, 0b0011_1111,
|
||||||
|
0b0101_0110, 0b1111_1010, 0b0011_1000, 0b0001_0000]),
|
||||||
|
.x2: Sprite(h: 8, rows: [0b0011_0000, 0b0111_0010, 0b0100_1110, 0b1011_1110,
|
||||||
|
0b0100_1111, 0b0011_1000, 0b0110_0111, 0b0001_1010]),
|
||||||
|
.x3: Sprite(h: 8, rows: [0b0011_0101, 0b1111_0011, 0b0100_1110, 0b1011_0101,
|
||||||
|
0b0100_0101, 0b0011_1110, 0b1101_1010, 0b0011_0110]),
|
||||||
|
.x4: Sprite(h: 8, rows: [0b0011_0100, 0b1111_0011, 0b0100_1110, 0b1010_0011,
|
||||||
|
0b1011_0101, 0b0100_0101, 0b0011_1110, 0b0101_1010]),
|
||||||
|
.x5: Sprite(h: 8, rows: [0b0010_0110, 0b0100_0001, 0b0010_0110, 0b0100_0101,
|
||||||
|
0b0000_0101, 0b0100_0100, 0b0001_0010, 0b1001_1010]),
|
||||||
|
.x6: Sprite(h: 8, rows: [0b0001_1000, 0b0100_0010, 0b0010_0000, 0b0000_0001,
|
||||||
|
0b0100_0100, 0b0010_0010, 0b1000_1010, 0b0010_0100]),
|
||||||
|
.x7: Sprite(h: 8, rows: [0b0000_1000, 0b0000_0001, 0b1100_0010, 0b0000_0000,
|
||||||
|
0b0010_0000, 0b0100_0000, 0b0000_0001, 0b0011_0000]),
|
||||||
|
.x8: Sprite(h: 8, rows: [0b0000_0100, 0b0000_0000, 0b0000_0000, 0b0100_0000,
|
||||||
|
0b0000_0000, 0b0000_0000, 0b0000_0001, 0b0010_0100]),
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Terrain tiles (terrain.inc)
|
||||||
|
//
|
||||||
|
// Original polarity: bit set = open sky, bit clear = solid rock. Each tile
|
||||||
|
// is 8×8 (one byte per row).
|
||||||
|
|
||||||
|
static let tiles: [[UInt8]] = [
|
||||||
|
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], // 0 open
|
||||||
|
[0x00, 0x80, 0xC0, 0xE0, 0xF0, 0xF8, 0xFC, 0xFE], // 1 slope
|
||||||
|
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // 2 solid
|
||||||
|
[0x01, 0x03, 0x07, 0x0F, 0x1F, 0x3F, 0x7F, 0xFF], // 3 slope
|
||||||
|
[0xFF, 0x7F, 0x3F, 0x1F, 0x0F, 0x07, 0x03, 0x01], // 4 slope
|
||||||
|
[0xFE, 0xFC, 0xF8, 0xF0, 0xE0, 0xC0, 0x80, 0x00], // 5 slope
|
||||||
|
[0x01, 0x83, 0xC7, 0xEF, 0xFF, 0xFF, 0xFF, 0xFF], // 6 spike
|
||||||
|
[0xFF, 0xFF, 0xFF, 0xFF, 0xEF, 0xC7, 0x83, 0x01], // 7 spike
|
||||||
|
[0x00, 0x00, 0x00, 0x00, 0x10, 0x38, 0x7C, 0xFE], // 8 notch
|
||||||
|
[0xFE, 0x7C, 0x38, 0x10, 0x00, 0x00, 0x00, 0x00], // 9 notch
|
||||||
|
[0x00, 0x08, 0x40, 0x02, 0x00, 0x88, 0x20, 0x00], // 10 base pad
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Level map (map0.inc)
|
||||||
|
//
|
||||||
|
// Rows 0…7 are tile indices (64 looping columns); row 8 is the enemy-wave
|
||||||
|
// trigger for each column (0 = none, 1…7 = entry routine).
|
||||||
|
|
||||||
|
static let map: [[Int]] = [
|
||||||
|
[0x01, 0x02, 0x03, 0x00, 0x00, 0x00, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x00, 0x00,
|
||||||
|
0x00, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x02, 0x02, 0x02, 0x02, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
[0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, 0x01, 0x02, 0x02, 0x03, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x01, 0x02, 0x02, 0x02, 0x08, 0x02, 0x02, 0x02, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x01, 0x08, 0x0A, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x01, 0x02, 0x0A, 0x03, 0x01, 0x08, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
[Int](repeating: 0, count: 64),
|
||||||
|
[0x00, 0x00, 0x05, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
[0x00, 0x05, 0x02, 0x02, 0x04, 0x05, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x05, 0x0A, 0x02, 0x09, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
[0x05, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x05, 0x02, 0x02, 0x02, 0x02, 0x02, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x04, 0x00,
|
||||||
|
0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x07, 0x05, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x04],
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Sine table (sines.inc) — 128 entries, values 40…80
|
||||||
|
|
||||||
|
static let sines: [Int] = [
|
||||||
|
60, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 71, 72, 73,
|
||||||
|
74, 74, 75, 76, 76, 77, 77, 78, 78, 78, 79, 79, 79, 79, 79, 79,
|
||||||
|
80, 79, 79, 79, 79, 79, 79, 78, 78, 78, 77, 77, 76, 76, 75, 74,
|
||||||
|
74, 73, 72, 71, 71, 70, 69, 68, 67, 66, 65, 64, 63, 62, 61, 60,
|
||||||
|
60, 59, 58, 57, 56, 55, 54, 53, 52, 51, 50, 49, 48, 48, 47, 46,
|
||||||
|
45, 45, 44, 43, 43, 42, 42, 41, 41, 41, 40, 40, 40, 40, 40, 40,
|
||||||
|
40, 40, 40, 40, 40, 40, 40, 41, 41, 41, 42, 42, 43, 43, 44, 45,
|
||||||
|
45, 46, 47, 48, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
import SpriteKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// HD remake of ZMercury. `MercurySim` is the faithful Z80 port; this scene
|
||||||
|
/// renders the scrolling terrain from prebuilt map-column textures, the
|
||||||
|
/// original sprites as neon pixel art, adds parallax stars, and drives the
|
||||||
|
/// ship with drag-anywhere touch + autofire.
|
||||||
|
final class MercuryScene: SKScene {
|
||||||
|
|
||||||
|
typealias D = MercuryData
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
private enum GameState { case ready, playing, paused, gameOver }
|
||||||
|
private var state: GameState = .ready
|
||||||
|
|
||||||
|
private var sim = MercurySim()
|
||||||
|
|
||||||
|
// MARK: Timing
|
||||||
|
private var lastUpdate: TimeInterval = 0
|
||||||
|
private var accumulator: TimeInterval = 0
|
||||||
|
private var fireCooldown = 0
|
||||||
|
private var lastHitHaptic: TimeInterval = 0
|
||||||
|
|
||||||
|
// MARK: Layout
|
||||||
|
private var unit: CGFloat = 3 // scene points per sim unit, horizontal
|
||||||
|
private var unitY: CGFloat = 3.9 // vertical POSITION/terrain scale
|
||||||
|
private var unitYSprite: CGFloat = 3.6 // vertical SPRITE scale (mild stretch)
|
||||||
|
private var fieldRect = CGRect.zero // visible window: 128 × 58 sim units (y 35…92)
|
||||||
|
private var topInset: CGFloat = 0
|
||||||
|
|
||||||
|
// MARK: Touch control
|
||||||
|
private var moveTouch: UITouch?
|
||||||
|
private var touchAnchor = CGPoint.zero
|
||||||
|
private var shipAnchor = CGPoint.zero
|
||||||
|
private var targetX = 42
|
||||||
|
private var targetY = 60
|
||||||
|
|
||||||
|
// MARK: Nodes
|
||||||
|
private let worldCrop = SKCropNode()
|
||||||
|
private var playerNode = SKSpriteNode()
|
||||||
|
private var enemyNodes: [SKSpriteNode] = []
|
||||||
|
private var pbNodes: [SKSpriteNode] = []
|
||||||
|
private var ebNodes: [SKSpriteNode] = []
|
||||||
|
private var textureCache: [String: SKTexture] = [:]
|
||||||
|
|
||||||
|
// Terrain: one texture per map column, nodes keyed by spawn scroll.
|
||||||
|
private var columnTextures: [SKTexture] = []
|
||||||
|
private var terrainNodes: [(node: SKSpriteNode, spawnScroll: Int)] = []
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
private let shieldValue = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let sectorValue = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let scoreValue = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let pauseButton = SKShapeNode(circleOfRadius: 16)
|
||||||
|
private let banner = SKLabelNode(fontNamed: "AvenirNext-Heavy")
|
||||||
|
|
||||||
|
// Overlays
|
||||||
|
private let readyOverlay = SKNode()
|
||||||
|
private let pausedOverlay = SKNode()
|
||||||
|
private let gameOverOverlay = SKNode()
|
||||||
|
private let gameOverDetail = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let gameOverRecord = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
|
||||||
|
private var builtSize: CGSize = .zero
|
||||||
|
#if DEBUG
|
||||||
|
private var demoFreeze = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: Tints
|
||||||
|
|
||||||
|
static let terrainColor = UIColor(red: 0.95, green: 0.52, blue: 0.28, alpha: 1)
|
||||||
|
|
||||||
|
static func tint(for img: D.Img) -> UIColor {
|
||||||
|
switch img {
|
||||||
|
case .player: return .white
|
||||||
|
case .bullet1: return Palette.mint
|
||||||
|
case .ebullet1, .ebullet2: return Palette.amber
|
||||||
|
case .enemy1: return Palette.mint
|
||||||
|
case .enemy2: return Palette.cyan
|
||||||
|
case .base: return UIColor(red: 1.0, green: 0.62, blue: 0.25, alpha: 1)
|
||||||
|
case .boss: return Palette.red
|
||||||
|
case .spin1, .spin2, .spin3, .spin4, .spin5: return Palette.violet
|
||||||
|
case .spint1, .spint2, .spint3, .spint4:
|
||||||
|
return UIColor(red: 1.0, green: 0.45, blue: 0.85, alpha: 1)
|
||||||
|
case .x1, .x2, .x3, .x4, .x5, .x6, .x7, .x8: return Palette.amber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
override func didMove(to view: SKView) {
|
||||||
|
anchorPoint = .zero
|
||||||
|
scaleMode = .resizeFill
|
||||||
|
layoutScene()
|
||||||
|
#if DEBUG
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
if env["ARCADE_AUTOPLAY"] == "1" { startGame() }
|
||||||
|
if env["ARCADE_DEMO"] == "1" {
|
||||||
|
startGame()
|
||||||
|
sim.debugWarpToColumn(0) // canyon + first waves
|
||||||
|
for _ in 0..<175 { stepSimRaw() }
|
||||||
|
demoFreeze = true
|
||||||
|
renderFrame()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didChangeSize(_ oldSize: CGSize) {
|
||||||
|
guard view != nil, size != builtSize, size.width > 0 else { return }
|
||||||
|
layoutScene()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Textures
|
||||||
|
|
||||||
|
/// Neon pixel texture for an 8-px-wide sprite (bit 7 = leftmost).
|
||||||
|
private func texture(for img: D.Img) -> SKTexture {
|
||||||
|
let key = "\(img)"
|
||||||
|
if let t = textureCache[key] { return t }
|
||||||
|
let sprite = D.sprites[img]!
|
||||||
|
let cell: CGFloat = 10
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8 * cell,
|
||||||
|
height: CGFloat(sprite.h) * cell))
|
||||||
|
let color = Self.tint(for: img)
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
cg.setShadow(offset: .zero, blur: cell * 0.6, color: color.withAlphaComponent(0.85).cgColor)
|
||||||
|
for pass in 0..<2 {
|
||||||
|
if pass == 1 { cg.setShadow(offset: .zero, blur: 0, color: nil) }
|
||||||
|
for (row, bits) in sprite.rows.enumerated() {
|
||||||
|
for col in 0..<8 where (bits & UInt8(0x80 >> col)) != 0 {
|
||||||
|
let rect = CGRect(x: CGFloat(col) * cell, y: CGFloat(row) * cell,
|
||||||
|
width: cell, height: cell)
|
||||||
|
color.setFill()
|
||||||
|
UIBezierPath(roundedRect: rect.insetBy(dx: cell * 0.05, dy: cell * 0.05),
|
||||||
|
cornerRadius: cell * 0.18).fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let t = SKTexture(image: image)
|
||||||
|
textureCache[key] = t
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One rock texture per map column (8 × 64 sim px): glowing rim pixels on
|
||||||
|
/// every edge facing open air, dark rock mass inside — cave walls with lit
|
||||||
|
/// edges instead of flat orange slabs.
|
||||||
|
private func buildColumnTextures() {
|
||||||
|
columnTextures = (0..<64).map { col in
|
||||||
|
// Solid grid for the column (bit clear = rock, per the inverted map).
|
||||||
|
var solid = [[Bool]](repeating: [Bool](repeating: false, count: 64), count: 8)
|
||||||
|
for tileRow in 0..<8 {
|
||||||
|
let tile = D.tiles[D.map[tileRow][col]]
|
||||||
|
for k in 0..<8 {
|
||||||
|
let bits = tile[k]
|
||||||
|
for px in 0..<8 where (bits & UInt8(0x80 >> px)) == 0 {
|
||||||
|
solid[px][tileRow * 8 + k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Out-of-column neighbours count as rock so column seams stay dark.
|
||||||
|
func isSolid(_ x: Int, _ y: Int) -> Bool {
|
||||||
|
guard y >= 0, y < 64 else { return true }
|
||||||
|
guard x >= 0, x < 8 else { return true }
|
||||||
|
return solid[x][y]
|
||||||
|
}
|
||||||
|
|
||||||
|
let cell: CGFloat = 6
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8 * cell, height: 64 * cell))
|
||||||
|
let rim = Self.terrainColor
|
||||||
|
let rock = UIColor(red: 0.255, green: 0.130, blue: 0.075, alpha: 1)
|
||||||
|
let rockDeep = UIColor(red: 0.210, green: 0.105, blue: 0.065, alpha: 1)
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
// pass 0: glow behind rim pixels; pass 1: crisp fills
|
||||||
|
cg.setShadow(offset: .zero, blur: cell * 1.1,
|
||||||
|
color: rim.withAlphaComponent(0.75).cgColor)
|
||||||
|
for pass in 0..<2 {
|
||||||
|
if pass == 1 { cg.setShadow(offset: .zero, blur: 0, color: nil) }
|
||||||
|
for px in 0..<8 {
|
||||||
|
for y in 0..<64 where solid[px][y] {
|
||||||
|
let isRim = !isSolid(px, y - 1) || !isSolid(px, y + 1)
|
||||||
|
|| !isSolid(px - 1, y) || !isSolid(px + 1, y)
|
||||||
|
if pass == 0 {
|
||||||
|
guard isRim else { continue }
|
||||||
|
rim.setFill()
|
||||||
|
} else if isRim {
|
||||||
|
rim.setFill()
|
||||||
|
} else {
|
||||||
|
// checker the interior slightly for grain
|
||||||
|
((px + y) % 2 == 0 ? rock : rockDeep).setFill()
|
||||||
|
}
|
||||||
|
cg.fill(CGRect(x: CGFloat(px) * cell, y: CGFloat(y) * cell,
|
||||||
|
width: cell, height: cell))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SKTexture(image: image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Far cave wall silhouettes: stalactites along the top, stalagmites along
|
||||||
|
/// the bottom. Pure parallax dressing behind the real terrain.
|
||||||
|
private func cavernTexture(width: CGFloat, height: CGFloat, seed: UInt64) -> SKTexture {
|
||||||
|
var rng = SeededGenerator(seed: seed)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height))
|
||||||
|
let body = UIColor(red: 0.11, green: 0.075, blue: 0.065, alpha: 1)
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
for top in [true, false] {
|
||||||
|
let path = CGMutablePath()
|
||||||
|
let baseY: CGFloat = top ? 0 : height
|
||||||
|
path.move(to: CGPoint(x: 0, y: baseY))
|
||||||
|
var x: CGFloat = 0
|
||||||
|
while x < width {
|
||||||
|
let step = CGFloat.random(in: 30...80, using: &rng)
|
||||||
|
let depth = CGFloat.random(in: height * 0.03...height * 0.16, using: &rng)
|
||||||
|
path.addLine(to: CGPoint(x: x + step * 0.5,
|
||||||
|
y: top ? depth : height - depth))
|
||||||
|
x += step
|
||||||
|
path.addLine(to: CGPoint(x: x, y: baseY))
|
||||||
|
}
|
||||||
|
path.addLine(to: CGPoint(x: width, y: baseY))
|
||||||
|
path.closeSubpath()
|
||||||
|
cg.addPath(path)
|
||||||
|
body.setFill()
|
||||||
|
cg.fillPath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SKTexture(image: image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout
|
||||||
|
|
||||||
|
private func layoutScene() {
|
||||||
|
builtSize = size
|
||||||
|
removeAllChildren()
|
||||||
|
textureCache.removeAll()
|
||||||
|
terrainNodes.removeAll()
|
||||||
|
backgroundColor = Palette.space1
|
||||||
|
|
||||||
|
let insets = view?.safeAreaInsets ?? .zero
|
||||||
|
topInset = insets.top
|
||||||
|
let bottomInset = insets.bottom
|
||||||
|
|
||||||
|
let bg = SKSpriteNode(texture: SceneArt.verticalGradient(size: size, top: Palette.space0, bottom: Palette.space1))
|
||||||
|
bg.anchorPoint = .zero
|
||||||
|
bg.zPosition = -10
|
||||||
|
addChild(bg)
|
||||||
|
let stars = SceneArt.starfield(size: size, count: 60, seed: 0x2E4C)
|
||||||
|
stars.zPosition = -9
|
||||||
|
addChild(stars)
|
||||||
|
let nebula = SceneArt.glowSprite(color: UIColor(red: 0.95, green: 0.5, blue: 0.3, alpha: 1), radius: 220)
|
||||||
|
nebula.position = CGPoint(x: size.width * 0.80, y: size.height * 0.72)
|
||||||
|
nebula.alpha = 0.12
|
||||||
|
nebula.zPosition = -9.4
|
||||||
|
addChild(nebula)
|
||||||
|
|
||||||
|
// Field: 128 × 58 visible sim units (y 35…92), full-bleed width.
|
||||||
|
// Positions and terrain map with a 1.9× vertical stretch (the canyon
|
||||||
|
// passages open up on a tall phone); ship/enemy sprites keep a mild
|
||||||
|
// 1.35× arcade stretch. Sim geometry and collisions are untouched.
|
||||||
|
unit = size.width / 128
|
||||||
|
unitY = unit * 1.9
|
||||||
|
unitYSprite = unit * 1.35
|
||||||
|
let fieldH = unitY * 58
|
||||||
|
let hudBottom = size.height - topInset - 92
|
||||||
|
fieldRect = CGRect(x: 0, y: hudBottom - fieldH, width: unit * 128, height: fieldH)
|
||||||
|
|
||||||
|
worldCrop.removeAllChildren()
|
||||||
|
let mask = SKSpriteNode(color: .white, size: fieldRect.size)
|
||||||
|
mask.position = CGPoint(x: fieldRect.midX, y: fieldRect.midY)
|
||||||
|
worldCrop.maskNode = mask
|
||||||
|
worldCrop.zPosition = 0
|
||||||
|
addChild(worldCrop)
|
||||||
|
|
||||||
|
// Parallax inside the cave: drifting stars plus a slow far-wall
|
||||||
|
// silhouette band (stalactites above, stalagmites below).
|
||||||
|
for (speed, count, seed) in [(14.0, 22, 0xAA01), (26.0, 14, 0xAA02)] {
|
||||||
|
let strip = SKNode()
|
||||||
|
let w = fieldRect.width
|
||||||
|
for i in 0..<2 {
|
||||||
|
let layer = SceneArt.starfield(size: CGSize(width: w, height: fieldRect.height),
|
||||||
|
count: count, seed: UInt64(seed))
|
||||||
|
layer.position = CGPoint(x: fieldRect.minX + CGFloat(i) * w, y: fieldRect.minY)
|
||||||
|
strip.addChild(layer)
|
||||||
|
}
|
||||||
|
strip.zPosition = 0.5
|
||||||
|
strip.run(.repeatForever(.sequence([
|
||||||
|
.moveBy(x: -w, y: 0, duration: Double(w) / speed / Double(unit)),
|
||||||
|
.moveBy(x: w, y: 0, duration: 0),
|
||||||
|
])))
|
||||||
|
worldCrop.addChild(strip)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let strip = SKNode()
|
||||||
|
let w = fieldRect.width
|
||||||
|
let tex = cavernTexture(width: w, height: fieldRect.height, seed: 0xCA4E)
|
||||||
|
for i in 0..<2 {
|
||||||
|
let layer = SKSpriteNode(texture: tex)
|
||||||
|
layer.anchorPoint = .zero
|
||||||
|
layer.position = CGPoint(x: fieldRect.minX + CGFloat(i) * w, y: fieldRect.minY)
|
||||||
|
strip.addChild(layer)
|
||||||
|
}
|
||||||
|
strip.alpha = 0.5
|
||||||
|
strip.zPosition = 0.7
|
||||||
|
strip.run(.repeatForever(.sequence([
|
||||||
|
.moveBy(x: -w, y: 0, duration: Double(w) / 8.0 / Double(unit)),
|
||||||
|
.moveBy(x: w, y: 0, duration: 0),
|
||||||
|
])))
|
||||||
|
worldCrop.addChild(strip)
|
||||||
|
}
|
||||||
|
|
||||||
|
let vignette = SceneArt.vignette(size: size, alpha: 0.40)
|
||||||
|
vignette.position = CGPoint(x: size.width / 2, y: size.height / 2)
|
||||||
|
addChild(vignette)
|
||||||
|
|
||||||
|
buildColumnTextures()
|
||||||
|
buildEntityNodes()
|
||||||
|
buildHUD(bottomInset: bottomInset)
|
||||||
|
buildReadyOverlay()
|
||||||
|
buildPausedOverlay()
|
||||||
|
buildGameOverOverlay()
|
||||||
|
|
||||||
|
applyStateVisibility()
|
||||||
|
rebuildTerrainNodes()
|
||||||
|
renderFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildEntityNodes() {
|
||||||
|
func makeNode() -> SKSpriteNode {
|
||||||
|
let n = SKSpriteNode()
|
||||||
|
n.isHidden = true
|
||||||
|
worldCrop.addChild(n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
enemyNodes = (0..<12).map { _ in let n = makeNode(); n.zPosition = 4; return n }
|
||||||
|
pbNodes = (0..<13).map { _ in let n = makeNode(); n.zPosition = 3; return n }
|
||||||
|
ebNodes = (0..<12).map { _ in let n = makeNode(); n.zPosition = 3; return n }
|
||||||
|
lastEnemyImgs = [D.Img?](repeating: nil, count: 12)
|
||||||
|
playerNode = makeNode()
|
||||||
|
playerNode.zPosition = 5
|
||||||
|
|
||||||
|
playerTrail.removeFromParent()
|
||||||
|
playerTrail = SceneArt.thrustEmitter(color: Palette.cyan, angle: .pi,
|
||||||
|
speed: 140, birthRate: 0, scale: 0.22, lifetime: 0.30)
|
||||||
|
playerTrail.position = CGPoint(x: -13, y: -1)
|
||||||
|
playerTrail.zPosition = -1
|
||||||
|
playerNode.addChild(playerTrail)
|
||||||
|
playerTrail.targetNode = worldCrop
|
||||||
|
}
|
||||||
|
|
||||||
|
private var playerTrail = SKEmitterNode()
|
||||||
|
private var lastEnemyImgs: [D.Img?] = []
|
||||||
|
|
||||||
|
/// Sim → scene coordinates. The visible window is sprite-space x 8…135,
|
||||||
|
/// y 35…92 (y grows downward).
|
||||||
|
private func point(x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat) -> CGPoint {
|
||||||
|
CGPoint(x: fieldRect.minX + (x - CGFloat(MercurySim.visibleLeft) + w / 2) * unit,
|
||||||
|
y: fieldRect.maxY - (y - CGFloat(MercurySim.minY) + h / 2) * unitY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func place(_ node: SKSpriteNode, img: D.Img, x: Int, y: Int) {
|
||||||
|
let sprite = D.sprites[img]!
|
||||||
|
node.texture = texture(for: img)
|
||||||
|
// Positions/terrain use the stretched map; sprites stay near-square,
|
||||||
|
// drawn slightly over their cell for presence (hitboxes are sim-side).
|
||||||
|
node.size = CGSize(width: 8 * unit * 1.12, height: CGFloat(sprite.h) * unitYSprite * 1.12)
|
||||||
|
node.position = point(x: CGFloat(x), y: CGFloat(y), w: 8, h: CGFloat(sprite.h))
|
||||||
|
node.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Terrain nodes
|
||||||
|
|
||||||
|
private func rebuildTerrainNodes() {
|
||||||
|
for (node, _) in terrainNodes { node.removeFromParent() }
|
||||||
|
terrainNodes.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func spawnTerrainColumn(mapColumn: Int) {
|
||||||
|
let node = SKSpriteNode(texture: columnTextures[mapColumn])
|
||||||
|
node.size = CGSize(width: 8 * unit, height: 64 * unitY)
|
||||||
|
node.zPosition = 1
|
||||||
|
worldCrop.addChild(node)
|
||||||
|
terrainNodes.append((node, sim.totalScroll))
|
||||||
|
positionTerrainNode(terrainNodes.count - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func positionTerrainNode(_ i: Int) {
|
||||||
|
let (node, spawnScroll) = terrainNodes[i]
|
||||||
|
// A fresh column occupies the staging band (sprite x 136…143) and
|
||||||
|
// scrolls left 1 px per tick from there.
|
||||||
|
let leftEdge = CGFloat(136 - (sim.totalScroll - spawnScroll))
|
||||||
|
// Column spans sim y 32…95; centre it against the visible window.
|
||||||
|
node.position = point(x: leftEdge, y: CGFloat(MercurySim.terrainTop), w: 8, h: 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTerrainNodes() {
|
||||||
|
var i = 0
|
||||||
|
while i < terrainNodes.count {
|
||||||
|
let leftEdge = 136 - (sim.totalScroll - terrainNodes[i].spawnScroll)
|
||||||
|
if leftEdge < 0 {
|
||||||
|
terrainNodes[i].node.removeFromParent()
|
||||||
|
terrainNodes.remove(at: i)
|
||||||
|
} else {
|
||||||
|
positionTerrainNode(i)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HUD
|
||||||
|
|
||||||
|
private func buildHUD(bottomInset: CGFloat) {
|
||||||
|
let hudY = size.height - topInset - 26
|
||||||
|
|
||||||
|
addChild(caption("SHIELD", x: 74, y: hudY + 12, align: .left))
|
||||||
|
shieldValue.fontSize = 17
|
||||||
|
shieldValue.fontColor = Palette.mint
|
||||||
|
shieldValue.horizontalAlignmentMode = .left
|
||||||
|
shieldValue.verticalAlignmentMode = .center
|
||||||
|
shieldValue.position = CGPoint(x: 74, y: hudY - 8)
|
||||||
|
shieldValue.zPosition = 20
|
||||||
|
addChild(shieldValue)
|
||||||
|
|
||||||
|
addChild(caption("SECTOR", x: size.width / 2, y: hudY + 12, align: .center))
|
||||||
|
sectorValue.fontSize = 17
|
||||||
|
sectorValue.fontColor = .white
|
||||||
|
sectorValue.horizontalAlignmentMode = .center
|
||||||
|
sectorValue.verticalAlignmentMode = .center
|
||||||
|
sectorValue.position = CGPoint(x: size.width / 2, y: hudY - 8)
|
||||||
|
sectorValue.zPosition = 20
|
||||||
|
addChild(sectorValue)
|
||||||
|
|
||||||
|
addChild(caption("SCORE", x: size.width - 20, y: hudY + 12, align: .right))
|
||||||
|
scoreValue.fontSize = 17
|
||||||
|
scoreValue.fontColor = .white
|
||||||
|
scoreValue.horizontalAlignmentMode = .right
|
||||||
|
scoreValue.verticalAlignmentMode = .center
|
||||||
|
scoreValue.position = CGPoint(x: size.width - 20, y: hudY - 8)
|
||||||
|
scoreValue.zPosition = 20
|
||||||
|
addChild(scoreValue)
|
||||||
|
|
||||||
|
pauseButton.fillColor = UIColor(white: 1, alpha: 0.08)
|
||||||
|
pauseButton.strokeColor = UIColor(white: 1, alpha: 0.25)
|
||||||
|
pauseButton.lineWidth = 1
|
||||||
|
pauseButton.position = CGPoint(x: fieldRect.maxX - 28, y: fieldRect.maxY - 24)
|
||||||
|
pauseButton.zPosition = 22
|
||||||
|
pauseButton.removeAllChildren()
|
||||||
|
if let tex = SceneArt.symbolTexture("pause.fill", pointSize: 13, color: Palette.white70) {
|
||||||
|
let icon = SKSpriteNode(texture: tex)
|
||||||
|
icon.size = tex.size()
|
||||||
|
pauseButton.addChild(icon)
|
||||||
|
}
|
||||||
|
addChild(pauseButton)
|
||||||
|
|
||||||
|
banner.fontSize = 22
|
||||||
|
banner.fontColor = Palette.red
|
||||||
|
banner.horizontalAlignmentMode = .center
|
||||||
|
banner.verticalAlignmentMode = .center
|
||||||
|
banner.position = CGPoint(x: fieldRect.midX, y: fieldRect.midY + fieldRect.height * 0.22)
|
||||||
|
banner.zPosition = 24
|
||||||
|
banner.alpha = 0
|
||||||
|
addChild(banner)
|
||||||
|
|
||||||
|
let hintY = bottomInset + (max(fieldRect.minY - bottomInset, 60)) * 0.35
|
||||||
|
addChild(caption("DRAG ANYWHERE TO FLY — CANNON AUTO-FIRES", x: size.width / 2,
|
||||||
|
y: max(bottomInset + 16, hintY), align: .center))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func caption(_ text: String, x: CGFloat, y: CGFloat, align: SKLabelHorizontalAlignmentMode) -> SKLabelNode {
|
||||||
|
let label = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
label.text = text
|
||||||
|
label.fontSize = 10
|
||||||
|
label.fontColor = Palette.white40
|
||||||
|
label.horizontalAlignmentMode = align
|
||||||
|
label.verticalAlignmentMode = .center
|
||||||
|
label.position = CGPoint(x: x, y: y)
|
||||||
|
label.zPosition = 20
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Overlays
|
||||||
|
|
||||||
|
private func bigLabel(_ text: String, fontNamed: String = "AvenirNext-Heavy", size fontSize: CGFloat,
|
||||||
|
color: UIColor, x: CGFloat, y: CGFloat, maxWidth: CGFloat? = nil) -> SKLabelNode {
|
||||||
|
let label = SKLabelNode(fontNamed: fontNamed)
|
||||||
|
label.text = text
|
||||||
|
label.fontSize = fontSize
|
||||||
|
label.fontColor = color
|
||||||
|
label.horizontalAlignmentMode = .center
|
||||||
|
label.verticalAlignmentMode = .center
|
||||||
|
label.position = CGPoint(x: x, y: y)
|
||||||
|
if let maxWidth {
|
||||||
|
label.preferredMaxLayoutWidth = maxWidth
|
||||||
|
label.numberOfLines = 0
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildReadyOverlay() {
|
||||||
|
readyOverlay.removeAllChildren()
|
||||||
|
readyOverlay.zPosition = 40
|
||||||
|
addChild(readyOverlay)
|
||||||
|
let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.66), size: size)
|
||||||
|
dim.anchorPoint = .zero
|
||||||
|
readyOverlay.addChild(dim)
|
||||||
|
|
||||||
|
let cx = size.width / 2
|
||||||
|
let midY = size.height * 0.62
|
||||||
|
readyOverlay.addChild(bigLabel("ZMERCURY", size: 36, color: .white, x: cx, y: midY + 64))
|
||||||
|
readyOverlay.addChild(bigLabel("Skim the caves. Duck the fire. Survive the run.",
|
||||||
|
fontNamed: "AvenirNext-Medium", size: 14.5,
|
||||||
|
color: Palette.white70, x: cx, y: midY + 30))
|
||||||
|
readyOverlay.addChild(bigLabel("Terrain touch drains shields fast — thread the gaps",
|
||||||
|
fontNamed: "AvenirNext-Medium", size: 13,
|
||||||
|
color: Palette.white40, x: cx, y: midY - 2,
|
||||||
|
maxWidth: size.width - 80))
|
||||||
|
|
||||||
|
let prompt = bigLabel("TAP TO LAUNCH", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint,
|
||||||
|
x: cx, y: midY - 64)
|
||||||
|
prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)])))
|
||||||
|
readyOverlay.addChild(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildPausedOverlay() {
|
||||||
|
pausedOverlay.removeAllChildren()
|
||||||
|
pausedOverlay.zPosition = 40
|
||||||
|
addChild(pausedOverlay)
|
||||||
|
let dim = SKSpriteNode(color: UIColor(white: 0.01, alpha: 0.80), size: size)
|
||||||
|
dim.anchorPoint = .zero
|
||||||
|
pausedOverlay.addChild(dim)
|
||||||
|
pausedOverlay.addChild(bigLabel("PAUSED", size: 30, color: .white, x: size.width / 2, y: size.height * 0.55))
|
||||||
|
let hint = bigLabel("TAP TO RESUME", fontNamed: "Menlo-Bold", size: 15, color: Palette.mint,
|
||||||
|
x: size.width / 2, y: size.height * 0.55 - 44)
|
||||||
|
hint.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)])))
|
||||||
|
pausedOverlay.addChild(hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildGameOverOverlay() {
|
||||||
|
gameOverOverlay.removeAllChildren()
|
||||||
|
gameOverOverlay.zPosition = 40
|
||||||
|
addChild(gameOverOverlay)
|
||||||
|
let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.72), size: size)
|
||||||
|
dim.anchorPoint = .zero
|
||||||
|
gameOverOverlay.addChild(dim)
|
||||||
|
let cx = size.width / 2
|
||||||
|
let midY = size.height * 0.6
|
||||||
|
gameOverOverlay.addChild(bigLabel("SHIELDS DOWN", size: 30, color: Palette.red, x: cx, y: midY + 36))
|
||||||
|
gameOverDetail.fontSize = 15
|
||||||
|
gameOverDetail.fontColor = .white
|
||||||
|
gameOverDetail.horizontalAlignmentMode = .center
|
||||||
|
gameOverDetail.position = CGPoint(x: cx, y: midY - 4)
|
||||||
|
gameOverOverlay.addChild(gameOverDetail)
|
||||||
|
gameOverRecord.fontSize = 14
|
||||||
|
gameOverRecord.fontColor = Palette.amber
|
||||||
|
gameOverRecord.horizontalAlignmentMode = .center
|
||||||
|
gameOverRecord.position = CGPoint(x: cx, y: midY - 30)
|
||||||
|
gameOverOverlay.addChild(gameOverRecord)
|
||||||
|
let prompt = bigLabel("TAP TO FLY AGAIN", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint,
|
||||||
|
x: cx, y: midY - 70)
|
||||||
|
prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)])))
|
||||||
|
gameOverOverlay.addChild(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game flow
|
||||||
|
|
||||||
|
private func startGame() {
|
||||||
|
sim = MercurySim()
|
||||||
|
targetX = 42
|
||||||
|
targetY = 60
|
||||||
|
accumulator = 0
|
||||||
|
fireCooldown = 0
|
||||||
|
rebuildTerrainNodes()
|
||||||
|
state = .playing
|
||||||
|
applyStateVisibility()
|
||||||
|
renderFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyStateVisibility() {
|
||||||
|
readyOverlay.isHidden = state != .ready
|
||||||
|
pausedOverlay.isHidden = state != .paused
|
||||||
|
gameOverOverlay.isHidden = state != .gameOver
|
||||||
|
pauseButton.isHidden = state != .playing
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endGame() {
|
||||||
|
// The ship goes out with a proper blast.
|
||||||
|
addChild(SceneArt.flash(size: size, color: Palette.amber, peak: 0.22, duration: 0.28))
|
||||||
|
let fire = SceneArt.sparkBurst(color: Palette.amber, count: 30, speed: 220, lifetime: 0.7, scale: 0.26)
|
||||||
|
fire.position = playerNode.position
|
||||||
|
fire.zPosition = 7
|
||||||
|
worldCrop.addChild(fire)
|
||||||
|
let core = SceneArt.glowSprite(color: Palette.red, radius: 46)
|
||||||
|
core.position = playerNode.position
|
||||||
|
core.zPosition = 6.5
|
||||||
|
core.run(.sequence([.group([.scale(to: 2.4, duration: 0.3), .fadeOut(withDuration: 0.34)]),
|
||||||
|
.removeFromParent()]))
|
||||||
|
worldCrop.addChild(core)
|
||||||
|
worldCrop.run(SceneArt.shake(intensity: 10, duration: 0.45))
|
||||||
|
|
||||||
|
let record = HighScoreStore.shared.submit(id: "zmercury", score: sim.score)
|
||||||
|
gameOverDetail.text = "SECTOR \(sim.totalScroll / 512 + 1) · SCORE \(sim.score)"
|
||||||
|
gameOverRecord.text = record ? "★ NEW BEST ★" : "BEST \(HighScoreStore.shared.best("zmercury"))"
|
||||||
|
state = .gameOver
|
||||||
|
applyStateVisibility()
|
||||||
|
Haptics.error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game loop
|
||||||
|
|
||||||
|
override func update(_ currentTime: TimeInterval) {
|
||||||
|
if lastUpdate == 0 { lastUpdate = currentTime }
|
||||||
|
var dt = currentTime - lastUpdate
|
||||||
|
lastUpdate = currentTime
|
||||||
|
if dt > 0.25 { dt = 0.25 }
|
||||||
|
guard state == .playing else { return }
|
||||||
|
#if DEBUG
|
||||||
|
if demoFreeze { return }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let interval = 1.0 / MercurySim.fps
|
||||||
|
accumulator += dt
|
||||||
|
var steps = 0
|
||||||
|
while accumulator >= interval, steps < 5 {
|
||||||
|
accumulator -= interval
|
||||||
|
steps += 1
|
||||||
|
stepSim(currentTime)
|
||||||
|
if state != .playing { break }
|
||||||
|
}
|
||||||
|
renderFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances the sim + terrain node bookkeeping without effects (for demos).
|
||||||
|
private func stepSimRaw() {
|
||||||
|
var input = MercurySim.Input()
|
||||||
|
if fireCooldown > 0 { fireCooldown -= 1 }
|
||||||
|
if fireCooldown == 0 { input.fire = true; fireCooldown = 6 }
|
||||||
|
sim.tick(input)
|
||||||
|
for event in sim.events {
|
||||||
|
switch event {
|
||||||
|
case .columnDrawn(let col): spawnTerrainColumn(mapColumn: col)
|
||||||
|
case .terrainCleared: rebuildTerrainNodes()
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTerrainNodes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stepSim(_ now: TimeInterval) {
|
||||||
|
var input = MercurySim.Input()
|
||||||
|
let dx = targetX - sim.playerX
|
||||||
|
let dy = targetY - sim.playerY
|
||||||
|
if dx != 0 { input.dx = dx > 0 ? 1 : -1 }
|
||||||
|
if dy != 0 { input.dy = dy > 0 ? 1 : -1 }
|
||||||
|
if fireCooldown > 0 { fireCooldown -= 1 }
|
||||||
|
if fireCooldown == 0 {
|
||||||
|
input.fire = true
|
||||||
|
fireCooldown = 6 // ~5 shots/sec at 28.6 fps
|
||||||
|
}
|
||||||
|
sim.tick(input)
|
||||||
|
|
||||||
|
for event in sim.events {
|
||||||
|
switch event {
|
||||||
|
case .columnDrawn(let col):
|
||||||
|
spawnTerrainColumn(mapColumn: col)
|
||||||
|
case .terrainCleared:
|
||||||
|
rebuildTerrainNodes()
|
||||||
|
case .waveStarted(let type):
|
||||||
|
if type == 7 { showBanner("WARSHIP CONTACT") }
|
||||||
|
case .enemyKilled:
|
||||||
|
Haptics.impact(.light, intensity: 0.5)
|
||||||
|
case .playerHit:
|
||||||
|
// Terrain scrape can hit 4× a frame — throttle the buzz.
|
||||||
|
if now - lastHitHaptic > 0.12 {
|
||||||
|
lastHitHaptic = now
|
||||||
|
Haptics.impact(.medium, intensity: 0.7)
|
||||||
|
let sparks = SceneArt.sparkBurst(color: Palette.amber, count: 8,
|
||||||
|
speed: 90, lifetime: 0.35, scale: 0.15)
|
||||||
|
sparks.position = playerNode.position
|
||||||
|
sparks.zPosition = 6
|
||||||
|
worldCrop.addChild(sparks)
|
||||||
|
worldCrop.run(SceneArt.shake(intensity: 4, duration: 0.18))
|
||||||
|
}
|
||||||
|
flashPlayer()
|
||||||
|
case .gameOver:
|
||||||
|
endGame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTerrainNodes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showBanner(_ text: String) {
|
||||||
|
banner.removeAllActions()
|
||||||
|
banner.text = text
|
||||||
|
banner.alpha = 0
|
||||||
|
banner.run(.sequence([
|
||||||
|
.fadeIn(withDuration: 0.2),
|
||||||
|
.wait(forDuration: 1.2),
|
||||||
|
.fadeOut(withDuration: 0.5),
|
||||||
|
]))
|
||||||
|
Haptics.warning()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flashPlayer() {
|
||||||
|
playerNode.removeAction(forKey: "hitflash")
|
||||||
|
playerNode.run(.sequence([
|
||||||
|
.colorize(with: .red, colorBlendFactor: 0.8, duration: 0.05),
|
||||||
|
.wait(forDuration: 0.06),
|
||||||
|
.colorize(withColorBlendFactor: 0, duration: 0.12),
|
||||||
|
]), withKey: "hitflash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rendering
|
||||||
|
|
||||||
|
private func renderFrame() {
|
||||||
|
place(playerNode, img: .player, x: sim.playerX, y: sim.playerY)
|
||||||
|
playerNode.isHidden = state == .gameOver
|
||||||
|
playerTrail.particleBirthRate = state == .playing ? 90 : 0
|
||||||
|
let bank = max(-1, min(1, CGFloat(targetY - sim.playerY) / 5))
|
||||||
|
playerNode.zRotation = playerNode.zRotation * 0.8 + (bank * -0.09) * 0.2
|
||||||
|
|
||||||
|
for (i, node) in enemyNodes.enumerated() {
|
||||||
|
let e = sim.enemies[i]
|
||||||
|
if e.type != 0 {
|
||||||
|
place(node, img: e.img, x: e.x, y: e.y)
|
||||||
|
// First explosion frame → pop sparks at the kill site.
|
||||||
|
if e.img == .x1, lastEnemyImgs[i] != .x1, lastEnemyImgs[i] != nil {
|
||||||
|
let sparks = SceneArt.sparkBurst(color: Palette.amber, count: 12,
|
||||||
|
speed: 130, lifetime: 0.45, scale: 0.18)
|
||||||
|
sparks.position = node.position
|
||||||
|
sparks.zPosition = 6
|
||||||
|
worldCrop.addChild(sparks)
|
||||||
|
}
|
||||||
|
lastEnemyImgs[i] = e.img
|
||||||
|
} else {
|
||||||
|
node.isHidden = true
|
||||||
|
lastEnemyImgs[i] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, node) in pbNodes.enumerated() {
|
||||||
|
let b = sim.playerBullets[i]
|
||||||
|
if b.active { place(node, img: .bullet1, x: b.x, y: b.y) }
|
||||||
|
else { node.isHidden = true }
|
||||||
|
}
|
||||||
|
for (i, node) in ebNodes.enumerated() {
|
||||||
|
let b = sim.enemyBullets[i]
|
||||||
|
if b.active { place(node, img: b.img, x: b.x, y: b.y) }
|
||||||
|
else { node.isHidden = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
let s = max(0, sim.shield)
|
||||||
|
shieldValue.text = "\(s)"
|
||||||
|
shieldValue.fontColor = s > 50 ? Palette.mint : (s > 20 ? Palette.amber : Palette.red)
|
||||||
|
sectorValue.text = "\(sim.totalScroll / 512 + 1)"
|
||||||
|
scoreValue.text = "\(sim.score)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Touch handling
|
||||||
|
|
||||||
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
for touch in touches {
|
||||||
|
let p = touch.location(in: self)
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
Haptics.selection()
|
||||||
|
startGame()
|
||||||
|
case .paused:
|
||||||
|
Haptics.selection()
|
||||||
|
state = .playing
|
||||||
|
applyStateVisibility()
|
||||||
|
case .gameOver:
|
||||||
|
Haptics.selection()
|
||||||
|
state = .ready
|
||||||
|
applyStateVisibility()
|
||||||
|
case .playing:
|
||||||
|
let dp = hypot(p.x - pauseButton.position.x, p.y - pauseButton.position.y)
|
||||||
|
if dp <= 30 {
|
||||||
|
Haptics.selection()
|
||||||
|
state = .paused
|
||||||
|
moveTouch = nil
|
||||||
|
applyStateVisibility()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moveTouch = touch
|
||||||
|
touchAnchor = p
|
||||||
|
shipAnchor = CGPoint(x: CGFloat(sim.playerX), y: CGFloat(sim.playerY))
|
||||||
|
targetX = sim.playerX
|
||||||
|
targetY = sim.playerY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
guard state == .playing, let moveTouch, touches.contains(moveTouch) else { return }
|
||||||
|
let p = moveTouch.location(in: self)
|
||||||
|
// Finger travel maps ~1.35× on-screen ship travel (per-axis).
|
||||||
|
targetX = Int(shipAnchor.x + (p.x - touchAnchor.x) * 1.35 / unit)
|
||||||
|
targetY = Int(shipAnchor.y - (p.y - touchAnchor.y) * 1.35 / unitY)
|
||||||
|
targetX = min(max(targetX, 8), 56)
|
||||||
|
targetY = min(max(targetY, 32), 84)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
if let moveTouch, touches.contains(moveTouch) { self.moveTouch = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A faithful port of ZMercury 0.34 (Patrick Davidson, 2004). Reproduces the
|
||||||
|
/// original's main-loop order, terrain scroller, wave table, enemy behaviors,
|
||||||
|
/// BCD scoring and collision rules. The game is fully deterministic — the
|
||||||
|
/// original uses no randomness at all; waves come from the looping map and
|
||||||
|
/// fire cadence from per-slot stagger.
|
||||||
|
///
|
||||||
|
/// Field: 136 sim units wide (128 visible + an 8-px staging column at the
|
||||||
|
/// right where new terrain is drawn), y grows downward with the visible
|
||||||
|
/// window at y 35…92. The player roams x 8…56, y 32…84 with an 8×12 hitbox.
|
||||||
|
/// Terrain scrolls left 1 px/frame; a new 8-px tile column enters every 8
|
||||||
|
/// frames from a 64-column looping map. Frame rate: the original waits 7
|
||||||
|
/// ticks of the ~200 Hz interrupt per frame ≈ 28.6 fps.
|
||||||
|
struct MercurySim {
|
||||||
|
typealias D = MercuryData
|
||||||
|
|
||||||
|
static let fps = 200.0 / 7.0
|
||||||
|
static let fieldWidth = 136
|
||||||
|
static let visibleLeft = 8 // screen shows sprite-space x 8…135
|
||||||
|
static let visibleWidth = 128
|
||||||
|
static let minY = 35 // first visible row
|
||||||
|
static let maxY = 93 // bullets/sprites clipped at y ≥ 93
|
||||||
|
static let visibleHeight = 58
|
||||||
|
static let terrainTop = 32 // terrain buffer covers y 32…95
|
||||||
|
|
||||||
|
// MARK: - Entities
|
||||||
|
|
||||||
|
struct Enemy {
|
||||||
|
var type = 0 // 0 none · 1 exploding · 4 basic · 7 phoenix
|
||||||
|
// 10 spin-sine · 13 base turret · 16 spin-T · 19 boss
|
||||||
|
var x = 0
|
||||||
|
var w = 0
|
||||||
|
var y = 0
|
||||||
|
var h = 0
|
||||||
|
var dmg = 0 // hit points; enemy survives at exactly 0
|
||||||
|
var img = D.Img.enemy1
|
||||||
|
var counter = 0
|
||||||
|
var phase = 0
|
||||||
|
var anim = 0 // animation/explosion frame index
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlayerBullet { var active = false; var x = 0; var y = 0 } // 8×4, dmg 3
|
||||||
|
struct EnemyBullet {
|
||||||
|
var active = false
|
||||||
|
var x = 0
|
||||||
|
var y = 0
|
||||||
|
var vy = 0 // -1 / 0 / +1, aimed at spawn
|
||||||
|
var img = D.Img.ebullet1
|
||||||
|
} // 4×4, dmg 5, moves left 4 px/frame
|
||||||
|
|
||||||
|
enum Event: Equatable {
|
||||||
|
case columnDrawn(mapColumn: Int)
|
||||||
|
case terrainCleared
|
||||||
|
case waveStarted(Int)
|
||||||
|
case enemyKilled(x: Int, y: Int)
|
||||||
|
case playerHit
|
||||||
|
case gameOver
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Input {
|
||||||
|
var dx = 0
|
||||||
|
var dy = 0
|
||||||
|
var fire = false // one shot per press
|
||||||
|
init(dx: Int = 0, dy: Int = 0, fire: Bool = false) {
|
||||||
|
self.dx = dx; self.dy = dy; self.fire = fire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
private(set) var enemies = [Enemy](repeating: Enemy(), count: 12)
|
||||||
|
private(set) var playerBullets = [PlayerBullet](repeating: PlayerBullet(), count: 13)
|
||||||
|
private(set) var enemyBullets = [EnemyBullet](repeating: EnemyBullet(), count: 12)
|
||||||
|
|
||||||
|
private(set) var playerX = 42
|
||||||
|
private(set) var playerY = 60
|
||||||
|
private(set) var shield = 100 // BCD "0100" in the original
|
||||||
|
private(set) var score = 0 // 6-digit BCD, capped
|
||||||
|
private(set) var gameTimer = 0
|
||||||
|
private(set) var gameStatus = 0 // 0 scroll · 1 open space until wave cleared
|
||||||
|
private(set) var enemiesLeft = 0 // last frame's live count (incl. explosions)
|
||||||
|
private(set) var over = false
|
||||||
|
private(set) var events: [Event] = []
|
||||||
|
|
||||||
|
// Terrain: 64 rows × 17 bytes, original polarity (bit set = open sky).
|
||||||
|
// Byte 16 (px 128…135) is the off-screen staging column.
|
||||||
|
private(set) var terrain = [[UInt8]](repeating: [UInt8](repeating: 0xFF, count: 17), count: 64)
|
||||||
|
private(set) var terrainCount = 3 // bit phase within the current column
|
||||||
|
private(set) var terrainX = -1 // map column last entered (wraps 0…63)
|
||||||
|
private(set) var totalScroll = 0 // total pixels scrolled (for renderers)
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
/// True if the terrain pixel at sprite-space (x, y) is solid rock. The
|
||||||
|
/// original offsets the terrain buffer one byte against sprite space
|
||||||
|
/// (`add hl,bc / dec hl`): terrain byte 0 covers sprite x 8…15, and the
|
||||||
|
/// staging column (byte 16) sits at sprite x 136…143, off the right edge.
|
||||||
|
func solid(x: Int, y: Int) -> Bool {
|
||||||
|
let row = y - Self.terrainTop
|
||||||
|
let byte = (x >> 3) - 1
|
||||||
|
guard row >= 0, row < 64, byte >= 0, byte < 17 else { return false }
|
||||||
|
return terrain[row][byte] & UInt8(0x80 >> (x & 7)) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main loop (original call order)
|
||||||
|
|
||||||
|
mutating func tick(_ input: Input) {
|
||||||
|
guard !over else { return }
|
||||||
|
events.removeAll(keepingCapacity: true)
|
||||||
|
|
||||||
|
gameTimer &+= 1
|
||||||
|
scrollTerrain()
|
||||||
|
movePlayer(input)
|
||||||
|
handlePlayerBullets()
|
||||||
|
processEnemies()
|
||||||
|
processEnemyBullets()
|
||||||
|
hitEnemy()
|
||||||
|
|
||||||
|
if shield < 0 {
|
||||||
|
over = true
|
||||||
|
events.append(.gameOver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Terrain scroller
|
||||||
|
|
||||||
|
private mutating func scrollTerrain() {
|
||||||
|
for r in 0..<64 {
|
||||||
|
var row = terrain[r]
|
||||||
|
for i in 0..<16 { row[i] = (row[i] << 1) | (row[i + 1] >> 7) }
|
||||||
|
row[16] = (row[16] << 1) | 1
|
||||||
|
terrain[r] = row
|
||||||
|
}
|
||||||
|
totalScroll += 1
|
||||||
|
|
||||||
|
terrainCount += 1
|
||||||
|
guard terrainCount == 8 else { return }
|
||||||
|
terrainCount = 0
|
||||||
|
|
||||||
|
if gameStatus != 0 {
|
||||||
|
if enemiesLeft != 0 {
|
||||||
|
// Wave gate: wipe the terrain and hold the map position.
|
||||||
|
for r in 0..<64 { terrain[r] = [UInt8](repeating: 0xFF, count: 17) }
|
||||||
|
events.append(.terrainCleared)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameStatus = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
terrainX = (terrainX + 1) % 64
|
||||||
|
|
||||||
|
for tileRow in 0..<8 {
|
||||||
|
let tile = D.tiles[D.map[tileRow][terrainX]]
|
||||||
|
for k in 0..<8 { terrain[tileRow * 8 + k][16] = tile[k] }
|
||||||
|
}
|
||||||
|
events.append(.columnDrawn(mapColumn: terrainX))
|
||||||
|
|
||||||
|
let wave = D.map[8][terrainX]
|
||||||
|
if wave != 0 {
|
||||||
|
events.append(.waveStarted(wave))
|
||||||
|
enemyEntry(wave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wave entry (entry.asm)
|
||||||
|
|
||||||
|
private mutating func enemyEntry(_ wave: Int) {
|
||||||
|
func install(count: Int, xStep: Int, yStep: Int, phaseStep: Int, _ template: Enemy) {
|
||||||
|
var t = template
|
||||||
|
for _ in 0..<count {
|
||||||
|
if let slot = enemies.firstIndex(where: { $0.type == 0 }) {
|
||||||
|
enemies[slot] = t
|
||||||
|
}
|
||||||
|
t.x += xStep
|
||||||
|
t.y += yStep
|
||||||
|
t.phase += phaseStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch wave {
|
||||||
|
case 1: // simple wave
|
||||||
|
install(count: 3, xStep: 0, yStep: 9, phaseStep: 0,
|
||||||
|
Enemy(type: 4, x: 136, w: 8, y: 50, h: 8, dmg: 5, img: .enemy1))
|
||||||
|
case 2: // phoenixes
|
||||||
|
install(count: 3, xStep: 8, yStep: 15, phaseStep: 0,
|
||||||
|
Enemy(type: 7, x: 135, w: 8, y: 50, h: 12, dmg: 10, img: .enemy2))
|
||||||
|
case 3: // spinning sines
|
||||||
|
install(count: 4, xStep: 10, yStep: 15, phaseStep: 10,
|
||||||
|
Enemy(type: 10, x: 138, w: 8, y: 1, h: 8, dmg: 15, img: .spin1,
|
||||||
|
counter: 40, phase: 138))
|
||||||
|
case 4: // open space until the field is clear
|
||||||
|
gameStatus = 1
|
||||||
|
case 5: // base turret on the pad tile in this column
|
||||||
|
for tileRow in stride(from: 7, through: 0, by: -1) where D.map[tileRow][terrainX] == 10 {
|
||||||
|
let y = (tileRow + 1) * 8 + Self.minY - 11
|
||||||
|
if let slot = enemies.firstIndex(where: { $0.type == 0 }) {
|
||||||
|
enemies[slot] = Enemy(type: 13, x: 137, w: 8, y: y, h: 8, dmg: 45, img: .base)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 6: // partial spinning sines
|
||||||
|
install(count: 2, xStep: 10, yStep: 15, phaseStep: 10,
|
||||||
|
Enemy(type: 10, x: 138, w: 8, y: 1, h: 8, dmg: 15, img: .spin1,
|
||||||
|
counter: 40, phase: 138))
|
||||||
|
case 7: // boss + escort of spinning Ts
|
||||||
|
install(count: 1, xStep: 0, yStep: 0, phaseStep: 0,
|
||||||
|
Enemy(type: 19, x: 138, w: 40, y: 8, h: 16, dmg: 50, img: .boss, counter: -44))
|
||||||
|
install(count: 3, xStep: 10, yStep: 15, phaseStep: 10,
|
||||||
|
Enemy(type: 16, x: 138, w: 8, y: 1, h: 8, dmg: 18, img: .spint1,
|
||||||
|
counter: 40, phase: 138))
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Player (player.asm)
|
||||||
|
|
||||||
|
private mutating func movePlayer(_ input: Input) {
|
||||||
|
// Original key order: down, left, right, up — each edge-blocked.
|
||||||
|
if input.dy > 0, playerY + 1 != 96 - 12 + 1 { playerY += 1 }
|
||||||
|
if input.dx < 0, playerX - 1 != 7 { playerX -= 1 }
|
||||||
|
if input.dx > 0, playerX + 1 != 57 { playerX += 1 }
|
||||||
|
if input.dy < 0, playerY - 1 != 31 { playerY -= 1 }
|
||||||
|
|
||||||
|
if input.fire { playerShoot() }
|
||||||
|
|
||||||
|
// Terrain collision: each of the ship's four corners drains 1 shield.
|
||||||
|
for (cx, cy) in [(playerX, playerY), (playerX, playerY + 11),
|
||||||
|
(playerX + 7, playerY), (playerX + 7, playerY + 11)]
|
||||||
|
where solid(x: cx, y: cy) {
|
||||||
|
hitPlayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func hitPlayer() {
|
||||||
|
shield -= 1
|
||||||
|
events.append(.playerHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func playerShoot() {
|
||||||
|
guard let slot = playerBullets.firstIndex(where: { !$0.active }) else { return }
|
||||||
|
playerBullets[slot] = PlayerBullet(active: true, x: playerX + 2, y: playerY + 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func handlePlayerBullets() {
|
||||||
|
for b in 0..<13 where playerBullets[b].active {
|
||||||
|
if playerBullets[b].x + 4 >= 136 {
|
||||||
|
playerBullets[b].active = false
|
||||||
|
} else {
|
||||||
|
playerBullets[b].x += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enemies (enemies.asm)
|
||||||
|
|
||||||
|
private mutating func processEnemies() {
|
||||||
|
enemiesLeft = 0
|
||||||
|
for i in 0..<12 {
|
||||||
|
guard enemies[i].type != 0 else { continue }
|
||||||
|
enemiesLeft += 1
|
||||||
|
processEnemy(i)
|
||||||
|
guard enemies[i].type > 1 else { continue }
|
||||||
|
if aabb(enemies[i].x, enemies[i].w, enemies[i].y, enemies[i].h,
|
||||||
|
playerX, 8, playerY, 12) {
|
||||||
|
hitPlayer()
|
||||||
|
damageEnemy(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func processEnemy(_ i: Int) {
|
||||||
|
switch enemies[i].type {
|
||||||
|
case 1: // exploding: advance every other frame
|
||||||
|
if gameTimer & 1 == 0 {
|
||||||
|
enemies[i].anim += 1
|
||||||
|
if enemies[i].anim >= 8 {
|
||||||
|
enemies[i].type = 0
|
||||||
|
addScore(20)
|
||||||
|
} else {
|
||||||
|
enemies[i].img = D.explosionFrames[enemies[i].anim]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 4: // basic: left 1.5 px/frame, straight shots
|
||||||
|
guard moveLeftOneAndAHalf(i) else { return }
|
||||||
|
if fireDue(i, mask: 63) {
|
||||||
|
fireBullet(from: i, yOff: 2, img: .ebullet1, aimed: false)
|
||||||
|
}
|
||||||
|
case 7: // phoenix: left 1.5, aimed shots
|
||||||
|
guard moveLeftOneAndAHalf(i) else { return }
|
||||||
|
if fireDue(i, mask: 63) {
|
||||||
|
fireBullet(from: i, yOff: 4, img: .ebullet2, aimed: true)
|
||||||
|
}
|
||||||
|
case 10: // spin-sine: approach then bob on the sine
|
||||||
|
enemies[i].anim = (enemies[i].anim + 1) % 5
|
||||||
|
enemies[i].img = D.spinFrames[enemies[i].anim]
|
||||||
|
if enemies[i].counter != 0 {
|
||||||
|
enemies[i].counter -= 1
|
||||||
|
enemies[i].x -= 1
|
||||||
|
}
|
||||||
|
enemies[i].phase += 1
|
||||||
|
enemies[i].y = D.sines[enemies[i].phase & 127]
|
||||||
|
if fireDue(i, mask: 63) {
|
||||||
|
fireBullet(from: i, yOff: 2, img: .ebullet1, aimed: true)
|
||||||
|
}
|
||||||
|
case 13: // base turret: rides the terrain
|
||||||
|
enemies[i].x -= 1
|
||||||
|
if enemies[i].x == 0 { enemies[i].type = 0; return }
|
||||||
|
if fireDue(i, mask: 31) {
|
||||||
|
fireBullet(from: i, yOff: 2, img: .ebullet1, aimed: true)
|
||||||
|
}
|
||||||
|
case 16: // spin-T: fast sine crosser
|
||||||
|
enemies[i].anim = (enemies[i].anim + 1) % 4
|
||||||
|
enemies[i].img = D.spintFrames[enemies[i].anim]
|
||||||
|
enemies[i].phase += 3
|
||||||
|
enemies[i].x -= 1
|
||||||
|
if enemies[i].x == 0 { enemies[i].type = 0; return }
|
||||||
|
enemies[i].y = D.sines[enemies[i].phase & 127] / 2 + 30
|
||||||
|
if fireDue(i, mask: 63) {
|
||||||
|
fireBullet(from: i, yOff: 2, img: .ebullet1, aimed: true)
|
||||||
|
}
|
||||||
|
case 19: // boss: approach x=128, triangle sweep
|
||||||
|
if enemies[i].x != 128 { enemies[i].x -= 1 }
|
||||||
|
var a = gameTimer & 255
|
||||||
|
if a & 32 != 0 { a = ~a & 255 }
|
||||||
|
enemies[i].y = (a & 31) + 40
|
||||||
|
enemies[i].counter += 1
|
||||||
|
if enemies[i].counter == 0 {
|
||||||
|
enemies[i].counter = -44
|
||||||
|
fireBullet(from: i, yOff: 1, img: .ebullet2, aimed: false)
|
||||||
|
fireBullet(from: i, yOff: 13, img: .ebullet2, aimed: false)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Left 1 px/frame plus an extra on odd frames; false once despawned.
|
||||||
|
private mutating func moveLeftOneAndAHalf(_ i: Int) -> Bool {
|
||||||
|
enemies[i].x -= 1
|
||||||
|
if enemies[i].x == 0 { enemies[i].type = 0; return false }
|
||||||
|
if gameTimer & 1 == 1 {
|
||||||
|
enemies[i].x -= 1
|
||||||
|
if enemies[i].x == 0 { enemies[i].type = 0; return false }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The original staggers fire by the enemy slot's memory address; slots
|
||||||
|
/// are 10 bytes apart, so (timer + slot*10) keeps the same relative phases.
|
||||||
|
private func fireDue(_ i: Int, mask: Int) -> Bool {
|
||||||
|
(gameTimer + i * 10) & mask == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func damageEnemy(_ i: Int, _ amount: Int) {
|
||||||
|
enemies[i].dmg -= amount
|
||||||
|
guard enemies[i].dmg < 0 else { return } // survives at exactly 0
|
||||||
|
enemies[i].type = 1
|
||||||
|
enemies[i].img = .x1
|
||||||
|
enemies[i].anim = 0
|
||||||
|
addScore(1)
|
||||||
|
events.append(.enemyKilled(x: enemies[i].x, y: enemies[i].y))
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func addScore(_ n: Int) {
|
||||||
|
score = min(999999, score + n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enemy fire (eshoot.asm)
|
||||||
|
|
||||||
|
private mutating func fireBullet(from i: Int, yOff: Int, img: D.Img, aimed: Bool) {
|
||||||
|
guard let slot = enemyBullets.firstIndex(where: { !$0.active }) else { return }
|
||||||
|
let bx = enemies[i].x
|
||||||
|
let by = enemies[i].y + yOff
|
||||||
|
var vy = 0
|
||||||
|
if aimed {
|
||||||
|
let dy = by - playerY - 5
|
||||||
|
let dx = bx - playerX
|
||||||
|
if dx >= 0 { // only aim while right of the player
|
||||||
|
let q = dx >> 2
|
||||||
|
if dy < 0 {
|
||||||
|
if q + dy < 0 { vy = 1 } // player far below: angle down
|
||||||
|
} else if q < dy {
|
||||||
|
vy = -1 // player far above: angle up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enemyBullets[slot] = EnemyBullet(active: true, x: bx, y: by, vy: vy, img: img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enemy bullets (ebullets.asm)
|
||||||
|
|
||||||
|
private mutating func processEnemyBullets() {
|
||||||
|
for b in 0..<12 where enemyBullets[b].active {
|
||||||
|
if enemyBullets[b].x <= 4 { // reached the left edge
|
||||||
|
enemyBullets[b].active = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enemyBullets[b].x -= 4
|
||||||
|
enemyBullets[b].y += enemyBullets[b].vy
|
||||||
|
let y = enemyBullets[b].y
|
||||||
|
if y < Self.minY - 10 || y >= Self.maxY {
|
||||||
|
enemyBullets[b].active = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if aabb(enemyBullets[b].x, 4, y, 4, playerX, 8, playerY, 12) {
|
||||||
|
enemyBullets[b].active = false
|
||||||
|
for _ in 0..<5 { hitPlayer() } // dmg 5, applied per point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Player bullets vs enemies (hitenemy.asm)
|
||||||
|
|
||||||
|
private mutating func hitEnemy() {
|
||||||
|
for b in 0..<13 where playerBullets[b].active {
|
||||||
|
let bx = playerBullets[b].x
|
||||||
|
let by = playerBullets[b].y
|
||||||
|
for i in 0..<12 where enemies[i].type >= 2 {
|
||||||
|
if aabb(enemies[i].x, enemies[i].w, enemies[i].y, enemies[i].h, bx, 8, by, 4) {
|
||||||
|
playerBullets[b].active = false
|
||||||
|
damageEnemy(i, 3)
|
||||||
|
}
|
||||||
|
// No break: one bullet can damage several overlapping enemies.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The original's exclusive AABB overlap test (collide.asm).
|
||||||
|
private func aabb(_ x1: Int, _ w1: Int, _ y1: Int, _ h1: Int,
|
||||||
|
_ x2: Int, _ w2: Int, _ y2: Int, _ h2: Int) -> Bool {
|
||||||
|
x1 - x2 < w2 && x2 - x1 < w1 && y1 - y2 < h2 && y2 - y1 < h1
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
mutating func debugSetShield(_ n: Int) { shield = n }
|
||||||
|
mutating func debugPlacePlayer(x: Int, y: Int) { playerX = x; playerY = y }
|
||||||
|
mutating func debugSetScore(_ n: Int) { score = n }
|
||||||
|
/// Position the map so column `col` is the next one drawn (8 ticks away).
|
||||||
|
mutating func debugWarpToColumn(_ col: Int) {
|
||||||
|
terrainX = (col + 63) % 64
|
||||||
|
terrainCount = 7
|
||||||
|
}
|
||||||
|
mutating func debugSpawnEnemy(_ e: Enemy, slot: Int) { enemies[slot] = e }
|
||||||
|
mutating func debugClearEnemies() {
|
||||||
|
enemies = [Enemy](repeating: Enemy(), count: 12)
|
||||||
|
enemiesLeft = 0
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,898 @@
|
|||||||
|
import SpriteKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// HD remake of ZTetris. `TetrisSim` is the faithful ASM port; this scene draws
|
||||||
|
/// the 10×16 well in crisp neon, adds the original's per-level background
|
||||||
|
/// patterns, and drives play with touch buttons (DAS auto-repeat on the move
|
||||||
|
/// keys, held soft drop, tap hard drop / rotates).
|
||||||
|
final class TetrisScene: SKScene {
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
private enum GameState { case ready, playing, paused, gameOver }
|
||||||
|
private var state: GameState = .ready
|
||||||
|
|
||||||
|
private var sim = TetrisSim()
|
||||||
|
private var chosenLevel = 0 // 0…9
|
||||||
|
private var chosenHigh = 0 // 0…5
|
||||||
|
|
||||||
|
// MARK: Timing
|
||||||
|
private var lastUpdate: TimeInterval = 0
|
||||||
|
private var gravityNext: TimeInterval = 0
|
||||||
|
|
||||||
|
// DAS (delayed auto-shift) for held ◄/►, and held soft drop.
|
||||||
|
private let dasDelay: TimeInterval = 0.167
|
||||||
|
private let dasRepeat: TimeInterval = 0.050
|
||||||
|
private let softRepeat: TimeInterval = 0.050
|
||||||
|
private var dasDirection = 0 // -1 left, +1 right, 0 none
|
||||||
|
private var dasNext: TimeInterval = 0
|
||||||
|
private var softHeld = false
|
||||||
|
private var softNext: TimeInterval = 0
|
||||||
|
|
||||||
|
// MARK: Layout
|
||||||
|
private var cellSize: CGFloat = 30
|
||||||
|
private var boardOrigin = CGPoint.zero // bottom-left of the visible well
|
||||||
|
private var topInset: CGFloat = 0
|
||||||
|
private var controlBarHeight: CGFloat = 190
|
||||||
|
private var boardFrameRect = CGRect.zero
|
||||||
|
|
||||||
|
// MARK: Nodes
|
||||||
|
private var cellSprites: [[SKSpriteNode]] = [] // [row(visible, 0=bottom)][col]
|
||||||
|
private var pieceSprites: [SKSpriteNode] = []
|
||||||
|
private var ghostSprites: [SKSpriteNode] = []
|
||||||
|
private var nextSprites: [SKSpriteNode] = []
|
||||||
|
private var blockTexture: SKTexture?
|
||||||
|
private var patternStrips: [SKSpriteNode] = []
|
||||||
|
private var appliedPattern = -1
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
private let scoreValue = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let levelValue = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let linesValue = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let pauseButton = SKShapeNode(circleOfRadius: 16)
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
private let btnLeft = ControlButton(symbol: "arrowtriangle.left.fill")
|
||||||
|
private let btnRight = ControlButton(symbol: "arrowtriangle.right.fill")
|
||||||
|
private let btnHard = ControlButton(symbol: "arrow.down.to.line")
|
||||||
|
private let btnSoft = ControlButton(symbol: "chevron.down")
|
||||||
|
private let btnCW = ControlButton(symbol: "arrow.clockwise")
|
||||||
|
private let btnCCW = ControlButton(symbol: "arrow.counterclockwise")
|
||||||
|
private var activeTouches = Set<UITouch>()
|
||||||
|
private var lastLeft = false, lastRight = false, lastSoft = false
|
||||||
|
private var lastHard = false, lastCW = false, lastCCW = false
|
||||||
|
|
||||||
|
// Overlays
|
||||||
|
private let readyOverlay = SKNode()
|
||||||
|
private let pausedOverlay = SKNode()
|
||||||
|
private let gameOverOverlay = SKNode()
|
||||||
|
private let levelValueLabel = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let highValueLabel = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private var stepperRects: [String: CGRect] = [:]
|
||||||
|
private let gameOverDetail = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
private let gameOverRecord = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
|
||||||
|
private var builtSize: CGSize = .zero
|
||||||
|
#if DEBUG
|
||||||
|
private var demoFreeze = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Piece palette (J, L, O, T, S, Z, I) + trash gray. HD modernization: the
|
||||||
|
// original well was monochrome; colors follow the familiar convention.
|
||||||
|
static let pieceColors: [UIColor] = [
|
||||||
|
UIColor(red: 0.36, green: 0.55, blue: 1.00, alpha: 1), // J blue
|
||||||
|
UIColor(red: 1.00, green: 0.62, blue: 0.25, alpha: 1), // L orange
|
||||||
|
UIColor(red: 0.96, green: 0.82, blue: 0.25, alpha: 1), // O yellow
|
||||||
|
UIColor(red: 0.71, green: 0.50, blue: 1.00, alpha: 1), // T purple
|
||||||
|
UIColor(red: 0.42, green: 0.94, blue: 0.60, alpha: 1), // S green
|
||||||
|
UIColor(red: 1.00, green: 0.35, blue: 0.40, alpha: 1), // Z red
|
||||||
|
UIColor(red: 0.36, green: 0.85, blue: 1.00, alpha: 1), // I cyan
|
||||||
|
UIColor(white: 0.62, alpha: 1), // trash
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The original's 16 per-level 8×8 background patterns (`Pattern` table in
|
||||||
|
/// ZTETRIS.ASM), used here to texture the side rails beside the well.
|
||||||
|
static let levelPatterns: [[UInt8]] = [
|
||||||
|
[0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55],
|
||||||
|
[0x88, 0xFF, 0x22, 0xFF, 0x88, 0xFF, 0x22, 0xFF],
|
||||||
|
[0xFF, 0x99, 0x99, 0xFF, 0xFF, 0x99, 0x99, 0xFF],
|
||||||
|
[0x50, 0xD7, 0x14, 0xF7, 0x00, 0xF7, 0x14, 0xD7],
|
||||||
|
[0xEA, 0xAA, 0xAE, 0x00, 0x57, 0x55, 0x75, 0x00],
|
||||||
|
[0xFE, 0xAA, 0xAA, 0xAA, 0x28, 0xAA, 0xFE, 0x00],
|
||||||
|
[0x55, 0x55, 0xAA, 0xAA, 0x55, 0x55, 0xAA, 0xAA],
|
||||||
|
[0x80, 0xFE, 0x02, 0xFB, 0x08, 0xEF, 0x20, 0xBF],
|
||||||
|
[0xEE, 0xEE, 0xEE, 0x00, 0x77, 0x77, 0x77, 0x00],
|
||||||
|
[0x66, 0xCC, 0x99, 0x33, 0x66, 0xCC, 0x99, 0x33],
|
||||||
|
[0xCC, 0x33, 0xCC, 0x33, 0xCC, 0x33, 0xCC, 0x33],
|
||||||
|
[0xFE, 0x82, 0xBA, 0xAA, 0xBA, 0x82, 0xFE, 0x00],
|
||||||
|
[0xCC, 0xCC, 0x33, 0x33, 0xCC, 0xCC, 0x33, 0x33],
|
||||||
|
[0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA],
|
||||||
|
[0x7C, 0xFE, 0x7C, 0x00, 0x7C, 0xFE, 0x7C, 0x00],
|
||||||
|
[0xFF, 0xEF, 0x47, 0x12, 0xB8, 0xFD, 0xFF, 0xFF],
|
||||||
|
]
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
override func didMove(to view: SKView) {
|
||||||
|
anchorPoint = .zero
|
||||||
|
scaleMode = .resizeFill
|
||||||
|
layoutScene()
|
||||||
|
#if DEBUG
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
if env["ARCADE_AUTOPLAY"] == "1" { startFromReady() }
|
||||||
|
if env["ARCADE_DEMO"] == "1" {
|
||||||
|
chosenLevel = 3
|
||||||
|
startFromReady()
|
||||||
|
buildDemoStack()
|
||||||
|
demoFreeze = true
|
||||||
|
refreshBoard()
|
||||||
|
refreshPiece()
|
||||||
|
updateHUD()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didChangeSize(_ oldSize: CGSize) {
|
||||||
|
guard view != nil, size != builtSize, size.width > 0 else { return }
|
||||||
|
layoutScene()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout
|
||||||
|
|
||||||
|
private func layoutScene() {
|
||||||
|
builtSize = size
|
||||||
|
removeAllChildren()
|
||||||
|
backgroundColor = Palette.space1
|
||||||
|
|
||||||
|
let insets = view?.safeAreaInsets ?? .zero
|
||||||
|
topInset = insets.top
|
||||||
|
let bottomInset = insets.bottom
|
||||||
|
|
||||||
|
let bg = SKSpriteNode(texture: SceneArt.verticalGradient(size: size, top: Palette.space0, bottom: Palette.space1))
|
||||||
|
bg.anchorPoint = .zero
|
||||||
|
bg.zPosition = -10
|
||||||
|
addChild(bg)
|
||||||
|
let stars = SceneArt.starfield(size: size, count: 60, seed: 0x7E7215)
|
||||||
|
stars.zPosition = -9
|
||||||
|
addChild(stars)
|
||||||
|
|
||||||
|
controlBarHeight = max(bottomInset + 172, size.height * 0.235)
|
||||||
|
let hudHeight: CGFloat = 64
|
||||||
|
let boardTop = size.height - topInset - hudHeight
|
||||||
|
let boardBottom = controlBarHeight + 12
|
||||||
|
let availableH = boardTop - boardBottom
|
||||||
|
let availableW = size.width - 24
|
||||||
|
cellSize = min(availableH / CGFloat(TetrisSim.visibleRows), availableW / CGFloat(TetrisSim.cols))
|
||||||
|
let boardW = cellSize * CGFloat(TetrisSim.cols)
|
||||||
|
let boardH = cellSize * CGFloat(TetrisSim.visibleRows)
|
||||||
|
boardOrigin = CGPoint(x: (size.width - boardW) / 2, y: boardBottom + (availableH - boardH) / 2)
|
||||||
|
boardFrameRect = CGRect(x: boardOrigin.x, y: boardOrigin.y, width: boardW, height: boardH)
|
||||||
|
|
||||||
|
// Frame
|
||||||
|
let frame = SKShapeNode(rect: boardFrameRect.insetBy(dx: -5, dy: -5), cornerRadius: 12)
|
||||||
|
frame.strokeColor = Palette.mint.withAlphaComponent(0.30)
|
||||||
|
frame.lineWidth = 1.5
|
||||||
|
frame.glowWidth = 1
|
||||||
|
frame.fillColor = UIColor(white: 0, alpha: 0.25)
|
||||||
|
frame.zPosition = -1
|
||||||
|
addChild(frame)
|
||||||
|
|
||||||
|
buildPatternStrips()
|
||||||
|
buildBoardNodes()
|
||||||
|
buildHUD()
|
||||||
|
buildControls(bottomInset: bottomInset)
|
||||||
|
buildReadyOverlay()
|
||||||
|
buildPausedOverlay()
|
||||||
|
buildGameOverOverlay()
|
||||||
|
|
||||||
|
appliedPattern = -1
|
||||||
|
applyLevelPattern()
|
||||||
|
refreshBoard()
|
||||||
|
refreshPiece()
|
||||||
|
refreshNext()
|
||||||
|
updateHUD()
|
||||||
|
applyStateVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeBlockTexture() -> SKTexture {
|
||||||
|
let px: CGFloat = 64
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: px, height: px))
|
||||||
|
let image = renderer.image { ctx in
|
||||||
|
let cg = ctx.cgContext
|
||||||
|
let rect = CGRect(x: 2, y: 2, width: px - 4, height: px - 4)
|
||||||
|
let path = UIBezierPath(roundedRect: rect, cornerRadius: 10)
|
||||||
|
UIColor(white: 0.92, alpha: 1).setFill()
|
||||||
|
path.fill()
|
||||||
|
// top-edge highlight + bottom shade for a subtle bevel
|
||||||
|
cg.saveGState()
|
||||||
|
path.addClip()
|
||||||
|
UIColor.white.setFill()
|
||||||
|
UIBezierPath(rect: CGRect(x: 0, y: 0, width: px, height: px * 0.30)).fill()
|
||||||
|
UIColor(white: 0.72, alpha: 1).setFill()
|
||||||
|
UIBezierPath(rect: CGRect(x: 0, y: px * 0.72, width: px, height: px * 0.28)).fill()
|
||||||
|
cg.restoreGState()
|
||||||
|
UIColor(white: 0.45, alpha: 0.9).setStroke()
|
||||||
|
path.lineWidth = 3
|
||||||
|
path.stroke()
|
||||||
|
}
|
||||||
|
return SKTexture(image: image)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildBoardNodes() {
|
||||||
|
let texture = makeBlockTexture()
|
||||||
|
blockTexture = texture
|
||||||
|
|
||||||
|
cellSprites = (0..<TetrisSim.visibleRows).map { row in
|
||||||
|
(0..<TetrisSim.cols).map { col in
|
||||||
|
let node = SKSpriteNode(texture: texture)
|
||||||
|
node.size = CGSize(width: cellSize, height: cellSize)
|
||||||
|
node.position = cellCenter(col: col, visibleRow: row)
|
||||||
|
node.colorBlendFactor = 1
|
||||||
|
node.zPosition = 1
|
||||||
|
node.isHidden = true
|
||||||
|
addChild(node)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pieceLayer(count: Int, z: CGFloat, alpha: CGFloat) -> [SKSpriteNode] {
|
||||||
|
(0..<count).map { _ in
|
||||||
|
let node = SKSpriteNode(texture: texture)
|
||||||
|
node.size = CGSize(width: cellSize, height: cellSize)
|
||||||
|
node.colorBlendFactor = 1
|
||||||
|
node.zPosition = z
|
||||||
|
node.alpha = alpha
|
||||||
|
node.isHidden = true
|
||||||
|
addChild(node)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ghostSprites = pieceLayer(count: 4, z: 2, alpha: 0.22)
|
||||||
|
pieceSprites = pieceLayer(count: 4, z: 3, alpha: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The original's level pattern, tiled up the rails on both sides of the well.
|
||||||
|
private func buildPatternStrips() {
|
||||||
|
patternStrips.forEach { $0.removeFromParent() }
|
||||||
|
patternStrips = []
|
||||||
|
let stripW = boardFrameRect.minX - 10
|
||||||
|
guard stripW > 12 else { return }
|
||||||
|
for side in 0..<2 {
|
||||||
|
let node = SKSpriteNode(color: .clear, size: CGSize(width: stripW - 8, height: boardFrameRect.height))
|
||||||
|
node.position = CGPoint(
|
||||||
|
x: side == 0 ? (boardFrameRect.minX - 5) / 2 : size.width - (boardFrameRect.minX - 5) / 2,
|
||||||
|
y: boardFrameRect.midY)
|
||||||
|
node.zPosition = -2
|
||||||
|
addChild(node)
|
||||||
|
patternStrips.append(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyLevelPattern() {
|
||||||
|
let index = min(state == .ready ? chosenLevel : sim.level, 15)
|
||||||
|
guard index != appliedPattern, !patternStrips.isEmpty else { return }
|
||||||
|
appliedPattern = index
|
||||||
|
let texture = Self.patternTexture(Self.levelPatterns[index],
|
||||||
|
size: patternStrips[0].size,
|
||||||
|
dotSize: 3.2,
|
||||||
|
color: Palette.mint.withAlphaComponent(0.16))
|
||||||
|
patternStrips.forEach { $0.texture = texture }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders an 8×8 1-bit pattern tiled over `size` (MSB = leftmost pixel).
|
||||||
|
static func patternTexture(_ bytes: [UInt8], size: CGSize, dotSize: CGFloat, color: UIColor) -> SKTexture {
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
let image = renderer.image { _ in
|
||||||
|
color.setFill()
|
||||||
|
let tile = dotSize * 8
|
||||||
|
var ty: CGFloat = 0
|
||||||
|
while ty < size.height {
|
||||||
|
var tx: CGFloat = 0
|
||||||
|
while tx < size.width {
|
||||||
|
for (row, byte) in bytes.enumerated() {
|
||||||
|
for bit in 0..<8 where (byte & (0x80 >> UInt8(bit))) != 0 {
|
||||||
|
UIBezierPath(rect: CGRect(x: tx + CGFloat(bit) * dotSize,
|
||||||
|
y: ty + CGFloat(row) * dotSize,
|
||||||
|
width: dotSize, height: dotSize)).fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx += tile
|
||||||
|
}
|
||||||
|
ty += tile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SKTexture(image: image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Coordinates
|
||||||
|
|
||||||
|
/// visibleRow 0 = bottom of the well (absolute y = 2).
|
||||||
|
private func cellCenter(col: Int, visibleRow: Int) -> CGPoint {
|
||||||
|
CGPoint(x: boardOrigin.x + (CGFloat(col) + 0.5) * cellSize,
|
||||||
|
y: boardOrigin.y + (CGFloat(visibleRow) + 0.5) * cellSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func centerFor(absX: Int, absY: Int) -> CGPoint {
|
||||||
|
cellCenter(col: absX - TetrisSim.playfieldMinX, visibleRow: absY - TetrisSim.bottomRowY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HUD
|
||||||
|
|
||||||
|
private func buildHUD() {
|
||||||
|
let hudY = size.height - topInset - 28
|
||||||
|
|
||||||
|
addChild(caption("LEVEL", x: 74, y: hudY + 14, align: .left))
|
||||||
|
levelValue.fontSize = 17
|
||||||
|
levelValue.fontColor = Palette.violet
|
||||||
|
levelValue.horizontalAlignmentMode = .left
|
||||||
|
levelValue.verticalAlignmentMode = .center
|
||||||
|
levelValue.position = CGPoint(x: 74, y: hudY - 5)
|
||||||
|
levelValue.zPosition = 20
|
||||||
|
addChild(levelValue)
|
||||||
|
|
||||||
|
addChild(caption("LINES", x: 134, y: hudY + 14, align: .left))
|
||||||
|
linesValue.fontSize = 17
|
||||||
|
linesValue.fontColor = .white
|
||||||
|
linesValue.horizontalAlignmentMode = .left
|
||||||
|
linesValue.verticalAlignmentMode = .center
|
||||||
|
linesValue.position = CGPoint(x: 134, y: hudY - 5)
|
||||||
|
linesValue.zPosition = 20
|
||||||
|
addChild(linesValue)
|
||||||
|
|
||||||
|
addChild(caption("SCORE", x: size.width * 0.56, y: hudY + 14, align: .center))
|
||||||
|
scoreValue.fontSize = 21
|
||||||
|
scoreValue.fontColor = .white
|
||||||
|
scoreValue.horizontalAlignmentMode = .center
|
||||||
|
scoreValue.verticalAlignmentMode = .center
|
||||||
|
scoreValue.position = CGPoint(x: size.width * 0.56, y: hudY - 5)
|
||||||
|
scoreValue.zPosition = 20
|
||||||
|
addChild(scoreValue)
|
||||||
|
|
||||||
|
// NEXT preview box at the right
|
||||||
|
addChild(caption("NEXT", x: size.width - 44, y: hudY + 14, align: .center))
|
||||||
|
let box = SKShapeNode(rectOf: CGSize(width: 66, height: 36), cornerRadius: 9)
|
||||||
|
box.fillColor = UIColor(white: 1, alpha: 0.06)
|
||||||
|
box.strokeColor = UIColor(white: 1, alpha: 0.14)
|
||||||
|
box.lineWidth = 1
|
||||||
|
box.position = CGPoint(x: size.width - 44, y: hudY - 6)
|
||||||
|
box.zPosition = 20
|
||||||
|
addChild(box)
|
||||||
|
|
||||||
|
nextSprites.forEach { $0.removeFromParent() }
|
||||||
|
nextSprites = (0..<4).map { _ in
|
||||||
|
let node = SKSpriteNode(texture: blockTexture)
|
||||||
|
node.size = CGSize(width: 13, height: 13)
|
||||||
|
node.colorBlendFactor = 1
|
||||||
|
node.zPosition = 21
|
||||||
|
addChild(node)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause toggle at the board frame's top-right corner
|
||||||
|
pauseButton.fillColor = UIColor(white: 1, alpha: 0.08)
|
||||||
|
pauseButton.strokeColor = UIColor(white: 1, alpha: 0.25)
|
||||||
|
pauseButton.lineWidth = 1
|
||||||
|
pauseButton.position = CGPoint(x: boardFrameRect.maxX - 6, y: boardFrameRect.maxY - 6)
|
||||||
|
pauseButton.zPosition = 22
|
||||||
|
pauseButton.removeAllChildren()
|
||||||
|
if let tex = SceneArt.symbolTexture("pause.fill", pointSize: 13, color: Palette.white70) {
|
||||||
|
let icon = SKSpriteNode(texture: tex)
|
||||||
|
icon.size = tex.size()
|
||||||
|
pauseButton.addChild(icon)
|
||||||
|
}
|
||||||
|
addChild(pauseButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func caption(_ text: String, x: CGFloat, y: CGFloat, align: SKLabelHorizontalAlignmentMode) -> SKLabelNode {
|
||||||
|
let label = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
label.text = text
|
||||||
|
label.fontSize = 10
|
||||||
|
label.fontColor = Palette.white40
|
||||||
|
label.horizontalAlignmentMode = align
|
||||||
|
label.verticalAlignmentMode = .center
|
||||||
|
label.position = CGPoint(x: x, y: y)
|
||||||
|
label.zPosition = 20
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Controls
|
||||||
|
|
||||||
|
private func buildControls(bottomInset: CGFloat) {
|
||||||
|
let cy = bottomInset + (controlBarHeight - bottomInset) * 0.42
|
||||||
|
|
||||||
|
btnLeft.configure(radius: 37, tint: Palette.cyan)
|
||||||
|
btnRight.configure(radius: 37, tint: Palette.cyan)
|
||||||
|
btnHard.configure(radius: 40, tint: Palette.amber)
|
||||||
|
btnSoft.configure(radius: 24, tint: Palette.amber)
|
||||||
|
btnCW.configure(radius: 37, tint: Palette.mint)
|
||||||
|
btnCCW.configure(radius: 27, tint: Palette.mint)
|
||||||
|
|
||||||
|
btnLeft.position = CGPoint(x: size.width * 0.12, y: cy)
|
||||||
|
btnRight.position = CGPoint(x: size.width * 0.315, y: cy)
|
||||||
|
btnHard.position = CGPoint(x: size.width * 0.5, y: cy)
|
||||||
|
btnSoft.position = CGPoint(x: size.width * 0.5, y: cy + 74)
|
||||||
|
btnCCW.position = CGPoint(x: size.width * 0.675, y: cy + 8)
|
||||||
|
btnCW.position = CGPoint(x: size.width * 0.87, y: cy)
|
||||||
|
[btnLeft, btnRight, btnHard, btnSoft, btnCW, btnCCW].forEach { $0.zPosition = 20; addChild($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Overlays
|
||||||
|
|
||||||
|
private func buildReadyOverlay() {
|
||||||
|
readyOverlay.removeAllChildren()
|
||||||
|
readyOverlay.zPosition = 40
|
||||||
|
addChild(readyOverlay)
|
||||||
|
|
||||||
|
let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.66), size: size)
|
||||||
|
dim.anchorPoint = .zero
|
||||||
|
readyOverlay.addChild(dim)
|
||||||
|
|
||||||
|
let cx = size.width / 2
|
||||||
|
let midY = size.height * 0.62
|
||||||
|
readyOverlay.addChild(bigLabel("ZTETRIS", size: 34, color: .white, x: cx, y: midY + 78))
|
||||||
|
readyOverlay.addChild(bigLabel("Stack. Clear. Don't top out.",
|
||||||
|
fontNamed: "AvenirNext-Medium", size: 15,
|
||||||
|
color: Palette.white70, x: cx, y: midY + 44))
|
||||||
|
|
||||||
|
buildStepper(title: "START LEVEL", value: levelValueLabel, valueText: "\(chosenLevel)",
|
||||||
|
y: midY - 14, keyMinus: "levelMinus", keyPlus: "levelPlus")
|
||||||
|
buildStepper(title: "START HEIGHT", value: highValueLabel, valueText: "\(chosenHigh)",
|
||||||
|
y: midY - 66, keyMinus: "highMinus", keyPlus: "highPlus")
|
||||||
|
|
||||||
|
let prompt = bigLabel("TAP TO START", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint,
|
||||||
|
x: cx, y: midY - 126)
|
||||||
|
prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)])))
|
||||||
|
readyOverlay.addChild(prompt)
|
||||||
|
|
||||||
|
readyOverlay.addChild(bigLabel("◄ ► move ⟳ ⟲ rotate ⌄ soft ⤓ drop",
|
||||||
|
fontNamed: "Menlo", size: 11.5, color: Palette.white40,
|
||||||
|
x: cx, y: max(controlBarHeight + 22, midY - 162)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildStepper(title: String, value: SKLabelNode, valueText: String, y: CGFloat,
|
||||||
|
keyMinus: String, keyPlus: String) {
|
||||||
|
let cx = size.width / 2
|
||||||
|
readyOverlay.addChild(bigLabel(title, fontNamed: "Menlo-Bold", size: 12, color: Palette.white70,
|
||||||
|
x: cx, y: y + 25))
|
||||||
|
let minusX = cx - 78, plusX = cx + 78
|
||||||
|
let btnSize = CGSize(width: 44, height: 40)
|
||||||
|
readyOverlay.addChild(stepperButton("−", center: CGPoint(x: minusX, y: y)))
|
||||||
|
readyOverlay.addChild(stepperButton("+", center: CGPoint(x: plusX, y: y)))
|
||||||
|
stepperRects[keyMinus] = CGRect(x: minusX - 22, y: y - 20, width: btnSize.width, height: btnSize.height)
|
||||||
|
stepperRects[keyPlus] = CGRect(x: plusX - 22, y: y - 20, width: btnSize.width, height: btnSize.height)
|
||||||
|
|
||||||
|
value.text = valueText
|
||||||
|
value.fontSize = 26
|
||||||
|
value.fontColor = .white
|
||||||
|
value.horizontalAlignmentMode = .center
|
||||||
|
value.verticalAlignmentMode = .center
|
||||||
|
value.position = CGPoint(x: cx, y: y)
|
||||||
|
readyOverlay.addChild(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stepperButton(_ glyph: String, center: CGPoint) -> SKNode {
|
||||||
|
let node = SKShapeNode(rectOf: CGSize(width: 44, height: 40), cornerRadius: 12)
|
||||||
|
node.fillColor = UIColor(white: 1, alpha: 0.10)
|
||||||
|
node.strokeColor = Palette.mint.withAlphaComponent(0.5)
|
||||||
|
node.lineWidth = 1
|
||||||
|
node.position = center
|
||||||
|
let label = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
label.text = glyph
|
||||||
|
label.fontSize = 24
|
||||||
|
label.fontColor = Palette.mint
|
||||||
|
label.verticalAlignmentMode = .center
|
||||||
|
label.horizontalAlignmentMode = .center
|
||||||
|
node.addChild(label)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildPausedOverlay() {
|
||||||
|
pausedOverlay.removeAllChildren()
|
||||||
|
pausedOverlay.zPosition = 40
|
||||||
|
addChild(pausedOverlay)
|
||||||
|
// Opaque enough to hide the well — the original cleared the screen on pause.
|
||||||
|
let dim = SKSpriteNode(color: UIColor(white: 0.01, alpha: 0.93), size: size)
|
||||||
|
dim.anchorPoint = .zero
|
||||||
|
pausedOverlay.addChild(dim)
|
||||||
|
pausedOverlay.addChild(bigLabel("PAUSED", size: 30, color: .white,
|
||||||
|
x: size.width / 2, y: size.height * 0.55))
|
||||||
|
let hint = bigLabel("TAP TO RESUME", fontNamed: "Menlo-Bold", size: 15, color: Palette.mint,
|
||||||
|
x: size.width / 2, y: size.height * 0.55 - 44)
|
||||||
|
hint.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)])))
|
||||||
|
pausedOverlay.addChild(hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildGameOverOverlay() {
|
||||||
|
gameOverOverlay.removeAllChildren()
|
||||||
|
gameOverOverlay.zPosition = 40
|
||||||
|
addChild(gameOverOverlay)
|
||||||
|
|
||||||
|
let dim = SKSpriteNode(color: UIColor(white: 0.02, alpha: 0.70), size: size)
|
||||||
|
dim.anchorPoint = .zero
|
||||||
|
gameOverOverlay.addChild(dim)
|
||||||
|
|
||||||
|
let cx = size.width / 2
|
||||||
|
let midY = size.height * 0.6
|
||||||
|
let title = bigLabel("GAME OVER", size: 34, color: Palette.red, x: cx, y: midY + 40)
|
||||||
|
gameOverOverlay.addChild(title)
|
||||||
|
|
||||||
|
gameOverDetail.fontSize = 15
|
||||||
|
gameOverDetail.fontColor = .white
|
||||||
|
gameOverDetail.horizontalAlignmentMode = .center
|
||||||
|
gameOverDetail.position = CGPoint(x: cx, y: midY)
|
||||||
|
gameOverOverlay.addChild(gameOverDetail)
|
||||||
|
|
||||||
|
gameOverRecord.fontSize = 14
|
||||||
|
gameOverRecord.fontColor = Palette.amber
|
||||||
|
gameOverRecord.horizontalAlignmentMode = .center
|
||||||
|
gameOverRecord.position = CGPoint(x: cx, y: midY - 26)
|
||||||
|
gameOverOverlay.addChild(gameOverRecord)
|
||||||
|
|
||||||
|
let prompt = bigLabel("TAP TO PLAY AGAIN", fontNamed: "Menlo-Bold", size: 16, color: Palette.mint,
|
||||||
|
x: cx, y: midY - 66)
|
||||||
|
prompt.run(.repeatForever(.sequence([.fadeAlpha(to: 0.35, duration: 0.7), .fadeAlpha(to: 1, duration: 0.7)])))
|
||||||
|
gameOverOverlay.addChild(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bigLabel(_ text: String, fontNamed: String = "AvenirNext-Heavy", size fontSize: CGFloat,
|
||||||
|
color: UIColor, x: CGFloat, y: CGFloat) -> SKLabelNode {
|
||||||
|
let label = SKLabelNode(fontNamed: fontNamed)
|
||||||
|
label.text = text
|
||||||
|
label.fontSize = fontSize
|
||||||
|
label.fontColor = color
|
||||||
|
label.horizontalAlignmentMode = .center
|
||||||
|
label.verticalAlignmentMode = .center
|
||||||
|
label.position = CGPoint(x: x, y: y)
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rendering
|
||||||
|
|
||||||
|
private func refreshBoard() {
|
||||||
|
for row in 0..<TetrisSim.visibleRows {
|
||||||
|
for col in 0..<TetrisSim.cols {
|
||||||
|
let value = sim.cell(col: col, y: row + TetrisSim.bottomRowY)
|
||||||
|
let node = cellSprites[row][col]
|
||||||
|
if value == 0 {
|
||||||
|
node.isHidden = true
|
||||||
|
} else {
|
||||||
|
node.isHidden = false
|
||||||
|
node.color = Self.pieceColors[min(value - 1, 7)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshPiece() {
|
||||||
|
let playing = state == .playing || state == .paused
|
||||||
|
guard playing, !sim.over else {
|
||||||
|
pieceSprites.forEach { $0.isHidden = true }
|
||||||
|
ghostSprites.forEach { $0.isHidden = true }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let color = Self.pieceColors[sim.current.kind]
|
||||||
|
let ghost = TetrisSim.absBlocks(sim.ghostPiece)
|
||||||
|
for (i, b) in sim.currentBlocks.enumerated() {
|
||||||
|
let node = pieceSprites[i]
|
||||||
|
let visible = b.y <= TetrisSim.topVisibleY
|
||||||
|
node.isHidden = !visible
|
||||||
|
if visible {
|
||||||
|
node.color = color
|
||||||
|
node.position = centerFor(absX: b.x, absY: b.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, b) in ghost.enumerated() {
|
||||||
|
let node = ghostSprites[i]
|
||||||
|
let visible = b.y <= TetrisSim.topVisibleY
|
||||||
|
node.isHidden = !visible
|
||||||
|
if visible {
|
||||||
|
node.color = color
|
||||||
|
node.position = centerFor(absX: b.x, absY: b.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshNext() {
|
||||||
|
let blocks = TetrisSim.blocks(kind: sim.nextKind, rot: 0)
|
||||||
|
let minX = blocks.map(\.dx).min() ?? 0
|
||||||
|
let maxX = blocks.map(\.dx).max() ?? 0
|
||||||
|
let minY = blocks.map(\.dy).min() ?? 0
|
||||||
|
let maxY = blocks.map(\.dy).max() ?? 0
|
||||||
|
let mini: CGFloat = 13
|
||||||
|
let boxCenter = CGPoint(x: size.width - 44, y: size.height - topInset - 34)
|
||||||
|
let w = CGFloat(maxX - minX + 1) * mini
|
||||||
|
let h = CGFloat(maxY - minY + 1) * mini
|
||||||
|
for (i, b) in blocks.enumerated() {
|
||||||
|
let node = nextSprites[i]
|
||||||
|
node.color = Self.pieceColors[sim.nextKind]
|
||||||
|
node.position = CGPoint(
|
||||||
|
x: boxCenter.x - w / 2 + (CGFloat(b.dx - minX) + 0.5) * mini,
|
||||||
|
y: boxCenter.y - h / 2 + (CGFloat(b.dy - minY) + 0.5) * mini)
|
||||||
|
node.isHidden = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateHUD() {
|
||||||
|
scoreValue.text = "\(sim.score)"
|
||||||
|
levelValue.text = "\(sim.level)"
|
||||||
|
linesValue.text = "\(sim.lines)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game loop
|
||||||
|
|
||||||
|
override func update(_ currentTime: TimeInterval) {
|
||||||
|
if lastUpdate == 0 {
|
||||||
|
lastUpdate = currentTime
|
||||||
|
gravityNext = currentTime + sim.gravityInterval
|
||||||
|
}
|
||||||
|
lastUpdate = currentTime
|
||||||
|
guard state == .playing, !sim.over else { return }
|
||||||
|
#if DEBUG
|
||||||
|
if demoFreeze { return }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Held-move auto repeat (DAS)
|
||||||
|
if dasDirection != 0, currentTime >= dasNext {
|
||||||
|
let moved = dasDirection < 0 ? sim.moveLeft() : sim.moveRight()
|
||||||
|
if moved { refreshPiece(); Haptics.impact(.light, intensity: 0.3) }
|
||||||
|
dasNext = currentTime + dasRepeat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held soft drop — also resets gravity like the original's Down key.
|
||||||
|
if softHeld, currentTime >= softNext {
|
||||||
|
softNext = currentTime + softRepeat
|
||||||
|
gravityNext = currentTime + sim.gravityInterval
|
||||||
|
handle(step: sim.softDropStep(), at: currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gravity
|
||||||
|
if currentTime >= gravityNext {
|
||||||
|
gravityNext = currentTime + sim.gravityInterval
|
||||||
|
handle(step: sim.gravityStep(), at: currentTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(step: TetrisSim.Step, at time: TimeInterval) {
|
||||||
|
switch step {
|
||||||
|
case .fell:
|
||||||
|
refreshPiece()
|
||||||
|
case .locked(let lock):
|
||||||
|
didLock(lock, at: time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func didLock(_ lock: TetrisSim.Lock, at time: TimeInterval) {
|
||||||
|
refreshBoard()
|
||||||
|
refreshPiece()
|
||||||
|
refreshNext()
|
||||||
|
updateHUD()
|
||||||
|
|
||||||
|
if lock.clearedYs.isEmpty {
|
||||||
|
Haptics.impact(.light, intensity: 0.6)
|
||||||
|
} else {
|
||||||
|
Haptics.success()
|
||||||
|
flashRows(lock.clearedYs)
|
||||||
|
popScore("+\(lock.scoreGained)")
|
||||||
|
gravityNext = time + sim.gravityInterval + 0.26 // brief beat for the flash
|
||||||
|
}
|
||||||
|
if lock.leveledUp {
|
||||||
|
Haptics.impact(.medium)
|
||||||
|
applyLevelPattern()
|
||||||
|
popScore("LEVEL \(sim.level)", color: Palette.violet, dy: 40)
|
||||||
|
}
|
||||||
|
if lock.gameOver { endGame() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flashRows(_ absYs: [Int]) {
|
||||||
|
for y in absYs where y <= TetrisSim.topVisibleY {
|
||||||
|
let flash = SKSpriteNode(color: .white,
|
||||||
|
size: CGSize(width: boardFrameRect.width, height: cellSize))
|
||||||
|
flash.position = CGPoint(x: boardFrameRect.midX,
|
||||||
|
y: boardOrigin.y + (CGFloat(y - TetrisSim.bottomRowY) + 0.5) * cellSize)
|
||||||
|
flash.zPosition = 4
|
||||||
|
flash.alpha = 0.85
|
||||||
|
addChild(flash)
|
||||||
|
flash.run(.sequence([.fadeOut(withDuration: 0.26), .removeFromParent()]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func popScore(_ text: String, color: UIColor = Palette.amber, dy: CGFloat = 0) {
|
||||||
|
let label = SKLabelNode(fontNamed: "Menlo-Bold")
|
||||||
|
label.text = text
|
||||||
|
label.fontSize = 20
|
||||||
|
label.fontColor = color
|
||||||
|
label.position = CGPoint(x: boardFrameRect.midX, y: boardFrameRect.midY + dy)
|
||||||
|
label.zPosition = 30
|
||||||
|
label.setScale(0.6)
|
||||||
|
addChild(label)
|
||||||
|
label.run(.sequence([
|
||||||
|
.group([.scale(to: 1.0, duration: 0.14),
|
||||||
|
.moveBy(x: 0, y: 26, duration: 0.7),
|
||||||
|
.sequence([.wait(forDuration: 0.35), .fadeOut(withDuration: 0.35)])]),
|
||||||
|
.removeFromParent(),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State transitions
|
||||||
|
|
||||||
|
private func startFromReady() {
|
||||||
|
sim = TetrisSim(startLevel: chosenLevel, high: chosenHigh)
|
||||||
|
appliedPattern = -1
|
||||||
|
state = .playing
|
||||||
|
gravityNext = lastUpdate + sim.gravityInterval
|
||||||
|
dasDirection = 0
|
||||||
|
softHeld = false
|
||||||
|
applyLevelPattern()
|
||||||
|
refreshBoard()
|
||||||
|
refreshPiece()
|
||||||
|
refreshNext()
|
||||||
|
updateHUD()
|
||||||
|
applyStateVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endGame() {
|
||||||
|
let final = sim.score
|
||||||
|
let record = HighScoreStore.shared.submit(id: "ztetris", score: final)
|
||||||
|
state = .gameOver
|
||||||
|
gameOverDetail.text = "SCORE \(final) LINES \(sim.lines) LEVEL \(sim.level)"
|
||||||
|
gameOverRecord.text = record ? "★ NEW BEST ★" : "BEST \(HighScoreStore.shared.best("ztetris"))"
|
||||||
|
Haptics.error()
|
||||||
|
applyStateVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyStateVisibility() {
|
||||||
|
readyOverlay.isHidden = state != .ready
|
||||||
|
pausedOverlay.isHidden = state != .paused
|
||||||
|
gameOverOverlay.isHidden = state != .gameOver
|
||||||
|
pauseButton.isHidden = state != .playing
|
||||||
|
let controlsActive = state == .playing
|
||||||
|
[btnLeft, btnRight, btnHard, btnSoft, btnCW, btnCCW].forEach { $0.alpha = controlsActive ? 1 : 0.28 }
|
||||||
|
refreshPiece()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Touch handling
|
||||||
|
|
||||||
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
for touch in touches {
|
||||||
|
let p = touch.location(in: self)
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
if handleStepperTap(at: p) { continue }
|
||||||
|
Haptics.selection()
|
||||||
|
startFromReady()
|
||||||
|
case .paused:
|
||||||
|
Haptics.selection()
|
||||||
|
state = .playing
|
||||||
|
gravityNext = lastUpdate + sim.gravityInterval
|
||||||
|
applyStateVisibility()
|
||||||
|
case .gameOver:
|
||||||
|
Haptics.selection()
|
||||||
|
state = .ready
|
||||||
|
applyStateVisibility()
|
||||||
|
case .playing:
|
||||||
|
let dp = hypot(p.x - pauseButton.position.x, p.y - pauseButton.position.y)
|
||||||
|
if dp <= 30 {
|
||||||
|
Haptics.selection()
|
||||||
|
state = .paused
|
||||||
|
dasDirection = 0; softHeld = false
|
||||||
|
applyStateVisibility()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
activeTouches.insert(touch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recomputeControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
recomputeControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
touches.forEach { activeTouches.remove($0) }
|
||||||
|
recomputeControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
touches.forEach { activeTouches.remove($0) }
|
||||||
|
recomputeControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleStepperTap(at p: CGPoint) -> Bool {
|
||||||
|
for (key, rect) in stepperRects where rect.contains(p) {
|
||||||
|
switch key {
|
||||||
|
case "levelMinus": chosenLevel = max(0, chosenLevel - 1)
|
||||||
|
case "levelPlus": chosenLevel = min(TetrisSim.maxStartLevel, chosenLevel + 1)
|
||||||
|
case "highMinus": chosenHigh = max(0, chosenHigh - 1)
|
||||||
|
case "highPlus": chosenHigh = min(TetrisSim.maxHigh, chosenHigh + 1)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
levelValueLabel.text = "\(chosenLevel)"
|
||||||
|
highValueLabel.text = "\(chosenHigh)"
|
||||||
|
Haptics.selection()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recomputeControls() {
|
||||||
|
var left = false, right = false, soft = false, hard = false, cw = false, ccw = false
|
||||||
|
if state == .playing {
|
||||||
|
for touch in activeTouches {
|
||||||
|
let p = touch.location(in: self)
|
||||||
|
if btnLeft.covers(p) { left = true }
|
||||||
|
if btnRight.covers(p) { right = true }
|
||||||
|
if btnSoft.covers(p) { soft = true }
|
||||||
|
if btnHard.covers(p) { hard = true }
|
||||||
|
if btnCW.covers(p) { cw = true }
|
||||||
|
if btnCCW.covers(p) { ccw = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move: immediate step on the rising edge, then DAS repeat in update().
|
||||||
|
if left && !lastLeft {
|
||||||
|
dasDirection = -1
|
||||||
|
dasNext = lastUpdate + dasDelay
|
||||||
|
if sim.moveLeft() { refreshPiece(); Haptics.impact(.light, intensity: 0.4) }
|
||||||
|
}
|
||||||
|
if right && !lastRight {
|
||||||
|
dasDirection = 1
|
||||||
|
dasNext = lastUpdate + dasDelay
|
||||||
|
if sim.moveRight() { refreshPiece(); Haptics.impact(.light, intensity: 0.4) }
|
||||||
|
}
|
||||||
|
// Releases: fall back to a still-held direction (re-armed), or stop.
|
||||||
|
if dasDirection == -1 && !left {
|
||||||
|
dasDirection = right ? 1 : 0
|
||||||
|
dasNext = lastUpdate + dasDelay
|
||||||
|
} else if dasDirection == 1 && !right {
|
||||||
|
dasDirection = left ? -1 : 0
|
||||||
|
dasNext = lastUpdate + dasDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotations and hard drop: single action per press.
|
||||||
|
if cw && !lastCW, sim.rotate() { refreshPiece(); Haptics.impact(.light, intensity: 0.5) }
|
||||||
|
if ccw && !lastCCW, sim.rotateBack() { refreshPiece(); Haptics.impact(.light, intensity: 0.5) }
|
||||||
|
if hard && !lastHard, state == .playing {
|
||||||
|
Haptics.impact(.medium, intensity: 0.8)
|
||||||
|
handle(step: sim.hardDrop(), at: lastUpdate)
|
||||||
|
gravityNext = lastUpdate + sim.gravityInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft drop: immediate step on press, repeats in update().
|
||||||
|
if soft && !lastSoft {
|
||||||
|
softNext = lastUpdate + softRepeat
|
||||||
|
gravityNext = lastUpdate + sim.gravityInterval
|
||||||
|
handle(step: sim.softDropStep(), at: lastUpdate)
|
||||||
|
}
|
||||||
|
softHeld = soft
|
||||||
|
|
||||||
|
lastLeft = left; lastRight = right; lastSoft = soft
|
||||||
|
lastHard = hard; lastCW = cw; lastCCW = ccw
|
||||||
|
|
||||||
|
btnLeft.setPressed(left)
|
||||||
|
btnRight.setPressed(right)
|
||||||
|
btnSoft.setPressed(soft)
|
||||||
|
btnHard.setPressed(hard)
|
||||||
|
btnCW.setPressed(cw)
|
||||||
|
btnCCW.setPressed(ccw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Demo (screenshots)
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private func buildDemoStack() {
|
||||||
|
// A believable mid-game stack across the bottom rows.
|
||||||
|
let rows: [[Int]] = [
|
||||||
|
[1, 1, 5, 5, 0, 3, 3, 2, 2, 8],
|
||||||
|
[4, 1, 5, 5, 6, 3, 3, 0, 2, 8],
|
||||||
|
[4, 4, 0, 7, 6, 6, 0, 2, 2, 8],
|
||||||
|
[4, 0, 0, 7, 0, 6, 0, 0, 8, 8],
|
||||||
|
]
|
||||||
|
for (i, row) in rows.enumerated() { // rows[0] = top of stack
|
||||||
|
for (col, value) in row.enumerated() where value != 0 {
|
||||||
|
sim.debugSetCell(col: col, y: TetrisSim.bottomRowY + (rows.count - 1 - i), kind: value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sim.debugPose(kind: 3, rot: 0, x: 6, y: 9) // T piece mid-fall
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A faithful port of ZTetris 3.0 (Jimmy Mårdell, 1998) game logic from
|
||||||
|
/// `ZTETRIS.ASM`. The well, piece data, movement rules, scoring and level
|
||||||
|
/// behavior reproduce the original exactly; the SpriteKit scene renders this in
|
||||||
|
/// HD and drives it with touch controls.
|
||||||
|
///
|
||||||
|
/// Original coordinate system (kept verbatim): `y` increases *upward* and
|
||||||
|
/// pieces fall by decrementing y. The board is a 16-wide bit-grid where columns
|
||||||
|
/// 0–2 and 13–15 are walls and column 3…12 is the 10-wide playfield; row y=1 is
|
||||||
|
/// the floor. Pieces spawn at (x=6, y=16); rows 2…17 are visible (18/19 are
|
||||||
|
/// headroom above the visible well). Each piece rotation is stored as four
|
||||||
|
/// (dy,dx) offsets packed into one 16-bit word — ported bit-for-bit from the
|
||||||
|
/// ASM's `BitData` table and decoded the same way `Uncrunch` does.
|
||||||
|
struct TetrisSim {
|
||||||
|
|
||||||
|
// MARK: - Original data tables (verbatim from ZTETRIS.ASM)
|
||||||
|
|
||||||
|
/// 7 pieces × 4 rotations. Each word packs 4 blocks × (dy,dx), 2 bits each,
|
||||||
|
/// least-significant pair first — exactly the `BitData` table.
|
||||||
|
static let bitData: [UInt16] = [
|
||||||
|
0b0001_0101_1001_1000, 0b0110_0101_0000_0100, 0b0010_0001_0101_1001, 0b0110_1010_0101_0100, // J
|
||||||
|
0b0001_0101_1001_0000, 0b0010_0110_0101_0100, 0b0001_0101_1001_1010, 0b0110_0101_0100_1000, // L
|
||||||
|
0b0101_1001_0100_1000, 0b0101_1001_0100_1000, 0b0101_1001_0100_1000, 0b0101_1001_0100_1000, // O
|
||||||
|
0b0001_0101_1001_0100, 0b0001_0110_0101_0100, 0b0001_0110_0101_1001, 0b0110_0101_0100_1001, // T
|
||||||
|
0b0000_0101_0100_1001, 0b0010_0001_0101_0100, 0b0000_0101_0100_1001, 0b0010_0001_0101_0100, // S
|
||||||
|
0b0001_0101_0100_1000, 0b0001_0000_0110_0101, 0b0001_0101_0100_1000, 0b0001_0000_0110_0101, // Z
|
||||||
|
0b0001_0101_1001_1101, 0b0111_0110_0101_0100, 0b0001_0101_1001_1101, 0b0111_0110_0101_0100, // I
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Gravity delay per level 0–20, in main-loop iterations (`levelcnts`).
|
||||||
|
/// One iteration = 4 HALTs ≈ 20 ms on the TI-85, so seconds = count × 0.02.
|
||||||
|
static let levelCounts = [40, 36, 32, 28, 25, 22, 19, 17, 15, 13, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
|
||||||
|
|
||||||
|
/// Base score for clearing 1–4 lines (`Scoring`), multiplied by (level+1).
|
||||||
|
static let lineScores = [4, 10, 30, 120]
|
||||||
|
|
||||||
|
/// Piece kind order as stored in `BitData` (index 0…6).
|
||||||
|
enum Kind: Int, CaseIterable { case j = 0, l, o, t, s, z, i }
|
||||||
|
|
||||||
|
// MARK: - Geometry constants (original values)
|
||||||
|
|
||||||
|
static let gridWidth = 16 // bit-grid width incl. walls
|
||||||
|
static let gridHeight = 20 // y = 0…19
|
||||||
|
static let playfieldMinX = 3 // playfield columns 3…12
|
||||||
|
static let playfieldMaxX = 12
|
||||||
|
static let floorY = 1 // solid floor row
|
||||||
|
static let bottomRowY = 2 // lowest playable row
|
||||||
|
static let topVisibleY = 17 // rows 2…17 are drawn (16 rows)
|
||||||
|
static let headroomY = 19 // rows 18/19 exist but are off-screen
|
||||||
|
static let spawnX = 6, spawnY = 16 // `ld hl,$0610` in CreateNew
|
||||||
|
static let cols = 10, visibleRows = 16
|
||||||
|
static let maxStartLevel = 9 // level select 0…9
|
||||||
|
static let maxHigh = 5 // "start high" option 0…5 (rows = high×2)
|
||||||
|
|
||||||
|
/// Cell contents: 0 = empty, 1…7 = locked piece (Kind+1), 8 = trash line.
|
||||||
|
static let trashCell = 8
|
||||||
|
|
||||||
|
struct Piece {
|
||||||
|
var kind: Int // 0…6
|
||||||
|
var rot: Int // 0…3
|
||||||
|
var x: Int
|
||||||
|
var y: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Step {
|
||||||
|
case fell
|
||||||
|
case locked(Lock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the renderer needs to react to a piece locking down.
|
||||||
|
struct Lock {
|
||||||
|
var clearedYs: [Int] // absolute y of rows removed (before shifting)
|
||||||
|
var scoreGained: Int // displayed-score delta from this lock
|
||||||
|
var leveledUp: Bool
|
||||||
|
var gameOver: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
private(set) var grid: [[Int]] // [y][x]
|
||||||
|
private(set) var current: Piece
|
||||||
|
private(set) var nextKind: Int
|
||||||
|
private(set) var score = 0 // displayed score (`Score`)
|
||||||
|
private(set) var scoreUnits = 0 // hidden tenths (`scoreU`)
|
||||||
|
private(set) var lines = 0
|
||||||
|
private(set) var level: Int
|
||||||
|
private(set) var startLevel: Int
|
||||||
|
private(set) var high: Int
|
||||||
|
private(set) var over = false
|
||||||
|
|
||||||
|
private var rng: TetrisRNG
|
||||||
|
|
||||||
|
// MARK: - Setup
|
||||||
|
|
||||||
|
init(startLevel: Int = 0, high: Int = 0, seed: UInt64? = nil) {
|
||||||
|
let clampedLevel = min(max(startLevel, 0), Self.maxStartLevel)
|
||||||
|
let clampedHigh = min(max(high, 0), Self.maxHigh)
|
||||||
|
self.startLevel = clampedLevel
|
||||||
|
self.high = clampedHigh
|
||||||
|
self.level = clampedLevel
|
||||||
|
self.rng = TetrisRNG(seed: seed ?? UInt64.random(in: 1...UInt64.max))
|
||||||
|
|
||||||
|
// Empty grid with walls: columns 0–2 / 13–15 and rows 0–1 are solid.
|
||||||
|
var g = [[Int]](repeating: [Int](repeating: 0, count: Self.gridWidth), count: Self.gridHeight)
|
||||||
|
for y in 0..<Self.gridHeight {
|
||||||
|
for x in 0..<Self.gridWidth where x < Self.playfieldMinX || x > Self.playfieldMaxX {
|
||||||
|
g[y][x] = Self.trashCell + 1 // wall sentinel (never drawn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for x in 0..<Self.gridWidth { g[0][x] = Self.trashCell + 1; g[1][x] = Self.trashCell + 1 }
|
||||||
|
grid = g
|
||||||
|
|
||||||
|
current = Piece(kind: 0, rot: 0, x: Self.spawnX, y: Self.spawnY)
|
||||||
|
nextKind = 0
|
||||||
|
current.kind = Int(rng.next() % 7)
|
||||||
|
nextKind = Int(rng.next() % 7)
|
||||||
|
|
||||||
|
// "Start high": high×2 trash rows, always scrambled (AddLines path).
|
||||||
|
if clampedHigh > 0 { addTrashRows(count: clampedHigh * 2) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrambled trash rows as in `AddTrashRow`: each row starts full and gets
|
||||||
|
/// 5 randomly-placed holes punched (duplicates allowed, so 1–5 holes).
|
||||||
|
private mutating func addTrashRows(count: Int) {
|
||||||
|
for i in 0..<count {
|
||||||
|
let y = Self.bottomRowY + i
|
||||||
|
for x in Self.playfieldMinX...Self.playfieldMaxX { grid[y][x] = Self.trashCell }
|
||||||
|
for _ in 0..<5 {
|
||||||
|
let gap = Int(rng.next() % 10)
|
||||||
|
grid[y][Self.playfieldMinX + gap] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Piece geometry
|
||||||
|
|
||||||
|
/// Decodes one rotation word into 4 (dx,dy) offsets — the `Uncrunch` logic.
|
||||||
|
static func blocks(kind: Int, rot: Int) -> [(dx: Int, dy: Int)] {
|
||||||
|
var word = bitData[kind * 4 + (rot & 3)]
|
||||||
|
var result: [(dx: Int, dy: Int)] = []
|
||||||
|
for _ in 0..<4 {
|
||||||
|
let dy = Int(word & 3); word >>= 2
|
||||||
|
let dx = Int(word & 3); word >>= 2
|
||||||
|
result.append((dx: dx, dy: dy))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func absBlocks(_ p: Piece) -> [(x: Int, y: Int)] {
|
||||||
|
blocks(kind: p.kind, rot: p.rot).map { (x: p.x + $0.dx, y: p.y + $0.dy) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentBlocks: [(x: Int, y: Int)] { Self.absBlocks(current) }
|
||||||
|
|
||||||
|
/// Where the current piece would come to rest (for the ghost outline).
|
||||||
|
var ghostPiece: Piece {
|
||||||
|
var p = current
|
||||||
|
while fits(applying: { $0.y -= 1 }, to: p) { p.y -= 1 }
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TestNewB`: the piece fits iff all four blocks land on empty cells.
|
||||||
|
private func fits(_ p: Piece) -> Bool {
|
||||||
|
for b in Self.absBlocks(p) {
|
||||||
|
guard b.x >= 0, b.x < Self.gridWidth, b.y >= 0, b.y < Self.gridHeight else { return false }
|
||||||
|
if grid[b.y][b.x] != 0 { return false }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fits(applying change: (inout Piece) -> Void, to piece: Piece) -> Bool {
|
||||||
|
var p = piece
|
||||||
|
change(&p)
|
||||||
|
return fits(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Player moves (fail silently, like the original's revert)
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func moveLeft() -> Bool { tryChange { $0.x -= 1 } }
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func moveRight() -> Bool { tryChange { $0.x += 1 } }
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func rotate() -> Bool { tryChange { $0.rot = ($0.rot + 1) & 3 } } // 2nd/Up key
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func rotateBack() -> Bool { tryChange { $0.rot = ($0.rot + 3) & 3 } } // Alpha key
|
||||||
|
|
||||||
|
private mutating func tryChange(_ change: (inout Piece) -> Void) -> Bool {
|
||||||
|
guard !over else { return false }
|
||||||
|
var p = current
|
||||||
|
change(&p)
|
||||||
|
guard fits(p) else { return false }
|
||||||
|
current = p
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Falling
|
||||||
|
|
||||||
|
/// One gravity step (`FallDown`): move down or lock.
|
||||||
|
mutating func gravityStep() -> Step {
|
||||||
|
guard !over else { return .fell }
|
||||||
|
if fits(applying: { $0.y -= 1 }, to: current) {
|
||||||
|
current.y -= 1
|
||||||
|
return .fell
|
||||||
|
}
|
||||||
|
return .locked(lockPiece())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Down key (`MoveDown`): +1 hidden unit, then a gravity step. The unit is
|
||||||
|
/// granted even on the step that locks — exactly as in the original.
|
||||||
|
mutating func softDropStep() -> Step {
|
||||||
|
guard !over else { return .fell }
|
||||||
|
scoreUnits += 1
|
||||||
|
return gravityStep()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop key (`Drop`): +1 unit per attempted step (including the final
|
||||||
|
/// blocked one), then lock immediately.
|
||||||
|
mutating func hardDrop() -> Step {
|
||||||
|
guard !over else { return .fell }
|
||||||
|
while true {
|
||||||
|
scoreUnits += 1
|
||||||
|
if fits(applying: { $0.y -= 1 }, to: current) {
|
||||||
|
current.y -= 1
|
||||||
|
} else {
|
||||||
|
return .locked(lockPiece())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Locking (`BotReached` → `NewB`)
|
||||||
|
|
||||||
|
private mutating func lockPiece() -> Lock {
|
||||||
|
let scoreBefore = score
|
||||||
|
|
||||||
|
for b in currentBlocks where b.y < Self.gridHeight {
|
||||||
|
grid[b.y][b.x] = current.kind + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreU rollover happens on every new piece (`NewB`).
|
||||||
|
score += scoreUnits / 10
|
||||||
|
scoreUnits %= 10
|
||||||
|
|
||||||
|
// Scan for full rows (`CheckLines`/`RepScan`), remove and shift down.
|
||||||
|
var cleared: [Int] = []
|
||||||
|
var y = Self.bottomRowY
|
||||||
|
while y <= Self.headroomY - 1 {
|
||||||
|
let full = (Self.playfieldMinX...Self.playfieldMaxX).allSatisfy { grid[y][$0] != 0 }
|
||||||
|
if full {
|
||||||
|
cleared.append(y + cleared.count) // report original position
|
||||||
|
for yy in y..<(Self.gridHeight - 1) {
|
||||||
|
for x in Self.playfieldMinX...Self.playfieldMaxX {
|
||||||
|
grid[yy][x] = grid[yy + 1][x]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for x in Self.playfieldMinX...Self.playfieldMaxX {
|
||||||
|
grid[Self.gridHeight - 1][x] = 0
|
||||||
|
}
|
||||||
|
lines += 1
|
||||||
|
// do not advance y: the shifted-down row needs rescanning
|
||||||
|
} else {
|
||||||
|
y += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cleared.isEmpty {
|
||||||
|
score += Self.lineScores[cleared.count - 1] * (level + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level = lines DIV 10, only ever increasing (capped at 20 per the doc).
|
||||||
|
var leveled = false
|
||||||
|
let target = min(lines / 10, 20)
|
||||||
|
if target > level {
|
||||||
|
level = target
|
||||||
|
leveled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn the next piece (`CreateNew`); if it doesn't fit, game over.
|
||||||
|
current = Piece(kind: nextKind, rot: 0, x: Self.spawnX, y: Self.spawnY)
|
||||||
|
nextKind = Int(rng.next() % 7)
|
||||||
|
if !fits(current) { over = true }
|
||||||
|
|
||||||
|
return Lock(clearedYs: cleared,
|
||||||
|
scoreGained: score - scoreBefore,
|
||||||
|
leveledUp: leveled,
|
||||||
|
gameOver: over)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived
|
||||||
|
|
||||||
|
/// Seconds per gravity row at the current level (levelcnts × ~20 ms).
|
||||||
|
var gravityInterval: TimeInterval { 0.02 * Double(Self.levelCounts[min(level, 20)]) }
|
||||||
|
|
||||||
|
/// Cell lookup in playfield coordinates (col 0…9, absolute y).
|
||||||
|
func cell(col: Int, y: Int) -> Int {
|
||||||
|
grid[y][col + Self.playfieldMinX]
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// Test/screenshot hooks — never in Release builds.
|
||||||
|
mutating func debugSetCell(col: Int, y: Int, kind: Int) {
|
||||||
|
grid[y][col + Self.playfieldMinX] = kind
|
||||||
|
}
|
||||||
|
mutating func debugSetLines(_ n: Int) { lines = n }
|
||||||
|
mutating func debugPose(kind: Int, rot: Int, x: Int, y: Int) {
|
||||||
|
current = Piece(kind: kind, rot: rot, x: x, y: y)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SplitMix64 — deterministic, seedable stand-in for the original's LFSR
|
||||||
|
/// (`PRandom`); piece selection is uniform 0…6 in both.
|
||||||
|
struct TetrisRNG {
|
||||||
|
private var state: UInt64
|
||||||
|
init(seed: UInt64) { state = seed == 0 ? 0xBAD_5EED : seed }
|
||||||
|
mutating func next() -> UInt64 {
|
||||||
|
state &+= 0x9E3779B97F4A7C15
|
||||||
|
var z = state
|
||||||
|
z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9
|
||||||
|
z = (z ^ (z >> 27)) &* 0x94D049BB133111EB
|
||||||
|
return z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SpriteKit
|
||||||
|
|
||||||
|
/// Metadata for one title in the collection.
|
||||||
|
struct GameInfo: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let tagline: String
|
||||||
|
let origin: String // author · year of the original TI-85 program
|
||||||
|
let symbol: String // SF Symbol shown on the home card
|
||||||
|
let tint: Color
|
||||||
|
let playable: Bool // false → routes to the "coming soon" preview scene
|
||||||
|
var scoreIsMoney = false // BEST renders as dollars (Dope Wars nets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The five TI-85 classics being remade, plus a factory that builds the
|
||||||
|
/// SpriteKit scene for a given id.
|
||||||
|
enum GameCatalog {
|
||||||
|
static let games: [GameInfo] = [
|
||||||
|
GameInfo(id: "lander", title: "Lunar Lander", tagline: "Cut your descent and set her down gently on the pad.",
|
||||||
|
origin: "Istar · 1997", symbol: "moon.stars.fill", tint: Theme.accent, playable: true),
|
||||||
|
// Galaxian's slot: swapped for a trading game (too much overlap with
|
||||||
|
// Phoenix). The Galaxian port stays in Games/Galaxian, dormant —
|
||||||
|
// restore by swapping this entry back. Dope Wars runs the NerdWars
|
||||||
|
// TI-85 engine re-skinned back to Dell's classic 1984 setup.
|
||||||
|
GameInfo(id: "dopewars", title: "Dope Wars", tagline: "Buy low, sell high, dodge Hardass, square the loan shark.",
|
||||||
|
origin: "J.E. Dell · 1984", symbol: "dollarsign.circle.fill", tint: Theme.accent2, playable: true,
|
||||||
|
scoreIsMoney: true),
|
||||||
|
GameInfo(id: "phoenix", title: "Phoenix", tagline: "Blast through waves, bank credits, upgrade your ship.",
|
||||||
|
origin: "P. Davidson · 2008", symbol: "flame.fill", tint: Theme.warning, playable: true),
|
||||||
|
GameInfo(id: "ztetris", title: "ZTetris", tagline: "Stack the falling blocks, clear lines, chase the count.",
|
||||||
|
origin: "J. Mårdell · 1998", symbol: "square.grid.3x3.fill", tint: Theme.violet, playable: true),
|
||||||
|
GameInfo(id: "zmercury", title: "ZMercury", tagline: "Skim the terrain and outshoot everything that moves.",
|
||||||
|
origin: "P. Davidson · 2004", symbol: "bolt.fill", tint: Theme.danger, playable: true),
|
||||||
|
]
|
||||||
|
|
||||||
|
static func game(id: String) -> GameInfo {
|
||||||
|
games.first { $0.id == id } ?? games[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func makeScene(id: String, size: CGSize) -> SKScene {
|
||||||
|
let scene: SKScene
|
||||||
|
switch id {
|
||||||
|
case "lander":
|
||||||
|
scene = LanderScene(size: size)
|
||||||
|
case "galaxian": // dormant, no catalog entry (see above)
|
||||||
|
scene = GalaxianScene(size: size)
|
||||||
|
case "dopewars":
|
||||||
|
scene = DopeWarsScene(size: size)
|
||||||
|
case "phoenix":
|
||||||
|
scene = PhoenixScene(size: size)
|
||||||
|
case "ztetris":
|
||||||
|
scene = TetrisScene(size: size)
|
||||||
|
case "zmercury":
|
||||||
|
scene = MercuryScene(size: size)
|
||||||
|
default:
|
||||||
|
scene = ComingSoonScene(size: size, info: game(id: id))
|
||||||
|
}
|
||||||
|
scene.scaleMode = .resizeFill
|
||||||
|
return scene
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SpriteKit
|
||||||
|
|
||||||
|
/// Full-screen host for a game's SpriteKit scene, with a floating back button.
|
||||||
|
/// The scene is built once at the measured size and owns its own HUD.
|
||||||
|
struct GameHostView: View {
|
||||||
|
let info: GameInfo
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var scene: SKScene?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
|
if let scene {
|
||||||
|
SpriteView(scene: scene,
|
||||||
|
options: [.ignoresSiblingOrder])
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Haptics.selection()
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 17, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.9))
|
||||||
|
.frame(width: 38, height: 38)
|
||||||
|
.background(.ultraThinMaterial, in: Circle())
|
||||||
|
.overlay(Circle().stroke(.white.opacity(0.15), lineWidth: 1))
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.leading, 14)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if scene == nil {
|
||||||
|
let size = geo.size == .zero ? UIScreen.main.bounds.size : geo.size
|
||||||
|
scene = GameCatalog.makeScene(id: info.id, size: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black.ignoresSafeArea())
|
||||||
|
.statusBarHidden(true)
|
||||||
|
.persistentSystemOverlays(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Thin wrapper over UIKit feedback generators so games can add tactile feel
|
||||||
|
/// without repeating boilerplate. All calls are cheap and safe to fire often.
|
||||||
|
enum Haptics {
|
||||||
|
static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle, intensity: CGFloat = 1.0) {
|
||||||
|
let generator = UIImpactFeedbackGenerator(style: style)
|
||||||
|
generator.impactOccurred(intensity: intensity)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func success() { UINotificationFeedbackGenerator().notificationOccurred(.success) }
|
||||||
|
static func warning() { UINotificationFeedbackGenerator().notificationOccurred(.warning) }
|
||||||
|
static func error() { UINotificationFeedbackGenerator().notificationOccurred(.error) }
|
||||||
|
static func selection() { UISelectionFeedbackGenerator().selectionChanged() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Persists a best score per game id in UserDefaults. Shared across the shell
|
||||||
|
/// (to show "BEST" on cards) and the SpriteKit scenes (to submit results).
|
||||||
|
final class HighScoreStore: ObservableObject {
|
||||||
|
static let shared = HighScoreStore()
|
||||||
|
|
||||||
|
private let storageKey = "arcade85.highscores.v1"
|
||||||
|
@Published private(set) var scores: [String: Int] = [:]
|
||||||
|
|
||||||
|
private init() { load() }
|
||||||
|
|
||||||
|
func best(_ id: String) -> Int { scores[id] ?? 0 }
|
||||||
|
|
||||||
|
/// Records `score` for `id` if it beats the stored best. Returns true on a new record.
|
||||||
|
@discardableResult
|
||||||
|
func submit(id: String, score: Int) -> Bool {
|
||||||
|
guard score > (scores[id] ?? 0) else { return false }
|
||||||
|
scores[id] = score
|
||||||
|
save()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the stored best for one game (used when a game's scoring scale changes).
|
||||||
|
func reset(_ id: String) {
|
||||||
|
guard scores[id] != nil else { return }
|
||||||
|
scores[id] = nil
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: storageKey),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: Int].self, from: data)
|
||||||
|
else { return }
|
||||||
|
scores = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard let data = try? JSONEncoder().encode(scores) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Home screen: a starfield backdrop, the "ARCADE 85" wordmark, and a stack of
|
||||||
|
/// game cards. Tapping a card presents the game full screen.
|
||||||
|
struct RootView: View {
|
||||||
|
@StateObject private var scores = HighScoreStore.shared
|
||||||
|
@State private var active: GameInfo?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(colors: [Theme.backgroundHi, Theme.background],
|
||||||
|
startPoint: .top, endPoint: .bottom)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
StarfieldView()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
header
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
ForEach(GameCatalog.games) { game in
|
||||||
|
GameCard(info: game, best: scores.best(game.id)) {
|
||||||
|
Haptics.selection()
|
||||||
|
active = game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullScreenCover(item: $active) { game in
|
||||||
|
GameHostView(info: game)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
#if DEBUG
|
||||||
|
if let id = ProcessInfo.processInfo.environment["ARCADE_DIRECT_GAME"],
|
||||||
|
let info = GameCatalog.games.first(where: { $0.id == id }) {
|
||||||
|
active = info
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
(Text("ARCADE ").foregroundStyle(.white) + Text("85").foregroundStyle(Theme.accent))
|
||||||
|
.font(Theme.title(40))
|
||||||
|
.kerning(1)
|
||||||
|
Text("Five TI-85 classics, remade for touch.")
|
||||||
|
.font(Theme.rounded(15, .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
Text("Faithful HD remakes, ported from the original calculator sources.")
|
||||||
|
.font(Theme.rounded(12, .regular))
|
||||||
|
.foregroundStyle(.white.opacity(0.35))
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One tappable game entry on the home screen.
|
||||||
|
struct GameCard: View {
|
||||||
|
let info: GameInfo
|
||||||
|
let best: Int
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(info.tint.opacity(0.16))
|
||||||
|
Image(systemName: info.symbol)
|
||||||
|
.font(.system(size: 28, weight: .semibold))
|
||||||
|
.foregroundStyle(info.tint)
|
||||||
|
}
|
||||||
|
.frame(width: 66, height: 66)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(info.title)
|
||||||
|
.font(Theme.title(19))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
if !info.playable {
|
||||||
|
Text("SOON")
|
||||||
|
.font(Theme.mono(9, .bold))
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||||
|
.background(Capsule().fill(.white.opacity(0.10)))
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(info.tagline)
|
||||||
|
.font(Theme.rounded(12.5, .regular))
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
.lineLimit(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Text(info.origin)
|
||||||
|
.font(Theme.mono(10, .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.35))
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
if info.playable {
|
||||||
|
Text("BEST")
|
||||||
|
.font(Theme.mono(8, .bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.35))
|
||||||
|
Text(info.scoreIsMoney ? Self.compactMoney(best) : "\(best)")
|
||||||
|
.font(Theme.mono(15, .bold))
|
||||||
|
.foregroundStyle(info.tint)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.30))
|
||||||
|
}
|
||||||
|
.frame(height: 66)
|
||||||
|
}
|
||||||
|
.padding(13)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||||
|
.fill(Theme.panel.opacity(0.85))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||||
|
.stroke(info.tint.opacity(0.22), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(CardButtonStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "$318,240" up to six figures, then "$13.4M" — card space is tight.
|
||||||
|
private static func compactMoney(_ n: Int) -> String {
|
||||||
|
switch n {
|
||||||
|
case 1_000_000_000...: return String(format: "$%.1fB", Double(n) / 1_000_000_000)
|
||||||
|
case 1_000_000...: return String(format: "$%.1fM", Double(n) / 1_000_000)
|
||||||
|
default:
|
||||||
|
let f = NumberFormatter()
|
||||||
|
f.numberStyle = .decimal
|
||||||
|
return "$" + (f.string(from: NSNumber(value: n)) ?? "\(n)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subtle press-scale for the cards.
|
||||||
|
private struct CardButtonStyle: ButtonStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.975 : 1)
|
||||||
|
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A static field of stars rendered with Canvas. Positions are deterministic so
|
||||||
|
/// the sky doesn't shuffle on every redraw.
|
||||||
|
struct StarfieldView: View {
|
||||||
|
private let stars: [Star] = {
|
||||||
|
var rng = SeededGenerator(seed: 0x5EED_1985)
|
||||||
|
return (0..<90).map { _ in
|
||||||
|
Star(x: .random(in: 0...1, using: &rng),
|
||||||
|
y: .random(in: 0...1, using: &rng),
|
||||||
|
r: .random(in: 0.5...1.7, using: &rng),
|
||||||
|
a: .random(in: 0.10...0.85, using: &rng))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Canvas { context, size in
|
||||||
|
for s in stars {
|
||||||
|
let rect = CGRect(x: s.x * size.width, y: s.y * size.height,
|
||||||
|
width: s.r * 2, height: s.r * 2)
|
||||||
|
context.fill(Path(ellipseIn: rect), with: .color(.white.opacity(s.a)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Star { let x, y, r, a: CGFloat }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tiny deterministic RNG so the starfield (and any other decorative layout) is stable.
|
||||||
|
struct SeededGenerator: RandomNumberGenerator {
|
||||||
|
private var state: UInt64
|
||||||
|
init(seed: UInt64) { state = seed == 0 ? 0xDEAD_BEEF : seed }
|
||||||
|
mutating func next() -> UInt64 {
|
||||||
|
state &+= 0x9E3779B97F4A7C15
|
||||||
|
var z = state
|
||||||
|
z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9
|
||||||
|
z = (z ^ (z >> 27)) &* 0x94D049BB133111EB
|
||||||
|
return z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Shared visual language for the collection: a dark, neon-retro palette that
|
||||||
|
/// every game and the shell draw from so the app reads as one product.
|
||||||
|
enum Theme {
|
||||||
|
// SwiftUI colors (shell UI)
|
||||||
|
static let background = Color(red: 0.020, green: 0.027, blue: 0.059)
|
||||||
|
static let backgroundHi = Color(red: 0.043, green: 0.071, blue: 0.149)
|
||||||
|
static let panel = Color(red: 0.071, green: 0.102, blue: 0.165)
|
||||||
|
static let accent = Color(red: 0.42, green: 0.94, blue: 0.80) // neon mint
|
||||||
|
static let accent2 = Color(red: 0.36, green: 0.72, blue: 1.00) // cyan
|
||||||
|
static let warning = Color(red: 0.96, green: 0.78, blue: 0.23) // amber
|
||||||
|
static let danger = Color(red: 1.00, green: 0.35, blue: 0.40) // red
|
||||||
|
static let violet = Color(red: 0.71, green: 0.50, blue: 1.00)
|
||||||
|
|
||||||
|
static func title(_ size: CGFloat) -> Font { .system(size: size, weight: .heavy, design: .rounded) }
|
||||||
|
static func rounded(_ size: CGFloat, _ weight: Font.Weight = .semibold) -> Font { .system(size: size, weight: weight, design: .rounded) }
|
||||||
|
static func mono(_ size: CGFloat, _ weight: Font.Weight = .semibold) -> Font { .system(size: size, weight: weight, design: .monospaced) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UIColor / SKColor mirrors of the palette, for use inside SpriteKit scenes.
|
||||||
|
enum Palette {
|
||||||
|
static let space0 = UIColor(red: 0.043, green: 0.071, blue: 0.149, alpha: 1) // upper sky
|
||||||
|
static let space1 = UIColor(red: 0.016, green: 0.024, blue: 0.055, alpha: 1) // lower sky
|
||||||
|
static let mint = UIColor(red: 0.42, green: 0.94, blue: 0.80, alpha: 1)
|
||||||
|
static let mintDim = UIColor(red: 0.42, green: 0.94, blue: 0.80, alpha: 0.35)
|
||||||
|
static let cyan = UIColor(red: 0.36, green: 0.72, blue: 1.00, alpha: 1)
|
||||||
|
static let amber = UIColor(red: 0.96, green: 0.78, blue: 0.23, alpha: 1)
|
||||||
|
static let red = UIColor(red: 1.00, green: 0.35, blue: 0.40, alpha: 1)
|
||||||
|
static let violet = UIColor(red: 0.71, green: 0.50, blue: 1.00, alpha: 1)
|
||||||
|
static let terrain = UIColor(red: 0.055, green: 0.086, blue: 0.114, alpha: 1)
|
||||||
|
static let ink = UIColor(red: 0.055, green: 0.086, blue: 0.129, alpha: 1)
|
||||||
|
static let white70 = UIColor(white: 1, alpha: 0.7)
|
||||||
|
static let white40 = UIColor(white: 1, alpha: 0.4)
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
# Arcade 85
|
||||||
|
|
||||||
|
A 5-in-1 iOS collection of faithful **HD remakes** of five classic TI-85
|
||||||
|
calculator games. Native **SwiftUI + SpriteKit**.
|
||||||
|
|
||||||
|
The games (from the original sources in this repo's sibling folders):
|
||||||
|
|
||||||
|
| Game | Original | Status |
|
||||||
|
|------|----------|--------|
|
||||||
|
| **Lunar Lander** | Tiger/Istar, 1997 (`lander/LANDER.C`) | ✅ Playable |
|
||||||
|
| **ZTetris** | J. Mårdell, 1998 (`zetris/ZTETRIS.ASM`) | ✅ Playable |
|
||||||
|
| **Phoenix** | P. Davidson, 2008 (`phx4.0ti85/`) | ✅ Playable |
|
||||||
|
| **Dope Wars** | J.E. Dell, 1984 · engine via J. Gannon's NerdWars, 2000 | ✅ Playable |
|
||||||
|
| **ZMercury** | P. Davidson, 2004 (`zmercury-ti85/`) | ✅ Playable |
|
||||||
|
| **Galaxian** | P. Davidson, 1998 (`galaxian-ti85/`) | 💤 Ported, dormant |
|
||||||
|
|
||||||
|
All five collection slots are playable. Galaxian was fully ported, then swapped
|
||||||
|
out of the line-up for a trading game (too much overlap with Phoenix); its code
|
||||||
|
stays in the project and swapping one `GameCatalog.swift` entry restores it.
|
||||||
|
The trading slot first shipped as NerdWars (the TI-85's Drugwars clone), then
|
||||||
|
was restored to the classic **Dope Wars** setup it secretly was all along —
|
||||||
|
see the Dope Wars section.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
Requires Xcode 16+ (developed against Xcode 26).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
open Arcade85/Arcade85.xcodeproj
|
||||||
|
# pick an iPhone simulator, then Cmd-R
|
||||||
|
```
|
||||||
|
|
||||||
|
Or from the command line:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd Arcade85
|
||||||
|
xcodebuild -scheme Arcade85 -sdk iphonesimulator \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' build
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no third-party dependency and no code-signing needed for the simulator.
|
||||||
|
The project uses Xcode "synchronized file groups", so **new files added under
|
||||||
|
`Arcade85/Arcade85/` are picked up automatically** — no `.pbxproj` editing.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
Arcade85/Arcade85/
|
||||||
|
Arcade85App.swift @main entry point
|
||||||
|
Shell/ the collection UI, shared across every game
|
||||||
|
Theme.swift palette + fonts (SwiftUI + SpriteKit mirrors)
|
||||||
|
Haptics.swift feedback helpers
|
||||||
|
HighScoreStore.swift per-game best scores (UserDefaults)
|
||||||
|
GameCatalog.swift game metadata + SKScene factory
|
||||||
|
RootView.swift home screen (starfield + game cards)
|
||||||
|
GameHostView.swift full-screen SpriteView host + back button
|
||||||
|
Games/
|
||||||
|
SceneArt.swift procedural gradients, starfields, symbol + sprite textures
|
||||||
|
ComingSoonScene.swift placeholder scene for the four unbuilt games
|
||||||
|
Lander/
|
||||||
|
LanderSim.swift faithful port of LANDER.C (pure logic)
|
||||||
|
LanderScene.swift HD rendering, multi-touch thrusters, game flow
|
||||||
|
ZTetris/
|
||||||
|
TetrisSim.swift faithful port of ZTETRIS.ASM (pure logic)
|
||||||
|
TetrisScene.swift HD rendering, DAS touch controls, game flow
|
||||||
|
Phoenix/
|
||||||
|
PhoenixData.swift sprites (images.asm) + campaign (levels.asm), verbatim
|
||||||
|
PhoenixSim.swift faithful port of the Z80 game logic
|
||||||
|
PhoenixScene.swift HD rendering, drag-to-fly control, shop, flow
|
||||||
|
Galaxian/ (dormant — swapped out of the catalog for Dope Wars)
|
||||||
|
GalaxianData.swift sprites, formation patterns + 20-level table, verbatim
|
||||||
|
GalaxianSim.swift faithful port of GALAXIAN.ASM
|
||||||
|
GalaxianScene.swift HD rendering, drag-to-fly + laser button, flow
|
||||||
|
DopeWars/
|
||||||
|
DopeWarsSim.swift the NerdWars engine restored to Dell's classic setup
|
||||||
|
DopeWarsScene.swift night-city trading board, event cards, cop chase, flow
|
||||||
|
ZMercury/
|
||||||
|
MercuryData.swift terrain tiles, map, sine table + sprites, verbatim
|
||||||
|
MercurySim.swift faithful port of the multi-file Z80 sources
|
||||||
|
MercuryScene.swift HD terrain scroller, parallax, drag-to-fly, flow
|
||||||
|
Assets.xcassets/ app icon (procedurally generated) + accent color
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design approach
|
||||||
|
|
||||||
|
Each game is split into a **pure simulation** and a **SpriteKit scene** that
|
||||||
|
renders and drives it. The simulation reproduces the original's mechanics
|
||||||
|
exactly; the scene is where the HD glass goes (neon vector art, particles,
|
||||||
|
haptics, touch controls). This keeps the remakes honest — the feel comes from
|
||||||
|
the original numbers, not an approximation.
|
||||||
|
|
||||||
|
### Lunar Lander (the reference implementation)
|
||||||
|
|
||||||
|
`LanderSim` ports `LANDER.C` line-for-line in the original integer space:
|
||||||
|
|
||||||
|
- the Dape/Istar landscape generator (random walk + erosion smoothing passes),
|
||||||
|
- the 10-wide landing pad carved 4 below the surrounding terrain,
|
||||||
|
- gravity, the ±2 thruster impulses, horizontal friction, velocity clamping,
|
||||||
|
- the 8-pixel-footprint landing/crash test (must be flat under all 8 columns
|
||||||
|
with descent speed ≥ −3), and
|
||||||
|
- the original scoring: `((90 − maxFuel) × fuel / maxFuel) × maxVel`.
|
||||||
|
|
||||||
|
The sim ticks at the original's real pace — LANDER.C ends every loop pass with
|
||||||
|
`delay(20)` ≈ 200 ms (the same `delay()` whose 200-unit calls are the ~2 s
|
||||||
|
message pauses), i.e. **~5 loops/sec** — with rendering interpolated between
|
||||||
|
ticks so motion stays smooth. Taps shorter than one tick are latched so they
|
||||||
|
still burn for exactly one loop, like one keypress in the original.
|
||||||
|
|
||||||
|
Deliberate HD liberties (everything else is the original's integer rules,
|
||||||
|
which hold verbatim on the Moon setting):
|
||||||
|
|
||||||
|
- The field is **128×96** instead of 128×64 — extra sky above the same 0…32
|
||||||
|
terrain for a phone-shaped playfield and a longer descent. Every rule is
|
||||||
|
height-relative, so nothing else changes.
|
||||||
|
- **Soft walls.** In the original the screen edge was fatal; on touch that
|
||||||
|
turned a mistimed start tap into an instant "lost in space". Now the top and
|
||||||
|
sides stop the lander (velocity zeroed) instead of ending the run, the
|
||||||
|
lander spawns with 7 px of ceiling headroom, and a tap that starts a run is
|
||||||
|
consumed by the transition — it can never double as a first-tick burn.
|
||||||
|
Crashing into terrain is the only way to die.
|
||||||
|
- A **GRAVITY** setting — the remake's third difficulty knob, added because
|
||||||
|
the original's self-braking lateral friction leaves little to fight:
|
||||||
|
- **MOON** — the original: pull 1/tick, drift damped every tick;
|
||||||
|
- **MARS** — pull 1,1,1,2 (avg 1.25), damping every *other* tick;
|
||||||
|
- **EARTH** — pull 1,2 alternating (avg 1.5), **no damping** — Newtonian
|
||||||
|
drift you must counter-burn, landing scores **×3** (Mars ×2).
|
||||||
|
The patterns are phase tables over the tick counter, so the sim stays
|
||||||
|
integer-exact.
|
||||||
|
|
||||||
|
The scene renders the actual TI-85 8×8 lander bitmap as crisp merged neon
|
||||||
|
pixels and adds on-screen thruster pads (with true multi-touch so you can fire
|
||||||
|
lateral + main thrust together), a live V-SPEED / FUEL / SCORE HUD, and
|
||||||
|
ready / landed / game-over flow. The original's adjustable **FUEL (30–80)**
|
||||||
|
lives on the ready screen next to **MAX SPEED (4–7)** — the original allowed
|
||||||
|
speed 3, but there terminal velocity *equals* the safe-touchdown limit and the
|
||||||
|
game cannot be lost, so the floor is 4. The classic's own defaults (40/7) are
|
||||||
|
its hardest speed setting, so the remake defaults to a gentler **60 fuel /
|
||||||
|
speed 4 / Moon**, remembers the player's last choice, and teaches the rule on
|
||||||
|
screen (`V-SPEED · SAFE ≤3` plus stepper hints). The score formula already
|
||||||
|
pays more for harder settings; the gravity multiplier extends that to the new
|
||||||
|
worlds. A 20-assertion standalone harness pins the spawn headroom, wall
|
||||||
|
clamps, all three pull/damping patterns, the score multipliers, and that
|
||||||
|
speed 4 remains losable.
|
||||||
|
|
||||||
|
### ZTetris
|
||||||
|
|
||||||
|
`TetrisSim` ports `ZTETRIS.ASM` mechanically — several of its tables are
|
||||||
|
embedded **verbatim**:
|
||||||
|
|
||||||
|
- the 28-word `BitData` piece table (7 pieces × 4 rotations, four (dy,dx)
|
||||||
|
offsets packed 2 bits each), decoded exactly like the ASM's `Uncrunch`;
|
||||||
|
- the well geometry: 10 columns, 16 visible rows + 2 rows of hidden headroom,
|
||||||
|
spawn at the original (6,16), no wall kicks, moves fail silently;
|
||||||
|
- `levelcnts` gravity (level 0–20), converted at the TI-85's real interrupt
|
||||||
|
timing: one main-loop iteration = 4 HALTs ≈ 20 ms, so level 0 = 0.8 s/row;
|
||||||
|
- scoring: line clears `4/10/30/120 × (level+1)`, plus the hidden units
|
||||||
|
counter (`scoreU`) — +1 per Down/Drop step *including the blocked one* —
|
||||||
|
carried into the displayed score ÷10 on each new piece, exactly as `NewB`
|
||||||
|
does it;
|
||||||
|
- level = lines ÷ 10 (only increasing, capped at 20), start level 0–9;
|
||||||
|
- "start high" trash (0–5 → ×2 rows), each row punched with 5 random gaps;
|
||||||
|
- the 16 per-level background patterns, tiled on the rails beside the well.
|
||||||
|
|
||||||
|
The scene adds the HD glass: colored beveled blocks, a ghost landing outline,
|
||||||
|
next-piece preview, DAS auto-repeat on held ◄ ► (0.167 s delay, 50 ms repeat),
|
||||||
|
held soft drop, tap hard drop, both rotation directions, pause, and line-clear
|
||||||
|
flash/score-pop feedback. The sim has a standalone test harness (68 assertions)
|
||||||
|
covering piece decode, drop scoring, clears, leveling, trash rows and top-out.
|
||||||
|
|
||||||
|
### Phoenix
|
||||||
|
|
||||||
|
The deepest port: the entire Phoenix 4.0 campaign and economy, extracted from
|
||||||
|
the Z80 sources file by file:
|
||||||
|
|
||||||
|
- **Campaign** (`levels.asm`): all 31 combat waves + 9 shop stops, with every
|
||||||
|
power/movement/weapon/fire-rate value and the four pattern paths (circle,
|
||||||
|
oval, figure-eight, huge oval) transcribed literally; `L_GOTO` chains inlined.
|
||||||
|
- **Enemy AI** (`emove.asm`): the synchronized rectangle formation wobble
|
||||||
|
(byte-timer, odd-tick moves, 17-tick phases, −60 entry descent), bosses
|
||||||
|
(descend then 83/90-px sweeps), bouncers, pattern followers driven by the
|
||||||
|
original's bit-reversed-timer fractional velocities (`Div_A_16`), rampagers
|
||||||
|
with the exact trigger columns, and the 4-path swoop system.
|
||||||
|
- **Weapons**: player F1–F5 with the original damage/speed/offsets (including
|
||||||
|
F4's alternating sides and F5's arcing swing shots), enemy weapons from
|
||||||
|
straight drops to the 7-angle-bucket aimed big/huge shots.
|
||||||
|
- **Economy**: money as dmg-0 "bonus bullets" dropped every 4–11 kills, $ value
|
||||||
|
by difficulty (100/50/25), cash cap 9900, and the shop's 7 items with the
|
||||||
|
original prices and purchase rules (shield cap 16, one-time weapons that
|
||||||
|
auto-select, companion refill).
|
||||||
|
- **Companion** (`helper.asm`): position-queue follower, 10-damage shots every
|
||||||
|
8 frames, its own 16-point shield and explosion.
|
||||||
|
- **Scoring** (`score16.asm`): shields×1000 + time bonus (19000 counting down
|
||||||
|
every other frame) + difficulty/speed bonus + cash, awarded on finishing.
|
||||||
|
- **Sprites** (`images.asm`): all 44 bitmaps rendered as neon pixel art,
|
||||||
|
including the 16-wide bosses and the 8-frame explosion.
|
||||||
|
|
||||||
|
Touch layer: drag anywhere to fly (original ±2/±1-px-per-frame speed limits
|
||||||
|
still govern the ship), always-on autofire at the original 10/4-frame cadence,
|
||||||
|
tap weapon selector, pause, and full title → shop → victory/game-over flow at
|
||||||
|
the original 25/29/35 fps speed options. Sim verified by a 172-assertion
|
||||||
|
standalone harness (progress additions covered by a follow-up suite).
|
||||||
|
|
||||||
|
**Progress scoring (remake addition).** The original only scored a *finished*
|
||||||
|
campaign (`score16.asm`), which on the phone meant the high score sat at 0
|
||||||
|
until you beat all 31 waves. The sim now tracks `wavesCleared` and
|
||||||
|
`totalEarned` (lifetime $, unspent or not), and every run posts
|
||||||
|
`waves × 400 + earnings` — a death shows `WAVE n/31 · SCORE`, and a victory
|
||||||
|
adds the untouched score16 tally (shields/time/bonus/cash) on top, itemised
|
||||||
|
as a PROGRESS line in the classic victory breakdown.
|
||||||
|
|
||||||
|
The 2:1 TI-85 window is drawn **full-bleed** with a **2× vertical stretch on
|
||||||
|
entity positions** while the sprites themselves keep a mild 1.25× arcade-monitor
|
||||||
|
stretch — the battle fills the phone instead of sitting in a letterboxed strip,
|
||||||
|
and the pixel art stays crisp. Sim geometry and collisions are untouched — the
|
||||||
|
visible window itself can't widen, because the original parks and spawns
|
||||||
|
entities just outside the y 32…96 band.
|
||||||
|
|
||||||
|
### Galaxian
|
||||||
|
|
||||||
|
`GalaxianSim` ports `GALAXIAN.ASM` (v3.0) at its ~35 fps frame rate:
|
||||||
|
|
||||||
|
- **Formation choreography**: each slot's pattern byte is *both* entry time
|
||||||
|
and formation depth — an enemy triggers when `timer & 127 == 128 - depth`,
|
||||||
|
descends 1 px/frame, and the 128-frame timer wrap locks it into formation
|
||||||
|
exactly at its row. The whole formation then sways ±32 px on timer bit 6.
|
||||||
|
- **Swoops**: the original's 8-stage dive state machine (types 5–12: up/out,
|
||||||
|
arc over, dive at double speed), started by the R-register roll
|
||||||
|
(`R < 19/128` on every 4th frame), aimed away from the screen edge, and
|
||||||
|
rejoining the entry queue at the bottom for −1 point.
|
||||||
|
- **20 levels** from `levels_jump_table`: plain waves, the ring formation,
|
||||||
|
mixed weak/strong waves (13 power-1 + 7 of the level type), bouncing cross
|
||||||
|
trainers (exactly half of which can flee, −50), aim-shooting "operator"
|
||||||
|
ships, patrolling 10-hit bosses, and two 98-hit superbosses that deploy
|
||||||
|
drones mid-fight (−200 each).
|
||||||
|
- **Weapons**: one cannon bullet per press from a 10-slot pool (+10 per hit,
|
||||||
|
+100 per kill), and 9 lasers that wipe an entire column but never hurt a
|
||||||
|
boss. Enemy bullets come in 5 drift types picked by your position.
|
||||||
|
- **Scoring** exactly as documented: +200 per level, +25 per boss hit, +1000
|
||||||
|
per boss kill, −16×16 per death, +5000 victory.
|
||||||
|
|
||||||
|
The scene adds drag-to-fly (4-way, original 1 px/frame speed), always-on
|
||||||
|
autofire, a big LASER button with the remaining count, wave banners, and the
|
||||||
|
9-lives / 9-lasers HUD. Sim verified by a 129-assertion standalone harness.
|
||||||
|
|
||||||
|
### ZMercury
|
||||||
|
|
||||||
|
`MercurySim` ports the multi-file 2004 sources — a fully deterministic
|
||||||
|
horizontal cave-shooter (the original contains *no* randomness at all):
|
||||||
|
|
||||||
|
- **Terrain scroller**: 1 px/frame leftward; every 8 frames a new 8-px tile
|
||||||
|
column enters from a 64-column looping map (9th map row = wave triggers).
|
||||||
|
The buffer's polarity quirk (bits set = open sky) and the one-byte offset
|
||||||
|
between sprite space and terrain space are both preserved.
|
||||||
|
- **Waves**: straight flyers, aimed-fire phoenixes, sine-bobbing spinners,
|
||||||
|
turret bases mounted on their map pad tiles (ceiling *and* floor), spin-T
|
||||||
|
sine crossers, an open-space gate that halts the map until the field is
|
||||||
|
clear, and a 50-hit boss that holds the right edge firing bullet pairs.
|
||||||
|
- **Damage model**: 100 shield points; every ship corner touching rock costs
|
||||||
|
1 per frame, enemy bullets cost 5, rams cost 1 (and hurt the enemy back).
|
||||||
|
Kills pay +1 immediately and +20 when the explosion finishes — the
|
||||||
|
original's BCD scoring, faithfully odd.
|
||||||
|
- **Aimed fire**: the shallow-cone rule — bullets angle up/down only when
|
||||||
|
the vertical offset beats deltaX/4 — plus per-slot fire stagger.
|
||||||
|
|
||||||
|
The scene renders the terrain from prebuilt map-column textures that slide
|
||||||
|
with the sim's scroll counter — full-bleed, positions/terrain stretched 1.9×
|
||||||
|
vertically (sprites 1.35×), every rock pixel facing open air lit as a glowing
|
||||||
|
rim over a dark rock mass — plus drifting star layers, a slower stalactite
|
||||||
|
silhouette band for cave depth, and the original ~28.6 fps (`timer ≥ 7`
|
||||||
|
against the ~200 Hz interrupt). Sim verified by an 81-assertion standalone
|
||||||
|
harness.
|
||||||
|
|
||||||
|
### Dope Wars
|
||||||
|
|
||||||
|
The genre swap for the fifth slot — and a restoration story. The engine is the
|
||||||
|
verbatim port of **NerdWars** (J. Gannon, 2000), the TI-85's only
|
||||||
|
Drugwars-style game, decoded from its `.85p`. Reading the BASIC revealed that
|
||||||
|
Gannon had kept John E. Dell's original **Drug Wars (1984)** item variables
|
||||||
|
`C,H,A,W,S,L` — Cocaine, Heroin, Acid, Weed, Speed, Ludes — and merely
|
||||||
|
rescaled the price bands and renamed the nouns. `DopeWarsSim` reverses the
|
||||||
|
re-skin: Dell's classic setup on Gannon's proven engine.
|
||||||
|
|
||||||
|
- **The month**: $2,000 cash, the classic **$5,500** debt to the loan shark
|
||||||
|
growing 10%/day, a 6%/day bank (shark and bank live in the Bronx), a
|
||||||
|
100-pocket trench coat, and 30 subway rides between six boroughs (Bronx,
|
||||||
|
Ghetto, Central Park, Manhattan, Coney Island, Brooklyn); each ride rerolls
|
||||||
|
the six prices plus one 0…20 event roll.
|
||||||
|
- **Market** (Dell's iconic bands): Cocaine 15000–30000 · Heroin 5000–14000 ·
|
||||||
|
Acid 1000–4500 · Weed 300–900 · Speed 90–250 · Ludes 10–60, bent by events:
|
||||||
|
cheap heroin at 850–1999, "Cops made a big bust!" heroin at 18000–42999,
|
||||||
|
$80,000+ cocaine manias, $2 pharmacy-raid ludes, weed and acid floods, a
|
||||||
|
subway mugging (keep 2/3), $200-coat and $400-gun offers (Baretta /
|
||||||
|
.38 Special / Saturday Night Special), free drugs on a dead dude — and the
|
||||||
|
weed on the sidewalk that always ends in one last wild trip.
|
||||||
|
- **Officer Hardass**: carrying 50+ units can trigger a chase — run (50/50;
|
||||||
|
escaping rerolls the day, possibly straight into another event) or fight
|
||||||
|
with a gun; every fight round draws return fire (+3 wounds, 50 = death),
|
||||||
|
and dropping the whole squad loots Hardass's body ($750–1999) with a
|
||||||
|
$1,000 doctor offer if you can afford it.
|
||||||
|
- **Scoring**: your **final net worth in dollars** (cash + bank − debt) is
|
||||||
|
the high score — "$13.4M" beats "$2.1M", the way a trading game should
|
||||||
|
brag. (Dell's `√(net ÷ 31.5)` 0–100 rating is still computed in the sim
|
||||||
|
for the curious; the old 0–100 stored best migrates to $0 once.)
|
||||||
|
- **The market grows with you (remake extension).** Below $100k net worth
|
||||||
|
the game is exactly the classic. Cross **$100k** and the ludes slot
|
||||||
|
retires — any stock is bought out at yesterday's price — and **METH**
|
||||||
|
moves in at $2,000–16,000, the widest swing on the board. Cross **$1M**
|
||||||
|
and speed retires for **CHINA WHITE** at $40,000–160,000, the whale
|
||||||
|
product that dwarfs even a cocaine mania. Shifts are one-way, announced
|
||||||
|
as street-word cards on arrival, and bring their own events on formerly
|
||||||
|
idle rolls of the 0…20 table: a Jersey meth-lab explosion spikes local
|
||||||
|
product to $30–48k (the tier-2 face of the old $2-ludes roll), a superlab
|
||||||
|
dump floors meth at $400–999, and the cartel flooding the docks drops
|
||||||
|
China White to $8–20k. The dead-dude loot names the tier's product —
|
||||||
|
yes, you can find china white on a dead dude in the subway.
|
||||||
|
- **The streets got real (round-5 extensions).** Each borough now prices its
|
||||||
|
own way — multipliers on the daily band rolls (Bronx neutral; event prices
|
||||||
|
are absolute news and ignore the bias): heroin and speed run cheap in the
|
||||||
|
Ghetto, acid and weed in the Park, everything's sleepy-cheap on Coney,
|
||||||
|
Manhattan's rich buyers pay up to +30%, Brooklyn's fences pay a little
|
||||||
|
over — so routing (buy in the Park, sell in Manhattan) is a real strategy,
|
||||||
|
and the subway sheet captions each borough's economy. Some days **word on
|
||||||
|
the street** names a product and a borough for tomorrow — ride there to
|
||||||
|
find out if the tip was good (×3 spike or ÷3 glut) or street garbage
|
||||||
|
(about 1 in 4). And the debt finally has teeth: run a **$15,000+ tab past
|
||||||
|
day 10** and you get one warning before the shark's two leg-breakers start
|
||||||
|
jumping you — the classic chase engine with his collection money as loot,
|
||||||
|
amber sodium strobe, and a nastier ending ("THE SHARK COLLECTED") if they
|
||||||
|
put you down. Rumors and shark muscle roll on a separate RNG stream, so
|
||||||
|
classic price/event sequences per seed stay byte-identical.
|
||||||
|
- **Month lengths**: 30 / 60 / 90 days or **endless** on the ready screen —
|
||||||
|
endless has no clock and posts no score (interest caps keep marathon runs
|
||||||
|
from overflowing). Amount sheets have a MAX chip, and tapping the number
|
||||||
|
opens an in-game keypad to type any amount directly (DONE trims to the
|
||||||
|
limit).
|
||||||
|
- **Engine quirks kept**: the dead-dude loot roll can never grant cocaine
|
||||||
|
(`int(rand*5)` is 0…4 over the cheap-to-dear order); the shark fronts at
|
||||||
|
most $5,000 *per visit* but unlimited visits; the final ride ends the month
|
||||||
|
with no fresh roll. One NerdWars quirk was *dropped* with the re-theme:
|
||||||
|
trading matched items **by price** (`If F==C …`), which could fill/empty
|
||||||
|
several pockets at once — no longer authentic to the classic fiction, so
|
||||||
|
trading is by item, the way Dell's original worked (the harness pins the
|
||||||
|
new behaviour with a regression test).
|
||||||
|
|
||||||
|
The scene wraps it in a night-city board — skyline silhouettes with lit
|
||||||
|
windows, sodium-lamp glow, gradient market rows with day-over-day ▲▼ movement
|
||||||
|
arrows and reroll flashes (arrows sit out a tier-shift day, since yesterday's
|
||||||
|
prices belong to retired products) — plus quantity-chip trade sheets,
|
||||||
|
SUBWAY / SHARK / BANK / END actions, the original's Pause screens as
|
||||||
|
tap-through event cards, and a RUN / FIGHT chase overlay under red/blue
|
||||||
|
strobe light. The ready screen states the rules as a plain bulleted list and
|
||||||
|
carries the 30/60/90/∞ month picker. Sim verified by a 133-assertion
|
||||||
|
standalone harness covering the classic engine end-to-end plus every
|
||||||
|
extension: tier mechanics (bands, liquidation, one-way + double promotion,
|
||||||
|
tier events and their tier-1 no-ops, tier-aware loot naming), borough bias
|
||||||
|
math and event-absolute pricing, rumor spawn/payout/garbage/missed/final-day
|
||||||
|
gating, the shark warning → chase → loot/death paths, and month-length /
|
||||||
|
endless / interest-cap behaviour.
|
||||||
|
|
||||||
|
## The HD art pass
|
||||||
|
|
||||||
|
A collection-wide visual up-level (round 3 of feedback: "still a little too
|
||||||
|
simple… as if they are straight ports"). Shared toolkit in `SceneArt.swift`:
|
||||||
|
radial glow textures, gradient panel textures, programmatic particle emitters
|
||||||
|
(spark bursts, thrust/trail flames, ambient dust), vignettes, screen-shake and
|
||||||
|
flash actions. Per game, all render-layer only — every sim stays verbatim:
|
||||||
|
|
||||||
|
- **Lander**: full-bleed field; terrain rebuilt as gradient rock with speckle
|
||||||
|
grain and a lit crust hugging the skyline, over a distant ridge silhouette;
|
||||||
|
particle exhaust jets + engine glow instead of triangle flames; pulsing pad
|
||||||
|
light-cone and sequenced approach beacons; touchdown dust ring; crash
|
||||||
|
flash + spark burst + tumbling debris + screen shake; nebula blooms,
|
||||||
|
two-depth starfield, drifting dust, vignette.
|
||||||
|
- **Phoenix**: full-bleed field (2× position / 1.25× sprite stretch); sprites
|
||||||
|
shaded top-lit so they read as bodies, not flat clusters; engine trail and
|
||||||
|
banking tilt on the ship; particle explosions; screen shake + red flash on
|
||||||
|
hits; WAVE / BOSS banners; two scrolling star layers over static far stars.
|
||||||
|
- **ZMercury**: full-bleed canyon (1.9× / 1.35×); terrain columns re-rendered
|
||||||
|
with glowing rim pixels on every air-facing edge over dark rock; a slow
|
||||||
|
stalactite/stalagmite silhouette band behind the real walls; engine trail,
|
||||||
|
scrape sparks + shake, kill sparks synced to the original explosion frames,
|
||||||
|
and a proper going-out blast.
|
||||||
|
- **Dope Wars**: built HD from the start (see its section).
|
||||||
|
- **ZTetris**: untouched — it set the bar.
|
||||||
|
|
||||||
|
## Porting notes
|
||||||
|
|
||||||
|
Tip from the ZTetris port: the 1990s sources use extended-ASCII + CRLF, which
|
||||||
|
makes `grep` treat them as binary — strip with
|
||||||
|
`tr -d '\r' < FILE | tr -c '[:print:]\n\t' '?'` first, then the ASM tables
|
||||||
|
(pieces, speeds, scoring) can be read and ported directly.
|
||||||
|
|
||||||
|
NerdWars (the Dope Wars engine) arrived as a `.85p` Graph Link file rather
|
||||||
|
than source — but that format's ASCII mode stores TI-85 BASIC nearly as plain
|
||||||
|
text, so a ~20-line decoder mapping the few special bytes (0xD6 newline,
|
||||||
|
0x1C the STO→ arrow, 0x19 ≥, 0x1A negate, 0x10 √) recovers the entire
|
||||||
|
listing.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Portrait-only, status bar hidden, targets iOS 17+.
|
||||||
|
- The app icon is generated by `scripts`-free CoreGraphics (see the git history
|
||||||
|
/ the 1024px asset in `Assets.xcassets`).
|
||||||
|
- `#if DEBUG` launch hooks exist for screenshots/tests and are stripped from
|
||||||
|
Release builds:
|
||||||
|
- `ARCADE_DIRECT_GAME=<id>` — open straight into a game.
|
||||||
|
- `ARCADE_AUTOPLAY=1` — start the run immediately (any game).
|
||||||
|
- `ARCADE_DEMO=1` — freeze a staged mid-game pose (any game).
|
||||||
|
- `ARCADE_DEMO_VIEW=buy|chase|tier3|over|ride|keypad|sharkchase|endless` —
|
||||||
|
with Dope Wars' demo, pose the buy sheet, cop chase, tier-3 board
|
||||||
|
(Meth + China White), net-worth game-over, borough-captioned subway
|
||||||
|
sheet, amount keypad, leg-breaker chase, or an endless-mode board.
|
||||||
|
- `ARCADE_SHOP=1` — open Phoenix's shop stocked with cash.
|
||||||
|
- `ARCADE_GAMEOVER=1` — pose Phoenix's death card mid-campaign (progress score).
|
||||||
|
|
||||||
|
e.g. `SIMCTL_CHILD_ARCADE_DIRECT_GAME=lander xcrun simctl launch booted com.albanese.arcade85`
|
||||||
Reference in New Issue
Block a user