├── src ├── assets │ └── ship.png ├── ico.svg ├── styles │ └── style.css ├── index.html └── scripts │ └── app.js ├── .gitignore ├── resources ├── externs.js ├── mf.webmanifest ├── sw_init.js └── service_worker.js ├── LICENSE ├── package.json ├── README.md └── gulpfile.js /src/assets/ship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foumart/JS.13kGames/HEAD/src/assets/ship.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | zip 3 | public 4 | temp 5 | package-lock.json 6 | .vscode 7 | .github 8 | .git -------------------------------------------------------------------------------- /src/ico.svg: -------------------------------------------------------------------------------- 1 | ico -------------------------------------------------------------------------------- /resources/externs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Explicitly list variables, objects or method whose names should not be mangled by the closer-compiler. 3 | * 4 | * @externs 5 | */ 6 | 7 | 8 | /** 9 | * Keep canvas roundRect (gets mangled for some reason) 10 | * @record 11 | */ 12 | CanvasRenderingContext2D.prototype.roundRect 13 | -------------------------------------------------------------------------------- /src/styles/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | padding: 0; 3 | margin: 0; 4 | overflow: hidden; 5 | } 6 | 7 | body { 8 | background-color: #012; 9 | color: #fff; 10 | } 11 | 12 | div, canvas { 13 | user-select: none; 14 | touch-action: none; 15 | -webkit-tap-highlight-color: transparent; 16 | box-sizing: border-box; 17 | } 18 | -------------------------------------------------------------------------------- /resources/mf.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "{TITLE}", 3 | "start_url": "index.html", 4 | "serviceworker": { 5 | "src": "service_worker.js" 6 | }, 7 | "icons": [ 8 | { 9 | "src": "ico.{ICON_EXTENSION}", 10 | "type": "{ICON_TYPE}", 11 | "sizes": "{ICON_SIZE}x{ICON_SIZE}" 12 | } 13 | ], 14 | "display": "fullscreen", 15 | "orientation": "{ORIENTATION}" 16 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {TITLE} 7 | 8 | rep_mobile 9 | 10 | rep_social 11 | 12 | rep_css 13 | 14 | 15 | 16 | rep_js 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Noncho Savov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js13k_template", 3 | "version": "0.0.1", 4 | "title": "JS13K Template", 5 | "description": "js13k progressive web app starter pack", 6 | "keywords": [ 7 | "js13kgames", 8 | "js13k", 9 | "pwa", 10 | "template" 11 | ], 12 | "orientation": "any", 13 | "iconExtension": "svg", 14 | "iconType": "image/svg+xml", 15 | "iconSize": "160", 16 | 17 | "main": "src/scripts/app.js", 18 | "scripts": { 19 | "preinstall": "npx npm-force-resolutions", 20 | "build": "gulp --dir public --pwa --mobile", 21 | "prod": "gulp --dir public --pwa --mobile --roadroll", 22 | "debug": "gulp --dir public --pwa --mobile --debug", 23 | "raw": "gulp --dir public --pwa --mobile --raw", 24 | "sync": "gulp sync --dir public", 25 | "test": "gulp zip --dir public" 26 | }, 27 | "devDependencies": { 28 | "google-closure-compiler": "20240317.0.0", 29 | "browser-sync": "3.0.2", 30 | "del": "7.1.0", 31 | "glob": "11.1.0", 32 | "gulp": "5.0.0", 33 | "gulp-advzip": "1.3.0", 34 | "gulp-clean-css": "4.3.0", 35 | "gulp-concat": "2.6.1", 36 | "gulp-htmlmin": "5.0.1", 37 | "gulp-if": "3.0.0", 38 | "gulp-imagemin": "9.1.0", 39 | "gulp-string-replace": "1.1.2", 40 | "gulp-zip": "6.0.0", 41 | "yargs": "17.7.2", 42 | "roadroller": "2.1.0" 43 | }, 44 | "dependencies": { 45 | "npm": "^8.11.0" 46 | }, 47 | "repository": { 48 | "type": "git", 49 | "url": "git+https://github.com/foumart/JS.13kGames.git" 50 | }, 51 | "author": { 52 | "name": "Noncho Savov", 53 | "email": "contact@foumartgames.com", 54 | "url": "https://www.foumartgames.com/" 55 | }, 56 | "license": "MIT" 57 | } 58 | -------------------------------------------------------------------------------- /src/scripts/app.js: -------------------------------------------------------------------------------- 1 | // Example game initialization script: 2 | function init() { 3 | const _div = document.createElement('div'); 4 | _div.innerHTML = '
JS13k game ready to launch!
'; 5 | document.body.appendChild(_div); 6 | 7 | const _img = document.createElement('img'); 8 | _img.src = 'assets/ship.png'; 9 | _img.style = 'position:absolute;left:50%;top:50%;margin:-90px 0 0 -90px;animation:fly 2s ease infinite'; 10 | document.body.appendChild(_img); 11 | 12 | const _btn = document.createElement('button'); 13 | _btn.innerHTML = 'Toggle Fullscreen'; 14 | _btn.style = 'position:absolute;left:50%;width:150px;height:30px;margin:0 0 0 -75px;'; 15 | document.body.appendChild(_btn); 16 | 17 | _btn.addEventListener('click', (e) => { 18 | toggleFullscreen(); 19 | }); 20 | 21 | const _play = document.createElement('button'); 22 | _play.innerHTML = 'PLAY!'; 23 | _play.style = 'position:absolute;left:50%;width:100px;height:50px;margin:0 0 0 -50px;bottom:100px'; 24 | document.body.appendChild(_play); 25 | 26 | _play.addEventListener('click', (e) => { 27 | _play.style.display = 'none'; 28 | _div.style.display = 'none'; 29 | _btn.style.display = 'none'; 30 | playButtonClick(); 31 | }); 32 | } 33 | 34 | function toggleFullscreen() { 35 | if (!document.fullscreenElement) { 36 | document.documentElement.requestFullscreen(); 37 | } else if (document.exitFullscreen) { 38 | document.exitFullscreen(); 39 | } 40 | } 41 | 42 | function playButtonClick() { 43 | 44 | // Generate the sky starfield 45 | const cssSky = window.document.styleSheets[0]; 46 | cssSky.insertRule( 47 | `@keyframes scroll {0% { top: -100%; } 25% { top: -50%; } 50% { top: 0; } 75% { top: 50%; } 100% { top: 100%; }}`, 48 | cssSky.cssRules.length 49 | ); 50 | createStarField(); 51 | 52 | // Start spaceship animation 53 | const cssShip = window.document.styleSheets[0]; 54 | cssShip.insertRule( 55 | `@keyframes fly {0% {margin-top: -90px;} 50% { margin-top: -110px; } 100% { margin-top: -90px; }}`, 56 | cssShip.cssRules.length 57 | ); 58 | } 59 | 60 | function createStarField() { 61 | const width = window.innerWidth; 62 | const height = window.innerHeight; 63 | let x, y; 64 | 65 | const stars1 = document.createElement('div'); 66 | stars1.style = `position:absolute;width:${width}px;height:${height}px;top:-${height}px;overflow:unset;animation:scroll 4s linear infinite`; 67 | document.body.insertBefore(stars1, document.body.firstChild); 68 | 69 | const stars2 = document.createElement('div'); 70 | stars2.style = `position:absolute;width:${width}px;height:${height}px;top:-${height}px;overflow:unset;animation:scroll 4s linear 2s infinite`; 71 | document.body.insertBefore(stars2, document.body.firstChild); 72 | 73 | setTimeout(i => { 74 | stars1.innerHTML = ``; 75 | stars2.innerHTML = ``; 76 | for (i = 99; i--;) { 77 | x = Math.random()*width; 78 | y = Math.random()*height; 79 | stars1.children[0].innerHTML += ``; 80 | stars2.children[0].innerHTML += ``; 81 | } 82 | }, 1); 83 | } -------------------------------------------------------------------------------- /resources/sw_init.js: -------------------------------------------------------------------------------- 1 | // Progressive Web App service worker initialization script - feel free to remove if you are not going to build a PWA. 2 | 3 | // Set debug to true if you want to see logs about caching / fetching of resources and other output. 4 | let _debug; 5 | 6 | // Progressive web apps can work only with secure connections. 7 | const _online = location.protocol.substring(0, 5) === "https"; 8 | 9 | // Service worker detection and installation script: 10 | if ("serviceWorker" in navigator && _online) { 11 | navigator.serviceWorker.getRegistrations().then(registrations => { 12 | let isRegistered; 13 | for (let i = 0; i < registrations.length; i++) { 14 | if (window.location.href.indexOf(registrations[i].scope) > -1) isRegistered = true; 15 | } 16 | if (isRegistered) { 17 | if (_debug) console.log("ServiceWorker already registered"); 18 | } else { 19 | if (_debug) { 20 | navigator.serviceWorker.register("service_worker.js").then(() => { 21 | console.log("ServiceWorker registered successfully"); 22 | }).catch(() => { 23 | console.log("ServiceWorker registration failed"); 24 | pwaInit(); 25 | }); 26 | } else { 27 | navigator.serviceWorker.register("service_worker.js").catch(() => { 28 | pwaInit(); 29 | }); 30 | } 31 | } 32 | }).catch(() => { 33 | if (_debug) console.log("ServiceWorker bypassed locally"); 34 | pwaInit(); 35 | }); 36 | navigator.serviceWorker.ready.then(() => { 37 | if (_debug) console.log('ServiceWorker is now active'); 38 | pwaInit(); 39 | }); 40 | } else { 41 | if (_debug) { 42 | if (location.protocol.substring(0, 5) != "https") { 43 | console.log("ServiceWorker is disabled on localhost"); 44 | } else { 45 | console.log("ServiceWorker not found in navigator"); 46 | } 47 | } 48 | 49 | window.addEventListener("load", pwaInit); 50 | } 51 | 52 | // Record if the game is being run as a PWA in its own window, separate from the browser. 53 | //let _standalone; 54 | 55 | function pwaInit() { 56 | //_standalone = window.matchMedia('(display-mode: standalone)').matches; 57 | 58 | // to be implemented - feel free to overwrite. 59 | init(); 60 | } 61 | 62 | /* 63 | // Provide your own in-app install experience: https://web.dev/customize-install/ 64 | // Here we are capturing the install prompt and invoking it later on user input: 65 | let _deferredPrompt; 66 | 67 | window.addEventListener("beforeinstallprompt", beforeInstallPrompt); 68 | 69 | // Generate the user input button which will trigger the install prompt 70 | const _btn = document.createElement('button'); 71 | _btn.innerHTML = "Install PWA"; 72 | _btn.style = "display: none; position: relative; left: 50%; width: 100px; margin: 5px 0 5px -50px"; 73 | document.body.appendChild(_btn); 74 | 75 | function beforeInstallPrompt(event) { 76 | event.preventDefault(); 77 | _deferredPrompt = event; 78 | _btn.style.display = 'block'; 79 | 80 | _btn.addEventListener('click', (e) => { 81 | _btn.style.display = 'none'; 82 | // Show the prompt 83 | _deferredPrompt.prompt(); 84 | // Wait for the user to respond to the prompt 85 | _deferredPrompt.userChoice.then((choiceResult) => { 86 | if (_debug) { 87 | if (choiceResult.outcome === 'accepted') { 88 | console.log('User accepted to install the app to his device home screen'); 89 | } else { 90 | console.log('User dismissed install prompt'); 91 | } 92 | } 93 | // Prevent triggering again the prompt on 'Add' or 'Cancel' click. 94 | window.removeEventListener("beforeinstallprompt", beforeInstallPrompt); 95 | _deferredPrompt = null; 96 | }); 97 | }); 98 | } 99 | 100 | // Listen for the event on successfull install. 101 | window.addEventListener("appinstalled", event => { 102 | if (_debug) console.log("PWA installed successfully!"); 103 | //... 104 | }); 105 | */ 106 | -------------------------------------------------------------------------------- /resources/service_worker.js: -------------------------------------------------------------------------------- 1 | // Service Worker script to enable PWA and offline support 2 | 3 | var version = "{ID_NAME}_{VERSION}"; 4 | var debug; 5 | var name = "[SW] "+version+": "; 6 | 7 | // Update the following list with all the needed files, so the game will work offline. 8 | var files = [ "index.html", "ico.{ICON_EXTENSION}" ]; 9 | 10 | if (debug) console.log(name+"%cService Worker initialized", "color:#3333cc"); 11 | 12 | /* The install event fires when the service worker is first installed. 13 | You can use this event to prepare the service worker to be able to serve 14 | files while visitors are offline. 15 | */ 16 | self.addEventListener("install", (event) => { 17 | if (debug) console.log(name+"%cInstalling", "color:#3399cc"); 18 | 19 | /* In an update process when a new service worker is installed it will wait for the opportunity 20 | to become activated. The outdated but currently active worker must be released first and this 21 | happens only if the user navigates away from the page, or a specified period of time has passed. 22 | It can be also triggered manually by calling self.skipWaiting() 23 | */ 24 | self.skipWaiting(); 25 | 26 | event.waitUntil( 27 | /* The caches built-in is a promise-based API that helps you cache responses, 28 | as well as finding and deleting them. 29 | After the cache is opened, it is filled with the resources needed for 30 | the offline functioning of the app. 31 | */ 32 | debug ? 33 | caches.open(version).then((cache) => { 34 | return cache.addAll(files); 35 | }).then(() => { 36 | console.log(name+"%cInstall complete", "color:#339933"); 37 | }) : 38 | caches.open(version).then((cache) => { 39 | return cache.addAll(files); 40 | }) 41 | ); 42 | }); 43 | 44 | 45 | /* The activate event fires after a service worker has been successfully installed. 46 | It is most useful when phasing out an older version of a service worker, as at 47 | this point you know that the new worker was installed correctly. 48 | Old caches that don't match the version of the newly installed worker are deleted. 49 | */ 50 | self.addEventListener("activate", (event) => { 51 | /* Just like with the install event, event.waitUntil blocks activate on a promise. 52 | Activation will fail unless the promise is fulfilled. 53 | */ 54 | if(debug) console.log(name+"%cActivate version: " + version, "color:#3333cc"); 55 | 56 | event.waitUntil( 57 | // This method returns a promise which will resolve to an array of available cache keys. 58 | caches.keys().then((cacheNames) => { 59 | // Return a promise that settles when all outdated caches are deleted. 60 | return Promise.all( 61 | cacheNames.filter((cacheName) => { 62 | if(cacheName != version){ 63 | if(debug) console.log(name+"%cDelete Cache ("+cacheName+")", "color:#cc3333"); 64 | return true; 65 | } 66 | }).map((cacheName) => { 67 | return caches.delete(cacheName); 68 | }) 69 | ); 70 | }).then(() => { 71 | if(debug) console.log(name+"%cClaiming clients for version: "+version, "color:#3333cc"); 72 | return self.clients.claim(); 73 | }) 74 | ); 75 | }); 76 | 77 | 78 | /* The fetch event fires whenever a page controlled by this service worker requests 79 | a resource. This isn't limited to 'fetch' or even XMLHttpRequest. Instead, it 80 | comprehends even the request for the HTML page on first load, as well as JS and 81 | CSS resources, fonts, any images, etc. 82 | */ 83 | self.addEventListener("fetch", (event) => { 84 | if (event.request.url == self.registration.scope) { 85 | return; 86 | } 87 | var request = event.request; 88 | var options = {}; 89 | event.respondWith( 90 | caches.match(request, options).then((cached) => { 91 | return cached || fetch(event.request).then((response) => { 92 | var cacheCopy = response.clone(); 93 | caches.open(version).then((cache) => { 94 | return cache.put(event.request, cacheCopy); 95 | }); 96 | return response; 97 | }); 98 | }) 99 | ); 100 | }); 101 | 102 | /* 103 | Service Worker by Noncho Savov 104 | https://www.FoumartGames.com 105 | */ 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JS13kGames Progressive Web App starter pack 2 | 3 | JS13K Games Competition website: https://js13kgames.com/ 4 | 5 | This template targets the compo's **Mobile** category by providing a convenient way to build Progressive Web Apps. 6 | 7 | PWAs at minimum need a service worker script, web manifest and icon files, which increases the final archive size by around a kilobyte. 8 | 9 | The template also uses a sophisticated Gulp process to parse and compile JS code with Google Closure Compiler and to pack it along with any CSS into a single minified HTML file. Additional compression is achieved utilizing Roadroller JS packer. Once successfully built the game will be opened in the default browser with BrowserSync live-reload enabled. Any modification in src/ folder will invoke a game reload on the localhost. 10 | 11 | ## Installation 12 | Run **`npm install`** to install build dependencies. 13 | 14 | ## Tasks 15 | **`npm build`** builds a mobile PWA with minified and inlined JS and CSS into a single HTML (except for the PWA assets), reports archive size and serves the game locally with browser sync live reload enabled. 16 | 17 | **`npm prod`** same as `npm build`, but additionally compresses JS with Roadroller. Builds for production. 18 | 19 | **`npm debug`** builds the game into a single HTML with inlined JS but without minifying. Sets a global `_debug` variable to provide detailed console logs of the Service Worker processes. 20 | 21 | **`npm raw`** builds the game with copied JS and CSS files directly into `src/scripts` and `src/styles` for easier debugging. 22 | 23 | **`npm test`** repacks the contents of the public folder to report the archive filesize. 24 | 25 | **`npm sync`** quickly repacks the game and refreshes the browser, automated via BrowserSync. 26 | 27 | ## Build task parameters 28 | *`--pwa`* instructs to build a Progressive Web App - will add 842 bytes when zipped. 29 | 30 | *`--roadroll`* instructs to use a JS packer to achieve up to 15% compression on top of the ZIP/gzip. 31 | 32 | *`--mobile`* adds some HTML tags regarding mobile and iOS icons - increases the ZIP filesize with 42 bytes. 33 | 34 | *`--social`* adds some HTML tags for SEO and social media (Twitter) - will add around 100 bytes, depending on description length. 35 | 36 | ## Template Structure 37 | ``` 38 | root/ 39 | ├── resources/ 40 | │ ├── externs.js - externs for Closure Compiler 41 | │ ├── mf.webmanifest - needed for the PWA functionality 42 | │ ├── service_worker.js - PWA 43 | │ └── sw_init.js - PWA 44 | ├── src/ 45 | │ ├── index.html - template ("rep_css" and "rep_js" should be kept intact) 46 | │ ├── ico.svg - PWA 47 | │ ├── scripts/ - should contain all JS scripts 48 | │ ├── styles/ - should contain all CSS styles 49 | │ └── assets/ - should contain any images the game needs 50 | ├── public - output folder 51 | ├── zip - output ZIP archives 52 | └── package.json - check Setup 53 | ``` 54 | 55 | ## Setup 56 | Setup is done in the **`package.json`**. Variables you have to modify: 57 | 58 | - name - *used for generating the cache name in the service_worker.js file* 59 | - version - *used for generating the cache name in the service_worker.js file* 60 | - title - *populated in the title tag of the HTML, in the webmanifest file and in the social meta tags* 61 | - description - *used only if social option is turned on* 62 | - keywords - *used only if social option is turned on* 63 | - orientation - *populated only in the webmanifest file* 64 | - icon extension - *needed for the HTML's icon link tag, used in the webmanifest file and in the service_worker.js file* 65 | - icon type - *needed for the HTML's icon link tag, in the webmanifest file and in the service_worker.js file* 66 | - icon size - *used in the webmanifest file* 67 | 68 | ## Filesize overview: 69 | Currently the ZIP output of the default *`npm:build`* is around 3KB, of which: 70 | - 1,312 bytes are occupied by the interactive demo (ship.png 612 bytes + scripts) 71 | - 842 bytes for PWA functionality (serviceworker + webmanifest + initialization scripts) 72 | - 256 bytes for ico.svg (an icon is needed for PWA functionality) 73 | 74 | ## Note: 75 | - *`npm:prod`* will be beneficial only if there is enough JS source supplied for compression. 76 | - PWA functionallity can be tested only with a secure (https) connection. 77 | - the icon needs to be at least 144x144 pixels in size. Using the PNG format will provide no less than 500 bytes image, so the SVG format remains best in terms of compression. 78 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const { src, dest, series } = require('gulp'); 2 | const gulp = require('gulp'); 3 | const concat = require('gulp-concat'); 4 | const htmlmin = require('gulp-htmlmin'); 5 | const replace = require('gulp-string-replace'); 6 | const cleanCSS = require('gulp-clean-css'); 7 | const browserSync = require('browser-sync').create(); 8 | const closureCompiler = require('google-closure-compiler').gulp(); 9 | const argv = require('yargs').argv; 10 | const gulpif = require('gulp-if'); 11 | const advzip = require('gulp-advzip'); 12 | const roadroller = require('roadroller'); 13 | const glob = require('glob'); 14 | const packageJson = require('./package.json'); 15 | 16 | // import ES modules 17 | let imagemin, optipng, svgo, gifsicle, mozjpeg; 18 | let del, zip, js, css, scripts; 19 | 20 | const replaceOptions = { logs: { enabled: false } }; 21 | const timestamp = getDateString(); 22 | 23 | // Data taken directly from package.json 24 | const name = packageJson.name; 25 | const title = packageJson.title; 26 | const id_name = `${name.replace(/\s/g, '')}`;//_${getDateString(true)} 27 | const version = packageJson.version; 28 | const iconExtension = packageJson.iconExtension; 29 | const iconType = packageJson.iconType; 30 | const iconSize = packageJson.iconSize; 31 | const orientation = packageJson.orientation; 32 | 33 | // Script Arguments: 34 | // --dir: set the output directory 35 | const dir = argv.dir || 'public'; 36 | 37 | // --test: don't use versioned zip file - useful for fast testing. 38 | const test = argv.test != undefined ? true : false; 39 | 40 | // --pwa: enable progressive web app - use a service worker, webmanifest and pwa initialization scripts. Adds ~850 bytes. 41 | const pwa = argv.pwa != undefined ? true : false; 42 | 43 | // --raw: don't pack the js files at all 44 | const raw = argv.raw != undefined ? true : false; 45 | 46 | // --debug: pack but don't compress js files, display service worker logs as well 47 | const debug = argv.debug != undefined ? true : false; 48 | 49 | // --roadroll: use a JS packer for up to 15% compression 50 | const roadroll = argv.roadroll != undefined ? true : false; 51 | 52 | // --mobile: should html` tags for mobile be included. Adds 42 bytes. 53 | const mobile = argv.mobile != undefined || argv.all != undefined ? ` 54 | 55 | 56 | ` : false; 57 | 58 | // --social: should html tags for social media be included. Adds around 100 bytes, depending on description length. 59 | // TODO: quotes should not be removed for content that has space characters 60 | const social = argv.social != undefined || argv.all != undefined ? ` 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | ` : false; 69 | 70 | // Prepare a web icon to be used by html and pwa 71 | function ico(callback) { 72 | (async () => { 73 | // Keep the imports, even if not copying an icon, the gulp image modules are needed for the assets. 74 | const gulpImageminModule = await import('gulp-imagemin'); 75 | imagemin = gulpImageminModule.default; 76 | gifsicle = gulpImageminModule.gifsicle; 77 | mozjpeg = gulpImageminModule.mozjpeg; 78 | optipng = gulpImageminModule.optipng; 79 | svgo = gulpImageminModule.svgo; 80 | 81 | if (!mobile) { 82 | return callback(); 83 | } 84 | 85 | if (iconExtension == "svg") { 86 | src(['src/ico.svg'], { allowEmpty: true }) 87 | .pipe(imagemin({silent: true, verbose: false}, [svgo()])) 88 | .pipe(dest(dir + '/')) 89 | .on('end', callback) 90 | } else { 91 | src(['src/ico.png'], { allowEmpty: true, encoding: false }) 92 | .pipe(imagemin({silent: true, verbose: false}, [optipng()])) 93 | .pipe(dest(dir + '/')) 94 | .on('end', callback) 95 | } 96 | })(); 97 | } 98 | 99 | // Compress other graphical assets (if any) 100 | function assets(callback) { 101 | src(['src/assets/*'], { allowEmpty: true, encoding: false }) 102 | .pipe(imagemin({silent: true, verbose: false}, [optipng(), gifsicle(), mozjpeg(), svgo()])) 103 | .pipe(dest(dir + '/assets/')) 104 | .on('end', callback); 105 | } 106 | 107 | // Prepare service worker script 108 | function sw(callback) { 109 | if (pwa) { 110 | src(['resources/service_worker.js'], { allowEmpty: true }) 111 | .pipe(replace('var debug;', `var debug = ${debug ? 'true' : 'false'};`, replaceOptions)) 112 | .pipe(replace('{ID_NAME}', id_name, replaceOptions)) 113 | .pipe(replace('{VERSION}', version, replaceOptions)) 114 | .pipe(replace('{ICON_EXTENSION}', iconExtension, replaceOptions)) 115 | .pipe(gulpif(!debug, replace('caches', 'window.caches', replaceOptions))) 116 | .pipe(gulpif(!debug, 117 | closureCompiler({ 118 | compilation_level: 'ADVANCED_OPTIMIZATIONS', 119 | warning_level: 'QUIET', 120 | language_in: 'ECMASCRIPT6', 121 | language_out: 'ECMASCRIPT6' 122 | }) 123 | )) 124 | .pipe(gulpif(!debug, replace('window.caches', 'caches', replaceOptions))) 125 | .pipe(gulpif(!debug, replace('"use strict";', '', replaceOptions))) 126 | .pipe(concat('sw.js')) 127 | .pipe(dest(dir + '/')) 128 | .on('end', callback) 129 | } else { 130 | callback(); 131 | } 132 | } 133 | 134 | // Compile (or copy if raw) the pwa initialization script as well as game logic scripts 135 | function app(callback) { 136 | const scripts = [ 137 | 'src/scripts/*.js' 138 | ]; 139 | if (pwa) { 140 | scripts.unshift('resources/sw_init.js'); 141 | } 142 | scripts.unshift('resources/app_init.js'); 143 | 144 | if (raw) { 145 | // If raw is true, just copy the source files 146 | src(scripts, { allowEmpty: true }) 147 | //.pipe(replace('_debug', 'debug', replaceOptions)) 148 | .pipe(replace('let _debug;', `let _debug = ${debug || raw ? 'true' : 'false'};`, replaceOptions)) 149 | .pipe(gulpif(pwa, replace('service_worker', 'sw', replaceOptions))) 150 | .pipe(replace('{VERSION}', version, replaceOptions)) 151 | .pipe(gulpif(!pwa, replace('function init', 'window.addEventListener("load",init);function init', replaceOptions))) 152 | .pipe(dest(dir + '/src/scripts/')) 153 | .on('end', callback); 154 | } else { 155 | // Otherwise compile 156 | src(scripts, { allowEmpty: true }) 157 | .pipe(replace('let _debug;', `let _debug = ${debug || raw ? 'true' : 'false'};`, replaceOptions)) 158 | .pipe(gulpif(pwa, replace('service_worker', 'sw', replaceOptions))) 159 | .pipe(replace('{VERSION}', version, replaceOptions)) 160 | .pipe(gulpif(!pwa, replace('function init', 'window.addEventListener("load",init);function init', replaceOptions))) 161 | .pipe(gulpif(!debug, 162 | closureCompiler({ 163 | compilation_level: 'ADVANCED_OPTIMIZATIONS', 164 | warning_level: 'QUIET', 165 | language_in: 'ECMASCRIPT_2017', 166 | language_out: 'ECMASCRIPT6', 167 | externs: 'resources/externs.js' 168 | }) 169 | )) 170 | .pipe(concat('app.js')) 171 | .pipe(dest(dir + '/tmp/')) 172 | .on('end', callback); 173 | } 174 | } 175 | 176 | // Minify CSS 177 | function cs(callback) { 178 | if (raw) { 179 | src('src/styles/*.css', { allowEmpty: true }) 180 | .pipe(dest(dir + '/src/styles/')) 181 | .on('end', callback); 182 | } else { 183 | src('src/styles/*.css', { allowEmpty: true }) 184 | .pipe(cleanCSS()) 185 | .pipe(concat('temp.css')) 186 | .pipe(dest(dir + '/tmp/')) 187 | .on('end', callback) 188 | } 189 | } 190 | 191 | // Prepare web manifest file 192 | function mf(callback) { 193 | if (pwa) { 194 | src('resources/mf.webmanifest', { allowEmpty: true }) 195 | .pipe(replace('service_worker', 'sw', replaceOptions)) 196 | .pipe(replace('{TITLE}', title, replaceOptions)) 197 | .pipe(replace('{ICON_EXTENSION}', iconExtension, replaceOptions)) 198 | .pipe(replace('{ICON_TYPE}', iconType, replaceOptions)) 199 | .pipe(replace('{ICON_SIZE}', iconSize, replaceOptions)) 200 | .pipe(replace('{ORIENTATION}', orientation, replaceOptions)) 201 | .pipe(htmlmin({ collapseWhitespace: true })) 202 | .pipe(dest(dir + '/')) 203 | .on('end', callback); 204 | } else { 205 | callback(); 206 | } 207 | } 208 | 209 | // Read the temporary JS and CSS files and compress the javascript with Roadroller 210 | async function mangle() { 211 | if (!raw) { 212 | const fs = require('fs'); 213 | css = fs.readFileSync(dir + '/tmp/temp.css', 'utf8'); 214 | js = fs.readFileSync(dir + '/tmp/app.js', 'utf8'); 215 | } 216 | 217 | if (roadroll && !debug) { 218 | if (roadroll) { 219 | const packer = new roadroller.Packer( 220 | [{ 221 | data: js, 222 | type: 'js', 223 | action: 'eval' 224 | }], 225 | { 226 | selectors: 32, 227 | maxMemoryMB: 640, 228 | precision: 16, 229 | recipLearningRate: 1500, 230 | modelMaxCount: 3, 231 | modelRecipBaseCount: 30, 232 | numAbbreviations: 64, 233 | allowFreeVars: 0 234 | } 235 | ); 236 | await packer.optimize(); 237 | const { firstLine, secondLine } = packer.makeDecoder(); 238 | js = firstLine + secondLine; 239 | } 240 | } else { 241 | let dummyPromise = new Promise(function(resolve) { 242 | setTimeout(resolve, 1); 243 | }) 244 | await dummyPromise; 245 | } 246 | } 247 | 248 | // Inline JS and CSS into index.html or just include them if raw is specified 249 | function pack(callback) { 250 | let stream = src('src/index.html', { allowEmpty: true }); 251 | let scriptTags, cssTags; 252 | 253 | if (raw) { 254 | // Use glob to get all JavaScript files 255 | const scriptFiles = glob.sync('src/scripts/*.js').reverse(); 256 | // Add initialization scripts as well 257 | if (pwa) { 258 | scriptFiles.unshift('src/scripts/sw_init.js'); 259 | } 260 | scriptFiles.unshift('src/scripts/app_init.js'); 261 | 262 | // Create script tags for each JavaScript file in the array 263 | scriptTags = scriptFiles.map(scriptFile => ``).join('\n\t'); 264 | 265 | // Use glob to get all CSS files matching the pattern 266 | const cssFiles = glob.sync('src/styles/*.css'); 267 | // Create link tags for each CSS file 268 | cssTags = cssFiles.map(cssFile => ``).join('\n\t'); 269 | } 270 | 271 | stream 272 | .pipe(gulpif(!pwa, replace('', '', replaceOptions))) 273 | .pipe(gulpif(!pwa, replace('', '', replaceOptions))) 274 | .pipe(replace('{TITLE}', title, replaceOptions)) 275 | .pipe(replace('{ICON_EXTENSION}', iconExtension, replaceOptions)) 276 | .pipe(replace('{ICON_TYPE}', iconType, replaceOptions)) 277 | .pipe(replace('rep_social', social != false ? social : '', replaceOptions)) 278 | .pipe(replace('rep_mobile', mobile != false ? mobile : '', replaceOptions)) 279 | .pipe(htmlmin({ collapseWhitespace: true, removeComments: true, removeAttributeQuotes: true })) 280 | .pipe(replace('rep_css', raw ? cssTags : '', replaceOptions)) 281 | .pipe(replace('rep_js', raw ? scriptTags : '', replaceOptions)) 282 | .pipe(concat('index.html')) 283 | .pipe(dest(dir + '/')) 284 | .on('end', callback); 285 | } 286 | 287 | // Delete the public folder at the beginning 288 | function prep(callback) { 289 | (async () => { 290 | del = (await import('del')).deleteAsync; 291 | del(dir); 292 | callback(); 293 | })(); 294 | } 295 | 296 | // Delete the temporary folder generated during packaging 297 | function clean(callback) { 298 | (async () => { 299 | del = (await import('del')).deleteAsync; 300 | del(dir + '/tmp/'); 301 | callback(); 302 | })(); 303 | callback(); 304 | } 305 | 306 | // Package zip (exclude any fonts that are used locally, like Twemoji.ttf) 307 | function archive(callback) { 308 | if (debug) callback(); 309 | else { 310 | (async () => { 311 | zip = (await import('gulp-zip')).default; 312 | src([dir + '/*', dir + '/*/*', '!'+ dir + '/*.ttf'], { allowEmpty: true }) 313 | .pipe(zip(test ? 'game.zip' : 'game_' + timestamp + '.zip')) 314 | .pipe(advzip({ optimizationLevel: 4, iterations: 10 })) 315 | .pipe(dest('zip/')) 316 | .on('end', callback); 317 | })(); 318 | } 319 | } 320 | 321 | // Output the zip filesize 322 | function check(callback) { 323 | if (debug) callback(); 324 | else { 325 | var fs = require('fs'); 326 | const size = fs.statSync(test ? 'zip/game.zip' : 'zip/game_' + timestamp + '.zip').size; 327 | const limit = 1024 * 13; 328 | const left = limit - size; 329 | const percent = Math.abs(Math.round((left / limit) * 10000) / 100); 330 | console.log(` ${size} ${left} bytes ${left < 0 ? 'overhead' : 'remaining'} (${percent}%)`); 331 | callback(); 332 | } 333 | } 334 | 335 | // Watch for changes in the source folder 336 | function watch(callback) { 337 | browserSync.init({ 338 | server: './public', 339 | ui: false, 340 | port: 8080 341 | }); 342 | 343 | gulp.watch('./src').on('change', () => { 344 | exports.sync(); 345 | }); 346 | 347 | callback(); 348 | }; 349 | 350 | // Reload the browser sync instance, or run a new server with live reload 351 | function reload(callback) { 352 | if (!browserSync.active) { 353 | watch(callback); 354 | } else { 355 | browserSync.reload(); 356 | callback(); 357 | } 358 | } 359 | 360 | // Helper function for timestamp and naming 361 | function getDateString(shorter) { 362 | const date = new Date(); 363 | const year = date.getFullYear(); 364 | const month = `${date.getMonth() + 1}`.padStart(2, '0'); 365 | const day =`${date.getDate()}`.padStart(2, '0'); 366 | if (shorter) return `${year}${month}${day}`; 367 | const signiture =`${date.getHours()}`.padStart(2, '0')+`${date.getMinutes()}`.padStart(2, '0')+`${date.getSeconds()}`.padStart(2, '0'); 368 | return `${year}${month}${day}_${signiture}`; 369 | } 370 | 371 | // Exports 372 | exports.default = series(prep, ico, sw, app, cs, mf, mangle, assets, pack, clean, archive, check, watch); 373 | exports.prod = series(prep, ico, sw, app, cs, mf, mangle, assets, pack, clean, watch); 374 | exports.sync = series(ico, app, cs, mangle, assets, pack, clean, reload); 375 | exports.zip = series(archive, check); 376 | 377 | /* 378 | JS13K Template Gulpfile by Noncho Savov 379 | https://www.FoumartGames.com 380 | */ 381 | --------------------------------------------------------------------------------