From fd65355c3ae607eb5009ac409dd22aaf463780b4 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 09:59:55 +0100 Subject: [PATCH 1/4] feat: upgrade campaign map into living galaxy chart --- web/game.js | 155 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 106 insertions(+), 49 deletions(-) diff --git a/web/game.js b/web/game.js index b7cd5d5..0b608af 100644 --- a/web/game.js +++ b/web/game.js @@ -198,17 +198,28 @@ function segDist(px, py, x1, y1, x2, y2) { const CAMPAIGN_SECTORS = [ { id: "sol-frontier", name: "SOL FRONTIER", subtitle: "THE FIRST BEACON", biome: "nebula", description: "A quiet frontier. An ancient beacon has begun transmitting again.", - objective: "Complete a run here to chart the frontier.", unlockAfter: null, position: [0.16, 0.22] }, + objective: "Complete a run here to chart the frontier.", unlockAfter: null, position: [0.16, 0.30] }, { id: "ember-reach", name: "EMBER REACH", subtitle: "SIGNAL IN THE FIRE", biome: "ember", description: "A red giant illuminates the last known route of a missing expedition.", - objective: "Complete Sol Frontier to open this route.", unlockAfter: "sol-frontier", position: [0.39, 0.36] }, + objective: "Complete Sol Frontier to open this route.", unlockAfter: "sol-frontier", position: [0.39, 0.72] }, { id: "silent-expanse", name: "THE SILENT EXPANSE", subtitle: "FROZEN ECHOES", biome: "ice", description: "Frozen stations drift beneath an atmosphere of blue ice and static.", - objective: "Complete Ember Reach to open this route.", unlockAfter: "ember-reach", position: [0.64, 0.19] }, + objective: "Complete Ember Reach to open this route.", unlockAfter: "ember-reach", position: [0.64, 0.20] }, { id: "void-crown", name: "THE VOID CROWN", subtitle: "EDGE OF THE UNKNOWN", biome: "void", description: "A dark anomaly bends starlight around the source of the signal.", - objective: "Complete the Silent Expanse to open this route.", unlockAfter: "silent-expanse", position: [0.83, 0.39] }, + objective: "Complete the Silent Expanse to open this route.", unlockAfter: "silent-expanse", position: [0.83, 0.61] }, ]; + +// Convert normalized sector metadata into logical canvas coordinates. +function campaignMapPoint(sector, width = W, top = 145, height = 500, padding = 42) { + const position = sector && sector.position; + if (!Array.isArray(position) || position.length < 2 || + !Number.isFinite(position[0]) || !Number.isFinite(position[1])) return null; + return { + x: padding + position[0] * (width - padding * 2), + y: top + position[1] * height, + }; +} const STORY_ENTRIES = [ { id: "beacon-awake", sectorId: "sol-frontier", title: "The Beacon Wakes", classification: "FIRST CONTACT", teaser: "A signal older than the frontier itself.", body: "The beacon did not wake by accident. Its repeating pulse predates every mapped station in Sol Frontier, yet its power source is still warm. Someone—or something—has been waiting for a receiver." }, @@ -2873,67 +2884,113 @@ class Game { } drawCampaignMap(c) { - c.fillStyle = "rgba(2, 5, 16, 0.97)"; c.fillRect(0, 0, W, H); - for (let i = 0; i < 70; i++) { + c.fillStyle = "rgba(2, 5, 16, 0.98)"; c.fillRect(0, 0, W, H); + // Stable star positions with subtle twinkle; no random gameplay state is introduced. + for (let i = 0; i < 96; i++) { const x = (i * 137 + 41) % W, y = (i * 211 + 19) % H; - circle(c, x, y, i % 9 === 0 ? 2 : 1, i % 3 === 0 ? "rgba(120,200,255,0.6)" : "rgba(210,225,255,0.38)"); + const alpha = 0.18 + 0.34 * (0.5 + 0.5 * Math.sin(this.time * 0.7 + i * 1.7)); + circle(c, x, y, i % 11 === 0 ? 2 : 1, + i % 3 === 0 ? `rgba(120,200,255,${alpha})` : `rgba(210,225,255,${alpha})`); } - text(c, "THE LOST SYSTEMS", W / 2, 68, 34, "#d9edff", true); - text(c, "SECTOR NAVIGATION / SELECT A DESTINATION", W / 2, 108, 14, "#7fa7c8", true); - c.strokeStyle = "rgba(95, 166, 220, 0.55)"; c.lineWidth = 3; c.beginPath(); - CAMPAIGN_SECTORS.forEach((sector, i) => { - const x = 104, y = 218 + i * 210; - if (i === 0) c.moveTo(x, y); else c.lineTo(x, y); - }); - c.stroke(); + if (typeof c.createRadialGradient === "function") { + const nebula = c.createRadialGradient(W * 0.52, 390, 20, W * 0.52, 390, 390); + nebula.addColorStop(0, "rgba(50, 95, 155, 0.16)"); + nebula.addColorStop(1, "rgba(5, 12, 30, 0)"); + c.fillStyle = nebula; c.fillRect(0, 130, W, 540); + } + text(c, "THE LOST SYSTEMS", W / 2, 58, 32, "#d9edff", true); + text(c, "GALACTIC CHART / SELECT A DESTINATION", W / 2, 94, 13, "#7fa7c8", true); + + const points = CAMPAIGN_SECTORS.map(sector => campaignMapPoint(sector)); + // Draw routes behind the planets. Locked routes are dashed and charted routes glow. + for (let i = 1; i < CAMPAIGN_SECTORS.length; i++) { + const sector = CAMPAIGN_SECTORS[i], previous = CAMPAIGN_SECTORS[i - 1]; + const a = points[i - 1], b = points[i]; + if (!a || !b) continue; + const unlocked = this.campaignUnlocked(sector); + const completed = this.save.campaign.completed.includes(previous.id); + c.save(); + if (c.setLineDash) c.setLineDash(unlocked ? [] : [7, 9]); + c.strokeStyle = completed ? "rgba(141,255,206,0.82)" : + unlocked ? "rgba(120,220,255,0.78)" : "rgba(103,133,164,0.45)"; + c.lineWidth = unlocked ? 3 : 2; + c.beginPath(); c.moveTo(a.x, a.y); c.lineTo(b.x, b.y); c.stroke(); + if (c.setLineDash) c.setLineDash([]); + const pulse = (this.time * 0.12 + i * 0.22) % 1; + circle(c, a.x + (b.x - a.x) * pulse, a.y + (b.y - a.y) * pulse, + 3, unlocked ? "#a8e9ff" : "rgba(130,150,175,0.65)"); + c.restore(); + } + CAMPAIGN_SECTORS.forEach((sector, i) => { - const x = 38, y = 150 + i * 210, w = W - 76, h = 178; + const point = points[i]; + if (!point) return; const selected = sector.id === this.campaignSelectedSector; const unlocked = this.campaignUnlocked(sector); const completed = this.save.campaign.completed.includes(sector.id); - panel(c, x, y, w, h, - selected ? "rgba(16, 36, 58, 0.94)" : "rgba(7, 15, 31, 0.88)", - selected ? "rgba(120, 220, 255, 0.9)" : "rgba(120, 170, 210, 0.24)", 16); - const px = 104, py = y + 68, pr = 27; + const mastery = this.sectorMastery(sector.id); + const px = point.x, py = point.y; + const planetCol = sector.biome === "ember" ? "#ff874f" : sector.biome === "ice" ? "#8deaff" : + sector.biome === "void" ? "#a18bff" : "#78c8ff"; + const glowRadius = selected ? 58 + 4 * Math.sin(this.time * 2.4) : 46; if (typeof c.createRadialGradient === "function") { - const glow = c.createRadialGradient(px, py, 2, px, py, 47); - glow.addColorStop(0, sector.biome === "ember" ? "rgba(255,125,70,0.9)" : - sector.biome === "ice" ? "rgba(110,225,255,0.9)" : - sector.biome === "void" ? "rgba(160,120,255,0.9)" : "rgba(100,180,255,0.9)"); + const glow = c.createRadialGradient(px, py, 2, px, py, glowRadius); + glow.addColorStop(0, selected ? "rgba(120,220,255,0.38)" : "rgba(100,180,255,0.22)"); glow.addColorStop(1, "rgba(0,0,0,0)"); - c.fillStyle = glow; c.fillRect(px - 48, py - 48, 96, 96); + c.fillStyle = glow; c.fillRect(px - glowRadius, py - glowRadius, glowRadius * 2, glowRadius * 2); + } + if (selected) circle(c, px, py, 36 + 3 * Math.sin(this.time * 2.4), "rgba(120,220,255,0.12)", 2); + if (completed) circle(c, px, py, 33, "rgba(141,255,206,0.78)", 2); + circle(c, px, py, 25, unlocked ? planetCol : "#3b4b62"); + circle(c, px - 7, py - 8, 9, unlocked ? "rgba(255,255,255,0.34)" : "rgba(220,230,245,0.12)"); + c.strokeStyle = unlocked ? "rgba(210,230,255,0.72)" : "rgba(130,148,170,0.42)"; + c.lineWidth = 2; c.beginPath(); c.ellipse(px, py, 34, 9, -0.24, 0, TAU); c.stroke(); + + const labelY = sector.position[1] < 0.46 ? py + 54 : py - 43; + const labelCol = unlocked ? "#e6f3ff" : "#718096"; + text(c, sector.name, px, labelY, 13, labelCol, true); + text(c, completed ? "CHARTED" : unlocked ? "AVAILABLE" : "LOCKED", + px, labelY + 17, 10, completed ? "#8dffce" : unlocked ? "#80d8ff" : "#718096", true); + this.buttons.push({ + x: px - 54, y: py - 52, w: 108, h: 104, + fn: () => this.selectCampaignSector(sector.id), + }); + if (mastery.mastered) { + circle(c, px + 27, py - 24, 8, "#8dffce"); + text(c, "✓", px + 27, py - 24, 10, "#07131d", true); } - const planetCol = sector.biome === "ember" ? "#ff874f" : sector.biome === "ice" ? "#8deaff" : - sector.biome === "void" ? "#a18bff" : "#78c8ff"; - circle(c, px, py, pr, planetCol); - circle(c, px - 8, py - 8, 10, "rgba(255,255,255,0.28)"); - c.strokeStyle = "rgba(210,230,255,0.55)"; c.lineWidth = 2; c.beginPath(); - c.ellipse(px, py, pr + 13, 8, -0.24, 0, TAU); c.stroke(); - const titleCol = unlocked ? "#e6f3ff" : "#718096"; - text(c, `${i + 1}. ${sector.name}`, 153, y + 36, 20, titleCol, true, "left"); - text(c, sector.subtitle, 153, y + 62, 12, unlocked ? "#80d8ff" : "#65738a", true, "left"); - text(c, sector.description, 153, y + 94, 13, "#b8c6d9", false, "left"); - const mastery = this.sectorMastery(sector.id); - text(c, mastery.mastered ? "SECTOR MASTERED" : completed ? "SECTOR CHARTED" : unlocked ? "ROUTE AVAILABLE" : "ROUTE LOCKED", - 153, y + 126, 13, mastery.mastered ? "#8dffce" : completed ? "#a5c9e8" : unlocked ? "#80d8ff" : "#768096", true, "left"); - text(c, "SURVEYS " + mastery.completedObjectives + "/" + mastery.totalObjectives + " • " + sector.biome.toUpperCase(), - 153, y + 151, 11, mastery.mastered ? "#8dffce" : "#778aa4", false, "left"); - this.buttons.push({ x, y, w, h, fn: () => this.selectCampaignSector(sector.id) }); }); + const selected = this.campaignSector(this.campaignSelectedSector) || CAMPAIGN_SECTORS[0]; const unlocked = this.campaignUnlocked(selected); + const mastery = this.sectorMastery(selected.id); + panel(c, 34, 650, W - 68, 252, "rgba(7, 18, 35, 0.92)", "rgba(120,220,255,0.62)", 16); + text(c, selected.name, 56, 684, 20, "#e6f3ff", true, "left"); + text(c, selected.subtitle, 56, 710, 11, unlocked ? "#80d8ff" : "#718096", true, "left"); + text(c, selected.description, 56, 746, 12, "#b8c6d9", false, "left"); + text(c, "OBJECTIVE / " + selected.objective, 56, 781, 11, "#a8c8e3", false, "left"); + text(c, mastery.mastered ? "SECTOR MASTERED" : + this.save.campaign.completed.includes(selected.id) ? "SECTOR CHARTED" : + unlocked ? "ROUTE AVAILABLE" : "ROUTE LOCKED — COMPLETE THE PREVIOUS SECTOR", + 56, 822, 12, mastery.mastered ? "#8dffce" : unlocked ? "#80d8ff" : "#9aa7ba", true, "left"); + text(c, "SURVEYS " + mastery.completedObjectives + "/" + mastery.totalObjectives + + " • " + selected.biome.toUpperCase(), 56, 852, 11, "#778aa4", false, "left"); + text(c, "SECTOR " + (CAMPAIGN_SECTORS.findIndex(s => s.id === selected.id) + 1) + + " / " + CAMPAIGN_SECTORS.length, W - 56, 684, 10, "#7fa7c8", true, "right"); + this.smallButton(c, unlocked ? "LAUNCH SECTOR" : "SECTOR LOCKED", - 38, 1005, 300, 64, unlocked ? "#ffd783" : "#6e778b", + 38, 925, 300, 62, unlocked ? "#ffd783" : "#6e778b", () => this.launchCampaignSector(selected.id)); this.smallButton(c, unlocked ? "EXPLORE PLANET" : "EXPLORE LOCKED", - 350, 1005, 332, 64, unlocked ? "#78c8ff" : "#6e778b", + 350, 925, 332, 62, unlocked ? "#78c8ff" : "#6e778b", () => this.openPlanetExplore(selected.id)); - this.smallButton(c, "STORY ARCHIVE", 52, 1080, 270, 56, "#b48cff", () => this.openStoryArchive("campaign")); - this.smallButton(c, "BACK TO TITLE", 340, 1080, 328, 56, "#83bfe9", () => this.goTitle()); + this.smallButton(c, "STORY ARCHIVE", 52, 1000, 270, 54, "#b48cff", () => this.openStoryArchive("campaign")); + this.smallButton(c, "BACK TO TITLE", 340, 1000, 328, 54, "#83bfe9", () => this.goTitle()); const progress = this.campaignMastery(); - text(c, this.campaignMessage || ("SECTORS MASTERED " + progress.masteredSectors + "/4 • OPTIONAL SURVEYS " + - progress.completedObjectives + "/" + progress.totalObjectives), W / 2, 1168, 13, "#9bb6ce", false); - text(c, "UP / DOWN SELECT • ENTER LAUNCH • L ARCHIVE • ESC BACK", W / 2, 1214, 12, "#607994"); + text(c, this.campaignMessage || ("SECTORS MASTERED " + progress.masteredSectors + "/" + CAMPAIGN_SECTORS.length + + " • OPTIONAL SURVEYS " + progress.completedObjectives + "/" + progress.totalObjectives), + W / 2, 1092, 12, "#9bb6ce", false); + text(c, "UP / DOWN SELECT • ENTER LAUNCH • L ARCHIVE • ESC BACK", W / 2, 1140, 11, "#607994"); } drawCampaignEnding(c) { @@ -3466,5 +3523,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, campaignMapPoint, W, H }; else window.addEventListener("load", boot); From cbb9cb4d505d1147ba040a50fa12a9fc7da32d67 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:00:01 +0100 Subject: [PATCH 2/4] feat: upgrade campaign map into living galaxy chart --- web/test/game.test.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/test/game.test.js b/web/test/game.test.js index 4aa1f23..548c428 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, campaignMapPoint } = require("../game.js"); function localStore() { const store = {}; @@ -537,6 +537,16 @@ test("campaign progression validates sector order and unknown IDs", () => { assert.equal(CAMPAIGN_SECTORS.length, 4); }); +test("campaign map positions produce usable canvas coordinates", () => { + const points = CAMPAIGN_SECTORS.map(sector => campaignMapPoint(sector)); + assert.equal(points.length, 4); + assert.ok(points.every(point => point && Number.isFinite(point.x) && Number.isFinite(point.y))); + assert.ok(points.every(point => point.x >= 42 && point.x <= 678)); + assert.ok(points.every(point => point.y >= 145 && point.y <= 645)); + assert.equal(campaignMapPoint({ position: ["bad", 0.5] }), null); + assert.equal(campaignMapPoint({}), null); +}); + test("campaign map prevents locked launches and starts selected biome", () => { const g = newGame(); g.openCampaignMap(); From 153182aa45d831be9905f3ea42d6d5c1193e9d52 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:00:04 +0100 Subject: [PATCH 3/4] feat: upgrade campaign map into living galaxy chart --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 961a11e..3a2ec5a 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ 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. +- 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. + ### Realism pass - **Orbital motion:** inner rings orbit faster (Kepler-style), reversing has From 595df8e6a55602e85be11e4e0521ddefd33de317 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:00:06 +0100 Subject: [PATCH 4/4] feat: upgrade campaign map into living galaxy chart --- web/sw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/sw.js b/web/sw.js index 8ac9df9..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-v16"; +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 => {