From 55652c9fb35643f85a034d9bcf5a52bd62688b4e Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:04:56 +0100 Subject: [PATCH 1/4] feat: add sector-specific campaign boss encounters --- web/game.js | 70 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/web/game.js b/web/game.js index b7cd5d5..fb19b7e 100644 --- a/web/game.js +++ b/web/game.js @@ -164,6 +164,29 @@ function flareWarningText(secondsRemaining, ringIndex) { if (!ring || !Number.isFinite(secondsRemaining) || secondsRemaining <= 0) return ""; return `FLARE IN ${secondsRemaining.toFixed(1)}s — ${ring.toUpperCase()} RING`; } +const CAMPAIGN_BOSS_PROFILES = [ + { sectorId: "sol-frontier", name: "BEACON WARDEN", intro: "BEACON WARDEN DETECTED", + timer: 14, need: 3, coreDelay: 1.5, coreLife: 6, spin: 0.75, ringMode: "any", + coreColor: "#78c8ff", clearLabel: "WARDEN DEFEATED", failLabel: "WARDEN WITHDREW" }, + { sectorId: "ember-reach", name: "EMBER COLOSSUS", intro: "EMBER COLOSSUS RISING", + timer: 13, need: 3, coreDelay: 1.2, coreLife: 5.5, spin: 1.1, ringMode: "outer", + coreColor: "#ff874f", clearLabel: "COLOSSUS DEFEATED", failLabel: "COLOSSUS SURVIVED" }, + { sectorId: "silent-expanse", name: "FROST SENTINEL", intro: "FROST SENTINEL AWAKENS", + timer: 15, need: 3, coreDelay: 1.8, coreLife: 7, spin: 0.5, ringMode: "inner", + coreColor: "#8deaff", clearLabel: "SENTINEL DISABLED", failLabel: "SENTINEL REFORMS" }, + { sectorId: "void-crown", name: "VOID ARCHON", intro: "VOID ARCHON MANIFESTS", + timer: 14, need: 4, coreDelay: 1.1, coreLife: 5, spin: -1.15, ringMode: "alternating", + coreColor: "#b48cff", clearLabel: "ARCHON DISPERSED", failLabel: "ARCHON RETREATS" }, +]; +const DEFAULT_BOSS_PROFILE = { + name: "CORONA SURGE", intro: "CORONA SURGE!", timer: 14, need: 3, + coreDelay: 1.5, coreLife: 6, spin: 0.75, ringMode: "any", + coreColor: "#ffd45a", clearLabel: "SURGE CLEARED", failLabel: "SURGE FADED", +}; +function campaignBossProfile(sectorId) { + const profile = CAMPAIGN_BOSS_PROFILES.find(item => item.sectorId === sectorId); + return { ...(profile || DEFAULT_BOSS_PROFILE) }; +} function bossWarningText(secondsRemaining, cores, need = 3) { if (!Number.isFinite(secondsRemaining) || secondsRemaining <= 0 || !Number.isFinite(cores) || !Number.isFinite(need) || need < 1) return ""; @@ -2112,11 +2135,15 @@ class Game { // ---- Corona Surge ----------------------------------------------------- startBoss() { if (this.boss) return; - this.boss = { timer: 14, need: 3, cores: 0, core: null, coreT: 1.5 }; + const profile = campaignBossProfile(this.currentCampaignSector); + this.boss = { + ...profile, timer: profile.timer, need: profile.need, cores: 0, core: null, + coreT: profile.coreDelay, coreSpawns: 0, + }; this.flareT = 0; this.flareRing = -1; this.flash = Math.max(this.flash, 0.35); this.hitStop = Math.max(this.hitStop, 0.06); this.shake = Math.max(this.shake, 0.35); - this.float(CX, CY - 160, "CORONA SURGE!", "#ff5a2a"); + this.float(CX, CY - 160, profile.intro, profile.coreColor); Sfx.play("boss"); Sfx.noise(700, 0.16, 600); } updateBoss(dt) { @@ -2124,13 +2151,21 @@ class Game { this.boss.timer -= dt; this.boss.coreT -= dt; if (!this.boss.core && this.boss.coreT <= 0 && this.boss.timer > 3) { - this.boss.core = { ring: this.rndInt(3), angle: this.rnd(0, TAU), life: 6 }; - this.boss.coreT = 3.5; + let ring; + if (this.boss.ringMode === "outer") ring = 1 + this.rndInt(2); + else if (this.boss.ringMode === "inner") ring = this.rndInt(2); + else if (this.boss.ringMode === "alternating") ring = this.boss.coreSpawns % 2 === 0 ? 0 : 2; + else ring = this.rndInt(3); + this.boss.coreSpawns++; + this.boss.core = { ring, angle: this.rnd(0, TAU), life: this.boss.coreLife, color: this.boss.coreColor }; + this.boss.coreT = this.boss.coreDelay; } if (this.boss.core) { this.boss.core.life -= dt; - this.boss.core.angle += 0.75 * dt; - if (this.boss.core.life <= 0) { this.boss.core = null; this.boss.coreT = 1; } + this.boss.core.angle += this.boss.spin * dt; + if (this.boss.core.life <= 0) { + this.boss.core = null; this.boss.coreT = this.boss.coreDelay; + } } if (this.boss.cores >= this.boss.need) this.clearBoss(); else if (this.boss.timer <= 0) this.failBoss(); @@ -2143,13 +2178,16 @@ class Game { this.save.stats.bosses++; this.bossesCleared++; this.flash = Math.max(this.flash, 0.4); this.hitStop = Math.max(this.hitStop, 0.12); this.shake = Math.max(this.shake, 0.5); - this.float(CX, CY - 120, `SURGE CLEARED +${bonus}`, "#ffd45a"); + const clearLabel = this.boss.clearLabel || "SURGE CLEARED"; + const coreColor = this.boss.coreColor || "#ffd45a"; + this.float(CX, CY - 120, `${clearLabel} +${bonus}`, coreColor); this.boss = null; Sfx.play("boss"); Sfx.noise(450, 0.16, 1200); } failBoss() { if (!this.boss) return; - this.float(CX, CY - 120, "SURGE FADED", "#9696aa"); + const failLabel = this.boss.failLabel || "SURGE FADED"; + this.float(CX, CY - 120, failLabel, "#9696aa"); this.boss = null; } @@ -2386,7 +2424,7 @@ class Game { this.float(gx, gy, `CORE +${pts}`, "#ffd45a"); this.burst(gx, gy, ["#ffd45a", "#ff9a3c"], 30, 280, .7); Sfx.play("power"); vibrate(30); - this.boss.core = null; this.boss.coreT = 1; + this.boss.core = null; this.boss.coreT = this.boss.coreDelay; if (this.boss && this.boss.cores >= this.boss.need) this.clearBoss(); } } @@ -2644,9 +2682,10 @@ class Game { const core = this.boss.core; const gx = CX + Math.cos(core.angle) * RINGS[core.ring] + ox; const gy = CY + Math.sin(core.angle) * RINGS[core.ring] + oy; - circle(c, gx, gy, 26 + 4 * Math.sin(this.time * 8), "#ffd45a33"); + const coreColor = core.color || "#ffd45a"; + circle(c, gx, gy, 26 + 4 * Math.sin(this.time * 8), coreColor + "33"); circle(c, gx, gy, 18, "#0c0a14"); - circle(c, gx, gy, 18, "#ffd45a", 4); + circle(c, gx, gy, 18, coreColor, 4); text(c, "C", gx, gy + 1, 20, "#ffd45a", true); } @@ -2844,10 +2883,11 @@ class Game { if (this.boss) { const surgeRemaining = Math.max(0, this.boss.timer); const surgeUrgent = surgeRemaining <= 4; + const bossColor = this.boss.coreColor || "#ff8a4a"; panel(c, W / 2 - 160, 154, 320, 58, "rgba(30,10,6,0.84)", - surgeUrgent ? "rgba(255,220,120,0.95)" : "rgba(255,106,60,0.65)", 10); - text(c, `CORONA SURGE ${this.boss.cores}/${this.boss.need}`, - W / 2, 169, 18, "#ffb06a", true); + surgeUrgent ? "rgba(255,220,120,0.95)" : bossColor, 10); + text(c, `${this.boss.name || "CORONA SURGE"} ${this.boss.cores}/${this.boss.need}`, + W / 2, 169, 16, bossColor, true); text(c, bossWarningText(surgeRemaining, this.boss.cores, this.boss.need), W / 2, 188, 13, surgeUrgent ? "#fff0a8" : "#f2e8dc", true); c.fillStyle = "rgba(255,255,255,0.16)"; @@ -3466,5 +3506,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, W, H }; + dateKey, weekKey, mulberry32, difficultyProfile, flareWarningText, bossWarningText, comboMilestoneText, shouldRenderScreenFlash, campaignBossProfile, W, H }; else window.addEventListener("load", boot); From ab6ce7b9728683c5ba5dc58b17dfad989b6f9967 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:05:09 +0100 Subject: [PATCH 2/4] feat: add sector-specific campaign boss encounters --- web/test/game.test.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/web/test/game.test.js b/web/test/game.test.js index 4aa1f23..988e2f6 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 } = require("../game.js"); + sanitizeCampaign, difficultyProfile, flareWarningText, comboMilestoneText, shouldRenderScreenFlash, campaignBossProfile } = require("../game.js"); function localStore() { const store = {}; @@ -296,6 +296,25 @@ test("ad session cap stops new rewarded ads", () => { assert.match(g.message, /No ads left/); }); +test("campaign sectors use distinct boss profiles while non-campaign runs retain Corona Surge", () => { + const profiles = CAMPAIGN_SECTORS.map(sector => campaignBossProfile(sector.id)); + assert.equal(new Set(profiles.map(profile => profile.name)).size, CAMPAIGN_SECTORS.length); + assert.ok(profiles.every(profile => profile.timer > 0 && profile.need >= 3 && profile.coreLife > 0)); + assert.deepEqual( + [campaignBossProfile("unknown-sector").name, campaignBossProfile(null).name], + ["CORONA SURGE", "CORONA SURGE"], + ); + const g = newGame(); + g.currentCampaignSector = "ember-reach"; + g.startBoss(); + assert.equal(g.boss.name, "EMBER COLOSSUS"); + assert.equal(g.boss.ringMode, "outer"); + g.boss = null; + g.currentCampaignSector = null; + g.startBoss(); + assert.equal(g.boss.name, "CORONA SURGE"); +}); + test("clearing a Corona Surge awards crystals and tracks the stat", () => { const g = newGame(); g.start(); From a19e84b4e9babf730b95c66402997b7c23b1c327 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:05:21 +0100 Subject: [PATCH 3/4] feat: add sector-specific campaign boss encounters --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 961a11e..7fa7742 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ with the tutorial and does not change daily or ranked challenge rules. shared schema makes campaign progress portable for future import/export tooling but does not automatically sync files between devices. -### Realism pass +- Campaign boss encounters now use sector-specific profiles, with distinct core movement, target-ring patterns, timers, and names. Daily/ranked runs keep the standard Corona Surge profile.\n\n### Realism pass - **Orbital motion:** inner rings orbit faster (Kepler-style), reversing has angular inertia, and ring changes use a spring-damper radial move. From 9683f268f477821b82236690d6fb4b5341c6c3bf Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:05:23 +0100 Subject: [PATCH 4/4] feat: add sector-specific campaign boss encounters --- web/sw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/sw.js b/web/sw.js index 8ac9df9..2432e1d 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-v16"; +const CACHE = "orbit-runner-v21"; const FILES = ["./", "./index.html", "./config.js", "./game.js", "./manifest.json", "./icon-192.png", "./icon-512.png"]; self.addEventListener("install", e => {