Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ runs keep their existing pause layout and rules.
- 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.
- 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.
- 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.

### Realism pass

Expand Down
77 changes: 75 additions & 2 deletions web/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,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);
Expand Down Expand Up @@ -1652,6 +1684,10 @@ class Game {
this.applySettings();
Save.write(this.save);
}
openAchievements() {
this.state = "achievements";
this.paused = false;
}
openShop() {
this.state = "shop";
this.paused = false;
Expand Down Expand Up @@ -2058,6 +2094,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";
Expand Down Expand Up @@ -2563,6 +2603,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);
Expand Down Expand Up @@ -2674,6 +2744,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);
Expand Down Expand Up @@ -3322,9 +3393,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;
Expand Down Expand Up @@ -3622,6 +3694,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.");
Expand Down Expand Up @@ -3660,5 +3733,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, campaignBossProfile, cosmeticPreviewItem, W, H };
dateKey, weekKey, mulberry32, difficultyProfile, flareWarningText, bossWarningText, comboMilestoneText, shouldRenderScreenFlash, shouldApplyHitStop, campaignPauseHint, campaignMapPoint, storySignalState, planetSurveyProfile, campaignBossProfile, cosmeticPreviewItem, achievementProgress, W, H };
else window.addEventListener("load", boot);
2 changes: 1 addition & 1 deletion web/sw.js
Original file line number Diff line number Diff line change
@@ -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-v23";
const CACHE = "orbit-runner-v24";
const FILES = ["./", "./index.html", "./config.js", "./game.js", "./manifest.json", "./icon-192.png", "./icon-512.png"];

self.addEventListener("install", e => {
Expand Down
20 changes: 19 additions & 1 deletion web/test/game.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, campaignBossProfile, cosmeticPreviewItem } = require("../game.js");
sanitizeCampaign, difficultyProfile, flareWarningText, comboMilestoneText, shouldRenderScreenFlash, shouldApplyHitStop, campaignPauseHint, campaignMapPoint, storySignalState, planetSurveyProfile, campaignBossProfile, cosmeticPreviewItem, achievementProgress } = require("../game.js");

function localStore() {
const store = {};
Expand Down Expand Up @@ -430,6 +430,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("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);
Expand Down
Loading