The game gets a shell: it buzzes in your hand, never turns sideways, and a swipe walks you back

A Capacitor iOS project (`npm run ios`), and the first three bullets of LATER item 2 built. The web
build is untouched and stays primary: every line here degrades to a no-op in a browser.

- HAPTICS ride the sound map, as the plan said they would: six call sites, all of them places an
  sfx already fires — your racket contact, the serve strike, `killZombie` (the one funnel every
  weapon routes through), `explode`, `hurtPlayer`, `killPlayer`, the weapon swap. The restraint
  rule lives in `haptics.js`, not at the call sites: a minimum gap per kind, so a bomb that kills
  twelve bodies is felt twice and the machine gun is a metronome rather than one long rumble.
  Pinned by a test. Untested on hardware — the simulator has no Taptic Engine.
- The plugin is reached through the INJECTED bridge, `Capacitor.nativePromise('Haptics', ...)`.
  `Capacitor.Plugins.Haptics` reads like the zero-dependency answer and is silently empty without
  @capacitor/core's registerPlugin — it would have fallen through to the web path inside the app
  and looked exactly like a phone with haptics switched off. No package JS ships.
- PORTRAIT is enforced where it can be: one orientation in Info.plist, so the app never rotates.
  That retires landscape as a surface rather than fixing it (OPEN 6). The web half is best-effort —
  `orientation` in both manifests and one guarded `screen.orientation.lock()`.
- SWIPE LEFT GOES BACK. The gesture finds the screen's one visible chevron and clicks it, so the
  shape of the wizard stays in the handlers that already own it. Not passive on purpose: a swipe
  inside a poster card also fires that card's click, and preventDefault is what stops the screen
  answering forward in the frame the chevron walked it back.
- The app has no server behind it, so `config.API` and one branch in `net.js` point the shell at the
  live host by name. Consequence: the app always talks to PROD, never staging.

`node test.js` green (105) over four seeds; builds, launches and reaches the native plugin on the
iOS 26.5 simulator; swipe-back walks the whole wizard in the browser pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-20 17:00:36 +02:00
1 parent c5eb6846e3
commit d71a26831a
35 files changed
+2124 -11

No files matched your search

