Skip to content

Instantly share code, notes, and snippets.

@hogart
Last active April 29, 2021 22:43
Show Gist options
  • Save hogart/bde3fb475893f3990c217b2bc1b62546 to your computer and use it in GitHub Desktop.
Save hogart/bde3fb475893f3990c217b2bc1b62546 to your computer and use it in GitHub Desktop.
twine-achievements
::StoryTitle
twine-achievements
::StoryAbout
This is Twee 3 code. Learn more here: [[https://twinery.org/cookbook/terms/terms_twee.html]].
Icon used ("icon-achievement" passage): [[https://game-icons.net/1x1/skoll/achievement.html]]. You ''must'' properly credit the original, or use your own image.
Features:
* displays notification in lower-right corner when achievement is awarded
* adds a button to sidebar to view player's achievements progress
* remembers achievements between playthroughs and page reloads
* achievements include title, description and (optionally) date when it was awarded
* achievements can be "hidden" to avoid spoilers and hints
* achievements can have "weight", so, for example, "golden" trophy contributes 10% of overall progress, "silver" just 5% and so on.
Conditions for achievements are tested when player transits to a passage, but you can call it manually any time:
{{{"""<<script>>window.achievementRenderer.manager.test()<</script>>"""}}}
Prerequsites:
* You should include {{{menuButton.js}}}: [[https://github.com/hogart/sugar-cube-utils#menubuttonjs]]
* You should include pluralizer of some sort (or write your own): [[https://github.com/hogart/sugar-cube-utils#plurals-enjs-and-plurals-rujs]]
::icon-achievement
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="achievement-icon">
<path d="M305.975 298.814l22.704 2.383V486l-62.712-66.965V312.499l18.214 8.895zm-99.95 0l-22.716 2.383V486l62.711-66.965V312.499l-18.213 8.895zm171.98-115.78l7.347 25.574-22.055 14.87-1.847 26.571-25.81 6.425-10.803 24.314-26.46-2.795-18.475 19.087L256 285.403l-23.902 11.677-18.475-19.15-26.46 2.795-10.803-24.313-25.81-6.363-1.847-26.534-22.118-14.92 7.348-25.573-15.594-21.544 15.644-21.52-7.398-25.523 22.068-14.87L150.5 73.03l25.86-6.362 10.803-24.313 26.46 2.794L232.098 26 256 37.677 279.902 26l18.475 19.149 26.46-2.794 10.803 24.313 25.81 6.425 1.847 26.534 22.055 14.87-7.347 25.574 15.656 21.407zm-49.214-21.556a72.242 72.242 0 1 0-72.242 72.242 72.355 72.355 0 0 0 72.242-72.242zm-72.242-52.283a52.282 52.282 0 1 0 52.282 52.283 52.395 52.395 0 0 0-52.282-52.245z"/>
</svg>
::Style [stylesheet]
.achievements-container {
position: fixed;
bottom: 1em;
right: 1em;
opacity: 0;
pointer-events: none;
transition: 150ms all ease-in;
cursor: pointer;
}
.achievements-container.open {
opacity: 1;
pointer-events: auto;
transition: all 50ms ease-out;
}
.achievements-container {
position: fixed;
bottom: 1em;
right: 1em;
opacity: 0;
pointer-events: none;
transition: 150ms all ease-in;
cursor: pointer;
}
.achievements-container.open {
opacity: 1;
pointer-events: auto;
transition: all 50ms ease-out;
}
.achievement {
width: 20em;
height: 5em;
padding: 0.5em;
border: 1px solid currentColor;
background-color: inherit !important;
box-shadow: 0em 0em 1em 1em;
display: flex;
justify-content: space-between;
align-items: stretch;
flex-direction: row;
}
.achievement-dialog .achievement {
box-shadow: none;
display: inline-flex;
margin-right: 0.5em;
margin-bottom: 0.5em;
}
.achievement-icon {
margin-right: 0.5em;
width: 6em;
fill: currentColor;
}
.achievement-content {
display: flex;
justify-content: space-around;
flex-direction: column;
height: 100%;
flex-grow: 1;
}
.achievement-title {
font-size: 130%;
margin: 0;
line-height: 1;
}
.achievement-text {
font-size: 90%;
margin: 0;
line-height: 1;
}
.achievement-date {
font-size: 80%;
margin: 0;
line-height: 1;
}
.achievement-dialog {
width: 44em;
}
::Script[script]
(function () {
'use strict';
const achievements = [
// this achievement will be awarded when user reaches passage titled "The end"
/*{
id: 'finish-the-game',
title: 'Beat the game',
description: 'Victory!',
unlocked: false,
hidden: true,
test() {
const currentPassage = passage();
if (['The end'].includes(currentPassage)) {
return true;
}
}
},*/
// this achievement will be awarded when user reached "The end" and killed the dragon
/*{
id: 'kill-the-dragon',
title: 'Dragonslayer',
description: 'Killed that pesky dragon!',
unlocked: false,
hidden: true,
test() {
const currentPassage = passage();
if (['The end'].includes(currentPassage) && Story.getVar('$isDragonDead')) {
return true;
}
}
},*/
];
window.game = Object.assign(window.game || {}, {
achievements,
});
}());
(function () {
'use strict';
/**
* @typedef {Object} IAchievement
* @property {String} id
* @property {String} title
* @property {String} description
* @property {Function} check
* @property {Boolean} unlocked
* @property {Boolean} [hidden=false]
* @property {Number} [weight=null]
* @property {Date} [date=null]
*/
/**
* @typedef {Object} IAchievementOverview
* @property {IAchievement[]} locked
* @property {IAchievement[]} unlocked
* @property {Number} hidden
* @property {Number} weight
*/
class AchievementManager {
/**
* @param {IAchievement[]} list
* @param {Function} onUnlock
*/
constructor(list, onUnlock) {
this.list = list;
this.onUnlock = onUnlock;
const weight = this.list.reduce((weight, /** @type {IAchievement} */achievement) => {
return weight + achievement.weight
}, 0);
if (!isNaN(weight)) {
this.totalWeight = weight;
}
}
test() {
/** @type IAchievement[] */
const unlocked = []; // it's possible to unlock several achievements at once
for (const achievement of this.list) {
if (!achievement.unlocked && achievement.test()) {
achievement.unlocked = true;
achievement.date = new Date();
unlocked.push(achievement);
}
}
if (unlocked.length) {
this.onUnlock(unlocked);
}
}
/**
* @return {IAchievementOverview}
*/
getOverview() {
/** @type IAchievementOverview */
const overview = {
locked: [],
unlocked: [],
hidden: 0,
weight: 0,
};
return this.list.reduce((overview, achievement) => {
if (achievement.unlocked) {
overview.unlocked.push(achievement);
if (this.totalWeight) {
overview.weight += achievement.weight;
}
} else {
if (achievement.hidden) {
overview.hidden += 1;
} else {
overview.locked.push(achievement);
}
}
return overview;
}, overview);
}
}
window.scUtils = Object.assign(window.scUtils || {}, {
AchievementManager,
});
}());
(function(dateFormat, dialogTitle, pluralize) {
'use strict';
/* globals scUtils, passage, Dialog, jQuery */
function defaultDateFormat (date) {
return date.toString();
}
dateFormat = dateFormat === null
? null
: (dateFormat || defaultDateFormat);
class AchievementRenderer {
constructor(achievements) {
const unlocked = this.load();
achievements.forEach((achievement) => {
const unlockedItem = unlocked.find((a) => achievement.id === a.id);
if (unlockedItem) {
achievement.unlocked = true;
achievement.date = unlockedItem.date;
}
});
this.manager = new window.scUtils.AchievementManager(achievements, this.onUnlock.bind(this));
jQuery(document).on(':passagedisplay', this.onPassageDisplay.bind(this));
this.$notificationContainer = jQuery('<div class="achievements-container"></div>');
this.$notificationContainer.appendTo('body');
this.$notificationContainer.on('click', () => {
this.displayAchievementsList();
});
scUtils.createHandlerButton(dialogTitle, '\\e809\\00a0', 'achievements', () => {
this.displayAchievementsList();
});
this._icon = Story.get('icon-achievement').processText();
}
load() {
return JSON.parse(localStorage.getItem(Story.title + '-unlocked') || '[]').map((a) => {
return {id: a.id, date: new Date(a.date)};
});
}
save(items) {
localStorage.setItem(
Story.title + '-unlocked',
JSON.stringify(items)
);
}
onUnlock(achievements) {
this.save([...this.load(), ...achievements.map((a) => {
return { id: a.id, date: a.date.toString() };
})])
this.displayNotification(achievements);
}
displayNotification(achievements) {
this.$notificationContainer.html(
achievements.map(this.renderAchievement, this)
);
this.$notificationContainer.addClass('open');
setTimeout(this.hideNotification.bind(this), 5000);
}
hideNotification() {
this.$notificationContainer.removeClass('open');
this.$notificationContainer.one('animationend', () => {
this.$notificationContainer.html('')
});
}
displayAchievementsList() {
const overview = this.manager.getOverview();
let html = `
${overview.unlocked.map(this.renderAchievement, this).join('')}
`;
if (overview.hidden > 0) {
const hiddenAchievements = pluralize(overview.hidden);
html += `<p>${hiddenAchievements}</p>`
}
Dialog.setup(dialogTitle, 'achievement-dialog');
Dialog.append(html);
Dialog.open();
}
onPassageDisplay() {
this.manager.test();
}
renderAchievement(achievement) {
return `
<div class="achievement">
${this._icon}
<div class="achievement-content">
<h6 class="achievement-title">${achievement.title}</h6>
<p class="achievement-text">${achievement.description}</p>
${dateFormat ? <p class="achievement-date">${dateFormat(achievement.date)}</p> : ''}
</div>
</div>
`
}
}
window.scUtils = Object.assign(window.scUtils || {}, {
AchievementRenderer,
});
}(
null /* null disables showing date completely; pass a function here to format date, or undefined for default formatting */,
'Achievements', /* Dialog and button title */
(hiddenAchievementsCount, unlockedAchievementsCount) => { /* Pluralizer function (which renders 'And 3 hidden achievements' after the list) */
const template = unlockedAchievementsCount > 0 ? : 'And ${amount} ${plural}.' : '${amount} ${plural}.';
return scUtils.pluralizeFmt(['hidden achievement', 'hidden achievements'], template)(hiddenAchievementsCount);
}
));
::StoryInit
<<script>>window.achievementRenderer = new scUtils.AchievementRenderer(window.game.achievements);<</script>>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment