Last active
April 29, 2021 22:43
-
-
Save hogart/bde3fb475893f3990c217b2bc1b62546 to your computer and use it in GitHub Desktop.
twine-achievements
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
::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