+8
View File
@@ -14,3 +14,11 @@ HANDOFF.md
# promo footage is a local build product (tools/capture.py), never served # promo footage is a local build product (tools/capture.py), never served
promo/clips promo/clips
tools tools
# the Capacitor shell — the server serves the repo root directly, and none of this is ever fetched
node_modules
www
ios
package.json
package-lock.json
capacitor.config.json
+5
View File
@@ -5,3 +5,8 @@ assets/_gen/
assets/_refcheck/ assets/_refcheck/
assets/__pycache__/ assets/__pycache__/
promo/clips/ promo/clips/
# the Capacitor shell. ios/ IS committed (it is the project, and Xcode settings live in it) —
# ios/.gitignore already keeps the copied web payload, Pods and Xcode's per-user junk out of it.
node_modules/
www/
+5 -1
View File
@@ -9,4 +9,8 @@
4. **Every push deploys** (Coolify → splatter-tennis.wuwi.one). Bump `main.js?v=N` in index.html on 4. **Every push deploys** (Coolify → splatter-tennis.wuwi.one). Bump `main.js?v=N` in index.html on
every push or phones keep the old build. New top-level files must be added to the Dockerfile COPY. every push or phones keep the old build. New top-level files must be added to the Dockerfile COPY.
5. **Zero dependencies, no build step, no framework.** This is a settled decision — solve it with 5. **Zero dependencies, no build step, no framework.** This is a settled decision — solve it with
what is already in the repo. what is already in the repo. The Capacitor shell (`package.json`, `ios/`) is the ONE exception
and it is outside `src/`: **no package JS ships.** Game code calls native plugins through the
injected bridge — `Capacitor.nativePromise(name, method, opts)`, see `src/haptics.js` — never an
`import` of a package (a bare specifier 404s on the web) and never `Capacitor.Plugins.*` (empty
without @capacitor/core, so it fails SILENTLY). `npm run ios` builds the app.
+75 -1
View File
@@ -20,7 +20,9 @@ anything, so if you add a `return` for an awkward deal, check the body still run
the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way. it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
Last updated 2026-08-20 (the friend across the net gets his OWN sheets — the player's kit, drawn Last updated 2026-08-20 (THE SHELL: a Capacitor iOS project, haptics on the existing sfx call
sites, portrait enforced in Info.plist, and swipe-left-to-go-back in the menus — the web build
unchanged and still primary. See "THE SHELL". Before that, same day: the friend across the net gets his OWN sheets — the player's kit, drawn
from the front, so his pick dyes the same garment yours does; the setup screen shows that front too. from the front, so his pick dyes the same garment yours does; the setup screen shows that front too.
See "THE FRIEND ACROSS THE NET WEARS YOUR KIT". Before that 2026-08-18: the store model in LATER revised: free download + €2.99 unlock-everything See "THE FRIEND ACROSS THE NET WEARS YOUR KIT". Before that 2026-08-18: the store model in LATER revised: free download + €2.99 unlock-everything
IAP with the free-tier gates, superseding paid-upfront; before that 2026-08-15, two sessions: the IAP with the free-tier gates, superseding paid-upfront; before that 2026-08-15, two sessions: the
@@ -2536,6 +2538,74 @@ The gates, and the numbers they were calibrated on:
**SUPERSEDED 2026-08-12 — see the next section.** Those 31 frames were not art: `--verify` found **SUPERSEDED 2026-08-12 — see the next section.** Those 31 frames were not art: `--verify` found
27 of them dead. The brute is now live (`ZFAMS` has `'br'`, the KEEPER wears it) off ONE sheet. 27 of them dead. The brute is now live (`ZFAMS` has `'br'`, the KEEPER wears it) off ONE sheet.
## THE SHELL — CAPACITOR, HAPTICS, PORTRAIT, SWIPE-BACK (2026-08-20)
The first three bullets of LATER item 2 ("What the shell buys GAMEPLAY-wise") are built. **The web
build is unchanged and stays the primary surface** — every line below degrades to a no-op in a
browser, and `splatter-tennis.wuwi.one` keeps deploying off the same repo on every push.
**The zero-dependency rule bends exactly once and exactly here.** `package.json`, `node_modules/` and
`ios/` exist for the SHELL. **No JS from any package ships.** `src/haptics.js` calls the bridge the
shell injects — `Capacitor.nativePromise('Haptics', 'impact', { style })` — never
`import ... from '@capacitor/haptics'`. There is no bundler in this project, so a bare specifier
inside a `<script type="module">` is a hard 404 the moment the same file is served from the web.
`@capacitor/haptics` stays an npm dependency for one reason only: `cap sync` installs its Swift pod.
**Anything added to the shell later has to reach the game the same way.**
> **THE TRAP, and it cost a rebuild to find.** `Capacitor.Plugins.Haptics` reads like the obvious
> zero-dependency answer and it is **silently wrong**. The injected `native-bridge.js` defines
> `Capacitor`, `isNativePlatform` and `nativePromise`, but leaves `Plugins` EMPTY — it is
> `@capacitor/core`'s `registerPlugin`, i.e. exactly the JS we are not shipping, that fills that
> object in. Going through `Plugins` therefore falls straight through to the web branch *inside the
> app*, where `navigator.vibrate` does not exist on iOS — which looks identical to a phone with
> haptics switched off, and would have been diagnosed as "the palette feels flat". Proven the only
> way worth trusting: `simctl launch --console-pty` shows `⚡️ To Native -> Haptics impact` for the
> `nativePromise` form. **That is the check to repeat for the next plugin**; `simctl spawn log
> stream` will NOT show it, because Capacitor `print()`s those lines to stdout.
- **`npm run www`** rsyncs `index.html`, the manifests, `src/` and `assets/` into `www/` with the
same `assets/_*` exclusion `.dockerignore` uses — 32 MB, not the 463 MB the sprite pipeline sits
on. `npm run sync` = tests + that copy + `npx cap sync ios`; `npm run ios` opens Xcode after it.
`www/` and `node_modules/` are gitignored; `ios/` IS committed (Capacitor's own `ios/.gitignore`
keeps the copied payload, Pods and Xcode's per-user junk out of it).
- **Portrait is enforced where it can be**: `Info.plist` lists ONE supported orientation, so the app
never rotates. That formally retires landscape as a surface (OPEN 6) rather than fixing it. The web
half is best-effort — `orientation: portrait` in both manifests and one guarded
`screen.orientation.lock()` in `main.js` — because iOS Safari has no lock at all and the browser
build keeps doing whatever landscape does today.
- **HAPTICS ride the sound map**, as planned: six call sites, all of them places an sfx already fires.
Your racket contact (light, medium on a perfect hit), the serve strike, `killZombie` — the ONE
funnel every weapon routes through — `explode`, `hurtPlayer`, `killPlayer`, and the weapon swap.
**The restraint rule is enforced in `haptics.js`, not at the call sites**: every kind carries a
minimum gap, so a bomb that kills twelve bodies is felt twice (one blast, one kill) and the machine
gun is a metronome rather than one long rumble. `test.js` pins exactly that. No mute switch on
purpose — iOS already routes these through the system's own vibration setting.
**Untested on hardware.** The simulator has no Taptic Engine, so every weight and gap below is a
guess on paper and needs its own phone round before any of it is assumed good.
- **SWIPE LEFT GOES BACK** in the menus. Every screen but the splash wears exactly one chevron, and
that button already knows where back is for its own screen — so the gesture finds the visible
`.chev` and clicks it rather than keeping a second map of the wizard that would drift the first
time a stage is added. Threshold 60 px, horizontal-dominant so the stats and board screens still
scroll. The `touchend` listener is deliberately **not passive**: a swipe that starts and ends
inside one of the big poster cards also produces that card's click, and `preventDefault` is what
stops the screen answering forward in the same frame the chevron walked it back.
**iOS convention is the opposite** (the system pop gesture drags rightward from the left edge).
Built to the brief as asked; flipping it is the sign on one comparison in `main.js`.
- **The app has no server behind it.** Inside the shell the page is served from the bundle
(`capacitor://localhost`), so `fetch('api/scores')` and the netplay socket would have resolved
against the app's own files. `config.NATIVE`/`config.API` and one branch in `net.js` point the
shell at the live host by name; the web build keeps the page-relative form, which is the only
reason localhost, staging and prod all work off one file with no switch. Consequence to remember:
**the app always talks to PROD**, never to staging.
Verified: `node test.js` green (105) across four seeds; the app builds and launches on the iOS 26.5
simulator; `isNativePlatform()` reads true inside it (so the board really does point at prod) and
both bridge calls reach the native plugin; swipe-back walks the whole wizard (mode -> modesub ->
back -> back -> splash) in the browser pane, and vertical drags, short slides and rightward swipes
correctly do nothing.
**Not verified: anything that needs a real device** — the Taptic Engine, 60 fps under WKWebView at
`RES` 4 (OPEN 1, and LATER named this as the second place that risk lands), and WKWebView audio.
## THE FRIEND ACROSS THE NET WEARS YOUR KIT, FROM THE FRONT (2026-08-20) ## THE FRIEND ACROSS THE NET WEARS YOUR KIT, FROM THE FRONT (2026-08-20)
Oliver, on a netplay match: *"the opponent color is displayed correctly, but the shirt is colored not Oliver, on a netplay match: *"the opponent color is displayed correctly, but the shirt is colored not
@@ -3551,6 +3621,10 @@ Nothing here is broken; these are known gaps and unverified risks.
6. **Landscape is under-maintained.** The readability pass (camZ/camY/camF/horizon), the adaptive 6. **Landscape is under-maintained.** The readability pass (camZ/camY/camF/horizon), the adaptive
height and the safe-area work are all portrait-only; landscape still runs the old camera and a fixed height and the safe-area work are all portrait-only; landscape still runs the old camera and a fixed
480x270. Portrait is the stated primary target, but landscape has drifted and is untested. 480x270. Portrait is the stated primary target, but landscape has drifted and is untested.
**RETIRED IN THE APP, 2026-08-20** — `Info.plist` lists one orientation, so the shell never rotates
and landscape is officially not-a-surface there. The WEB build can still be turned sideways (iOS
Safari has no orientation lock), so this stays open, at a lower stake: it is now a browser-only
cosmetic gap rather than something a store reviewer will see.
7. **`drawCard` (the chapter/set cards) still cuts in** with no entrance, while modals swoosh 7. **`drawCard` (the chapter/set cards) still cuts in** with no entrance, while modals swoosh
(`TUNE.modalIn`). Offered and not yet requested. (`TUNE.modalIn`). Offered and not yet requested.
8. **A chapter can be skipped.** `G.bossDue` takes the max, so going a long stretch without ever 8. **A chapter can be skipped.** `G.bossDue` takes the max, so going a long stretch without ever
+10
View File
@@ -0,0 +1,10 @@
{
"appId": "one.wuwi.splattertennis",
"appName": "Splatter Tennis",
"webDir": "www",
"backgroundColor": "#10141c",
"ios": {
"contentInset": "never",
"scrollEnabled": false
}
}
+4 -2
View File
@@ -20,7 +20,9 @@
never has to be remembered. Prod is the one named host; everything else (staging, localhost, an never has to be remembered. Prod is the one named host; everything else (staging, localhost, an
IP) is a test build and says so. Runs in <head>, before the install prompt can read any of it. --> IP) is a test build and says so. Runs in <head>, before the install prompt can read any of it. -->
<script> <script>
if (location.hostname !== 'splatter-tennis.wuwi.one') { // (`Capacitor`: the SHELL is a prod build whatever its origin says — inside the app the hostname is
// `localhost`, which would otherwise put the staging face on the shipped game.)
if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNativePlatform?.()) {
for (const [sel, href] of [['link[rel=icon]', 'assets/favicon-stg.png'], for (const [sel, href] of [['link[rel=icon]', 'assets/favicon-stg.png'],
['link[rel=apple-touch-icon]', 'assets/icon-180-stg.png'], ['link[rel=apple-touch-icon]', 'assets/icon-180-stg.png'],
['link[rel=manifest]', 'manifest-stg.webmanifest']]) ['link[rel=manifest]', 'manifest-stg.webmanifest']])
@@ -659,6 +661,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=295"></script> <script type="module" src="src/main.js?v=296"></script>
</body> </body>
</html> </html>
+13
View File
@@ -0,0 +1,13 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata
# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
+408
View File
@@ -0,0 +1,408 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 48;
objects = {
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
isa = PBXGroup;
children = (
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
);
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
);
name = Products;
sourceTree = "<group>";
};
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
path = App;
sourceTree = "<group>";
};
7F8756D8B27F46E3366F6CEA /* Pods */ = {
isa = PBXGroup;
children = (
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
);
name = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = (
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 0920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC30C1FED79650016851F /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC3111FED79650016851F /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = 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_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;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
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 = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
504EC3151FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = 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_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;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
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 = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = one.wuwi.splattertennis;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = one.wuwi.splattertennis;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3141FED79650016851F /* Debug */,
504EC3151FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,
504EC3181FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:App.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
+49
View File
@@ -0,0 +1,49 @@
import UIKit
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Called when the app was launched with an activity, including Universal Links.
// Feel free to add additional processing here, but if you want the App API to support
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-512@2x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "splash-2732x2732-2.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</imageView>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="Splash" width="1366" height="1366"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
</dependencies>
<scenes>
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Splatter Tennis</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
</plist>
+24
View File
@@ -0,0 +1,24 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '14.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
# Product -> Clean Build Folder after new Cordova plugins installed
# Requires CocoaPods 1.6 or newer
install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
end
target 'App' do
capacitor_pods
# Add your Pods here
end
post_install do |installer|
assertDeploymentTarget(installer)
end
+28
View File
@@ -0,0 +1,28 @@
PODS:
- Capacitor (7.6.8):
- CapacitorCordova
- CapacitorCordova (7.6.8)
- CapacitorHaptics (7.0.5):
- Capacitor
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorHaptics (from `../../node_modules/@capacitor/haptics`)"
EXTERNAL SOURCES:
Capacitor:
:path: "../../node_modules/@capacitor/ios"
CapacitorCordova:
:path: "../../node_modules/@capacitor/ios"
CapacitorHaptics:
:path: "../../node_modules/@capacitor/haptics"
SPEC CHECKSUMS:
Capacitor: ff6bf01336ac353098378828aff465095a89e459
CapacitorCordova: e61ee8c40101b8cd011d0224261606a290c082cf
CapacitorHaptics: 1b145fa83c622e5383a3bac04ea81e2514f0b0f5
PODFILE CHECKSUM: 77e136fb208c6c2a6494b7b510a7f97b304ff314
COCOAPODS: 1.16.2
+1
View File
@@ -5,6 +5,7 @@
"start_url": "./", "start_url": "./",
"scope": "./", "scope": "./",
"display": "standalone", "display": "standalone",
"orientation": "portrait",
"background_color": "#10141c", "background_color": "#10141c",
"theme_color": "#14334a", "theme_color": "#14334a",
"icons": [ "icons": [
+1
View File
@@ -5,6 +5,7 @@
"start_url": "./", "start_url": "./",
"scope": "./", "scope": "./",
"display": "standalone", "display": "standalone",
"orientation": "portrait",
"background_color": "#10141c", "background_color": "#10141c",
"theme_color": "#2b2735", "theme_color": "#2b2735",
"icons": [ "icons": [
+1175
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "splatter-tennis",
"version": "1.0.0",
"description": "Fifteen. Thirty. Forty. Dead.",
"private": true,
"type": "module",
"scripts": {
"test": "node test.js",
"www": "rm -rf www && mkdir -p www && rsync -a --exclude '_*' --exclude '.DS_Store' index.html manifest.webmanifest manifest-stg.webmanifest src assets www/",
"sync": "npm run test && npm run www && npx cap sync ios",
"ios": "npm run sync && npx cap open ios"
},
"devDependencies": {
"@capacitor/cli": "^7.0.0"
},
"dependencies": {
"@capacitor/core": "^7.0.0",
"@capacitor/haptics": "^7.0.0",
"@capacitor/ios": "^7.0.0"
}
}
+11
View File
@@ -1,6 +1,17 @@
// All tuning and constants. TUNE is live-editable in game via the T panel; // All tuning and constants. TUNE is live-editable in game via the T panel;
// the exported consts below are structural values that code shares but the panel doesn't show. // the exported consts below are structural values that code shares but the panel doesn't show.
// THE APP HAS NO SERVER BEHIND IT. Inside the Capacitor shell the page is served out of the app
// bundle (capacitor://localhost), so every page-relative call — the world board, the netplay socket
// — would resolve against the app's own files and 404. The shell therefore talks to the live host
// BY NAME; the web build keeps the page-relative form, which is the only reason staging, localhost
// and prod all work off one file with no switch. Consequence worth remembering: THE APP ALWAYS TALKS
// TO PROD, never to staging. Deliberately no `location` here — this module is evaluated by the
// headless test suite, which has no DOM; net.js reads the protocol inside its connect function.
export const NATIVE = !!globalThis.Capacitor?.isNativePlatform?.();
export const HOST = 'splatter-tennis.wuwi.one';
export const API = NATIVE ? 'https://' + HOST + '/api/' : 'api/';
export const TUNE = { export const TUNE = {
camZ: 42, camY: 9, camF: 406, horizon: 40, // camF/horizon are per-orientation, set in fitViewport camZ: 42, camY: 9, camF: 406, horizon: 40, // camF/horizon are per-orientation, set in fitViewport
gravity: 9.2, // slightly floaty vs 9.8 for readability gravity: 9.2, // slightly floaty vs 9.8 for readability
+7
View File
@@ -3,6 +3,7 @@
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, CUP_ROUNDS } from './config.js'; import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, CUP_ROUNDS } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js'; import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js'; import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { haptic } from './haptics.js';
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js'; import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateDrill } from './drill.js'; import { updateDrill } from './drill.js';
@@ -273,6 +274,9 @@ function doHit(ch, quality, aim) {
// the court stays clean) // the court stays clean)
if (ch.side === 'P') G.aiReactT = 0; // CPU hasn't read the shot yet if (ch.side === 'P') G.aiReactT = 0; // CPU hasn't read the shot yet
sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side
// CONTACT IS THE TICK. Only YOUR racket: the far side's strike is something you watch, not
// something your hand did, and buzzing for both halves of every rally is the numbing failure mode.
if (ch.side === 'P') haptic(quality > 0.999 ? 'medium' : 'light'); // a perfect hit is felt as one
if (quality > 0.999 && ch.side === 'P') { // the rally hit: a real 45 ms window (perfectMs) if (quality > 0.999 && ch.side === 'P') { // the rally hit: a real 45 ms window (perfectMs)
G.hitstop = 0.05; pock(0.5, 1600); G.hitstop = 0.05; pock(0.5, 1600);
const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact
@@ -512,6 +516,7 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
} }
// (spendSlot already hands you back plain balls when the last one goes) // (spendSlot already hands you back plain balls when the last one goes)
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side
if (sv.side === 'P') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit
sv.pose = 'serve'; sv.poseT = 0; sv.pose = 'serve'; sv.poseT = 0;
if (perfect && sv.side === 'P') { if (perfect && sv.side === 'P') {
G.hitstop = 0.05; G.hitstop = 0.05;
@@ -807,6 +812,7 @@ export function selectWeapon(i) {
} }
G.weapon = i; // temp has just been cleared, and then it is BALL again) G.weapon = i; // temp has just been cleared, and then it is BALL again)
sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud
haptic('select'); // ...and it is felt, which is what a physical rack would do
} }
// Grant a weapon and equip it. Tops up what you already carry, else takes a free slot, else rides // Grant a weapon and equip it. Tops up what you already carry, else takes a free slot, else rides
@@ -984,6 +990,7 @@ function transition(dt) {
// rather than as a glitch — the eye is given something to blame the change on. // rather than as a glitch — the eye is given something to blame the change on.
once('turn', 3.4, () => { once('turn', 3.4, () => {
G.hitstop = 0.16; // the world holds its breath G.hitstop = 0.16; // the world holds its breath
haptic('heavy'); // ...and the hand feels the world stop
G.boomFlash = 0.16; G.shake = { t: 0.7, mag: 7 }; G.boomFlash = 0.16; G.shake = { t: 0.7, mag: 7 };
shriek(); splat(0.9); boom(); shriek(); splat(0.9); boom();
for (let k = 0; k < 40; k++) parts.push({ // he bursts, and what stands up is not him for (let k = 0; k < 40; k++) parts.push({ // he bursts, and what stands up is not him
+54
View File
@@ -0,0 +1,54 @@
// The Taptic Engine, behind one call. iOS Safari exposes no vibration API of any kind, so on the WEB
// build this is navigator.vibrate on Android and a silent no-op on iPhone — the feature is really
// only alive inside the Capacitor shell, which is the point (PROJECT.md, "What the shell buys").
//
// Reached through the bridge the shell INJECTS — `Capacitor.nativePromise('Haptics', ...)` — and not
// through `import { Haptics } from '@capacitor/haptics'`. Two reasons, and the second is the one
// that bites:
// 1. This project has no bundler and no build step, so a bare specifier inside a
// <script type="module"> is a hard 404 the moment the same file is served on the web.
// 2. `Capacitor.Plugins.Haptics` LOOKS like the zero-dependency answer and is not: the injected
// native-bridge.js declares `Capacitor` and `nativePromise` but leaves `Plugins` empty — it is
// @capacitor/core's registerPlugin, i.e. JS we deliberately do not ship, that fills it in. Going
// through Plugins therefore silently falls through to the web path INSIDE the app, which looks
// exactly like a phone whose haptics are switched off. `nativePromise` needs no JS package;
// @capacitor/haptics stays an npm dependency only so `cap sync` installs its Swift pod.
//
// THE MAP IS THE SOUND MAP. The sfx taxonomy already decided which moments in this game deserve
// punctuation, so the haptics ride the same handful of call sites instead of inventing a second
// opinion about what matters. Restraint rule: contact is a tick, kills punctuate, heavy is rare — a
// game that buzzes continuously numbs the hand inside a minute and then means nothing at all.
//
// That rule is enforced HERE rather than at the call sites, by a minimum gap per kind. The machine
// gun may reach `haptic('medium')` twenty times a second and a bomb may kill twelve bodies in one
// frame; the hand still feels a metronome instead of one long rumble, and no caller has to know it.
//
// No mute switch on purpose: iOS already routes every one of these through the system's own
// vibration setting, so a player who has turned haptics off has turned THESE off.
const KINDS = {
select: { gap: 0.05, impact: 'LIGHT', ms: 8 }, // a menu answer, a weapon swapped
light: { gap: 0.06, impact: 'LIGHT', ms: 12 }, // racket contact — the metronome of a rally
medium: { gap: 0.08, impact: 'MEDIUM', ms: 20 }, // a body comes apart
heavy: { gap: 0.13, impact: 'HEAVY', ms: 35 }, // a blast, a beat, a hit you took
dead: { gap: 1.0, notify: 'ERROR', ms: [0, 40, 90, 40, 90, 110] }, // eaten — once a run, if that
};
const at = Object.create(null); // last fire per kind, ms
export function haptic(kind) {
const k = KINDS[kind];
if (!k) return;
const now = performance.now();
if (now - (at[kind] ?? -1e9) < k.gap * 1000) return;
at[kind] = now;
const cap = globalThis.Capacitor;
try { // a buzz is never worth a thrown frame
if (cap?.isNativePlatform?.())
cap.nativePromise('Haptics', k.notify ? 'notification' : 'impact',
k.notify ? { type: k.notify } : { style: k.impact })?.catch?.(() => {});
else globalThis.navigator?.vibrate?.(k.ms);
} catch {}
}
// test hook: what the throttle would let through, without a platform under it
export const hapticTest = { KINDS, at };
+39 -1
View File
@@ -12,6 +12,7 @@ import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './ren
import { voice, ambient, menuMusic, menuDuck } from './audio.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
import { cv } from './dom.js'; import { cv } from './dom.js';
import { haptic } from './haptics.js';
import * as stats from './stats.js'; import * as stats from './stats.js';
import './input.js'; import './input.js';
@@ -45,6 +46,14 @@ function frame(now) {
update(dt); render(); update(dt); render();
} }
// PORTRAIT ONLY, from 2026-08-20. The app enforces it where it can actually be enforced — the
// shell's Info.plist lists one supported orientation, so an iPhone running the store build simply
// never rotates. This line is the WEB half of the same decision and is best-effort by nature:
// Chrome/Android honours a lock once the page is installed or fullscreen, and iOS Safari has no
// Screen Orientation lock at all, so the browser build keeps doing whatever landscape does today
// (PROJECT.md OPEN 6 — landscape is untested, not a surface). Nothing downstream may assume it took.
try { screen.orientation?.lock?.('portrait')?.catch(() => {}); } catch {}
fitViewport(); fitViewport();
// iOS PWA cold start. Launched from the homescreen, WebKit resolves the viewport AND every env() // iOS PWA cold start. Launched from the homescreen, WebKit resolves the viewport AND every env()
// safe-area inset once, against a provisional geometry, and never revisits them until the geometry // safe-area inset once, against a provisional geometry, and never revisits them until the geometry
@@ -528,6 +537,35 @@ document.getElementById('sBack').addEventListener('click', () => {
}); });
document.getElementById('bsBack').addEventListener('click', () => go('secret')); document.getElementById('bsBack').addEventListener('click', () => go('secret'));
// SWIPE TO GO BACK. Every screen but the splash wears exactly one chevron in its header (.hdr in
// index.html) and that button ALREADY knows where back is for its own screen — the wizard's shape
// lives in the handlers above and nowhere else. So the gesture finds the one visible chevron and
// clicks it, rather than keeping a second map that would drift the first time a stage is added.
// A screen with no chevron (splash, newbest) has nowhere to go and the swipe is simply ignored.
// Touch only: a trackpad two-finger scroll is not a swipe and must not navigate.
const SWIPE_MIN = 60; // px of travel before it counts — a tap that slides is not a swipe
let swipe = null;
intro.addEventListener('touchstart', e => {
swipe = e.touches.length === 1 ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null;
}, { passive: true });
intro.addEventListener('touchend', e => {
const from = swipe; swipe = null;
if (!from || e.touches.length) return;
const t = e.changedTouches[0];
const dx = t.clientX - from.x, dy = t.clientY - from.y;
// leftward, and clearly horizontal: the stats and board screens scroll vertically under the finger
if (dx > -SWIPE_MIN || Math.abs(dy) > Math.abs(dx) * 0.7) return;
const back = [...intro.querySelectorAll('.chev')].find(b => b.offsetParent); // the one on screen
if (!back) return;
// NOT passive, and this is why: a swipe that starts and ends inside one of the big poster cards
// still produces that card's click, which would answer the screen forward in the same frame the
// chevron walked it back. preventDefault on touchend cancels the compatibility mouse events, so
// the gesture means one thing only.
e.preventDefault();
haptic('select');
back.click();
});
// ---- THE BACK ROOM: five taps in the TOP-LEFT CORNER of the splash (#hot, an invisible box in // ---- THE BACK ROOM: five taps in the TOP-LEFT CORNER of the splash (#hot, an invisible box in
// index.html), and nothing on the screen says so. It replaces the triple-tap on the in-play // index.html), and nothing on the screen says so. It replaces the triple-tap on the in-play
// scoreboard, which sat on top of the miss-log tap and which the testteam kept opening by accident. // scoreboard, which sat on top of the miss-log tap and which the testteam kept opening by accident.
@@ -639,7 +677,7 @@ const tick = setInterval(function watchLoad() {
// real buttons now. This is the gesture the autoplay policy is waiting for, which is why the theme // real buttons now. This is the gesture the autoplay policy is waiting for, which is why the theme
// is not started at boot. menuMusic itself is a no-op once the bed exists (loopBed's beds.has // is not started at boot. menuMusic itself is a no-op once the bed exists (loopBed's beds.has
// guard), so coming back from a run picks up the track already playing rather than restarting it. // guard), so coming back from a run picks up the track already playing rather than restarting it.
intro.addEventListener('pointerdown', () => menuMusic()); intro.addEventListener('pointerdown', e => { menuMusic(); if (e.target.closest('button')) haptic('select'); });
// which build am I looking at? Push = deploy and the browser cannot see git, so the served file's // which build am I looking at? Push = deploy and the browser cannot see git, so the served file's
// own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump. // own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump.
// Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date. // Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date.
+3 -2
View File
@@ -17,7 +17,7 @@
// //
// ponytail: no reconnect-and-resume. A dropped radio ends the match with a card; the recorded // ponytail: no reconnect-and-resume. A dropped radio ends the match with a card; the recorded
// plan calls reconnection "the bulk of the real work" and a prototype must not start there. // plan calls reconnection "the bulk of the real work" and a prototype must not start there.
import { COURT, UI, KITS, SKINS, TOURS } from './config.js'; import { COURT, UI, KITS, SKINS, TOURS, NATIVE, HOST } from './config.js';
import { G, P, O, ball, score, setupPoint, selectCourt, court, kit, skin, tour } from './state.js'; import { G, P, O, ball, score, setupPoint, selectCourt, court, kit, skin, tour } from './state.js';
import { pointTo, fault, tossBall } from './gameplay.js'; import { pointTo, fault, tossBall } from './gameplay.js';
import { startMatch, matchReady, resolveInterlude, netMatchWon } from './beats.js'; // runtime-only cycle: beats sends verdicts back through this module import { startMatch, matchReady, resolveInterlude, netMatchWon } from './beats.js'; // runtime-only cycle: beats sends verdicts back through this module
@@ -239,7 +239,8 @@ export function netConnect(code, death = false, courtId = null) {
// joining carries only the code — the host's room already knows the mode and the court // joining carries only the code — the host's room already knows the mode and the court
const q = code ? ['room=' + encodeURIComponent(code)] const q = code ? ['room=' + encodeURIComponent(code)]
: [death ? 'death=1' : '', courtId ? 'court=' + encodeURIComponent(courtId) : ''].filter(Boolean); : [death ? 'death=1' : '', courtId ? 'court=' + encodeURIComponent(courtId) : ''].filter(Boolean);
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host const url = (NATIVE ? 'wss://' + HOST // the app: the live host, by name
: (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host)
+ '/ws' + (q.length ? '?' + q.join('&') : ''); + '/ws' + (q.length ? '?' + q.join('&') : '');
ws = new WebSocket(url); ws = new WebSocket(url);
ws.onmessage = e => { let m; try { m = JSON.parse(e.data); } catch { return; } netMsg(m); }; ws.onmessage = e => { let m; try { m = JSON.parse(e.data); } catch { return; } netMsg(m); };
+3 -3
View File
@@ -10,7 +10,7 @@
// knows nothing about stats — fine today, and exactly the kind of coupling that breaks quietly the // knows nothing about stats — fine today, and exactly the kind of coupling that breaks quietly the
// first time the reset order changes. endRun() is the only thing that empties this bag. // first time the reset order changes. endRun() is the only thing that empties this bag.
import { WEAPONS, ENEMIES } from './config.js'; import { WEAPONS, ENEMIES, API } from './config.js';
const KEY = 'splatterRuns'; const KEY = 'splatterRuns';
// ponytail: the array is capped, so "ALL TIME" means "your last CAP runs". At ~170 bytes a record // ponytail: the array is capped, so "ALL TIME" means "your last CAP runs". At ~170 bytes a record
@@ -197,7 +197,7 @@ let last = null;
export function lastRun() { const r = last; last = null; return r; } export function lastRun() { const r = last; last = null; return r; }
export async function board(mode = MODE_GORE) { export async function board(mode = MODE_GORE) {
const r = await fetch('api/scores?m=' + mode, { cache: 'no-store' }); const r = await fetch(API + 'scores?m=' + mode, { cache: 'no-store' });
if (!r.ok) throw new Error('board ' + r.status); if (!r.ok) throw new Error('board ' + r.status);
return (await r.json()).scores || []; return (await r.json()).scores || [];
} }
@@ -221,7 +221,7 @@ export const saveName = s => {
export async function submit(rawName, k, g, mode = MODE_GORE) { export async function submit(rawName, k, g, mode = MODE_GORE) {
const name = saveName(rawName); const name = saveName(rawName);
if (!name) throw new Error('empty name'); if (!name) throw new Error('empty name');
const r = await fetch('api/scores', { method: 'POST', const r = await fetch(API + 'scores', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ n: name, k, g, m: mode, id: myId() }) }); body: JSON.stringify({ n: name, k, g, m: mode, id: myId() }) });
if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; } if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; }
+5
View File
@@ -6,6 +6,7 @@
import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js'; import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js'; import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js';
import { haptic } from './haptics.js';
import { drillKill, drillCaught } from './drill.js'; import { drillKill, drillCaught } from './drill.js';
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one
@@ -266,6 +267,7 @@ export function hurtPlayer(n) {
if (G.mode !== 'splatter' || G.hurtT > 0 || G.dodgeI > 0 || G.mode === 'eaten') return; if (G.mode !== 'splatter' || G.hurtT > 0 || G.dodgeI > 0 || G.mode === 'eaten') return;
G.hp -= n; G.hp -= n;
G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.3; G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.3;
haptic('heavy'); // taking one is rare (hurtGrace gates it) and is the loudest thing the hand gets
G.shake = { t: 0.22, mag: 4 }; G.shake = { t: 0.22, mag: 4 };
splat(0.7); aww(0.5); splat(0.7); aww(0.5);
for (let k = 0; k < 14; k++) parts.push({ // he takes it in the chest for (let k = 0; k < 14; k++) parts.push({ // he takes it in the chest
@@ -292,6 +294,7 @@ export function killPlayer() {
splat(1); splat(1);
voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans
// the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after // the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after
haptic('dead'); // the notification pattern, once a run — the only place it is ever used
G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z }; G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z };
G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience
G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; G.gunId = null; // everything stops G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; G.gunId = null; // everything stops
@@ -485,6 +488,7 @@ function bossDeath(zb) {
function killZombie(i, quiet) { function killZombie(i, quiet) {
const zb = zombies[i]; const zb = zombies[i];
haptic('medium'); // every weapon routes through here, so the kill is felt exactly once per kill
// The drill's ONE rule, read at the single point every weapon routes through — hooking the ball // The drill's ONE rule, read at the single point every weapon routes through — hooking the ball
// path instead would have the swish, the pellets and the blast silently exempt from the order. // path instead would have the swish, the pellets and the blast silently exempt from the order.
const mul = GORE_MUL[zb.variant] || 1; const mul = GORE_MUL[zb.variant] || 1;
@@ -856,6 +860,7 @@ function tickHorde(dt) {
// the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom. // the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom.
export function explode(x, z, why = 'bomb') { export function explode(x, z, why = 'bomb') {
CAUSE = why; // the rocket detonates through here too, and it is its own row CAUSE = why; // the rocket detonates through here too, and it is its own row
haptic('heavy'); // the blast, before the kills it causes — heavy and medium throttle separately
for (let i = zombies.length - 1; i >= 0; i--) { // backwards: splatter() splices (and can sweep) the array for (let i = zombies.length - 1; i >= 0; i--) { // backwards: splatter() splices (and can sweep) the array
const zb = zombies[i]; const zb = zombies[i];
if (!zb || Math.hypot(zb.x - x, zb.z - z) >= TUNE.bombRadius) continue; if (!zb || Math.hypot(zb.x - x, zb.z - z) >= TUNE.bombRadius) continue;
+19 -1
View File
@@ -71,6 +71,11 @@ globalThis.window = globalThis; // audio.js reads window.AudioContext; render re
globalThis.AudioContext = function () { return absorb; }; globalThis.AudioContext = function () { return absorb; };
globalThis.webkitAudioContext = globalThis.AudioContext; globalThis.webkitAudioContext = globalThis.AudioContext;
globalThis.matchMedia = () => ({ matches: false, addEventListener() {} }); globalThis.matchMedia = () => ({ matches: false, addEventListener() {} });
// haptics.js falls back to navigator.vibrate when there is no Capacitor bridge. Node ships a REAL
// navigator and it is getter-only, so the stub has to be defined over it rather than assigned.
globalThis.hapticLog = [];
Object.defineProperty(globalThis, 'navigator', {
configurable: true, value: { vibrate: ms => globalThis.hapticLog.push(ms) } });
globalThis.addEventListener = globalThis.addEventListener || (() => {}); globalThis.addEventListener = globalThis.addEventListener || (() => {});
globalThis.removeEventListener = globalThis.removeEventListener || (() => {}); globalThis.removeEventListener = globalThis.removeEventListener || (() => {});
globalThis.Image = class { constructor() { this.complete = false; } addEventListener() {} set src(v) {} }; globalThis.Image = class { constructor() { this.complete = false; } addEventListener() {} set src(v) {} };
@@ -90,13 +95,14 @@ const { mirror, netMsg, netTest, netTick } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
// player would, so the tests step a live court // player would, so the tests step a live court
const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; }; const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; };
const { zombies, spawnZombie, pellets, parts } = await import('./src/zombies.js'); const { zombies, spawnZombie, pellets, parts, explode } = await import('./src/zombies.js');
const { killPlayer } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js');
const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js'); const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js');
const cfg = await import('./src/config.js'); const cfg = await import('./src/config.js');
const { TUNE, bossArt, COURT, racketStage, matchWound } = cfg; const { TUNE, bossArt, COURT, racketStage, matchWound } = cfg;
const statsMod = await import('./src/stats.js'); const statsMod = await import('./src/stats.js');
const audio = await import('./src/audio.js'); const audio = await import('./src/audio.js');
const { hapticTest } = await import('./src/haptics.js');
const { read: readStats, check: statsCheck } = statsMod; const { read: readStats, check: statsCheck } = statsMod;
const DT = 1 / 60; const DT = 1 / 60;
@@ -1881,5 +1887,17 @@ test('every kit and skin in the tables is a live, distinct pick', () => {
assert.equal(tintPx(...SKINPX, cfg.KITS[0], cfg.SKINS[0]), null, 'defaults change nothing'); assert.equal(tintPx(...SKINPX, cfg.KITS[0], cfg.SKINS[0]), null, 'defaults change nothing');
}); });
test('haptics: a blast that kills a dozen bodies is felt twice, not thirteen times', () => {
// The restraint rule is the whole feature: a hand that buzzes on every body in a bomb radius stops
// reading any of them. The gap lives in haptics.js so no call site has to know about it, and this
// is what proves it — the funnel (killZombie) is deliberately called once per kill.
const buzz = globalThis.hapticLog; buzz.length = 0; // the web fallback; no Capacitor bridge here
for (const k in hapticTest.at) delete hapticTest.at[k]; // a fresh hand
for (let i = 0; i < 12; i++) spawnZombie(0.3 * i - 1.8, 12, 'a');
explode(0, 12);
assert.equal(zombies.length, 0, 'the blast cleared its radius (or this proves nothing)');
assert.equal(buzz.length, 2, 'one heavy for the blast, one medium for the kills — not one per body');
});
console.log(`\n${passed} tests passed`); console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report