diff --git a/.gitignore b/.gitignore index cdade93f..2cf3b9f2 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ gulp/runnable-texturepacker.jar tmp_standalone_files tmp_standalone_files_china tmp_standalone_files_wegame +contributors.json # Local config config.local.js diff --git a/gulp/contributors.js b/gulp/contributors.js new file mode 100644 index 00000000..4a1ccbfe --- /dev/null +++ b/gulp/contributors.js @@ -0,0 +1,180 @@ +const fs = require("fs"); +const fsp = require("fs/promises"); +const path = require("path"); +const nodeFetch = require("node-fetch"); + +const APILink = "https://api.github.com/repos/tobspr/shapez.io"; +const numOfReqPerPage = 100; // Max is 100, change to something lower if loads are too long +const personalAccessToken = "PUT TOKEN HERE"; + +const JSONFileLocation = path.join(__dirname, "..", "contributors.json"); + +function fetch(url) { + return nodeFetch(url, { + headers: [["Authorization", "token " + personalAccessToken]], + }); +} + +function JSONFileExists() { + return fs.existsSync(JSONFileLocation); +} + +function readJSONFile() { + return fsp.readFile(JSONFileLocation, { encoding: "utf-8" }).then(res => JSON.parse(res)); +} + +function writeJSONFile(translators, contributors) { + return fsp.writeFile( + JSONFileLocation, + JSON.stringify({ + lastUpdatedAt: Date.now(), + //@ts-ignore + translators: Array.from(translators, ([username, value]) => ({ username, value })), + //@ts-ignore + contributors: Array.from(contributors, ([username, value]) => ({ username, value })), + }) + ); +} + +function getTotalNumOfPRs() { + return fetch(APILink + "/pulls?state=closed&per_page=1&page=0").then(res => { + // This part is very messy + let link = res.headers.get("Link"); + link = link.slice(link.indexOf(",") + 1); // Gets rid of the first "next" link + return parseInt(link.slice(link.indexOf("&page=") + 6, link.indexOf(">"))); + }); +} + +function shouldDownloadPRs() { + if (!JSONFileExists()) return Promise.resolve(true); + else { + return readJSONFile().then(res => Date.now() - res.lastUpdatedAt > 1000 * 60 * 30); // once every 30 min + } +} + +function PRIsTranslation(link) { + // We just say that if a PR only edits translation files, its a translation, all others are something else + return fetch(link + "/files") + .then(res => res.json()) + .then(res => { + if (res.message) { + console.log("GITHUB HAS RATE-LIMITED THIS MACHINE, PLEASE WAIT ABOUT AN HOUR"); + throw new Error("rate-limit reached"); + } + + for (let file of res) { + if (!file.filename.startsWith("translations/")) return false; + } + return true; + }); +} + +async function sortPRs(prs) { + const contributors = new Map(); + const translators = new Map(); + + const clearLine = () => { + process.stdout.moveCursor(0, -1); // up one line + process.stdout.clearLine(1); + }; + + for (let i = 0; i < prs.length; i++) { + let map; + + if (await PRIsTranslation(prs[i].url)) map = translators; + else map = contributors; + + if (!map.has(prs[i].username)) map.set(prs[i].username, []); + + map.get(prs[i].username).push(prs[i]); + + if (i !== 0) clearLine(); + console.log(`PR's Downloaded: ${i} out of ${prs.length} (${prs.length - i} left)`); + } + + clearLine(); + console.log("Downloaded All PR's"); + + return { + contributors, + translators, + }; +} + +function reqPage(page) { + return fetch(APILink + `/pulls?state=closed&per_page=${numOfReqPerPage}&page=${page}`) + .then(res => res.json()) + .then(async res => { + const prs = []; + + for (let i = 0; i < res.length; i++) { + if (!res[i].merged_at) { + continue; + } // Skip files that were never merged + + const prInfo = { + username: res[i].user.login, + html_url: res[i].html_url, + url: res[i].url, + title: res[i].title, + }; + + prs.push(prInfo); + // if (await PRIsTranslation(res[i].url)) { + // translations.push(prInfo); + // } else { + // others.push(prInfo); + // } + } + + return prs; + }); +} + +async function downloadAllPrs() { + const totalNumOfPrs = await getTotalNumOfPRs(); + const numOfPageReqs = Math.ceil(totalNumOfPrs / numOfReqPerPage); + + const prs = []; + + for (let i = 1; i < numOfPageReqs + 1; i++) { + prs.push(...(await reqPage(i))); // Yes promise.all would be good, but I wanna keep them in order (at least for now) + } + + return prs; +} + +async function tryToUpdateContributors() { + if (personalAccessToken === "PUT TOKEN HERE") { + console.log("A github token was not provided, writing default contributors.json"); + await writeJSONFile([], []); + return; + } + + if (!(await shouldDownloadPRs())) { + console.log("Not updating contributors to prevent github API from rate-limiting this computer"); + console.log("If you wish to force a contributors update, use contributors.build.force"); + return; + } + + await updateContributors(); +} + +async function updateContributors() { + const allPrs = await downloadAllPrs(); + console.log(`Discovered ${allPrs.length} PRs`); + + const sorted = await sortPRs(allPrs); + + await writeJSONFile(sorted.translators, sorted.contributors); + console.log("Wrote JSON File"); +} + +function gulpTaskContributors($, gulp) { + gulp.task("contributors.build", cb => tryToUpdateContributors().then(() => cb)); + gulp.task("contributors.build.force", cb => updateContributors().then(() => cb)); +} + +module.exports = { + gulpTaskContributors, +}; diff --git a/gulp/gulpfile.js b/gulp/gulpfile.js index 0f4f4185..63ff23ea 100644 --- a/gulp/gulpfile.js +++ b/gulp/gulpfile.js @@ -74,6 +74,8 @@ releaseUploader.gulptasksReleaseUploader($, gulp, buildFolder); const translations = require("./translations"); translations.gulptasksTranslations($, gulp, buildFolder); +const contributors = require("./contributors"); +contributors.gulpTaskContributors($, gulp, buildFolder); ///////////////////// BUILD TASKS ///////////////////// // Cleans up everything diff --git a/gulp/package.json b/gulp/package.json index 2a17b4fd..59148d73 100644 --- a/gulp/package.json +++ b/gulp/package.json @@ -15,7 +15,7 @@ "@babel/preset-env": "^7.5.4", "@types/cordova": "^0.0.34", "@types/filesystem": "^0.0.29", - "@types/node": "^12.7.5", + "@types/node": "^15.12.4", "ajv": "^6.10.2", "audiosprite": "^0.7.2", "babel-core": "^6.26.3", @@ -41,6 +41,7 @@ "ignore-loader": "^0.1.2", "lz-string": "^1.4.4", "markdown-loader": "^5.1.0", + "node-fetch": "^2.6.1", "node-sri": "^1.1.1", "phonegap-plugin-mobile-accessibility": "^1.0.5", "postcss": ">=5.0.0", diff --git a/gulp/yarn.lock b/gulp/yarn.lock index f4f3ba7f..3bf19500 100644 --- a/gulp/yarn.lock +++ b/gulp/yarn.lock @@ -994,11 +994,16 @@ resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== -"@types/node@*", "@types/node@^12.7.5": +"@types/node@*": version "12.7.5" resolved "https://registry.npmjs.org/@types/node/-/node-12.7.5.tgz" integrity sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w== +"@types/node@^15.12.4": + version "15.12.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.4.tgz#e1cf817d70a1e118e81922c4ff6683ce9d422e26" + integrity sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA== + "@types/q@^1.5.1": version "1.5.2" resolved "https://registry.npmjs.org/@types/q/-/q-1.5.2.tgz" @@ -8318,6 +8323,11 @@ no-case@^2.2.0: dependencies: lower-case "^1.1.1" +node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-libs-browser@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz" diff --git a/src/css/main.scss b/src/css/main.scss index 1bd82828..7aae2c3f 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -30,6 +30,7 @@ @import "states/mobile_warning"; @import "states/changelog"; @import "states/puzzle_menu"; +@import "states/credits.scss"; @import "ingame_hud/buildings_toolbar"; @import "ingame_hud/building_placer"; diff --git a/src/css/states/credits.scss b/src/css/states/credits.scss new file mode 100644 index 00000000..af6d53be --- /dev/null +++ b/src/css/states/credits.scss @@ -0,0 +1,35 @@ +#state_CreditsState { + .container .content { + text-align: center; + + .tobspr .title { + } + + .section { + @include S(margin-top, 10px); + @include S(padding, 5px); + background-color: #f8f8f8; + @include DarkThemeOverride { + background: rgba(0, 10, 20, 0.1); + } + + .title { + @include Heading; + + color: #555; + @include DarkThemeOverride { + color: #eee; + } + } + + .people { + max-height: 0; + overflow: hidden; + transition: max-height 0.5s ease-out; + } + .people > :first-child { + @include S(margin-top, 8px); + } + } + } +} diff --git a/src/js/application.js b/src/js/application.js index 4e74b014..42755f8d 100644 --- a/src/js/application.js +++ b/src/js/application.js @@ -34,6 +34,7 @@ import { RestrictionManager } from "./core/restriction_manager"; import { PuzzleMenuState } from "./states/puzzle_menu"; import { ClientAPI } from "./platform/api"; import { LoginState } from "./states/login"; +import { CreditsState } from "./states/credits"; /** * @typedef {import("./platform/achievement_provider").AchievementProviderInterface} AchievementProviderInterface @@ -165,6 +166,7 @@ export class Application { ChangelogState, PuzzleMenuState, LoginState, + CreditsState, ]; for (let i = 0; i < states.length; ++i) { diff --git a/src/js/states/about.js b/src/js/states/about.js index 4380b02c..5739fcac 100644 --- a/src/js/states/about.js +++ b/src/js/states/about.js @@ -35,6 +35,14 @@ export class AboutState extends TextualGameState { { preventClick: true } ); }); + + const stateChangers = this.htmlElement.querySelectorAll("a[state]"); + console.log(stateChangers); + stateChangers.forEach(element => { + this.trackClicks(element, () => this.moveToState(element.getAttribute("state")), { + preventClick: true, + }); + }); } getDefaultPreviousState() { diff --git a/src/js/states/credits.js b/src/js/states/credits.js new file mode 100644 index 00000000..b5feb5c2 --- /dev/null +++ b/src/js/states/credits.js @@ -0,0 +1,112 @@ +import { TextualGameState } from "../core/textual_game_state"; +import { contributors, translators } from "../../../contributors.json"; +import { T } from "../translations"; + +export class CreditsState extends TextualGameState { + constructor() { + super("CreditsState"); + } + + getStateHeaderTitle() { + return T.credits.title; + } + + getMainContentHTML() { + return ` +
+ +
+
${this.linkify("https://github.com/tobspr", "Tobias Springer")}
+
+
+
+ +
+
${this.linkify( + "https://soundcloud.com/pettersumelius", + "Peppsen" + )} - ${T.credits.specialThanks.descriptions.peppsen}
+
Add some other people here (Whoever you think deserves it)
+ +
+
+
+ +
+ ${this.getGithubHTML(translators)} +
+
+
+ +
+ ${this.getGithubHTML(contributors)} +
+
+ `; + } + + linkify(href, text) { + return `${text}`; + } + + getGithubHTML(list) { + let html = ""; + for (let i = 0; i < list.length; i++) { + html += ` + ${i === 0 ? "" : "
"} +
+ ${this.linkify(`https://github.com/${list[i].username}`, list[i].username)}:
${list[ + i + ].value + .map(pr => { + return `${this.linkify(pr.html_url, this.getGoodTitle(pr.title))}, `; + }) + .reduce((p, c) => p + c) + .slice(0, -2)} +
+ `; + } + return html; + } + + getGoodTitle(title) { + if (title.endsWith(".")) return title.slice(0, -1); + + return title; + } + + onEnter() { + // Allow the user to close any section by clicking on the title + const buttons = this.htmlElement.querySelectorAll("button.title"); + buttons.forEach(button => { + /** @type {HTMLElement} */ + //@ts-ignore + const people = button.nextElementSibling; + + button.addEventListener("click", e => { + if (people.style.maxHeight) { + people.style.maxHeight = null; + } else { + people.style.maxHeight = people.scrollHeight + "px"; + } + }); + + // Set them to open at the start + people.style.maxHeight = people.scrollHeight + "px"; + }); + + // Link stuff + const links = this.htmlElement.querySelectorAll("a[href]"); + links.forEach(link => { + this.trackClicks( + link, + () => this.app.platformWrapper.openExternalLink(link.getAttribute("href")), + { preventClick: true } + ); + }); + } + + getDefaultPreviousState() { + return "AboutState"; + } +} diff --git a/translations/base-en.yaml b/translations/base-en.yaml index c37a4610..b8ea8a28 100644 --- a/translations/base-en.yaml +++ b/translations/base-en.yaml @@ -1368,6 +1368,8 @@ about: body: >- This game is open source and developed by Tobias Springer (this is me).

+ Have a look at the Credits to see every who has helped out by translating or contributing.

+ If you want to contribute, check out shapez.io on GitHub.

This game wouldn't have been possible without the great Discord community around my games - You should really join the Discord server!

@@ -1379,6 +1381,18 @@ about: changelog: title: Changelog +credits: + title: Credits + tobspr: Main Programer and Artist + specialThanks: + title: Special Thanks To + descriptions: + peppsen: Created the awesome soundtrack + translators: + title: Translators + contributors: + title: Contributors + demo: features: restoringGames: Restoring savegames