diff --git a/README.md b/README.md index 42cf068..e7f4fed 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ runs keep their existing pause layout and rules. - The story archive presents the existing sector discoveries as one signal chain, with a recovery meter and the next unrecovered lead. It reuses the campaign discovery IDs and does not add a separate save format. - The web galaxy map uses the existing sector positions to draw connected routes, distinguish locked and charted systems, and show the selected sector's objective without duplicating campaign progression or save data. - Planetary surveys show sector-specific environmental conditions, hazards, research focus, and field notes while reusing the existing 12 optional objectives and rewards. +- 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. ### Realism pass diff --git a/web/game.js b/web/game.js index ddbe19c..7be8107 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 ""; @@ -2161,11 +2184,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) { @@ -2173,13 +2200,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(); @@ -2192,13 +2227,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; } @@ -2440,7 +2478,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(); } } @@ -2698,9 +2736,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); } @@ -2898,10 +2937,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)"; @@ -3590,5 +3630,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, shouldApplyHitStop, campaignPauseHint, campaignMapPoint, storySignalState, planetSurveyProfile, W, H }; + dateKey, weekKey, mulberry32, difficultyProfile, flareWarningText, bossWarningText, comboMilestoneText, shouldRenderScreenFlash, shouldApplyHitStop, campaignPauseHint, campaignMapPoint, storySignalState, planetSurveyProfile, campaignBossProfile, W, H }; else window.addEventListener("load", boot); diff --git a/web/sw.js b/web/sw.js index 2432e1d..0b1cce9 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-v21"; +const CACHE = "orbit-runner-v22"; 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 420aebd..e972c7a 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, shouldApplyHitStop, campaignPauseHint, campaignMapPoint, storySignalState, planetSurveyProfile } = require("../game.js"); + sanitizeCampaign, difficultyProfile, flareWarningText, comboMilestoneText, shouldRenderScreenFlash, shouldApplyHitStop, campaignPauseHint, campaignMapPoint, storySignalState, planetSurveyProfile, 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();