From c590dc6bf68f1caaf81088977b7ddc05c876d30d Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:08:08 +0100 Subject: [PATCH 1/4] feat: add pilot achievement badges and progress --- web/game.js | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/web/game.js b/web/game.js index b7cd5d5..c89b1df 100644 --- a/web/game.js +++ b/web/game.js @@ -113,6 +113,38 @@ const MISSION_POOL = [ { id: "total_powerups", text: "Power-ups collected", metric: "total_powerups", target: 6, reward: 35 }, ]; +const ACHIEVEMENT_DEFS = [ + { id: "first-flight", title: "FIRST FLIGHT", detail: "Complete your first run.", metric: "runs", target: 1 }, + { id: "flare-dancer", title: "FLARE DANCER", detail: "Dodge ten solar flares.", metric: "flares", target: 10 }, + { id: "near-miss-ace", title: "NEAR-MISS ACE", detail: "Record twenty-five near misses.", metric: "near", target: 25 }, + { id: "surge-breaker", title: "SURGE BREAKER", detail: "Clear three boss encounters.", metric: "bosses", target: 3 }, + { id: "crystal-archive", title: "CRYSTAL ARCHIVE", detail: "Collect 500 lifetime crystals.", metric: "lifetime", target: 500 }, + { id: "galaxy-cartographer", title: "GALAXY CARTOGRAPHER", detail: "Chart all four campaign sectors.", metric: "sectors", target: 4 }, + { id: "signal-decoder", title: "SIGNAL DECODER", detail: "Recover all four story records.", metric: "discoveries", target: 4 }, + { id: "survey-specialist", title: "SURVEY SPECIALIST", detail: "Complete six planetary surveys.", metric: "surveys", target: 6 }, + { id: "style-collector", title: "STYLE COLLECTOR", detail: "Unlock eight cosmetic items.", metric: "cosmetics", target: 8 }, +]; +function achievementProgress(save = {}) { + const stats = save.stats && typeof save.stats === "object" ? save.stats : {}; + const campaign = save.campaign && typeof save.campaign === "object" ? save.campaign : {}; + const completed = Array.isArray(campaign.completed) ? campaign.completed : []; + const discoveries = Array.isArray(campaign.discoveries) ? campaign.discoveries : []; + const exploration = campaign.exploration && typeof campaign.exploration === "object" ? campaign.exploration : {}; + const cosmetics = save.cosmetics && Array.isArray(save.cosmetics.owned) ? save.cosmetics.owned : []; + const count = value => Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; + const values = { + runs: count(stats.runs), flares: count(stats.flares), near: count(stats.near), + bosses: count(stats.bosses), lifetime: count(save.lifetime), + sectors: completed.length, discoveries: discoveries.length, + surveys: Object.values(exploration).reduce((sum, ids) => sum + (Array.isArray(ids) ? ids.length : 0), 0), + cosmetics: cosmetics.length, + }; + return ACHIEVEMENT_DEFS.map(def => ({ + ...def, progress: Math.min(def.target, values[def.metric] || 0), + unlocked: (values[def.metric] || 0) >= def.target, + })); +} + const CONFIG = (typeof window !== "undefined" && window.ORBIT_CONFIG) || {}; const rand = (a, b) => a + Math.random() * (b - a); @@ -1574,6 +1606,10 @@ class Game { this.applySettings(); Save.write(this.save); } + openAchievements() { + this.state = "achievements"; + this.paused = false; + } openShop() { this.state = "shop"; this.paused = false; @@ -1980,6 +2016,10 @@ class Game { else if (kk === "f") this.connectFacebook(); return; } + if (this.state === "achievements") { + if (kk === "escape" || kk === "b" || kk === "q") this.goTitle(); + return; + } if (this.state === "shop") { if (kk === "escape" || kk === "b" || kk === "q") this.goTitle(); else if (kk === "1") this.shopTab = "trail"; @@ -2465,6 +2505,36 @@ class Game { text(c, "1-5 toggle - B / ESC back", W / 2, 940, 17, "#828296"); } + drawAchievements(c) { + this.drawSpace(c, 0, 0); + c.fillStyle = "rgba(3,5,16,0.82)"; c.fillRect(0, 0, W, H); + const achievements = achievementProgress(this.save); + const unlocked = achievements.filter(item => item.unlocked).length; + panel(c, 32, 42, W - 64, 1080, "rgba(7,10,22,0.92)", "rgba(255,212,90,0.22)", 24); + text(c, "PILOT ACHIEVEMENTS", W / 2, 91, 32, "#ffd45a", true); + text(c, `${unlocked} / ${achievements.length} BADGES UNLOCKED`, W / 2, 126, 14, "#9aa6c0", true); + achievements.forEach((item, i) => { + const col = i % 2, row = Math.floor(i / 2); + const x = 48 + col * 324, y = 164 + row * 166; + const w = 300, h = 146; + panel(c, x, y, w, h, + item.unlocked ? "rgba(120,255,140,0.08)" : "rgba(255,255,255,0.025)", + item.unlocked ? "rgba(120,255,140,0.5)" : "rgba(120,150,190,0.18)", 12); + circle(c, x + 27, y + 29, 12, item.unlocked ? "#8dffce" : "#39445b"); + text(c, item.unlocked ? "✓" : String(i + 1), x + 27, y + 29, 12, + item.unlocked ? "#07131d" : "#9aa6c0", true); + text(c, item.title, x + 48, y + 31, 13, item.unlocked ? "#8dffce" : "#e6edf8", true, "left"); + text(c, item.detail, x + 16, y + 63, 11, "#a9b8cc", false, "left"); + c.fillStyle = "rgba(255,255,255,0.10)"; c.fillRect(x + 16, y + 91, w - 32, 6); + c.fillStyle = item.unlocked ? "#8dffce" : "#78c8ff"; + c.fillRect(x + 16, y + 91, (w - 32) * item.progress / item.target, 6); + text(c, item.unlocked ? "UNLOCKED" : `${item.progress} / ${item.target}`, + x + 16, y + 121, 11, item.unlocked ? "#8dffce" : "#9aa6c0", true, "left"); + }); + this.button(c, "BACK TO TITLE", 1010, "#c8c8dc", () => this.goTitle(), 58); + text(c, "BADGES ARE CALCULATED FROM YOUR EXISTING LOCAL PROGRESS", W / 2, 1092, 12, "#828296"); + } + drawShop(c) { this.drawSpace(c, 0, 0); c.fillStyle = "rgba(3,3,10,0.72)"; c.fillRect(0, 0, W, H); @@ -2552,6 +2622,7 @@ class Game { if (this.state === "explore") return this.drawPlanetExplore(c); if (this.state === "archive") return this.drawStoryArchive(c); if (this.state === "board") return this.drawBoard(c); + if (this.state === "achievements") return this.drawAchievements(c); if (this.state === "shop") return this.drawShop(c); if (this.state === "settings") return this.drawSettings(c); if (this.state === "gems" && !this.ads.showing) return this.drawGemStore(c); @@ -3132,9 +3203,10 @@ class Game { [ ["BOARD", "#b48cff", () => this.openBoard(challengeDay ? "daily" : "weekly")], ["CUSTOMIZE", "#ff9adf", () => this.openShop()], + ["BADGES", "#ffd45a", () => this.openAchievements()], [`GEMS ${this.save.gems}`, "#3cdcb4", () => this.openGemStore("title")], ["SETTINGS", "#78c8dc", () => this.openSettings()], - ].forEach(([label, col, fn], i) => this.smallButton(c, label, 80 + i * 144, 1034, 128, 62, col, fn)); + ].forEach(([label, col, fn], i) => this.smallButton(c, label, 28 + i * 134, 1034, 124, 62, col, fn)); const dailyBest = this.save.daily.date === dailyDate ? this.save.daily.best : 0; const rankedBest = this.save.ranked.week === (challengeWeek || weekKey()) ? this.save.ranked.best : 0; @@ -3428,6 +3500,7 @@ function boot() { story: "Story archive. Browse the unlocked entries and return to the star map or title.", settings: "Settings. Toggle visual effects, audio, and display options.", shop: "Cosmetics shop. Browse items and equip or unlock available cosmetics.", + achievements: "Pilot achievements. Review unlocked badges and progress toward future badges.", board: "Leaderboard. Browse tabs, refresh results, or return to the title.", gems: "Gem store. Browse packs and return when finished." }[state] || "Orbit Runner screen changed."); @@ -3466,5 +3539,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, achievementProgress, W, H }; else window.addEventListener("load", boot); From b1e7a6ba62addd21cdce8cc3683417002458e434 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:08:18 +0100 Subject: [PATCH 2/4] feat: add pilot achievement badges and progress --- web/test/game.test.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/web/test/game.test.js b/web/test/game.test.js index 4aa1f23..13ea95f 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, achievementProgress } = require("../game.js"); function localStore() { const store = {}; @@ -411,6 +411,24 @@ test("volatile meteors explode and clear nearby rocks", () => { assert.equal(other.dead, true); }); +test("achievement progress derives badges from existing stats and campaign saves", () => { + const empty = achievementProgress({}); + assert.ok(empty.length >= 8); + assert.equal(empty.find(item => item.id === "first-flight").unlocked, false); + const progress = achievementProgress({ + stats: { runs: 1, flares: 10, near: 25, bosses: 3 }, + lifetime: 500, + campaign: { + completed: ["sol-frontier", "ember-reach", "silent-expanse", "void-crown"], + discoveries: ["beacon-awake", "ember-expedition", "silent-echo", "void-origin"], + exploration: { "sol-frontier": ["a", "b", "c"], "ember-reach": ["d", "e", "f"] }, + }, + cosmetics: { owned: ["1", "2", "3", "4", "5", "6", "7", "8"] }, + }); + assert.ok(progress.every(item => item.unlocked)); + assert.equal(achievementProgress({ stats: { runs: -10 } })[0].progress, 0); +}); + test("biomes drive speed, score and crystal rates", () => { const g = newGame(); g.start("daily"); From a75112d06aa795ede96368c95f8e712a134ed637 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:08:24 +0100 Subject: [PATCH 3/4] feat: add pilot achievement badges and progress --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 961a11e..f7491db 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 +- The title screen links to a pilot badge board with progress derived from existing run stats, campaign discoveries, surveys, lifetime crystals, and cosmetics. Achievements add no separate save schema or gameplay advantage.\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 a50ad20b5f600a92280405469f6e1324eecb32e1 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:08:27 +0100 Subject: [PATCH 4/4] feat: add pilot achievement badges and progress --- web/sw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/sw.js b/web/sw.js index 8ac9df9..3fe06a9 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-v23"; const FILES = ["./", "./index.html", "./config.js", "./game.js", "./manifest.json", "./icon-192.png", "./icon-512.png"]; self.addEventListener("install", e => {