Initial commit — Meridian OS browser desktop demo
This commit is contained in:
76
js/achievements/catalog.js
Normal file
76
js/achievements/catalog.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// Achievement definitions for Meridian OS
|
||||
|
||||
const catalog = [
|
||||
{
|
||||
id: 'first_grace',
|
||||
title: 'First Grace',
|
||||
desc: 'Welcome to the Lands Between.',
|
||||
game: 'ER',
|
||||
trigger: { event: 'user:login' }
|
||||
},
|
||||
{
|
||||
id: 'getting_tactical',
|
||||
title: 'Getting Tactical',
|
||||
desc: 'Run 5 terminal commands.',
|
||||
game: 'CoD',
|
||||
trigger: { event: 'term:cmd', count: 5 }
|
||||
},
|
||||
{
|
||||
id: 'career_mode',
|
||||
title: 'Career Mode',
|
||||
desc: 'Open 3 different apps.',
|
||||
game: 'FIFA',
|
||||
trigger: { event: 'app:open', uniqueField: 'app', count: 3 }
|
||||
},
|
||||
{
|
||||
id: 'tarnished_typist',
|
||||
title: 'Tarnished Typist',
|
||||
desc: 'Type 500 characters in Notepad.',
|
||||
game: 'ER',
|
||||
trigger: { event: 'text:typed', accumulate: 'chars', threshold: 500 }
|
||||
},
|
||||
{
|
||||
id: 'dj',
|
||||
title: 'DJ',
|
||||
desc: 'Play music for 30 seconds.',
|
||||
game: 'CoD',
|
||||
trigger: { event: 'music:played', accumulate: 'seconds', threshold: 30 }
|
||||
},
|
||||
{
|
||||
id: 'speedrun',
|
||||
title: 'Speedrun',
|
||||
desc: 'Open and close a window in under 2 seconds.',
|
||||
game: 'FIFA',
|
||||
trigger: { custom: 'speedrun' }
|
||||
},
|
||||
{
|
||||
id: 'dataminer',
|
||||
title: 'Dataminer',
|
||||
desc: 'Discover 3 hidden terminal commands.',
|
||||
game: 'ER',
|
||||
trigger: { event: 'easter:found', count: 3, unique: true }
|
||||
},
|
||||
{
|
||||
id: 'grace_bestowed',
|
||||
title: 'Touched by Grace',
|
||||
desc: 'Praise the Sun.',
|
||||
game: 'ER',
|
||||
trigger: { event: 'easter:found', id: 'er_grace' }
|
||||
},
|
||||
{
|
||||
id: 'siuuu_master',
|
||||
title: 'SIUUU Master',
|
||||
desc: 'Celebrate in the terminal.',
|
||||
game: 'FIFA',
|
||||
trigger: { event: 'easter:found', id: 'er_siuuu' }
|
||||
},
|
||||
{
|
||||
id: 'cheater',
|
||||
title: 'Cheat Code Activated',
|
||||
desc: 'Enter the Konami code.',
|
||||
game: 'CoD',
|
||||
trigger: { event: 'easter:found', id: 'er_konami' }
|
||||
}
|
||||
];
|
||||
|
||||
export default catalog;
|
||||
128
js/achievements/engine.js
Normal file
128
js/achievements/engine.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// Achievements engine — subscribes to bus events, unlocks achievements.
|
||||
// On unlock: pushes id into State, emits 'achievement:unlocked' for the widget.
|
||||
|
||||
import Bus from '../core/bus.js';
|
||||
import State from '../core/state.js';
|
||||
import catalog from './catalog.js';
|
||||
|
||||
// In-memory counters per achievement id
|
||||
const counters = {};
|
||||
// For uniqueField tracking: Set of seen values per achievement id
|
||||
const uniqueSets = {};
|
||||
// For speedrun: map of windowId -> open timestamp
|
||||
const openTimestamps = {};
|
||||
|
||||
let initialized = false;
|
||||
|
||||
function hasUnlocked(id) {
|
||||
const unlocked = State.get('achievements');
|
||||
return unlocked && unlocked.includes(id);
|
||||
}
|
||||
|
||||
function unlock(ach) {
|
||||
if (hasUnlocked(ach.id)) return;
|
||||
State.addAchievement(ach.id);
|
||||
Bus.emit('achievement:unlocked', {
|
||||
id: ach.id,
|
||||
title: ach.title,
|
||||
desc: ach.desc,
|
||||
game: ach.game
|
||||
});
|
||||
}
|
||||
|
||||
function handleEvent(ach, data) {
|
||||
const trigger = ach.trigger;
|
||||
|
||||
// Event+id specific trigger (easter egg matches)
|
||||
if (trigger.id) {
|
||||
if (data && data.id === trigger.id) {
|
||||
unlock(ach);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple event trigger (no count, no accumulate) — unlock on first fire
|
||||
if (!trigger.count && !trigger.accumulate && !trigger.uniqueField) {
|
||||
unlock(ach);
|
||||
return;
|
||||
}
|
||||
|
||||
// Count-based trigger
|
||||
if (trigger.count) {
|
||||
counters[ach.id] = (counters[ach.id] || 0) + 1;
|
||||
if (counters[ach.id] >= trigger.count) {
|
||||
unlock(ach);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// UniqueField trigger
|
||||
if (trigger.uniqueField) {
|
||||
if (!uniqueSets[ach.id]) {
|
||||
uniqueSets[ach.id] = new Set();
|
||||
}
|
||||
const val = data && data[trigger.uniqueField];
|
||||
if (val) {
|
||||
uniqueSets[ach.id].add(val);
|
||||
if (uniqueSets[ach.id].size >= trigger.count) {
|
||||
unlock(ach);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Accumulate trigger
|
||||
if (trigger.accumulate) {
|
||||
const key = trigger.accumulate;
|
||||
const value = data && data[key];
|
||||
if (typeof value === 'number') {
|
||||
counters[ach.id] = (counters[ach.id] || 0) + value;
|
||||
if (counters[ach.id] >= trigger.threshold) {
|
||||
unlock(ach);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
// Pre-initialize counters for all achievements
|
||||
catalog.forEach(ach => {
|
||||
counters[ach.id] = 0;
|
||||
uniqueSets[ach.id] = ach.trigger.uniqueField ? new Set() : undefined;
|
||||
});
|
||||
|
||||
// Subscribe to each achievement's trigger event
|
||||
catalog.forEach(ach => {
|
||||
const trigger = ach.trigger;
|
||||
if (trigger.custom === 'speedrun') {
|
||||
// Speedrun: listen to app:open and app:close on wm.js
|
||||
Bus.on('app:open', (data) => {
|
||||
if (data && data.id) {
|
||||
openTimestamps[data.id] = Date.now();
|
||||
}
|
||||
});
|
||||
Bus.on('app:close', (data) => {
|
||||
if (data && data.id && openTimestamps[data.id]) {
|
||||
const delta = Date.now() - openTimestamps[data.id];
|
||||
if (delta < 2000) {
|
||||
unlock(ach);
|
||||
}
|
||||
delete openTimestamps[data.id];
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger.event) {
|
||||
Bus.on(trigger.event, (data) => {
|
||||
handleEvent(ach, data);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default { init };
|
||||
Reference in New Issue
Block a user