diff --git a/README.md b/README.md index 9fbb116..626cf9c 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ still runs offline with a personal local board. crystals), volatile (chain explosion) and splitter (splits on approach). - Crystal shop: trails, ship skins and titles. Cosmetic-only; no ranked advantage. -- Settings: colour-blind flare cues, reduced motion (including no full-screen flash overlays), low-FX mode, music and SFX. +- Settings: colour-blind flare cues, reduced motion (no screen shake, full-screen flash overlays, or impact hit-stop pauses), low-FX mode, music and SFX. - Share result cards (PNG) and challenge links, plus PWA install prompt and manifest shortcuts. - Local analytics counters plus server event logging for the run/board/install diff --git a/web/game.js b/web/game.js index e540ad4..1473739 100644 --- a/web/game.js +++ b/web/game.js @@ -185,11 +185,13 @@ function comboMilestoneText(combo) { function shouldRenderScreenFlash(settings = {}) { return settings.reducedMotion !== true; } +function shouldApplyHitStop(settings = {}) { + return settings.reducedMotion !== true; +} function campaignPauseHint(sectorId) { const sector = CAMPAIGN_SECTORS.find(item => item.id === sectorId); - return sector ? `${sector.name} — ${sector.objective}` : ""; + return sector ? `${sector.name} - ${sector.objective}` : ""; } - function segDist(px, py, x1, y1, x2, y2) { const dx = x2 - x1, dy = y2 - y1; const len2 = dx * dx + dy * dy; @@ -2163,7 +2165,12 @@ class Game { this.time += dt; this.ads.update(dt); this.flash = Math.max(0, this.flash - dt); if (this.paused) return; - if (this.hitStop > 0) { this.hitStop = Math.max(0, this.hitStop - dt); return; } + if (this.hitStop > 0 && shouldApplyHitStop(this.save.settings)) { + this.hitStop = Math.max(0, this.hitStop - dt); + return; + } + // Reduced Motion skips the brief simulation freeze used for impact emphasis. + this.hitStop = 0; this.shake = Math.max(0, this.shake - dt); this.parts = this.parts.filter(p => { p.x += p.vx * dt; p.y += p.vy * dt; p.vx *= .96; p.vy *= .96; return (p.life -= dt) > 0; }); this.floats = this.floats.filter(f => { f.y -= 50 * dt; return (f.life -= dt) > 0; }); @@ -3475,5 +3482,5 @@ if (typeof module !== "undefined" && module.exports) module.exports = { Game, MockAdProvider, AdMobProvider, createAdProvider, Save, Sfx, Social, Leaderboard, CAMPAIGN_SECTORS, STORY_ENTRIES, EXPLORATION_OBJECTIVES, freshCampaign, sanitizeCampaign, DemoIapProvider, DisabledIapProvider, CdvIapProvider, createIapProvider, GEM_PACKS, CONTINUE_BASE_COST, SHIPS, CONTRACTS, MISSION_POOL, BIOMES, METEOR_TYPES, SHOP_ITEMS, - dateKey, weekKey, mulberry32, difficultyProfile, flareWarningText, bossWarningText, comboMilestoneText, shouldRenderScreenFlash, campaignPauseHint, W, H }; + dateKey, weekKey, mulberry32, difficultyProfile, flareWarningText, bossWarningText, comboMilestoneText, shouldRenderScreenFlash, shouldApplyHitStop, campaignPauseHint, W, H }; else window.addEventListener("load", boot); diff --git a/web/sw.js b/web/sw.js index 83fc774..0550b61 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,7 +1,7 @@ // Service worker: caches the game so it works offline once installed. // Bump CACHE whenever the game files change. Navigation is network-first so a // deployed update is picked up as soon as the player is online. -const CACHE = "orbit-runner-v17"; +const CACHE = "orbit-runner-v18"; const FILES = ["./", "./index.html", "./config.js", "./game.js", "./manifest.json", "./icon-192.png", "./icon-512.png"]; self.addEventListener("install", e => { diff --git a/web/test/game.test.js b/web/test/game.test.js index 4629a7d..4c6f55e 100644 --- a/web/test/game.test.js +++ b/web/test/game.test.js @@ -4,7 +4,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { Game, MockAdProvider, AdMobProvider, Save, Social, Leaderboard, MISSION_POOL, CONTRACTS, BIOMES, SHOP_ITEMS, GEM_PACKS, DemoIapProvider, dateKey, weekKey, CAMPAIGN_SECTORS, STORY_ENTRIES, EXPLORATION_OBJECTIVES, freshCampaign, - sanitizeCampaign, difficultyProfile, flareWarningText, comboMilestoneText, shouldRenderScreenFlash, campaignPauseHint } = require("../game.js"); + sanitizeCampaign, difficultyProfile, flareWarningText, comboMilestoneText, shouldRenderScreenFlash, shouldApplyHitStop, campaignPauseHint } = require("../game.js"); function localStore() { const store = {}; @@ -675,7 +675,7 @@ test("replaying an already-charted sector does not trigger the campaign ending a test("campaign pause hint explains the selected sector objective and ignores unknown sectors", () => { assert.equal(campaignPauseHint("sol-frontier"), - "SOL FRONTIER — Complete a run here to chart the frontier."); + "SOL FRONTIER - Complete a run here to chart the frontier."); assert.equal(campaignPauseHint("not-a-sector"), ""); assert.equal(campaignPauseHint(null), ""); }); @@ -687,6 +687,12 @@ test("reduced motion disables full-screen flash overlays without changing defaul assert.equal(shouldRenderScreenFlash(), true); }); +test("reduced motion skips hit-stop pauses while normal settings retain them", () => { + assert.equal(shouldApplyHitStop({ reducedMotion: true }), false); + assert.equal(shouldApplyHitStop({ reducedMotion: false }), true); + assert.equal(shouldApplyHitStop(), true); +}); + test("combo milestone copy names the streak tiers clearly and rejects invalid values", () => { assert.equal(comboMilestoneText(2), "x2 WARM STREAK"); assert.equal(comboMilestoneText(4), "x4 HOT STREAK");