/**
* LittleJS Medal System
* - Achievement/trophy system for games
* - Medal class with name, description, icon, and unlock tracking
* - Automatic saving to local storage, unless a service like Newgrounds holds the medal (see Medal.isLocal)
* - Visual display queue with slide-in notifications
* - The Newgrounds plugin extends it with NewgroundsMedal, held on the server while logged in
* - Setting debugMedals = true in the game code before medalsInit skips the load and the save, and in the debug build logs the Newgrounds traffic; it is not exported, so only a script tag build can set it
* @namespace Medals
*/
'use strict';
let debugMedals = false; // see the header
///////////////////////////////////////////////////////////////////////////////
// Medals settings
/** How long to show medals for in seconds
* @type {number}
* @default
* @memberof Settings */
let medalDisplayTime = 5;
/** How quickly to slide on/off medals in seconds
* @type {number}
* @default
* @memberof Settings */
let medalDisplaySlideTime = .5;
/** Size of medal display
* @type {Vector2}
* @default Vector2(640,80)
* @memberof Settings */
let medalDisplaySize = vec2(640, 80);
/** Set to stop medals from being unlockable (like if cheats are enabled)
* @type {boolean}
* @default
* @memberof Settings */
let medalsPreventUnlock = false;
/** List of all medals
* @type {Object<number, Medal>}
* @memberof Medals */
const medals = {};
// Engine internal variables not exposed to documentation
let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast, medalsRenderAdded;
let medalsLoadWaiting = false; // medalsInit came before any medal, each one made reads its own unlock
///////////////////////////////////////////////////////////////////////////////
/** Initialize medals with a save name used for storage
* - Call this after creating all medals
* - Loads which medals are unlocked from the save, and writes the catalog back
* - A medal a service like Newgrounds holds is left as it is, see Medal.isLocal
* @param {string} saveName - The localStorage key the medals are kept under, a different one from the game's own
* readSaveData and writeSaveData, or each would overwrite the other
* @memberof Medals */
function medalsInit(saveName)
{
medalsSaveName = saveName;
medalsLoad();
// add the medal display once, however often this is called
if (!medalsRenderAdded)
engineAddPlugin(undefined, medalsRender);
medalsRenderAdded = true;
}
// check which local medals are unlocked in the save, and write the catalog back
function medalsLoad()
{
// with no medals made yet, the save is left as it is for them, a game that calls medalsInit first keeps its unlocks;
// it keeps waiting from then on, so loading again (a dropped Newgrounds session) does not drop medals still to come
medalsLoadWaiting ||= !Object.keys(medals).length;
if (debugMedals || !medalsSaveName) return;
const saved = readSaveData(medalsSaveName);
ASSERT(Object.keys(saved).every(key=> isNumber(+key)),
'the medals save name holds other data, give medalsInit a name of its own');
medalsForEach(medal=> {
if (medal.isLocal())
medal.unlocked = !!saved[medal.id]?.unlocked;
});
medalsSave();
}
// show the first medal in the queue, sliding it on and off
function medalsRender()
{
if (!medalsDisplayQueue.length) return;
// update first medal in queue
const medal = medalsDisplayQueue[0];
const elapsed = timeReal - medalsDisplayTimeLast;
if (!medalsDisplayTimeLast)
medalsDisplayTimeLast = timeReal;
else if (elapsed > medalDisplayTime)
{
medalsDisplayTimeLast = 0;
medalsDisplayQueue.shift();
}
else
{
// slide on/off medals, the slides share the display time when it is short
const slideTime = min(medalDisplaySlideTime, medalDisplayTime/2);
const slideOffTime = medalDisplayTime - slideTime;
const hidePercent =
elapsed < slideTime ? 1 - elapsed / slideTime :
elapsed > slideOffTime ? (elapsed - slideOffTime) / slideTime : 0;
medal.render(hidePercent);
}
}
/**
* @callback MedalCallbackFunction - Function that processes a medal
* @param {Medal} medal
* @memberof Medals
*/
/** Calls a function for each medal
* @param {MedalCallbackFunction} callback
* @memberof Medals */
function medalsForEach(callback)
{ Object.values(medals).forEach(medal=> callback(medal)); }
/** Reset all medals to locked and persist the cleared catalog
* - A medal a service like Newgrounds holds is left alone, the service has it
* @memberof Medals */
function medalsReset()
{
medalsForEach(medal=> medal.isLocal() && (medal.unlocked = false));
if (medalsLoadWaiting && medalsSaveName && !debugMedals)
{
// the saved unlocks of medals not made yet are cleared too, they are read when those medals are made
const saved = readSaveData(medalsSaveName);
for (const id in saved)
if (!medals[id] && saved[id] && typeof saved[id] === 'object')
saved[id].unlocked = false;
writeSaveData(medalsSaveName, saved);
}
medalsSave();
}
// write the local medals to the save, keeping the entries of medals a service holds
function medalsSave()
{
if (debugMedals || !medalsSaveName) return;
// while medalsInit waits for medals made later, their saved entries are kept for them
const saved = readSaveData(medalsSaveName);
const data = medalsLoadWaiting ? {...saved} : {};
for (const key in saved) // what is not a medal is the game's own, saved under the same name, and stays
isNumber(+key) || (data[key] = saved[key]);
medalsForEach(medal=> {
if (!medal.isLocal())
{
// a service holds this medal, its entry stays as it was for when it is local again
if (saved[medal.id]) data[medal.id] = saved[medal.id];
return;
}
const entry = {
name: medal.name,
description: medal.description,
icon: medal.icon,
unlocked: medal.unlocked,
};
if (medal.image) entry.src = medal.image.src;
data[medal.id] = entry;
});
writeSaveData(medalsSaveName, data);
}
///////////////////////////////////////////////////////////////////////////////
/**
* Medal - Tracks an unlockable medal
* @memberof Medals
* @example
* // create a medal
* const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️');
*
* // initialize medals
* medalsInit('Example Game');
*
* // unlock the medal
* medal_example.unlock();
*/
class Medal
{
/** Create a medal and add it to the list of medals
* @param {number} id - The unique identifier of the medal
* @param {string} name - Name of the medal
* @param {string} [description] - Description of the medal
* @param {string} [icon] - Icon for the medal
* @param {string} [src] - Image location for the medal
*/
constructor(id, name, description='', icon='🏆', src)
{
ASSERT(isNumber(id) && id >= 0 && !medals[id], 'medal id must be a unique number of 0 or more');
/** @property {number} - The unique identifier of the medal */
this.id = id;
/** @property {string} - Name of the medal */
this.name = name;
/** @property {string} - Description of the medal */
this.description = description;
/** @property {string} - Icon for the medal */
this.icon = icon;
/** @property {boolean} - Is the medal unlocked? */
this.unlocked = false;
/** @property {HTMLImageElement|undefined} - Source image for the medal icon, if any
* @type {HTMLImageElement|undefined} */
this.image = undefined;
if (src)
(this.image = new Image).src = src;
// add this to list of medals, unlocked if the save says so when medalsInit came before any medal
medals[id] = this;
if (medalsLoadWaiting && !debugMedals && this.isLocal())
this.unlocked = !!readSaveData(medalsSaveName)[id]?.unlocked;
}
/** Unlocks a medal if not already unlocked
* - The promise is optional, for when a game wants to know the outcome
* @return {Promise<boolean>} - Whether the medal is unlocked, right away unless a service like Newgrounds has to confirm */
unlock()
{
if (!medalsPreventUnlock && !this.unlocked)
{
ASSERT(medalsSaveName, 'save name must be set');
this.unlocked = true;
medalsSave();
medalsDisplayQueue.push(this);
}
return Promise.resolve(this.unlocked);
}
/** Whether the local save holds this medal, it is neither loaded nor written while a service like Newgrounds holds it
* @return {boolean} */
isLocal() { return true; }
/** Render a medal
* @param {number} [hidePercent] - How much to slide the medal off screen
*/
render(hidePercent=0)
{
const context = mainContext;
const width = min(medalDisplaySize.x, mainCanvasSize.x);
const height = medalDisplaySize.y;
const x = mainCanvasSize.x - width;
const y = -height*hidePercent;
const backgroundColor = hsl(0,0,.9);
// draw containing rect and clip to that region
context.save();
context.beginPath();
context.fillStyle = backgroundColor.toString();
context.strokeStyle = BLACK.toString();
const lineWidth = context.lineWidth = 3;
context.rect(x + lineWidth/2, y + lineWidth/2, width - lineWidth, height - lineWidth); // the whole border shows
context.fill();
context.stroke();
context.clip();
// draw the icon
const gap = vec2(.1, .05).scale(height);
const iconSize = height - 2*gap.x;
this.renderIcon(vec2(x + gap.x + iconSize/2, y + height/2), iconSize);
// draw the name
const nameSize = height*.5;
const descriptionSize = height*.3;
const pos = vec2(x + iconSize + 2*gap.x, y + gap.y*2 + nameSize/2);
const textWidth = width - iconSize - 3*gap.x;
drawTextScreen(this.name, pos, nameSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
// draw the description
pos.y = y + height - gap.y*2 - descriptionSize/2;
drawTextScreen(this.description, pos, descriptionSize, BLACK, 0, undefined, 'left', undefined, undefined, textWidth);
context.restore();
}
/** Render the icon for a medal
* @param {Vector2} pos - Screen space position
* @param {number} size - Screen space size
*/
renderIcon(pos, size)
{
// draw the image once it has loaded, or the icon; a broken image would throw
const image = this.image;
if (image && image.complete && image.naturalWidth)
mainContext.drawImage(image, pos.x-size/2, pos.y-size/2, size, size);
else
drawTextScreen(this.icon, pos, size*.7, BLACK);
}
}
///////////////////////////////////////////////////////////////////////////////
// Medals setting setters
/** Set how long to show medals for in seconds
* @param {number} time
* @memberof Settings */
function setMedalDisplayTime(time) { medalDisplayTime = time; }
/** Set how quickly to slide on/off medals in seconds
* @param {number} time
* @memberof Settings */
function setMedalDisplaySlideTime(time) { medalDisplaySlideTime = time; }
/** Set size of medal display
* @param {Vector2} size
* @memberof Settings */
function setMedalDisplaySize(size) { medalDisplaySize = size.copy(); }
/** Set to stop medals from being unlockable
* @param {boolean} preventUnlock
* @memberof Settings */
function setMedalsPreventUnlock(preventUnlock) { medalsPreventUnlock = preventUnlock; }