From 61c99fe7d4284d6a7153eb8051f8faabf28a3aae Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:06:11 +0100 Subject: [PATCH 1/4] feat: preview cosmetics before purchase or equip --- web/game.js | 58 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/web/game.js b/web/game.js index b7cd5d5..ec63c6d 100644 --- a/web/game.js +++ b/web/game.js @@ -91,6 +91,12 @@ const SHOP_ITEMS = [ { id: "title_legend", type: "title", name: "Orbit Legend", cost: 900, value: "ORBIT LEGEND" }, ]; +function cosmeticPreviewItem(type, previewId, equipped = {}, items = SHOP_ITEMS) { + const candidates = items.filter(item => item.type === type); + return candidates.find(item => item.id === previewId) || + candidates.find(item => item.id === equipped[type]) || candidates[0] || null; +} + const SKIN_LIVERIES = { scout: { hull: "#e8eef8", wing: "#9fb3cc", trim: "#78d8ff", accent: "#ff6a6a" }, interceptor: { hull: "#eef6ff", wing: "#c0d4e8", trim: "#78d8ff", accent: "#ff6a6a" }, @@ -2477,26 +2483,50 @@ class Game { this.shopTab === id ? "#ffd45a" : "#55557a", () => { this.shopTab = id; }); }); const items = SHOP_ITEMS.filter(i => i.type === this.shopTab); + const preview = cosmeticPreviewItem(this.shopTab, this.shopPreviewId, this.save.cosmetics.equipped); + panel(c, 60, 230, W - 120, 86, "rgba(16,25,44,0.92)", "rgba(120,200,255,0.24)", 14); + text(c, "LIVE PREVIEW", 78, 252, 11, "#7fa7c8", true, "left"); + if (preview) { + text(c, preview.name, 78, 282, 18, "#e8eef8", true, "left"); + if (preview.type === "skin") { + const livery = SKIN_LIVERIES[preview.value] || SKIN_LIVERIES.scout; + drawAircraft(c, W - 160, 270, -Math.PI / 2, livery, this.time, 0); + } else if (preview.type === "trail") { + c.globalAlpha = 0.28; + c.strokeStyle = preview.value; c.lineWidth = 14; + c.beginPath(); c.moveTo(W - 260, 270); c.lineTo(W - 130, 270); c.stroke(); + c.globalAlpha = 1; + for (let i = 0; i < 5; i++) circle(c, W - 250 + i * 24, 270, 4 + (i % 2) * 2, preview.value); + } else { + text(c, preview.value, W - 170, 270, 18, "#b48cff", true); + } + const equipped = this.save.cosmetics.equipped[preview.type] === preview.id; + text(c, equipped ? "CURRENTLY EQUIPPED" : + this.save.cosmetics.owned.includes(preview.id) ? "OWNED — READY TO EQUIP" : + `COST ${preview.cost} CRYSTALS`, + W - 78, 297, 10, equipped ? "#78ff8c" : "#9aa6c0", true, "right"); + } items.forEach((item, i) => { - const y = 252 + i * 104; + const y = 330 + i * 100; const owned = this.save.cosmetics.owned.includes(item.id); const equipped = this.save.cosmetics.equipped[item.type] === item.id; - panel(c, 60, y, W - 120, 88, - equipped ? "rgba(120,255,140,0.08)" : "rgba(255,255,255,0.035)", - equipped ? "rgba(120,255,140,0.45)" : "rgba(255,255,255,0.07)", 14); - text(c, item.name, 82, y + 30, 20, equipped ? "#78ff8c" : "#e8eef8", true, "left"); + const selected = preview && preview.id === item.id; + panel(c, 60, y, W - 120, 86, + equipped ? "rgba(120,255,140,0.08)" : selected ? "rgba(120,200,255,0.08)" : "rgba(255,255,255,0.035)", + equipped ? "rgba(120,255,140,0.45)" : selected ? "rgba(120,200,255,0.55)" : "rgba(255,255,255,0.07)", 14); + text(c, item.name, 82, y + 28, 19, equipped ? "#78ff8c" : "#e8eef8", true, "left"); const status = equipped ? "EQUIPPED" : owned ? "OWNED" : `${item.cost} CRYSTALS`; - text(c, status, 82, y + 60, 14, equipped ? "#78ff8c" : owned ? "#9696aa" : "#ffd45a", false, "left"); + text(c, status, 82, y + 56, 13, equipped ? "#78ff8c" : owned ? "#9696aa" : "#ffd45a", false, "left"); this.smallButton(c, equipped ? "ACTIVE" : owned ? "EQUIP" : "BUY", - W - 250, y + 22, 170, 44, equipped ? "#78ff8c" : owned ? "#78c8ff" : "#ffd45a", + W - 250, y + 20, 170, 44, equipped ? "#78ff8c" : owned ? "#78c8ff" : "#ffd45a", () => this.buyOrEquip(item)); - if (item.type === "trail") { - circle(c, W - 300, y + 44, 9, item.value); - } + if (item.type === "trail") circle(c, W - 300, y + 43, 9, item.value); + // Row selection is registered after the action button so BUY/EQUIP remains clickable. + this.buttons.push({ x: 60, y, w: W - 120, h: 86, fn: () => { this.shopPreviewId = item.id; } }); }); - if (this.shopMessage) text(c, this.shopMessage, W / 2, 820, 18, "#ffd45a"); - this.button(c, "BACK", 850, "#c8c8dc", () => this.goTitle(), 72); - text(c, "1/2/3 tabs - B / ESC back", W / 2, 960, 17, "#828296"); + if (this.shopMessage) text(c, this.shopMessage, W / 2, 890, 16, "#ffd45a"); + this.button(c, "BACK", 950, "#c8c8dc", () => this.goTitle(), 62); + text(c, "SELECT AN ITEM TO PREVIEW • BUY / EQUIP TO APPLY • B / ESC BACK", W / 2, 1035, 13, "#828296"); } drawSpace(c, ox = 0, oy = 0) { @@ -3466,5 +3496,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, cosmeticPreviewItem, W, H }; else window.addEventListener("load", boot); From 16d16d43fc26abd70d16bc1e34cd4ee75d8c19a0 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:06:14 +0100 Subject: [PATCH 2/4] feat: preview cosmetics before purchase or equip --- 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..4e29be9 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, cosmeticPreviewItem } = require("../game.js"); function localStore() { const store = {}; @@ -411,6 +411,16 @@ test("volatile meteors explode and clear nearby rocks", () => { assert.equal(other.dead, true); }); +test("cosmetic preview selects the requested item without changing equipped state", () => { + const equipped = { trail: "trail_default", skin: "skin_scout", title: "title_rookie" }; + const selected = cosmeticPreviewItem("skin", "skin_stealth", equipped); + assert.equal(selected.id, "skin_stealth"); + assert.equal(equipped.skin, "skin_scout"); + assert.equal(cosmeticPreviewItem("trail", "unknown-item", equipped).id, "trail_default"); + assert.equal(cosmeticPreviewItem("title", null, equipped).id, "title_rookie"); + assert.equal(cosmeticPreviewItem("unknown", null, equipped), null); +}); + test("biomes drive speed, score and crystal rates", () => { const g = newGame(); g.start("daily"); From 6ee6dbd1559714b9aa5ac05b6524180566877f91 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:06:17 +0100 Subject: [PATCH 3/4] feat: preview cosmetics before purchase or equip --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 961a11e..55f71a0 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 existing cosmetics shop has a live preview for ship skins, trails, and titles. Preview selection does not equip or purchase an item; the existing BUY/EQUIP action remains explicit.\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 c944a29d9ed71f99039f04d60d58565383d9c829 Mon Sep 17 00:00:00 2001 From: Ayinkx Date: Sat, 24 Oct 2026 10:06:20 +0100 Subject: [PATCH 4/4] feat: preview cosmetics before purchase or equip --- web/sw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/sw.js b/web/sw.js index 8ac9df9..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-v16"; +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 => {