├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── pitall-js.code-workspace ├── public_html ├── app │ ├── app.html │ ├── icons │ │ ├── apple-touch-icon.png │ │ ├── favicon.ico │ │ ├── favicon.svg │ │ ├── google-touch-icon-192.png │ │ ├── google-touch-icon-512.png │ │ └── mask-icon.svg │ ├── manifest.json │ ├── resources.zip │ ├── scripts │ │ ├── app.bundle.js │ │ ├── bootstrap.bundle.js │ │ ├── vendors.bundle.js │ │ └── vendors.bundle.js.LICENSE.txt │ ├── styles │ │ └── app.css │ └── sw.bundle.js ├── images │ ├── harry.png │ └── title.svg ├── index.html ├── scripts │ └── index.bundle.js └── styles │ └── index.css ├── sfx ├── die.mp3 ├── fall.mp3 ├── jump.mp3 ├── kneel.mp3 ├── swing.mp3 └── treasure.mp3 ├── src ├── animate.ts ├── app.ts ├── audio.ts ├── bootstrap.ts ├── death.ts ├── download.ts ├── game │ ├── clock.ts │ ├── cobra-and-fire.ts │ ├── dijkstra.ts │ ├── fibonacci-priority-queue.ts │ ├── game-state.ts │ ├── game.ts │ ├── harry.ts │ ├── map.ts │ ├── pit.ts │ ├── rattle.ts │ ├── rolling-log.ts │ ├── scorpion.ts │ ├── stationary-log.ts │ ├── treasure-map.ts │ ├── treasure.ts │ ├── vine-state.ts │ └── vine.ts ├── graphics.ts ├── index.ts ├── input.ts ├── math.ts ├── no-param-void-func.ts ├── progress.ts ├── screen.ts ├── start.ts ├── store.ts ├── sw.ts └── wake-lock.ts ├── tsconfig.json ├── webpack.config.bootstrap.mjs ├── webpack.config.index.mjs └── webpack.config.sw.mjs /.gitattributes: -------------------------------------------------------------------------------- 1 | ## GITATTRIBUTES FOR WEB PROJECTS 2 | # 3 | # These settings are for any web project. 4 | # 5 | # Details per file setting: 6 | # text These files should be normalized (i.e. convert CRLF to LF). 7 | # binary These files are binary and should be left untouched. 8 | # 9 | # Note that binary is a macro for -text -diff. 10 | ###################################################################### 11 | 12 | ## AUTO-DETECT 13 | ## Handle line endings automatically for files detected as 14 | ## text and leave all files detected as binary untouched. 15 | ## This will handle all files NOT defined below. 16 | * text=auto 17 | 18 | ## SOURCE CODE 19 | *.bat text eol=crlf 20 | *.coffee text 21 | *.css text 22 | *.htm text 23 | *.html text 24 | *.inc text 25 | *.ini text 26 | *.js text 27 | *.json text 28 | *.jsx text 29 | *.less text 30 | *.od text 31 | *.onlydata text 32 | *.php text 33 | *.pl text 34 | *.py text 35 | *.rb text 36 | *.sass text 37 | *.scm text 38 | *.scss text 39 | *.sh text eol=lf 40 | *.sql text 41 | *.styl text 42 | *.tag text 43 | *.ts text 44 | *.tsx text 45 | *.xml text 46 | *.xhtml text 47 | 48 | ## DOCKER 49 | *.dockerignore text 50 | Dockerfile text 51 | 52 | ## DOCUMENTATION 53 | *.markdown text 54 | *.md text 55 | *.mdwn text 56 | *.mdown text 57 | *.mkd text 58 | *.mkdn text 59 | *.mdtxt text 60 | *.mdtext text 61 | *.txt text 62 | AUTHORS text 63 | CHANGELOG text 64 | CHANGES text 65 | CONTRIBUTING text 66 | COPYING text 67 | copyright text 68 | *COPYRIGHT* text 69 | INSTALL text 70 | license text 71 | LICENSE text 72 | NEWS text 73 | readme text 74 | *README* text 75 | TODO text 76 | 77 | ## TEMPLATES 78 | *.dot text 79 | *.ejs text 80 | *.haml text 81 | *.handlebars text 82 | *.hbs text 83 | *.hbt text 84 | *.jade text 85 | *.latte text 86 | *.mustache text 87 | *.njk text 88 | *.phtml text 89 | *.tmpl text 90 | *.tpl text 91 | *.twig text 92 | 93 | ## LINTERS 94 | .babelrc text 95 | .csslintrc text 96 | .eslintrc text 97 | .htmlhintrc text 98 | .jscsrc text 99 | .jshintrc text 100 | .jshintignore text 101 | .prettierrc text 102 | .stylelintrc text 103 | 104 | ## CONFIGS 105 | *.bowerrc text 106 | *.cnf text 107 | *.conf text 108 | *.config text 109 | .browserslistrc text 110 | .editorconfig text 111 | .gitattributes text 112 | .gitconfig text 113 | .gitignore text 114 | .htaccess text 115 | *.npmignore text 116 | *.yaml text 117 | *.yml text 118 | browserslist text 119 | Makefile text 120 | makefile text 121 | 122 | ## HEROKU 123 | Procfile text 124 | .slugignore text 125 | 126 | ## GRAPHICS 127 | *.ai binary 128 | *.bmp binary 129 | *.eps binary 130 | *.gif binary 131 | *.ico binary 132 | *.jng binary 133 | *.jp2 binary 134 | *.jpg binary 135 | *.jpeg binary 136 | *.jpx binary 137 | *.jxr binary 138 | *.pdf binary 139 | *.png binary 140 | *.psb binary 141 | *.psd binary 142 | *.svg text 143 | *.svgz binary 144 | *.tif binary 145 | *.tiff binary 146 | *.wbmp binary 147 | *.webp binary 148 | 149 | ## AUDIO 150 | *.kar binary 151 | *.m4a binary 152 | *.mid binary 153 | *.midi binary 154 | *.mp3 binary 155 | *.ogg binary 156 | *.ra binary 157 | 158 | ## VIDEO 159 | *.3gpp binary 160 | *.3gp binary 161 | *.as binary 162 | *.asf binary 163 | *.asx binary 164 | *.fla binary 165 | *.flv binary 166 | *.m4v binary 167 | *.mng binary 168 | *.mov binary 169 | *.mp4 binary 170 | *.mpeg binary 171 | *.mpg binary 172 | *.ogv binary 173 | *.swc binary 174 | *.swf binary 175 | *.webm binary 176 | 177 | ## ARCHIVES 178 | *.7z binary 179 | *.gz binary 180 | *.jar binary 181 | *.rar binary 182 | *.tar binary 183 | *.zip binary 184 | 185 | ## FONTS 186 | *.ttf binary 187 | *.eot binary 188 | *.otf binary 189 | *.woff binary 190 | *.woff2 binary 191 | 192 | ## EXECUTABLES 193 | *.exe binary 194 | *.pyc binary 195 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Project page: https://meatfighter.com/pitfall-web/ -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pitfall-js", 3 | "version": "1.2.0", 4 | "description": "A browser port of a platformer originally released in 1982 for the Atari 2600.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/meatfighter/pitfall-js" 8 | }, 9 | "author": "Mike Birken (https://meatfighter.com/pitfall-js)", 10 | "license": "GPL-3.0-only", 11 | "type": "module", 12 | "scripts": { 13 | "clean:index": "rimraf --glob public_html/scripts/*.bundle.js public_html/scripts/*.bundle.js.map public_html/scripts/*.bundle.js.LICENSE.txt", 14 | "clean:sw": "rimraf --glob public_html/app/*.bundle.js public_html/app/*.bundle.js.map public_html/app/*.bundle.js.LICENSE.txt", 15 | "clean:app": "rimraf --glob public_html/app/resources.zip public_html/app/scripts/*.bundle.js public_html/app/scripts/*.bundle.js.map public_html/app/scripts/*.bundle.js.LICENSE.txt", 16 | "clean:quick:app": "rimraf --glob public_html/app/scripts/*.bundle.js public_html/app/scripts/*.bundle.js.map public_html/app/scripts/*.bundle.js.LICENSE.txt", 17 | "clean": "npm run clean:index && npm run clean:sw && npm run clean:app", 18 | "pack:prod:index": "npx webpack --mode production --config webpack.config.index.mjs", 19 | "pack:prod:sw": "npx webpack --mode production --config webpack.config.sw.mjs", 20 | "pack:prod:bootstrap": "npx webpack --mode production --config webpack.config.bootstrap.mjs", 21 | "pack:prod": "npm run pack:prod:index && npm run pack:prod:sw && npm run pack:prod:bootstrap", 22 | "pack:dev:index": "npx webpack --mode development --config webpack.config.index.mjs", 23 | "pack:dev:sw": "npx webpack --mode development --config webpack.config.sw.mjs", 24 | "pack:dev:bootstrap": "npx webpack --mode development --config webpack.config.bootstrap.mjs", 25 | "pack:dev": "npm run pack:dev:index && npm run pack:dev:sw && npm run pack:dev:bootstrap", 26 | "zip": "bestzip public_html/app/resources.zip sfx/*", 27 | "build:prod": "npm run clean && npm run zip && npm run pack:prod", 28 | "start": "http-server public_html -p 8080", 29 | "refresh:prod": "npm run build:prod && npm run start", 30 | "build:dev": "npm run clean && npm run zip && npm run pack:dev", 31 | "refresh:dev": "npm run build:dev && npm run start", 32 | "refresh:quick:dev": "npm run pack:dev:bootstrap && npm run start" 33 | }, 34 | "devDependencies": { 35 | "bestzip": "^2.2.1", 36 | "http-server": "^14.1.1", 37 | "rimraf": "^5.0.10", 38 | "thread-loader": "^4.0.4", 39 | "ts-loader": "^9.5.1", 40 | "typescript": "^5.6.3", 41 | "webpack": "^5.96.1", 42 | "webpack-cli": "^5.1.4", 43 | "webpack-dev-server": "^5.2.0", 44 | "worker-loader": "^3.0.8" 45 | }, 46 | "dependencies": { 47 | "jszip": "^3.10.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pitall-js.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /public_html/app/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | Pitfall! 22 | 23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /public_html/app/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/public_html/app/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public_html/app/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/public_html/app/icons/favicon.ico -------------------------------------------------------------------------------- /public_html/app/icons/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public_html/app/icons/google-touch-icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/public_html/app/icons/google-touch-icon-192.png -------------------------------------------------------------------------------- /public_html/app/icons/google-touch-icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/public_html/app/icons/google-touch-icon-512.png -------------------------------------------------------------------------------- /public_html/app/icons/mask-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public_html/app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Pitfall!", 3 | "name": "Pitfall!", 4 | "description": "A platformer where the player searches for 32 treasures hidden in a perilous jungle maze within a strict time limit.", 5 | "icons": [ 6 | { 7 | "src": "icons/google-touch-icon-192.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "icons/google-touch-icon-512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "icons/favicon.svg", 18 | "type": "image/svg+xml", 19 | "sizes": "any" 20 | } 21 | ], 22 | "scope": ".", 23 | "start_url": "app.html", 24 | "background_color": "#0D1117", 25 | "theme_color": "#0D1117", 26 | "display": "fullscreen", 27 | "orientation": "portrait-primary", 28 | "lang": "en-US" 29 | } -------------------------------------------------------------------------------- /public_html/app/resources.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/public_html/app/resources.zip -------------------------------------------------------------------------------- /public_html/app/scripts/bootstrap.bundle.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";var e,t,r,n,o={},i={};function a(e){var t=i[e];if(void 0!==t)return t.exports;var r=i[e]={exports:{}};return o[e](r,r.exports,a),r.exports}function l(e){a.e(524).then(a.bind(a,157)).then((t=>{clearInterval(e),t.init()}))}a.m=o,t=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__,a.t=function(r,n){if(1&n&&(r=this(r)),8&n)return r;if("object"==typeof r&&r){if(4&n&&r.__esModule)return r;if(16&n&&"function"==typeof r.then)return r}var o=Object.create(null);a.r(o);var i={};e=e||[null,t({}),t([]),t(t)];for(var l=2&n&&r;"object"==typeof l&&!~e.indexOf(l);l=t(l))Object.getOwnPropertyNames(l).forEach((e=>i[e]=()=>r[e]));return i.default=()=>r,a.d(o,i),o},a.d=(e,t)=>{for(var r in t)a.o(t,r)&&!a.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},a.f={},a.e=e=>Promise.all(Object.keys(a.f).reduce(((t,r)=>(a.f[r](e,t),t)),[])),a.u=e=>({96:"vendors",524:"app"}[e]+".bundle.js"),a.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r={},n="pitfall-js:",a.l=(e,t,o,i)=>{if(r[e])r[e].push(t);else{var l,c;if(void 0!==o)for(var u=document.getElementsByTagName("script"),s=0;s{l.onerror=l.onload=null,clearTimeout(f);var o=r[e];if(delete r[e],l.parentNode&&l.parentNode.removeChild(l),o&&o.forEach((e=>e(n))),t)return t(n)},f=setTimeout(p.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=p.bind(null,l.onerror),l.onload=p.bind(null,l.onload),c&&document.head.appendChild(l)}},a.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;a.g.importScripts&&(e=a.g.location+"");var t=a.g.document;if(!e&&t&&(t.currentScript&&"SCRIPT"===t.currentScript.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){var r=t.getElementsByTagName("script");if(r.length)for(var n=r.length-1;n>-1&&(!e||!/^http(s?):/.test(e));)e=r[n--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),a.p=e})(),(()=>{var e={547:0};a.f.j=(t,r)=>{var n=a.o(e,t)?e[t]:void 0;if(0!==n)if(n)r.push(n[2]);else{var o=new Promise(((r,o)=>n=e[t]=[r,o]));r.push(n[2]=o);var i=a.p+a.u(t),l=new Error;a.l(i,(r=>{if(a.o(e,t)&&(0!==(n=e[t])&&(e[t]=void 0),n)){var o=r&&("load"===r.type?"missing":r.type),i=r&&r.target&&r.target.src;l.message="Loading chunk "+t+" failed.\n("+o+": "+i+")",l.name="ChunkLoadError",l.type=o,l.request=i,n[1](l)}}),"chunk-"+t,t)}};var t=(t,r)=>{var n,o,[i,l,c]=r,u=0;if(i.some((t=>0!==e[t]))){for(n in l)a.o(l,n)&&(a.m[n]=l[n]);c&&c(a)}for(t&&t(r);u...';const e=document.getElementById("loading-div"),t=window.setInterval((()=>{e.textContent="..."===e.textContent?"":e.textContent+"."}),400);setTimeout((()=>function(e){"serviceWorker"in navigator?navigator.serviceWorker.register("sw.bundle.js?v=2025-01-25").then((()=>l(e))):l(e)}(t)),10)}))})(); -------------------------------------------------------------------------------- /public_html/app/scripts/vendors.bundle.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | 3 | JSZip v3.10.1 - A JavaScript class for generating and reading zip files 4 | 5 | 6 | (c) 2009-2016 Stuart Knightley 7 | Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown. 8 | 9 | JSZip uses the library pako released under the MIT license : 10 | https://github.com/nodeca/pako/blob/main/LICENSE 11 | */ 12 | -------------------------------------------------------------------------------- /public_html/app/styles/app.css: -------------------------------------------------------------------------------- 1 | @media only screen and (max-device-width: 400px) { 2 | html { 3 | -webkit-text-size-adjust: none; 4 | text-size-adjust: none; 5 | } 6 | } 7 | 8 | body { 9 | background-color: #0C0C0C; 10 | color: #FFFFFF; 11 | margin: 0; 12 | padding: 0; 13 | user-select: none; 14 | } 15 | 16 | body, button, input, select, textarea { 17 | font-family: 'Open Sans', sans-serif; 18 | font-size: 16px; 19 | } 20 | 21 | #main-canvas { 22 | display: block; 23 | position: absolute; 24 | touch-action: none; 25 | -webkit-user-select: none; 26 | -webkit-touch-callout: none; 27 | -webkit-tap-highlight-color: transparent; 28 | } 29 | 30 | #start-container { 31 | display: block; 32 | position: absolute; 33 | } 34 | 35 | #start-div { 36 | position: absolute; 37 | display: flex; 38 | justify-content: center; 39 | flex-direction: column; 40 | align-items: center; 41 | } 42 | 43 | #volume-input { 44 | -webkit-appearance: none; 45 | appearance: none; 46 | flex-grow: 1; 47 | width: 300px; 48 | height: 8px; 49 | background: transparent; 50 | border-radius: 10px; 51 | margin: 0 auto; 52 | position: relative; 53 | --thumb-position: 10%; 54 | } 55 | 56 | #volume-input:focus { 57 | outline: none; 58 | } 59 | 60 | /* Chrome, Safari, and newer Edge versions */ 61 | #volume-input::-webkit-slider-runnable-track { 62 | -webkit-appearance: none; 63 | background: linear-gradient(to right, #0080FF 0%, #0080FF var(--thumb-position, 50%), gray var(--thumb-position, 50%), gray 100%); 64 | border-radius: 10px; 65 | height: 8px; 66 | } 67 | 68 | #volume-input::-webkit-slider-thumb { 69 | -webkit-appearance: none; 70 | border: 3px solid white; 71 | height: 24px; 72 | width: 24px; 73 | border-radius: 50%; 74 | background: #0069CC; 75 | margin-top: -8px; 76 | position: relative; 77 | z-index: 2; 78 | } 79 | 80 | /* Firefox */ 81 | #volume-input::-moz-range-track { 82 | background: gray; 83 | border-radius: 10px; 84 | height: 8px; 85 | } 86 | 87 | #volume-input::-moz-range-progress { 88 | background: #0080FF; 89 | border-radius: 10px; 90 | height: 8px; 91 | } 92 | 93 | #volume-input::-moz-range-thumb { 94 | -moz-appearance: none; 95 | border: 3px solid white; 96 | height: 18px; 97 | width: 18px; 98 | border-radius: 50%; 99 | background: #0069CC; 100 | } 101 | 102 | .volume-div { 103 | display: inline-flex; 104 | align-items: center; 105 | justify-content: center; 106 | margin-top: 30px; 107 | margin-bottom: 25px; 108 | } 109 | 110 | .left-volume-label { 111 | display: inline-block; 112 | margin-right: 10px; 113 | font-size: 24px; 114 | } 115 | 116 | .right-volume-label { 117 | display: inline-block; 118 | margin-left: 10px; 119 | } 120 | 121 | #go-div { 122 | margin-top: 44px; 123 | } 124 | 125 | #high-score-div { 126 | font-size: 20px; 127 | } 128 | 129 | button { 130 | background-color: #0075FF; 131 | color: #F0F0F0; 132 | border: none; 133 | padding: 10px 20px; 134 | margin: 0 5px; 135 | text-align: center; 136 | text-decoration: none; 137 | display: inline-block; 138 | font-size: 18px; 139 | border-radius: 30px; 140 | text-shadow: 1px 1px 2px black; 141 | } 142 | 143 | button:active { 144 | transform: translateY(2px); 145 | } 146 | 147 | button:disabled { 148 | background-color: #005CC8; 149 | } 150 | 151 | button:disabled:active { 152 | transform: none; 153 | } 154 | 155 | button:focus { 156 | outline: none; 157 | } 158 | 159 | #progress-container { 160 | display: block; 161 | position: absolute; 162 | } 163 | 164 | #progress-div { 165 | position: absolute; 166 | display: flex; 167 | justify-content: center; 168 | flex-direction: column; 169 | align-items: center; 170 | } 171 | 172 | #loading-progress { 173 | width: 300px; 174 | border: none; 175 | height: 8px; 176 | border-radius: 4px; 177 | color: #0075FF; 178 | background: #CCCCCC; 179 | -webkit-appearance: none; 180 | } 181 | 182 | #loading-progress::-webkit-progress-bar { 183 | background-color: #CCCCCC; 184 | border-radius: 4px; 185 | } 186 | 187 | #loading-progress::-webkit-progress-value { 188 | background-color: #0075FF; 189 | border-radius: 4px; 190 | } 191 | 192 | #loading-progress::-moz-progress-bar { 193 | background: #0075FF; 194 | border-radius: 4px; 195 | } 196 | 197 | #death-div { 198 | position: absolute; 199 | display: flex; 200 | justify-content: center; 201 | flex-direction: column; 202 | align-items: center; 203 | } 204 | 205 | #fatal-error { 206 | font-size: 4em; 207 | } 208 | 209 | .loading-container { 210 | display: grid; 211 | place-items: center; 212 | height: 100vh; 213 | font-size: 64px; 214 | } 215 | 216 | label { 217 | margin-right: 8px; 218 | } 219 | 220 | #dropdown-label { 221 | display: inline-block; 222 | margin-bottom: 4px; 223 | margin-right: 6px; 224 | } 225 | 226 | .custom-dropdown { 227 | position: relative; 228 | display: inline-block; 229 | cursor: pointer; 230 | border: 1px solid #ccc; 231 | width: 120px; 232 | padding: 6px 8px; 233 | user-select: none; 234 | } 235 | 236 | /* Pseudo-element to create a triangle. */ 237 | .custom-dropdown::after { 238 | content: ""; 239 | position: absolute; 240 | right: 8px; 241 | top: 50%; 242 | 243 | transform: translateY(-50%) rotate(0deg); 244 | transition: transform 0.2s ease; 245 | 246 | /* Creates a triangle 10px wide, 5px tall */ 247 | border-left: 5px solid transparent; 248 | border-right: 5px solid transparent; 249 | border-top: 5px solid #fff; 250 | pointer-events: none; 251 | } 252 | 253 | /* Flip the triangle when open */ 254 | .custom-dropdown.open::after { 255 | transform: translateY(-50%) rotate(180deg); 256 | } 257 | 258 | /* The visible "selected" value */ 259 | .dropdown-selected { 260 | padding-right: 14px; /* Some space so the text does not collide with the arrow */ 261 | } 262 | 263 | /* Hidden options container (absolute) */ 264 | .dropdown-options { 265 | position: absolute; 266 | top: 100%; 267 | left: -1px; 268 | width: 100%; 269 | background-color: #222; 270 | border: 1px solid #ccc; 271 | display: none; /* hidden by default */ 272 | z-index: 999; 273 | } 274 | 275 | /* Show the options when .open is applied */ 276 | .custom-dropdown.open .dropdown-options { 277 | display: block; 278 | } 279 | 280 | .dropdown-option { 281 | padding: 6px 8px; 282 | } 283 | 284 | .dropdown-option:hover { 285 | background-color: #0075FF; 286 | } -------------------------------------------------------------------------------- /public_html/app/sw.bundle.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";const e="pitfall-cache-2025-01-25";self.addEventListener("activate",(t=>{t.waitUntil(caches.keys().then((t=>Promise.all(t.filter((t=>t!==e)).map((e=>caches.delete(e)))))).then((()=>self.clients.claim())))})),self.addEventListener("fetch",(t=>{t.request.url.startsWith("http")&&t.respondWith(caches.open(e).then((e=>e.match(t.request).then((s=>{if(s)return s;const n=new URL(t.request.url).hostname!==self.location.hostname?{mode:"cors",credentials:"omit"}:{};return async function(e,t={}){for(let s=4;s>=0;--s)try{const s=await fetch(e,t);if(!s.ok)continue;const n=s.headers.get("Content-Length"),a=n?parseInt(n,10):0,r=a>0&&e.url.includes("resources.zip"),c=s.body;if(null===c)continue;const l=c.getReader(),i=[];let o=0;for(;;){const{done:e,value:t}=await l.read();if(e)break;i.push(t),o+=t.length,r&&self.clients.matchAll().then((e=>{e.forEach((e=>{e.postMessage(o/a)}))}))}const h=new Uint8Array(o);let u=0;return i.forEach((e=>{h.set(e,u),u+=e.length})),new Response(h,{status:200,statusText:"OK",headers:s.headers})}catch(e){if(0===s)throw e}throw new Error("Failed to fetch.")}(t.request,n).then((s=>(e.put(t.request,s.clone()).then((e=>{})),s)))})))).catch((()=>new Response("Service Unavailable",{status:503}))))})),self.addEventListener("install",(()=>self.skipWaiting()))})(); -------------------------------------------------------------------------------- /public_html/images/harry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/public_html/images/harry.png -------------------------------------------------------------------------------- /public_html/images/title.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public_html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Pitfall! 27 | 28 | 29 | 30 | 31 | 32 |
33 | 34 |

Pitfall!

35 | 36 |

37 | 38 |

[ About | Controls | Setup | Differences | Source | Acknowledgements]

39 | 40 | 41 |

About

42 | 43 |

Pitfall! is a platformer originally released in 1982 for the Atari 2600. It dares the player to step into the boots of Pitfall Harry, an adventurer searching for 32 treasures hidden in a perilous jungle maze. To succeed before time runs out, the player must master the underground network of shortcut tunnels.

44 | 45 |

Press the Play button below to launch a browser port of Pitfall!.

46 | 47 | 48 | 49 | 50 |

Controls

51 | 52 |

The browser port supports input from these devices:

53 | 54 |
    55 |
  • 56 |
    57 | Keyboard 58 |
      59 |
    • Run and climb: Arrow keys or the WASD keys.
    • 60 |
    • Jump: Any other key. Hold jump while running to hop across crocodile pits.
    • 61 |
    62 |
    63 |
  • 64 |
  • 65 |
    66 | Gamepad 67 |
      68 |
    • Run and climb: D-pad or analog stick.
    • 69 |
    • Jump: Any other button. Hold jump while running to hop across crocodile pits.
    • 70 |
    71 |
    72 |
  • 73 |
  • 74 |
    75 | Touchscreen 76 |
      77 |
    • Run: Hold down the left or right side of the screen to run in those directions.
    • 78 |
    • Jump: While holding down the right side of the screen to run, tap the left side of the screen, or vice versa. Hold jump while running to hop across crocodile pits.
    • 79 |
    • Climb down: Run onto a ladder shaft, then run in the direction you were facing.
    • 80 |
    • Climb up: Stop at the base of a ladder, then run in the direction you are facing. Or jump onto a ladder, then run in the direction you were facing.
    • 81 |
    82 |
    83 |
  • 84 |
85 | 86 | 87 |

Setup

88 | 89 |

The browser port introduces Easy, Normal, and Hard modes to accommodate a range of player skill levels:

90 | 91 |
    92 |
  • Easy Mode: Players start with five lives. Arrows guide them through the maze, visible perimeters highlight shifting pits, and the HUD displays the scene index and the number of treasures collected.
  • 93 |
  • Normal Mode: Players start with four lives. Shifting pit perimeters remain visible, and the HUD displays the scene index and treasure count.
  • 94 |
  • Hard Mode: Closest to the original game, this mode starts players with three lives, removes visible perimeters for shifting pits, and excludes any additional HUD metrics.
  • 95 |
96 | 97 |

The port retains the map from the original. While the optimal route spans leftward across the jungle, Easy mode guides the player rightward along a path traversable within the time limit. Its route is consistent with the rightward scrolling convention established by platformers released after Pitfall!.

98 | 99 | 100 |

Differences

101 | 102 |

The browser port is not an emulation of the Atari 2600 version. Rather, it was developed in TypeScript through careful observation of gameplay and in-depth analysis of the original’s 6507 assembly language source code. While it faithfully preserves the sound effects and graphics of the original, it introduces several notable changes:

103 | 104 |
    105 |
  • Side-Scrolling: Unlike the original, where the scene changes when the player crosses a screen boundary, the port is a side-scroller. Any partially visible adjacent screen will transition to a different scene when the player drops into or climbs out of a tunnel because the screen boundaries of tunnels warp to scenes three screens away rather than one screen away. Screen widths were also narrowed to remove the left and right margins the player cannot enter.
  • 106 |
  • Midair Control: Instead of a fixed trajectory, the player can now change direction midair. To release a vine without falling in a pit, the player must hold left or right, as opposed to tapping down.
  • 107 |
  • Croc Hopping: Unlike the original, holding jump causes the player to hop. Doing so while running is an easy way to get across a crocodile pit. The duration crocodile mouths remain closed was also extended.
  • 108 |
  • Obstacle Directions: Logs now roll in the direction the player entered the scene, their hitboxes were narrowed, and they appear and disappear at scene boundaries. Additionally, crocs and cobras face the direction the player entered the scene.
  • 109 |
  • Scorpion Behavior: Instead of targeting the player’s position, the scorpion moves toward a spot beyond the player. When the player is above ground, the scorpion wanders without tracking the player.
  • 110 |
  • Respawning Location: Respawn position now depends on the direction the player was facing and proximity to scene boundaries when killed, rather than always placing the player on the left side of the screen.
  • 111 |
  • Ladder Behavior: Stepping onto a ladder shaft now causes the player to cling to the ladder rather than falling into the tunnel. From below, players can jump onto a ladder, and the controls for getting on and off ladders are smoother.
  • 112 |
  • Wall Behavior: Bumping into a wall no longer triggers a sound effect.
  • 113 |
  • Vine Adjustment: The vine pivot point was shifted two pixels right to better center it over a pit.
  • 114 |
  • Difficulty Modes: The port introduces Easy, Normal, and Hard modes to accommodate a range of player skill levels. Easy and Normal modes provide features unavailable in the original, such as arrows that guide the player through the maze, a visible perimeter around shifting pits, and additional HUD metrics.
  • 115 |
  • High Scores: The port records a separate high score for each difficulty mode. Upon achieving a high score, the value displayed in the HUD color cycles.
  • 116 |
  • Animation Enhancements: The port refines animations that were originally limited by Atari 2600 hardware, such as respawning from behind tree leaves and sliding into a croc’s mouth.
  • 117 |
118 | 119 | 120 |

Source

121 | 122 |

The source is available here.

123 | 124 | 125 |

Acknowledgements

126 | 127 |

The original Pitfall! was designed and programmed by David Crane and published by Activision.

128 | 129 | 130 |
131 |
132 |
133 | © 2025 meatfighter.com
This content is licensed under CC BY-SA 4.0CCBYSA 134 |
135 |
136 | Home 137 |
138 |
139 | 140 |
141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /public_html/scripts/index.bundle.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";var e={};function t(){window.location.href="app/app.html"}e.d=(t,n)=>{for(var o in n)e.o(n,o)&&!e.o(t,o)&&Object.defineProperty(t,o,{enumerable:!0,get:n[o]})},e.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),document.addEventListener("DOMContentLoaded",(function(){document.getElementById("play-button").addEventListener("click",t)}))})(); -------------------------------------------------------------------------------- /public_html/styles/index.css: -------------------------------------------------------------------------------- 1 | @media only screen and (max-device-width: 400px) { 2 | html { 3 | -webkit-text-size-adjust: none; 4 | text-size-adjust: none; 5 | } 6 | } 7 | 8 | html { 9 | scrollbar-color: #686868 #424242; 10 | } 11 | 12 | body { 13 | background-color: #0C0C0C; 14 | color: #E6EDF3; 15 | padding: 0; 16 | font-family: 'Open Sans', sans-serif; 17 | max-width: 978px; 18 | margin: 0 auto; 19 | } 20 | 21 | body, button, input, select, textarea { 22 | font-family: 'Open Sans', sans-serif; 23 | } 24 | 25 | ::-webkit-scrollbar-thumb { 26 | background-color: #686868; 27 | border: 3px solid #424242; 28 | } 29 | 30 | ::-webkit-scrollbar-track { 31 | background-color: #424242; 32 | } 33 | 34 | p { 35 | font-size: 16px; 36 | line-height: 27px; 37 | font-weight: 300; 38 | } 39 | 40 | button { 41 | background-color: #0075FF; 42 | color: #F0F0F0; 43 | border: none; 44 | padding: 10px 20px; 45 | margin: 0 5px; 46 | text-align: center; 47 | text-decoration: none; 48 | display: inline-block; 49 | font-size: 18px; 50 | border-radius: 30px; 51 | text-shadow: 1px 1px 2px black; 52 | } 53 | 54 | button:active { 55 | transform: translateY(2px); 56 | } 57 | 58 | button:disabled { 59 | background-color: #005CC8; 60 | } 61 | 62 | button:disabled:active { 63 | transform: none; 64 | } 65 | 66 | button:focus { 67 | outline: none; 68 | } 69 | 70 | button.centered { 71 | margin-top: 30px; 72 | margin-left: auto; 73 | margin-right: auto; 74 | display: block; 75 | } 76 | 77 | p.centered { 78 | text-align: center; 79 | margin-left: auto; 80 | margin-right: auto; 81 | padding-top: 10px; 82 | } 83 | 84 | p.copyright { 85 | font-size: 12px; 86 | line-height: 16px; 87 | } 88 | 89 | p.home { 90 | font-family: 'Open Sans', sans-serif; 91 | font-size: 16px; 92 | line-height: 27px; 93 | font-weight: 300; 94 | text-align: right; 95 | } 96 | 97 | a,a:visited { 98 | color: #58A6FF; 99 | text-decoration: none; 100 | } 101 | 102 | a:hover,a:active { 103 | color: #58A6FF; 104 | text-decoration: underline; 105 | } 106 | 107 | a.header,a.header:visited { 108 | color: #E6EDF3; 109 | text-decoration: none; 110 | } 111 | 112 | a.header:hover { 113 | color: #C1C7CC; 114 | text-decoration: none; 115 | } 116 | 117 | hr { 118 | margin-top: 60px; 119 | } 120 | 121 | dl { 122 | display: grid; 123 | grid-template-columns: max-content auto; 124 | margin-left: 20px; 125 | } 126 | 127 | dt { 128 | grid-column-start: 1; 129 | } 130 | 131 | dd { 132 | grid-column-start: 2; 133 | margin-left: 15px; 134 | margin-bottom: 5px; 135 | } 136 | 137 | img.centered { 138 | display: block; 139 | width: 100%; 140 | max-width: 500px; 141 | height: auto; 142 | margin-left: auto; 143 | margin-right: auto; 144 | } 145 | 146 | img.cga { 147 | display: block; 148 | width: 100%; 149 | max-width: 640px; 150 | height: auto; 151 | margin-left: auto; 152 | margin-right: auto; 153 | image-rendering: pixelated; 154 | image-rendering: -moz-crisp-edges; 155 | image-rendering: crisp-edges; 156 | } 157 | 158 | img.svg { 159 | display: block; 160 | max-width: 100%; 161 | height: auto; 162 | margin-left: auto; 163 | margin-right: auto; 164 | image-rendering: pixelated; 165 | image-rendering: -moz-crisp-edges; 166 | image-rendering: crisp-edges; 167 | } 168 | 169 | 170 | i { 171 | font-style: italic; 172 | font-weight: normal; 173 | color: inherit; 174 | font-size: inherit; 175 | } 176 | 177 | strong { 178 | font-style: normal; 179 | font-weight: bold; 180 | color: inherit; 181 | font-size: inherit; 182 | } 183 | 184 | h1 { 185 | font-size: 2em; 186 | font-weight: bold; 187 | margin-top: 1.1em; 188 | margin-bottom: 0.67em; 189 | font-family: inherit; 190 | line-height: normal; 191 | display: block; 192 | color: inherit; 193 | } 194 | 195 | span { 196 | white-space: nowrap; 197 | } 198 | 199 | pre.code { 200 | font-family: 'Source Code Pro', monospace; 201 | font-size: 16px; 202 | line-height: normal; 203 | background-color: #161B22; 204 | color: #E6EDF3; 205 | padding: 15px 15px 15px 15px; 206 | max-width: 978px; 207 | overflow-x: auto; 208 | white-space: pre; 209 | } 210 | 211 | pre::-webkit-scrollbar { 212 | background-color: #424242; 213 | } 214 | 215 | pre::-webkit-scrollbar-thumb { 216 | background-color: #686868; 217 | border: 3px solid #424242; 218 | } 219 | 220 | .func { 221 | color: #D2A8FF; 222 | } 223 | 224 | .keyword { 225 | color: #FF7B72; 226 | } 227 | 228 | .str { 229 | color: #A5D6FF; 230 | } 231 | 232 | .num { 233 | color: #79C0FF; 234 | } 235 | 236 | .comment { 237 | color: #8B949E; 238 | } 239 | 240 | code { 241 | font-family: 'Source Code Pro', monospace; 242 | font-size: 16px; 243 | overflow-x: auto; 244 | } 245 | 246 | dd { 247 | margin-left: 30px; 248 | } 249 | 250 | ul { 251 | list-style-type: disc; 252 | margin: 0; 253 | margin-bottom: 14px; 254 | padding-left: 32px; 255 | } 256 | 257 | ul li { 258 | margin-bottom: 14px; 259 | } 260 | 261 | .indented { 262 | padding-left: 6px; 263 | } 264 | 265 | strong.item { 266 | margin-bottom: 14px; 267 | display: inline-block; 268 | } 269 | 270 | footer { 271 | display: flex; 272 | justify-content: space-between; 273 | align-items: center; 274 | margin-bottom: 25px; 275 | } 276 | 277 | footer .left { 278 | text-align: left; 279 | font-size: 12px; 280 | line-height: 16px; 281 | } 282 | 283 | footer .right { 284 | text-align: right; 285 | } 286 | 287 | footer img { 288 | height: 22px !important; 289 | margin-left: 3px; 290 | vertical-align: text-bottom; 291 | } -------------------------------------------------------------------------------- /sfx/die.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/sfx/die.mp3 -------------------------------------------------------------------------------- /sfx/fall.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/sfx/fall.mp3 -------------------------------------------------------------------------------- /sfx/jump.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/sfx/jump.mp3 -------------------------------------------------------------------------------- /sfx/kneel.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/sfx/kneel.mp3 -------------------------------------------------------------------------------- /sfx/swing.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/sfx/swing.mp3 -------------------------------------------------------------------------------- /sfx/treasure.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meatfighter/pitfall-js/33f2fc8036281999fd93ab271687c87f312526cd/sfx/treasure.mp3 -------------------------------------------------------------------------------- /src/animate.ts: -------------------------------------------------------------------------------- 1 | import { update } from './game/game'; 2 | import { render } from './screen'; 3 | 4 | const FRAMES_PER_SECOND = 60; 5 | const MILLIS_PER_FRAME = 1000 / FRAMES_PER_SECOND; 6 | const MAX_UPDATES_WITHOUT_RENDER = 5; 7 | 8 | let animationRunning = false; 9 | let frameID = 0; 10 | let previousTime = 0; 11 | let lagTime = 0; 12 | 13 | export function startAnimation() { 14 | if (animationRunning) { 15 | return; 16 | } 17 | animationRunning = true; 18 | lagTime = 0; 19 | frameID = requestAnimationFrame(renderAndUpdate); 20 | previousTime = performance.now(); 21 | } 22 | 23 | export function stopAnimation() { 24 | if (!animationRunning) { 25 | return; 26 | } 27 | animationRunning = false; 28 | cancelAnimationFrame(frameID); 29 | } 30 | 31 | export function renderAndUpdate() { 32 | if (!animationRunning) { 33 | return; 34 | } 35 | 36 | frameID = requestAnimationFrame(renderAndUpdate); 37 | 38 | render(); 39 | 40 | const currentTime = performance.now(); 41 | const elapsedTime = currentTime - previousTime; 42 | previousTime = currentTime; 43 | lagTime += elapsedTime; 44 | 45 | let count = 0; 46 | while ((lagTime >= MILLIS_PER_FRAME) && animationRunning) { 47 | update(); 48 | lagTime -= MILLIS_PER_FRAME; 49 | if (++count > MAX_UPDATES_WITHOUT_RENDER) { 50 | lagTime = 0; 51 | previousTime = performance.now(); 52 | break; 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { enter as enterProgress } from "./progress"; 2 | import { enter as enterDeath } from "./death"; 3 | 4 | export function init() { 5 | window.addEventListener('error', e => { 6 | console.error(`Caught in global handler: ${e.message}`, { 7 | source: e.filename, 8 | lineno: e.lineno, 9 | colno: e.colno, 10 | error: e.error 11 | }); 12 | e.preventDefault(); 13 | enterDeath(); 14 | }); 15 | window.addEventListener('unhandledrejection', e => e.preventDefault()); 16 | document.addEventListener('dblclick', e => e.preventDefault(), { passive: false }); 17 | 18 | enterProgress(); 19 | } -------------------------------------------------------------------------------- /src/audio.ts: -------------------------------------------------------------------------------- 1 | import { JSZipObject } from "jszip"; 2 | 3 | const audioContext = new AudioContext(); 4 | audioContext.onstatechange = () => { 5 | if (audioContext.state === 'suspended') { 6 | stopAll(); 7 | } 8 | }; 9 | 10 | let resumePromise: Promise | null = null; 11 | 12 | function resume(): Promise { 13 | if (!resumePromise) { 14 | resumePromise = audioContext.resume(); 15 | resumePromise.then(() => resumePromise = null).catch(() => resumePromise = null); 16 | } 17 | return resumePromise; 18 | } 19 | 20 | let suspendPromise: Promise | null = null; 21 | 22 | function suspend(): Promise { 23 | if (!suspendPromise) { 24 | suspendPromise = audioContext.suspend(); 25 | suspendPromise.then(() => suspendPromise = null).catch(() => suspendPromise = null); 26 | } 27 | return suspendPromise; 28 | } 29 | 30 | let docVisible = true; 31 | 32 | document.addEventListener('visibilitychange', () => { 33 | if (document.visibilityState === 'visible') { 34 | docVisible = true; 35 | if (audioContext.state === 'suspended') { 36 | resume(); 37 | } 38 | } else if (document.visibilityState === 'hidden') { 39 | docVisible = false; 40 | stopAll(); 41 | if (audioContext.state === 'running') { 42 | suspend(); 43 | } 44 | } 45 | }); 46 | 47 | const masterGain = audioContext.createGain(); 48 | masterGain.connect(audioContext.destination); 49 | masterGain.gain.value = 0.1; 50 | 51 | const promises: Promise>[] = []; 52 | 53 | const audioBuffers = new Map(); 54 | 55 | const activeSources = new Map(); 56 | 57 | export function setVolume(volume: number) { 58 | if (audioContext.state === 'suspended') { 59 | if (docVisible) { 60 | resume().then(() => setVolume(volume)); 61 | } 62 | return; 63 | } 64 | masterGain.gain.value = volume / 100; 65 | } 66 | 67 | export function decodeAudioData(name: string, obj: JSZipObject) { 68 | promises.push(obj.async('arraybuffer') 69 | .then(data => audioContext.decodeAudioData(data)) 70 | .then(buffer => audioBuffers.set(name, buffer))); 71 | } 72 | 73 | export async function waitForDecodes() { 74 | return Promise.all(promises).then(() => promises.length = 0); 75 | } 76 | 77 | export function play(name: string, loop = false) { 78 | if (audioContext.state === 'suspended') { 79 | if (docVisible) { 80 | resume().then(() => play(name)); 81 | } 82 | return; 83 | } 84 | 85 | if (loop) { 86 | if (activeSources.has(name)) { 87 | return; 88 | } 89 | } else { 90 | stop(name); 91 | } 92 | 93 | const source = audioContext.createBufferSource(); 94 | source.buffer = audioBuffers.get(name) as AudioBuffer; 95 | source.connect(masterGain); 96 | source.loop = loop; 97 | 98 | activeSources.set(name, source); 99 | source.onended = () => activeSources.delete(name); 100 | 101 | source.start(); 102 | } 103 | 104 | export function stop(name: string) { 105 | const source = activeSources.get(name); 106 | if (source) { 107 | activeSources.delete(name); 108 | source.stop(); 109 | } 110 | } 111 | 112 | export function stopAll() { 113 | for (const source of activeSources.values()) { 114 | source.stop(); 115 | } 116 | activeSources.clear(); 117 | } -------------------------------------------------------------------------------- /src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | function init() { 2 | const mainElement = document.getElementById('main-content') as HTMLElement; 3 | mainElement.innerHTML = '
...
'; 4 | const loadingDiv = document.getElementById('loading-div') as HTMLDivElement; 5 | const intervalId = window.setInterval(() => { 6 | loadingDiv.textContent = (loadingDiv.textContent === '...') 7 | ? '' 8 | : loadingDiv.textContent + '.'; 9 | }, 400); 10 | setTimeout(() => registerServiceWorker(intervalId), 10); 11 | } 12 | 13 | function registerServiceWorker(intervalId: number) { 14 | if ('serviceWorker' in navigator) { 15 | navigator.serviceWorker.register('sw.bundle.js?v=2025-01-25').then(() => importApp(intervalId)); 16 | } else { 17 | importApp(intervalId); 18 | } 19 | } 20 | 21 | function importApp(intervalId: number) { 22 | import(/* webpackChunkName: "app" */ './app').then(module => { 23 | clearInterval(intervalId); 24 | module.init(); 25 | }); 26 | } 27 | 28 | document.addEventListener('DOMContentLoaded', init); -------------------------------------------------------------------------------- /src/death.ts: -------------------------------------------------------------------------------- 1 | let landscape = false; 2 | 3 | export function enter() { 4 | window.addEventListener('resize', windowResized); 5 | window.addEventListener('touchmove', onTouchMove, { passive: false }); 6 | 7 | const mainElement = document.getElementById('main-content') as HTMLElement; 8 | mainElement.innerHTML = '
💀
'; 9 | 10 | windowResized(); 11 | } 12 | 13 | export function exit() { 14 | window.removeEventListener('resize', windowResized); 15 | window.removeEventListener('touchmove', onTouchMove); 16 | } 17 | 18 | function onTouchMove(e: TouchEvent) { 19 | e.preventDefault(); 20 | } 21 | 22 | function windowResized() { 23 | const deathDiv = document.getElementById('death-div') as HTMLDivElement; 24 | 25 | deathDiv.style.top = deathDiv.style.left = deathDiv.style.transform = ''; 26 | deathDiv.style.display = 'none'; 27 | 28 | const innerWidth = window.innerWidth; 29 | const innerHeight = window.innerHeight; 30 | landscape = (innerWidth >= innerHeight); 31 | deathDiv.style.display = 'flex'; 32 | 33 | if (landscape) { 34 | const rect = deathDiv.getBoundingClientRect(); 35 | deathDiv.style.left = `${(innerWidth - rect.width) / 2}px` 36 | deathDiv.style.top = `${(innerHeight - rect.height) / 2}px`; 37 | } else { 38 | deathDiv.style.transform = 'rotate(-90deg)'; 39 | const rect = deathDiv.getBoundingClientRect(); 40 | deathDiv.style.left = `${(innerWidth - rect.height) / 2}px` 41 | deathDiv.style.top = `${(innerHeight - rect.width) / 2}px`; 42 | } 43 | } -------------------------------------------------------------------------------- /src/download.ts: -------------------------------------------------------------------------------- 1 | const MAX_FETCH_RETRIES = 5; 2 | 3 | export type ProgressListener = (frac: number) => void; 4 | 5 | export async function download(url: string, progressListener: ProgressListener) { 6 | for (let i = MAX_FETCH_RETRIES - 1; i >= 0; --i) { 7 | try { 8 | const response = await fetch(url); 9 | if (!response.ok) { 10 | continue; 11 | } 12 | const contentLengthStr = response.headers.get('Content-Length'); 13 | if (!contentLengthStr) { 14 | continue; 15 | } 16 | const contentLength = parseInt(contentLengthStr); 17 | if (isNaN(contentLength) || contentLength <= 0) { 18 | continue; 19 | } 20 | const body = response.body; 21 | if (body === null) { 22 | continue; 23 | } 24 | 25 | const reader = body.getReader(); 26 | const chunks = []; 27 | let bytesReceived = 0; 28 | while (true) { 29 | const { done, value: chunk } = await reader.read(); 30 | if (done) { 31 | break; 32 | } 33 | chunks.push(chunk); 34 | bytesReceived += chunk.length; 35 | progressListener(bytesReceived / contentLength); 36 | } 37 | 38 | const uint8Array = new Uint8Array(bytesReceived); 39 | let position = 0; 40 | chunks.forEach(chunk => { 41 | uint8Array.set(chunk, position); 42 | position += chunk.length; 43 | }); 44 | return uint8Array; 45 | } catch (error) { 46 | if (i === 0) { 47 | throw error; 48 | } 49 | } 50 | } 51 | throw new Error("Failed to fetch."); 52 | } -------------------------------------------------------------------------------- /src/game/clock.ts: -------------------------------------------------------------------------------- 1 | import { printNumber, Colors, charSprites } from '@/graphics'; 2 | import { store, Difficulty } from '@/store'; 3 | import { GameState } from './game-state'; 4 | 5 | export class Clock { 6 | 7 | minutes: number; 8 | seconds: number; 9 | frames: number; 10 | 11 | constructor(clock: { 12 | minutes: number; 13 | seconds: number; 14 | frames: number; 15 | } = { 16 | minutes: (store.difficulty === Difficulty.EASY) ? 22 : (store.difficulty === Difficulty.NORMAL) ? 21 : 20, 17 | seconds: 0, 18 | frames: 59, 19 | }) { 20 | this.minutes = clock.minutes; 21 | this.seconds = clock.seconds; 22 | this.frames = clock.frames; 23 | } 24 | 25 | update(gs: GameState) { 26 | if (this.minutes === 0 && this.seconds === 0 && this.frames === 0) { 27 | gs.endGame(); 28 | return; 29 | } 30 | 31 | if (--this.frames >= 0) { 32 | return; 33 | } 34 | 35 | this.frames = 59; 36 | 37 | if (--this.seconds >= 0) { 38 | return; 39 | } 40 | 41 | this.seconds = 59; 42 | 43 | --this.minutes; 44 | } 45 | 46 | render(ctx: OffscreenCanvasRenderingContext2D) { 47 | printNumber(ctx, this.minutes, 29, 16, Colors.OFF_WHITE); 48 | ctx.drawImage(charSprites[Colors.OFF_WHITE][10], 37, 16); 49 | printNumber(ctx, this.seconds, 53, 16, Colors.OFF_WHITE, 2); 50 | } 51 | } -------------------------------------------------------------------------------- /src/game/cobra-and-fire.ts: -------------------------------------------------------------------------------- 1 | import { GameState } from './game-state'; 2 | import { cobraSprites, cobraMasks, fireSprites, fireMasks, Mask, Sprite } from '@/graphics'; 3 | import { map, ObsticleType } from './map'; 4 | 5 | export class CobraAndFire { 6 | 7 | constructor(_: { } = { }) { 8 | } 9 | 10 | update(gs: GameState) { 11 | let mask: Mask; 12 | switch (map[gs.harry.scene].obsticles) { 13 | case ObsticleType.COBRA: 14 | mask = cobraMasks[gs.sceneStates[gs.harry.scene].enteredLeft ? 0 : 1][gs.rattle.getValue()]; 15 | break; 16 | case ObsticleType.FIRE: 17 | mask = fireMasks[gs.rattle.getValue()]; 18 | break; 19 | default: 20 | return; 21 | } 22 | 23 | if (gs.harry.intersects(mask, 108, 111)) { 24 | gs.harry.injure(); 25 | } 26 | } 27 | 28 | render(gs: GameState, ctx: OffscreenCanvasRenderingContext2D, scene: number, ox: number) { 29 | let sprite: Sprite; 30 | switch (map[scene].obsticles) { 31 | case ObsticleType.COBRA: 32 | sprite = cobraSprites[gs.sceneStates[scene].enteredLeft ? 0 : 1][gs.rattle.getValue()]; 33 | break; 34 | case ObsticleType.FIRE: 35 | sprite = fireSprites[gs.rattle.getValue()]; 36 | break; 37 | default: 38 | return; 39 | } 40 | 41 | ctx.drawImage(sprite, 108 - ox, 111); 42 | } 43 | } -------------------------------------------------------------------------------- /src/game/dijkstra.ts: -------------------------------------------------------------------------------- 1 | import { FibonacciPriorityQueue, FibNode } from './fibonacci-priority-queue'; 2 | 3 | /** 4 | * An interface to describe edges in the graph. 5 | * Each node can point to any number of neighbors, each with a given weight. 6 | */ 7 | export interface Edge { 8 | node: T; 9 | weight: number; 10 | } 11 | 12 | /** 13 | * Dijkstra's algorithm that computes: 14 | * 1) The distance to the seed node for every node in the graph. 15 | * 2) The immediate neighbor to follow to reach the seed. 16 | * 17 | * @param graph A Map whose keys are nodes and whose values are an array of edges. 18 | * @param seed The seed node from which all distances are calculated. 19 | * @returns A Map describing each node's 20 | * distance and the neighbor to go to first on route to the seed. 21 | */ 22 | export function dijkstra(graph: Map[]>, seed: T): Map { 23 | 24 | // Store the best-known distance for each node and the link to the first step back to seed 25 | const distances = new Map(); 26 | const firstStepLink = new Map(); 27 | 28 | // Priority queue to pick the node with the smallest distance 29 | const pq = new FibonacciPriorityQueue(); 30 | 31 | // We need to keep track of the FibNode handles to decrease their priorities later 32 | const nodeHandles = new Map>(); 33 | 34 | // Initialize all nodes 35 | for (const node of graph.keys()) { 36 | distances.set(node, Number.POSITIVE_INFINITY); 37 | firstStepLink.set(node, null); 38 | 39 | // Add to Fibonacci priority queue with an initially large priority 40 | const handle = pq.add(node, Number.POSITIVE_INFINITY); 41 | nodeHandles.set(node, handle); 42 | } 43 | 44 | // Set the seed node's distance to 0 and update priority 45 | distances.set(seed, 0); 46 | pq.decreasePriority(nodeHandles.get(seed)!, 0); 47 | 48 | // Dijkstra's main loop 49 | while (pq.size() > 0) { 50 | // Extract node with the smallest distance 51 | const fibNode = pq.extractMin(); 52 | if (!fibNode) break; // No more nodes 53 | 54 | const currentNode = fibNode.key; 55 | const currentDistance = distances.get(currentNode)!; 56 | 57 | // Explore each neighbor 58 | const edges = graph.get(currentNode) || []; 59 | for (const { node: neighbor, weight } of edges) { 60 | const alt = currentDistance + weight; 61 | if (alt < distances.get(neighbor)!) { 62 | // Found a better route to neighbor 63 | distances.set(neighbor, alt); 64 | firstStepLink.set(neighbor, currentNode); 65 | pq.decreasePriority(nodeHandles.get(neighbor)!, alt); 66 | } 67 | } 68 | } 69 | 70 | // Build the final map with distance and link 71 | const result = new Map(); 72 | for (const node of graph.keys()) { 73 | result.set(node, { 74 | distance: distances.get(node)!, 75 | link: firstStepLink.get(node)!, 76 | }); 77 | } 78 | return result; 79 | } 80 | -------------------------------------------------------------------------------- /src/game/fibonacci-priority-queue.ts: -------------------------------------------------------------------------------- 1 | export class FibNode { 2 | public key: T; 3 | public priority: number; 4 | public degree: number; 5 | public parent: FibNode | null; 6 | public child: FibNode | null; 7 | public left: FibNode; 8 | public right: FibNode; 9 | public mark: boolean; 10 | 11 | constructor(key: T, priority: number) { 12 | this.key = key; 13 | this.priority = priority; 14 | this.degree = 0; 15 | this.parent = null; 16 | this.child = null; 17 | this.left = this; 18 | this.right = this; 19 | this.mark = false; 20 | } 21 | } 22 | 23 | export class FibonacciPriorityQueue { 24 | private min: FibNode | null; 25 | private nodeCount: number; 26 | 27 | constructor() { 28 | this.min = null; 29 | this.nodeCount = 0; 30 | } 31 | 32 | /** 33 | * Inserts a new node with the given key and priority. 34 | * Returns the newly created node. 35 | */ 36 | public add(key: T, priority: number): FibNode { 37 | const node = new FibNode(key, priority); 38 | 39 | // Merge this node into the root list 40 | if (!this.min) { 41 | this.min = node; 42 | } else { 43 | // Insert into the min's right position 44 | node.left = this.min; 45 | node.right = this.min.right; 46 | if (this.min.right) { 47 | this.min.right.left = node; 48 | } 49 | this.min.right = node; 50 | 51 | // Update min if necessary 52 | if (node.priority < this.min.priority) { 53 | this.min = node; 54 | } 55 | } 56 | 57 | this.nodeCount++; 58 | return node; 59 | } 60 | 61 | /** 62 | * Extracts the node with the smallest priority. 63 | * Returns the extracted node, or null if empty. 64 | */ 65 | public extractMin(): FibNode | null { 66 | const z = this.min; 67 | if (!z) { 68 | return null; 69 | } 70 | 71 | // Move each child of z into the root list 72 | if (z.child) { 73 | let child = z.child; 74 | do { 75 | const nextChild = child.right; 76 | // Remove child from its sibling list 77 | child.left.right = child.right; 78 | child.right.left = child.left; 79 | // Add child to the root list 80 | child.left = this.min!; 81 | child.right = this.min!.right; 82 | if (this.min!.right) { 83 | this.min!.right.left = child; 84 | } 85 | this.min!.right = child; 86 | child.parent = null; 87 | child = nextChild; 88 | } while (child !== z.child); 89 | } 90 | 91 | // Remove z from the root list 92 | z.left.right = z.right; 93 | z.right.left = z.left; 94 | 95 | if (z === z.right) { 96 | this.min = null; 97 | } else { 98 | this.min = z.right; 99 | this.consolidate(); 100 | } 101 | 102 | this.nodeCount--; 103 | return z; 104 | } 105 | 106 | /** 107 | * Decreases the priority of a given node to newPriority. 108 | * Assumes newPriority is strictly less than the node's current priority. 109 | */ 110 | public decreasePriority(node: FibNode, newPriority: number): void { 111 | if (newPriority > node.priority) { 112 | throw new Error('New priority must be lower than the current priority.'); 113 | } 114 | 115 | node.priority = newPriority; 116 | const parent = node.parent; 117 | if (parent && node.priority < parent.priority) { 118 | this.cut(node, parent); 119 | this.cascadingCut(parent); 120 | } 121 | 122 | // Update min if needed 123 | if (this.min && node.priority < this.min.priority) { 124 | this.min = node; 125 | } 126 | } 127 | 128 | /** 129 | * Private method to merge the trees of the heap by degree. 130 | * Called after extracting the minimum. 131 | */ 132 | private consolidate() { 133 | const A: Array | null> = []; 134 | // Upper bound on degrees 135 | const maxDegree = Math.floor(Math.log2(this.nodeCount)) + 2; 136 | for (let i = 0; i < maxDegree; i++) { 137 | A[i] = null; 138 | } 139 | 140 | // Collect all root nodes in a list 141 | const roots: FibNode[] = []; 142 | if (this.min) { 143 | let node: FibNode = this.min; 144 | do { 145 | roots.push(node); 146 | node = node.right; 147 | } while (node !== this.min); 148 | } 149 | 150 | // For each root, merge with same degree 151 | for (const w of roots) { 152 | let x = w; 153 | let d = x.degree; 154 | while (A[d]) { 155 | let y = A[d]!; 156 | if (x.priority > y.priority) { 157 | // Swap x and y 158 | [x, y] = [y, x]; 159 | } 160 | this.link(y, x); 161 | A[d] = null; 162 | d++; 163 | } 164 | A[d] = x; 165 | } 166 | 167 | // Rebuild the root list 168 | this.min = null; 169 | for (const node of A) { 170 | if (node) { 171 | if (!this.min) { 172 | this.min = node; 173 | node.left = node; 174 | node.right = node; 175 | } else { 176 | // Insert node into root list 177 | node.left = this.min; 178 | node.right = this.min.right; 179 | if (this.min.right) { 180 | this.min.right.left = node; 181 | } 182 | this.min.right = node; 183 | if (node.priority < this.min.priority) { 184 | this.min = node; 185 | } 186 | } 187 | } 188 | } 189 | } 190 | 191 | /** 192 | * Private method to make node y a child of node x. 193 | */ 194 | private link(y: FibNode, x: FibNode): void { 195 | // Remove y from the root list 196 | y.left.right = y.right; 197 | y.right.left = y.left; 198 | // Make y a child of x 199 | y.parent = x; 200 | if (!x.child) { 201 | x.child = y; 202 | y.left = y; 203 | y.right = y; 204 | } else { 205 | y.left = x.child; 206 | y.right = x.child.right; 207 | if (x.child.right) { 208 | x.child.right.left = y; 209 | } 210 | x.child.right = y; 211 | } 212 | x.degree++; 213 | y.mark = false; 214 | } 215 | 216 | /** 217 | * Private method to cut a node from its parent and move it to the root list. 218 | */ 219 | private cut(x: FibNode, y: FibNode): void { 220 | // Remove x from child list of y 221 | if (x.right === x) { 222 | y.child = null; 223 | } else { 224 | x.left.right = x.right; 225 | x.right.left = x.left; 226 | if (y.child === x) { 227 | y.child = x.right; 228 | } 229 | } 230 | y.degree--; 231 | 232 | // Add x to root list 233 | x.left = this.min!; 234 | x.right = this.min!.right; 235 | if (this.min!.right) { 236 | this.min!.right.left = x; 237 | } 238 | this.min!.right = x; 239 | 240 | x.parent = null; 241 | x.mark = false; 242 | } 243 | 244 | /** 245 | * Private method for recursively cutting marked parents. 246 | */ 247 | private cascadingCut(y: FibNode): void { 248 | const z = y.parent; 249 | if (z) { 250 | if (!y.mark) { 251 | y.mark = true; 252 | } else { 253 | this.cut(y, z); 254 | this.cascadingCut(z); 255 | } 256 | } 257 | } 258 | 259 | /** 260 | * Returns the number of elements in the queue. 261 | */ 262 | public size(): number { 263 | return this.nodeCount; 264 | } 265 | 266 | /** 267 | * Peeks at the minimum node without removing it. 268 | */ 269 | public peekMin(): FibNode | null { 270 | return this.min; 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/game/game-state.ts: -------------------------------------------------------------------------------- 1 | import { store, saveStore, Difficulty } from '@/store'; 2 | import { Harry } from './harry'; 3 | import { Scorpion } from './scorpion'; 4 | import { Vine } from './vine'; 5 | import { Pit } from './pit'; 6 | import { RollingLog } from './rolling-log'; 7 | import { StationaryLog } from './stationary-log'; 8 | import { Rattle } from './rattle'; 9 | import { CobraAndFire } from './cobra-and-fire'; 10 | import { Treasure } from './treasure'; 11 | import { Clock } from './clock'; 12 | import { map, TreasureType } from './map'; 13 | import { Colors } from '@/graphics'; 14 | 15 | export class SceneState { 16 | 17 | enteredLeft = true; 18 | treasure: TreasureType; 19 | 20 | constructor(sceneState: { 21 | enteredLeft: boolean; 22 | treasure: TreasureType; 23 | } = { 24 | enteredLeft: true, 25 | treasure: TreasureType.NONE, 26 | }) { 27 | this.enteredLeft = sceneState.enteredLeft; 28 | this.treasure = sceneState.treasure; 29 | } 30 | } 31 | 32 | export class GameState { 33 | 34 | sceneStates: SceneState[]; 35 | harry: Harry; 36 | scorpion: Scorpion; 37 | vine: Vine; 38 | pit: Pit; 39 | rollingLog: RollingLog; 40 | stationaryLog: StationaryLog; 41 | rattle: Rattle; 42 | cobraAndFire: CobraAndFire; 43 | treasure: Treasure; 44 | clock: Clock; 45 | scrollX: number; 46 | lastScrollX: number; 47 | ox: number; 48 | lastOx: number; 49 | nextOx: number; 50 | nextScene: number; 51 | lastNextScene: number; 52 | lastHarryUnderground: boolean; 53 | sceneAlpha: number; 54 | score: number; 55 | extraLives: number; 56 | gameOver: boolean; 57 | gameOverDelay: number; 58 | newHighScore: boolean; 59 | scoreColor: number; 60 | treasureCount: number; 61 | treasureMapIndex: number; 62 | 63 | constructor(gameState: { 64 | sceneStates: SceneState[] | undefined; 65 | harry: Harry | undefined; 66 | scorpion: Scorpion | undefined; 67 | vine: Vine | undefined; 68 | pit: Pit | undefined; 69 | rollingLog: RollingLog | undefined; 70 | stationaryLog: StationaryLog | undefined; 71 | rattle: Rattle | undefined; 72 | cobraAndFire: CobraAndFire | undefined; 73 | treasure: Treasure | undefined; 74 | clock: Clock | undefined; 75 | scrollX: number; 76 | lastScrollX: number; 77 | ox: number; 78 | lastOx: number; 79 | nextOx: number; 80 | nextScene: number; 81 | lastNextScene: number; 82 | lastHarryUnderground: boolean; 83 | sceneAlpha: number; 84 | score: number; 85 | extraLives: number; 86 | gameOver: boolean; 87 | gameOverDelay: number; 88 | newHighScore: boolean; 89 | scoreColor: number; 90 | treasureCount: number; 91 | treasureMapIndex: number; 92 | } = { 93 | sceneStates: undefined, 94 | harry: undefined, 95 | scorpion: undefined, 96 | vine: undefined, 97 | pit: undefined, 98 | rollingLog: undefined, 99 | stationaryLog: undefined, 100 | rattle: undefined, 101 | cobraAndFire: undefined, 102 | treasure: undefined, 103 | clock: undefined, 104 | scrollX: 4, 105 | lastScrollX: 4, 106 | ox: 0, 107 | lastOx: 0, 108 | nextOx: 0, 109 | nextScene: 0, 110 | lastNextScene: 0, 111 | lastHarryUnderground: false, 112 | sceneAlpha: 1, 113 | score: 2000, 114 | extraLives: (store.difficulty === Difficulty.EASY) ? 4 : (store.difficulty === Difficulty.NORMAL) ? 3 : 2, 115 | gameOver: false, 116 | gameOverDelay: 180, 117 | newHighScore: false, 118 | scoreColor: Colors.OFF_WHITE, 119 | treasureCount: 0, 120 | treasureMapIndex: 0, 121 | }) { 122 | this.sceneStates = new Array(map.length); 123 | if (gameState.sceneStates?.length === map.length) { 124 | for (let i = map.length - 1; i >= 0; --i) { 125 | this.sceneStates[i] = new SceneState(gameState.sceneStates[i]); 126 | } 127 | } else { 128 | for (let i = map.length - 1; i >= 0; --i) { 129 | this.sceneStates[i] = new SceneState({ enteredLeft: true, treasure: map[i].treasure, }); 130 | } 131 | } 132 | 133 | this.harry = new Harry(gameState.harry); 134 | this.scorpion = new Scorpion(gameState.scorpion); 135 | this.vine = new Vine(gameState.vine); 136 | this.pit = new Pit(gameState.pit); 137 | this.rollingLog = new RollingLog(gameState.rollingLog); 138 | this.stationaryLog = new StationaryLog(gameState.stationaryLog); 139 | this.rattle = new Rattle(gameState.rattle); 140 | this.cobraAndFire = new CobraAndFire(gameState.cobraAndFire); 141 | this.treasure = new Treasure(gameState.treasure); 142 | this.clock = new Clock(gameState.clock); 143 | this.scrollX = gameState.scrollX; 144 | this.lastScrollX = gameState.lastScrollX; 145 | this.ox = gameState.ox; 146 | this.lastOx = gameState.lastOx; 147 | this.nextOx = gameState.nextOx; 148 | this.nextScene = gameState.nextScene; 149 | this.lastNextScene = gameState.lastNextScene; 150 | this.lastHarryUnderground = gameState.lastHarryUnderground; 151 | this.sceneAlpha = gameState.sceneAlpha; 152 | this.score = gameState.score; 153 | this.extraLives = gameState.extraLives; 154 | this.gameOver = gameState.gameOver; 155 | this.gameOverDelay = gameState.gameOverDelay; 156 | this.newHighScore = gameState.newHighScore; 157 | this.scoreColor = gameState.scoreColor; 158 | this.treasureCount = gameState.treasureCount;; 159 | this.treasureMapIndex = gameState.treasureMapIndex; 160 | } 161 | 162 | endGame() { 163 | if (!this.gameOver) { 164 | this.gameOver = true; 165 | this.gameOverDelay = (this.treasureCount === 32) ? 600 : 180; 166 | store.gameState = undefined; 167 | if (this.score > store.highScores[store.difficulty]) { 168 | this.newHighScore = true; 169 | store.highScores[store.difficulty] = this.score; 170 | } 171 | } 172 | } 173 | } -------------------------------------------------------------------------------- /src/game/game.ts: -------------------------------------------------------------------------------- 1 | import { map, WallType, PitType, ObsticleType, TreasureType } from './map'; 2 | import { GameState } from './game-state'; 3 | import { colors, Colors, Resolution, leavesSprites, branchesSprite, wallSprite, printNumber, charSprites, arrowSprites } 4 | from '@/graphics'; 5 | import { clamp } from '@/math'; 6 | import { updateInput, upJustPressed, downJustPressed, leftJustPressed, rightJustPressed, jumpJustPressed } 7 | from '@/input'; 8 | import { Tier, treasureCells, updateTreasureMapIndex, Direction } from './treasure-map'; 9 | import { store, Difficulty } from '@/store'; 10 | 11 | const SCENE_ALPHA_DELTA = 1 / 30; 12 | 13 | const MIN_SCROLL_DELTA = .5; 14 | const SCROLL_MARGIN = 4; 15 | 16 | const TRUNKS = [ 17 | [ 0, 32, 92, 124 ], 18 | [ 8, 40, 84, 116 ], 19 | [ 16, 48, 76, 108 ], 20 | [ 20, 52, 72, 104 ], 21 | ]; 22 | 23 | let gs: GameState; 24 | 25 | export function initGame() { 26 | store.gameState = gs = new GameState(store.gameState); 27 | updateTreasureMapIndex(gs); 28 | } 29 | 30 | export function update() { 31 | updateInput(gs); 32 | 33 | if (gs.gameOver) { 34 | if (gs.newHighScore) { 35 | gs.scoreColor = (gs.scoreColor + 1) & 0xFF; 36 | } 37 | if (gs.gameOverDelay > 0) { 38 | --gs.gameOverDelay; 39 | return; 40 | } 41 | if (upJustPressed || downJustPressed || leftJustPressed || rightJustPressed || jumpJustPressed) { 42 | initGame(); 43 | } 44 | return; 45 | } 46 | 47 | gs.harry.teleported = false; 48 | 49 | const scene0 = gs.harry.scene; 50 | const scene1 = gs.nextScene; 51 | 52 | if (!gs.harry.isInjured()) { 53 | gs.clock.update(gs); 54 | gs.rattle.update(); 55 | gs.cobraAndFire.update(gs); 56 | gs.treasure.update(gs); 57 | gs.scorpion.update(gs); 58 | gs.vine.update(gs); 59 | gs.pit.update(gs); 60 | gs.rollingLog.update(gs); 61 | gs.stationaryLog.update(gs); 62 | if (gs.sceneAlpha < 1) { 63 | gs.sceneAlpha += SCENE_ALPHA_DELTA; 64 | if (gs.sceneAlpha > 1) { 65 | gs.sceneAlpha = 1; 66 | } 67 | } 68 | } 69 | 70 | gs.harry.update(gs); 71 | 72 | const underground = gs.harry.isUnderground(); 73 | if (gs.lastHarryUnderground !== underground) { 74 | gs.lastHarryUnderground = underground; 75 | gs.lastNextScene = gs.nextScene; 76 | gs.sceneAlpha = clamp(1 - gs.sceneAlpha, 0, 1); 77 | } 78 | 79 | const targetScrollX = Math.floor(gs.harry.absoluteX); 80 | if (targetScrollX < gs.scrollX - SCROLL_MARGIN) { 81 | if (gs.lastScrollX === targetScrollX || gs.harry.teleported) { 82 | gs.scrollX -= MIN_SCROLL_DELTA; 83 | } else { 84 | gs.scrollX -= Math.max(MIN_SCROLL_DELTA, gs.lastScrollX - targetScrollX); 85 | } 86 | } else if (targetScrollX > gs.scrollX + SCROLL_MARGIN) { 87 | if (gs.lastScrollX === targetScrollX || gs.harry.teleported) { 88 | gs.scrollX += MIN_SCROLL_DELTA; 89 | } else { 90 | gs.scrollX += Math.max(MIN_SCROLL_DELTA, targetScrollX - gs.lastScrollX); 91 | } 92 | } 93 | gs.lastScrollX = targetScrollX; 94 | gs.ox = Math.floor(gs.harry.x) - 68 + Math.floor(gs.scrollX) - targetScrollX; 95 | 96 | if (gs.lastOx !== gs.ox) { 97 | gs.rollingLog.sync(); 98 | gs.lastOx = gs.ox; 99 | } 100 | 101 | if (gs.ox < 0) { 102 | gs.nextOx = gs.ox + Resolution.WIDTH; 103 | gs.nextScene = gs.harry.scene - (underground ? 3 : 1); 104 | if (gs.nextScene < 0) { 105 | gs.nextScene += map.length; 106 | } 107 | if (gs.nextScene !== scene0 && gs.nextScene !== scene1) { 108 | gs.sceneStates[gs.nextScene].enteredLeft = false; 109 | } 110 | } else { 111 | gs.nextOx = gs.ox - Resolution.WIDTH; 112 | gs.nextScene = gs.harry.scene + (underground ? 3 : 1); 113 | if (gs.nextScene >= map.length) { 114 | gs.nextScene -= map.length; 115 | } 116 | if (gs.nextScene !== scene0 && gs.nextScene !== scene1) { 117 | gs.sceneStates[gs.nextScene].enteredLeft = true; 118 | } 119 | } 120 | } 121 | 122 | function renderStrips(ctx: OffscreenCanvasRenderingContext2D) { 123 | ctx.fillStyle = colors[Colors.GREEN]; 124 | ctx.fillRect(0, 51, Resolution.WIDTH, 60); 125 | ctx.fillStyle = colors[Colors.LIGHT_YELLOW]; 126 | ctx.fillRect(0, 111, Resolution.WIDTH, 16); 127 | ctx.fillStyle = colors[Colors.DARK_YELLOW]; 128 | ctx.fillRect(0, 127, Resolution.WIDTH, 15); 129 | ctx.fillRect(0, 174, Resolution.WIDTH, 6); 130 | ctx.fillStyle = colors[Colors.BLACK]; 131 | ctx.fillRect(0, 142, Resolution.WIDTH, 32); 132 | } 133 | 134 | function renderBackground(ctx: OffscreenCanvasRenderingContext2D, scene: number, ox: number) { 135 | const { trees, ladder, holes, wall, vine, pit, obsticles, scorpion } = map[scene]; 136 | const trunks = TRUNKS[trees]; 137 | 138 | if (store.difficulty === Difficulty.EASY) { 139 | const cells = treasureCells[gs.treasureMapIndex][scene]; 140 | ctx.drawImage(arrowSprites[Tier.UPPER][cells[Tier.UPPER].direction], 60 - ox, 75); 141 | const lowerDirection = cells[Tier.LOWER].direction; 142 | let lowerOffset: number; 143 | switch (wall) { 144 | case WallType.LEFT: 145 | lowerOffset = (lowerDirection === Direction.RIGHT || lowerDirection === Direction.LEFT) ? 52 : 53; 146 | break; 147 | case WallType.RIGHT: 148 | lowerOffset = 68; 149 | break; 150 | default: 151 | lowerOffset = 60; 152 | break; 153 | } 154 | ctx.drawImage(arrowSprites[Tier.LOWER][cells[Tier.LOWER].direction], lowerOffset - ox, 150); 155 | } 156 | 157 | ctx.fillStyle = colors[Colors.DARK_BROWN]; 158 | for (let i = 3; i >= 0; --i) { 159 | ctx.drawImage(branchesSprite, trunks[i] - 2 - ox, 51); 160 | ctx.fillRect(trunks[i] - ox, 59, 4, 52); 161 | } 162 | 163 | if (ladder) { 164 | ctx.fillStyle = colors[Colors.BLACK]; 165 | ctx.fillRect(60 - ox, 116, 8, 6); 166 | ctx.fillRect(60 - ox, 127, 8, 15); 167 | ctx.fillStyle = colors[Colors.DARK_YELLOW]; 168 | for (let i = 10, y = 130; i >= 0; --i, y += 4) { 169 | ctx.fillRect(62 - ox, y, 4, 2); 170 | } 171 | } 172 | 173 | if (holes) { 174 | ctx.fillStyle = colors[Colors.BLACK]; 175 | ctx.fillRect(32 - ox, 116, 12, 6); 176 | ctx.fillRect(32 - ox, 127, 12, 15); 177 | ctx.fillRect(84 - ox, 116, 12, 6); 178 | ctx.fillRect(84 - ox, 127, 12, 15); 179 | } 180 | 181 | switch(wall) { 182 | case WallType.LEFT: 183 | ctx.drawImage(wallSprite, 2 - ox, 142); 184 | ctx.drawImage(wallSprite, 2 - ox, 158); 185 | break; 186 | case WallType.RIGHT: 187 | ctx.drawImage(wallSprite, 120 - ox, 142); 188 | ctx.drawImage(wallSprite, 120 - ox, 158); 189 | break; 190 | } 191 | 192 | if (scorpion) { 193 | gs.scorpion.render(gs, ctx, ox); 194 | } 195 | 196 | if (vine) { 197 | gs.vine.render(gs, ctx, ox); 198 | } 199 | 200 | if (pit !== PitType.NONE) { 201 | gs.pit.render(gs, ctx, scene, ox); 202 | } 203 | 204 | if (gs.sceneStates[scene].treasure !== TreasureType.NONE) { 205 | gs.treasure.render(gs, ctx, scene, ox); 206 | } 207 | 208 | switch (obsticles) { 209 | case ObsticleType.ONE_ROLLING_LOG: 210 | case ObsticleType.TWO_ROLLING_LOGS_NEAR: 211 | case ObsticleType.TWO_ROLLING_LOGS_FAR: 212 | case ObsticleType.THREE_ROLLING_LOGS: 213 | gs.rollingLog.render(gs, ctx, scene, ox); 214 | break; 215 | 216 | case ObsticleType.ONE_STATIONARY_LOG: 217 | case ObsticleType.THREE_STATIONARY_LOGS: 218 | gs.stationaryLog.render(gs, ctx, scene, ox); 219 | break; 220 | 221 | case ObsticleType.COBRA: 222 | case ObsticleType.FIRE: 223 | gs.cobraAndFire.render(gs, ctx, scene, ox); 224 | break; 225 | } 226 | } 227 | 228 | function renderLeaves(ctx: OffscreenCanvasRenderingContext2D, scene: number, ox: number) { 229 | const { trees } = map[scene]; 230 | ctx.fillStyle = colors[Colors.DARK_GREEN]; 231 | ctx.fillRect(0, 0, Resolution.WIDTH, 51); 232 | for (let i = 1; i < 5; ++i) { 233 | ctx.drawImage(leavesSprites[(i & 1) ^ 1][trees], ((i - 1) << 5) - ox, 51, 32, 8); 234 | } 235 | ctx.drawImage(leavesSprites[0][trees], 0, 0, 2, 4, 128 - ox, 51, 8, 8); 236 | } 237 | 238 | function renderHUD(ctx: OffscreenCanvasRenderingContext2D) { 239 | printNumber(ctx, gs.score, 45, 3, gs.scoreColor); 240 | gs.clock.render(ctx); 241 | ctx.fillStyle = colors[Colors.OFF_WHITE]; 242 | for (let i = gs.extraLives - 1, x = 5; i >= 0; --i, x += 2) { 243 | ctx.fillRect(x, 16, 1, 8); 244 | } 245 | 246 | if (store.difficulty !== Difficulty.HARD) { 247 | printNumber(ctx, gs.harry.scene + 1, 116, 3, Colors.OFF_WHITE); 248 | printNumber(ctx, gs.treasureCount, 92, 16, Colors.OFF_WHITE); 249 | const sprites = charSprites[Colors.OFF_WHITE]; 250 | ctx.drawImage(sprites[10], 100, 16); 251 | ctx.drawImage(sprites[3], 108, 16); 252 | ctx.drawImage(sprites[2], 116, 16); 253 | } 254 | } 255 | 256 | export function renderScreen(ctx: OffscreenCanvasRenderingContext2D) { 257 | 258 | renderStrips(ctx); 259 | 260 | renderBackground(ctx, gs.harry.scene, gs.ox); 261 | if (gs.sceneAlpha === 1) { 262 | renderBackground(ctx, gs.nextScene, gs.nextOx); 263 | } else { 264 | ctx.globalAlpha = 1 - gs.sceneAlpha; 265 | renderBackground(ctx, gs.lastNextScene, gs.nextOx); 266 | ctx.globalAlpha = gs.sceneAlpha; 267 | renderBackground(ctx, gs.nextScene, gs.nextOx); 268 | ctx.globalAlpha = 1; 269 | } 270 | 271 | gs.harry.render(gs, ctx, gs.ox); 272 | 273 | renderLeaves(ctx, gs.harry.scene, gs.ox); 274 | if (gs.sceneAlpha === 1) { 275 | renderLeaves(ctx, gs.nextScene, gs.nextOx); 276 | } else { 277 | ctx.globalAlpha = 1 - gs.sceneAlpha; 278 | renderLeaves(ctx, gs.lastNextScene, gs.nextOx); 279 | ctx.globalAlpha = gs.sceneAlpha; 280 | renderLeaves(ctx, gs.nextScene, gs.nextOx); 281 | ctx.globalAlpha = 1; 282 | } 283 | 284 | renderHUD(ctx); 285 | } -------------------------------------------------------------------------------- /src/game/harry.ts: -------------------------------------------------------------------------------- 1 | import { harryMasks, harrySprites, Resolution, Mask, vineStates, crocSprites } from '@/graphics'; 2 | import { GameState } from './game-state'; 3 | import { 4 | leftPressed, leftJustPressed, leftJustReleased, 5 | rightPressed, rightJustPressed, rightJustReleased, 6 | upPressed, upJustPressed, upJustReleased, 7 | downPressed, downJustPressed, downJustReleased, 8 | jumpPressed, jumpJustPressed, jumpJustReleased, 9 | } from '@/input'; 10 | import { map, WallType } from './map'; 11 | import { spritesIntersect } from '@/math'; 12 | import { play, stop } from '@/audio'; 13 | 14 | const Y_UPPER_LEVEL = 119; 15 | const Y_LOWER_LEVEL = 174; 16 | const Y_HOLE_BOTTOM = 157; 17 | 18 | const JUMP_ARC_BASE = 17; 19 | const JUMP_ARC_HEIGHT = 11; 20 | 21 | const T = JUMP_ARC_BASE; 22 | const G = 2 * JUMP_ARC_HEIGHT / (T * T); 23 | const VY0 = -G * T; 24 | 25 | const INJURED_DELAY = 20 // 134; 26 | 27 | const X_SPAWN_MARGIN = 38; 28 | 29 | enum MainState { 30 | STANDING, 31 | FALLING, 32 | CLIMBING, 33 | INJURED, 34 | SWINGING, 35 | SINKING, 36 | KNEELING, 37 | SKIDDING, 38 | } 39 | 40 | export class Harry { 41 | mainState: MainState; 42 | lastMainState: MainState; 43 | scene: number; 44 | absoluteX: number; 45 | x: number; 46 | y: number; 47 | vy: number; 48 | dir: number; 49 | sprite: number; 50 | runCounter: number; 51 | climbCounter: number; 52 | teleported: boolean; 53 | injuredCounter: number; 54 | tunnelSpawning: boolean; 55 | releasedVine: boolean; 56 | swallow: boolean; 57 | kneeling: boolean; 58 | kneelingDelay: boolean; 59 | rightTouchMeansDown: boolean; 60 | rollingDelay: number; 61 | 62 | constructor(harry: { 63 | mainState: MainState; 64 | lastMainState: MainState; 65 | scene: number; 66 | absoluteX: number; 67 | x: number; 68 | y: number; 69 | vy: number; 70 | dir: number; 71 | sprite: number; 72 | runCounter: number; 73 | climbCounter: number; 74 | teleported: boolean; 75 | injuredCounter: number; 76 | tunnelSpawning: boolean; 77 | releasedVine: boolean; 78 | swallow: boolean; 79 | kneeling: boolean; 80 | kneelingDelay: boolean; 81 | rightTouchMeansDown: boolean; 82 | rollingDelay: number; 83 | } = { 84 | mainState: MainState.STANDING, 85 | lastMainState: MainState.STANDING, 86 | scene: 0, 87 | absoluteX: 4, 88 | x: 4, 89 | y: Y_UPPER_LEVEL, 90 | vy: 0, 91 | dir: 0, 92 | sprite: 0, 93 | runCounter: 0, 94 | climbCounter: 0, 95 | teleported: false, 96 | injuredCounter: 0, 97 | tunnelSpawning: false, 98 | releasedVine: false, 99 | swallow: false, 100 | kneeling: false, 101 | kneelingDelay: false, 102 | rightTouchMeansDown: false, 103 | rollingDelay: 0, 104 | }) { 105 | this.mainState = harry.mainState; 106 | this.lastMainState = harry.lastMainState; 107 | this.scene = harry.scene; 108 | this.absoluteX = harry.absoluteX; 109 | this.x = harry.x; 110 | this.y = harry.y; 111 | this.vy = harry.vy; 112 | this.dir = harry.dir; 113 | this.sprite = harry.sprite; 114 | this.runCounter = harry.runCounter; 115 | this.climbCounter = harry.climbCounter; 116 | this.teleported = harry.teleported; 117 | this.injuredCounter = harry.injuredCounter; 118 | this.tunnelSpawning = harry.tunnelSpawning; 119 | this.releasedVine = harry.releasedVine; 120 | this.swallow = harry.swallow; 121 | this.kneeling = harry.kneeling; 122 | this.kneelingDelay = harry.kneelingDelay; 123 | this.rightTouchMeansDown = harry.rightTouchMeansDown; 124 | this.rollingDelay = harry.rollingDelay; 125 | } 126 | 127 | intersects(mask: Mask, x: number, y: number): boolean { 128 | return spritesIntersect(mask, x, y, harryMasks[this.dir][this.sprite], Math.floor(this.x) - 4, 129 | Math.floor(this.y) - 22); 130 | } 131 | 132 | canBeHitByRollingLog() { 133 | return this.mainState === MainState.STANDING || this.mainState === MainState.KNEELING 134 | || this.mainState === MainState.CLIMBING; 135 | } 136 | 137 | isClimbing() { 138 | return this.mainState === MainState.CLIMBING; 139 | } 140 | 141 | isFalling() { 142 | return this.mainState === MainState.FALLING; 143 | } 144 | 145 | isUnderground() { 146 | switch (this.mainState) { 147 | case MainState.SINKING: 148 | case MainState.SWINGING: 149 | case MainState.SKIDDING: 150 | case MainState.KNEELING: 151 | return false; 152 | } 153 | return this.y > 146; 154 | } 155 | 156 | private teleport(x: number) { 157 | this.teleported = true; 158 | this.setX(x); 159 | } 160 | 161 | private setX(x: number) { 162 | this.incrementX(x - this.x); 163 | } 164 | 165 | private incrementX(deltaX: number) { 166 | this.absoluteX += deltaX; 167 | this.x += deltaX; 168 | 169 | if (this.x < 0) { 170 | this.x += Resolution.WIDTH; 171 | if (this.y > Y_UPPER_LEVEL) { 172 | this.scene -= 3; 173 | } else { 174 | --this.scene; 175 | } 176 | if (this.scene < 0) { 177 | this.scene += map.length; 178 | } 179 | } else if (this.x >= Resolution.WIDTH) { 180 | this.x -= Resolution.WIDTH; 181 | if (this.y > Y_UPPER_LEVEL) { 182 | this.scene += 3; 183 | } else { 184 | ++this.scene; 185 | } 186 | if (this.scene >= map.length) { 187 | this.scene -= map.length; 188 | } 189 | } 190 | } 191 | 192 | private startFalling(gs: GameState, v0: number) { 193 | this.mainState = MainState.FALLING; 194 | this.y += v0; 195 | this.vy = G + v0; 196 | this.sprite = 2; 197 | this.kneeling = false; 198 | this.updateShift(gs) 199 | } 200 | 201 | private endFalling(gs: GameState, y: number) { 202 | this.mainState = MainState.STANDING; 203 | this.y = y; 204 | this.vy = 0; 205 | this.sprite = 2; 206 | this.runCounter = 0; 207 | this.tunnelSpawning = false; 208 | this.releasedVine = false; 209 | this.updateShift(gs); 210 | } 211 | 212 | private startClimbing(y: number) { 213 | this.mainState = MainState.CLIMBING; 214 | this.teleport(64); 215 | this.y = y; 216 | this.sprite = 7; 217 | this.kneeling = false; 218 | this.climbCounter = 0; 219 | this.rightTouchMeansDown = (y === 134) !== (this.dir !== 0); 220 | } 221 | 222 | private endClimbing(x: number, y: number, dir: number) { 223 | this.mainState = MainState.STANDING; 224 | this.teleport(x); 225 | this.y = y; 226 | this.runCounter = 0; 227 | this.sprite = 0; 228 | this.dir = dir; 229 | } 230 | 231 | private updateShift(gs: GameState): boolean { 232 | const { wall } = map[this.scene]; 233 | 234 | let shifting = false; 235 | if (rightPressed) { 236 | let moveRight = true; 237 | if (this.y >= 120 && ((wall === WallType.RIGHT && this.x === 119) 238 | || (wall === WallType.LEFT && this.x === 1))) { 239 | moveRight = false; 240 | } else if (this.y > Y_UPPER_LEVEL && this.y <= Y_HOLE_BOTTOM) { 241 | if (this.x >= 32 && this.x <= 44) { 242 | if (this.x > 43.5) { 243 | moveRight = false; 244 | } 245 | } else if (this.x >= 84 && this.x <= 96) { 246 | if (this.x > 95.5) { 247 | moveRight = false; 248 | } 249 | } 250 | } 251 | if (moveRight) { 252 | this.incrementX(.5); 253 | this.dir = 0; 254 | shifting = true; 255 | } 256 | } else if (leftPressed) { 257 | let moveLeft = true; 258 | if (this.y >= 120 && ((wall === WallType.LEFT && this.x === 10) 259 | || (wall === WallType.RIGHT && this.x === 128))) { 260 | moveLeft = false; 261 | } else if (this.y > Y_UPPER_LEVEL && this.y <= Y_HOLE_BOTTOM) { 262 | if (this.x >= 32 && this.x <= 43) { 263 | if (this.x < 32.5) { 264 | moveLeft = false; 265 | } 266 | } else if (this.x >= 84 && this.x <= 95) { 267 | if (this.x < 84.5) { 268 | moveLeft = false; 269 | } 270 | } 271 | } 272 | if (moveLeft) { 273 | this.incrementX(-.5); 274 | this.dir = 1; 275 | shifting = true; 276 | } 277 | } 278 | return shifting; 279 | } 280 | 281 | canStartClimbingUp(): boolean { 282 | return this.mainState == MainState.STANDING && this.y === Y_LOWER_LEVEL && this.x >= 56 && this.x <= 72 283 | && !(leftPressed || rightPressed || upPressed || downPressed || jumpPressed); 284 | } 285 | 286 | private updateStanding(gs: GameState) { 287 | const { ladder, holes } = map[this.scene]; 288 | 289 | if (holes && this.y === Y_UPPER_LEVEL && ((this.x >= 32 && this.x <= 44) || (this.x >= 84 && this.x <= 96))) { 290 | this.startFalling(gs, G); 291 | gs.score = Math.max(0, gs.score - 100); 292 | play('sfx/fall.mp3') 293 | return; 294 | } 295 | 296 | if (jumpPressed) { 297 | this.startFalling(gs, VY0); 298 | play('sfx/jump.mp3'); 299 | return; 300 | } 301 | 302 | if (ladder) { 303 | if (this.y === Y_UPPER_LEVEL && ((this.x >= 60 && this.x <= 67) 304 | || (((downPressed && !(leftPressed || rightPressed)) || downJustPressed) 305 | && this.x >= 56 && this.x <= 72))) { 306 | this.startClimbing(134); 307 | return; 308 | } 309 | if (this.y === Y_LOWER_LEVEL && ((upPressed && !(leftPressed || rightPressed)) || upJustPressed) 310 | && this.x >= 56 && this.x <= 72) { 311 | this.startClimbing(this.y); 312 | return; 313 | } 314 | } 315 | 316 | if (this.updateShift(gs)) { 317 | if (this.runCounter === 0 && ++this.sprite === 6) { 318 | this.sprite = 1; 319 | } 320 | this.runCounter = (this.runCounter + 1) & 3; 321 | } else { 322 | this.runCounter = 0; 323 | this.sprite = (this.lastMainState === MainState.FALLING) ? 2 : 0; 324 | } 325 | } 326 | 327 | private updateFalling(gs: GameState) { 328 | const { ladder, holes, wall } = map[this.scene]; 329 | 330 | if (ladder && this.y >= 134 && this.y < Y_LOWER_LEVEL && this.x >= 60 && this.x <= 67) { 331 | const stepsToTop = Math.floor((this.y - 134) / 4); 332 | this.startClimbing(134 + 4 * stepsToTop); 333 | this.dir ^= stepsToTop & 1; 334 | return; 335 | } 336 | 337 | const nextY = this.y + this.vy; 338 | 339 | if (this.y <= Y_UPPER_LEVEL && nextY >= Y_UPPER_LEVEL) { 340 | if (ladder && this.x >= 60 && this.x <= 67) { 341 | this.startClimbing(134); 342 | return; 343 | } 344 | if (!holes || this.x < 32 || this.x > 95 || (this.x > 43 && this.x < 84)) { 345 | this.endFalling(gs, Y_UPPER_LEVEL); 346 | return; 347 | } 348 | } 349 | 350 | if (this.y <= Y_LOWER_LEVEL && nextY >= Y_LOWER_LEVEL) { 351 | this.endFalling(gs, Y_LOWER_LEVEL); 352 | return; 353 | } 354 | 355 | this.y += this.vy; 356 | this.vy += G; 357 | this.sprite = 5; 358 | 359 | this.updateShift(gs); 360 | } 361 | 362 | private climbUpward() { 363 | if (this.y === 134) { 364 | this.climbCounter = 0; 365 | } else if (++this.climbCounter >= 8) { 366 | this.climbCounter = 0; 367 | this.y -= 4; 368 | this.dir ^= 1; 369 | } 370 | } 371 | 372 | private climbDownward(): boolean { 373 | if (this.y === Y_LOWER_LEVEL) { 374 | this.endClimbing(this.x, Y_LOWER_LEVEL, this.dir); 375 | return true; 376 | } 377 | if (++this.climbCounter >= 8) { 378 | this.climbCounter = 0; 379 | this.y += 4; 380 | this.dir ^= 1; 381 | } 382 | return false; 383 | } 384 | 385 | private updateClimbing(gs: GameState) { 386 | if (this.y <= 142) { 387 | if (this.y === 134 && upPressed) { 388 | if (this.rightTouchMeansDown) { 389 | this.endClimbing(59, Y_UPPER_LEVEL, 1); 390 | } else { 391 | this.endClimbing(69, Y_UPPER_LEVEL, 0); 392 | } 393 | return; 394 | } 395 | 396 | if (rightJustPressed || (jumpJustPressed && this.dir === 0)) { 397 | this.endClimbing(69, Y_UPPER_LEVEL, 0); 398 | return; 399 | } 400 | 401 | if (leftJustPressed || (jumpJustPressed && this.dir === 1)) { 402 | this.endClimbing(59, Y_UPPER_LEVEL, 1); 403 | return; 404 | } 405 | } 406 | 407 | if (this.y >= 170 && !upPressed && (leftPressed || rightPressed)) { 408 | this.endClimbing(this.x, Y_LOWER_LEVEL, this.dir); 409 | return; 410 | } 411 | 412 | if (upPressed) { 413 | this.climbUpward(); 414 | } else if (downPressed && this.climbDownward()) { 415 | return; 416 | } 417 | } 418 | 419 | isInjured() { 420 | return this.mainState === MainState.INJURED || this.mainState === MainState.SINKING; 421 | } 422 | 423 | injure() { 424 | this.mainState = MainState.INJURED; 425 | this.injuredCounter = INJURED_DELAY; 426 | play('sfx/die.mp3'); 427 | } 428 | 429 | private startTunnelSpawn(gs: GameState) { 430 | if (gs.extraLives === 0) { 431 | gs.endGame(); 432 | return; 433 | } 434 | --gs.extraLives; 435 | 436 | this.mainState = MainState.FALLING; 437 | this.tunnelSpawning = true; 438 | let spawnX: number; 439 | if (this.dir === 0) { 440 | spawnX = this.x - X_SPAWN_MARGIN; 441 | if (spawnX < 4) { 442 | spawnX = this.x + X_SPAWN_MARGIN; 443 | } 444 | } else { 445 | spawnX = this.x + X_SPAWN_MARGIN; 446 | if (spawnX >= 140) { 447 | spawnX = this.x - X_SPAWN_MARGIN; 448 | } 449 | } 450 | this.teleport(spawnX); 451 | this.y = 149; 452 | this.vy = 0; 453 | this.sprite = 2; 454 | } 455 | 456 | private startTreeSpawn(gs: GameState) { 457 | if (gs.extraLives === 0) { 458 | gs.endGame(); 459 | return; 460 | } 461 | --gs.extraLives; 462 | 463 | this.mainState = MainState.FALLING; 464 | this.teleport((this.dir === 0) ? 8 : 127); 465 | this.y = 51; 466 | this.vy = 0; 467 | this.sprite = 2; 468 | this.swallow = false; 469 | } 470 | 471 | private updateInjured(gs: GameState) { 472 | if (--this.injuredCounter === 0) { 473 | if (this.isUnderground()) { 474 | this.startTunnelSpawn(gs); 475 | } else { 476 | this.startTreeSpawn(gs); 477 | } 478 | return; 479 | } 480 | } 481 | 482 | swing() { 483 | this.mainState = MainState.SWINGING; 484 | this.sprite = 6; 485 | this.teleported = true; 486 | play('sfx/swing.mp3'); 487 | } 488 | 489 | private updateSwinging(gs: GameState) { 490 | const v = vineStates[gs.vine.sprite]; 491 | this.setX(this.dir === 0 ? v.x + 1 : v.x); 492 | this.y = v.y + 17; 493 | 494 | if ((this.dir === 0 && rightJustPressed) || (this.dir === 1 && leftJustPressed)) { 495 | this.startFalling(gs, v.vy); 496 | this.releasedVine = true; 497 | return; 498 | } 499 | } 500 | 501 | checkSink(xMin: number, xMax: number): boolean { 502 | const X = Math.floor(this.x); 503 | if (this.mainState !== MainState.STANDING || this.y !== Y_UPPER_LEVEL || X < xMin || X > xMax) { 504 | return false; 505 | } 506 | this.mainState = MainState.SINKING; 507 | this.sprite = 0; 508 | play('sfx/die.mp3'); 509 | return true; 510 | } 511 | 512 | checkSwallow(xMin: number, xMax: number) { 513 | const X = Math.floor(this.x); 514 | if (X < xMin || X > xMax) { 515 | return; 516 | } 517 | this.swallow = true; 518 | } 519 | 520 | private updateSinking(gs: GameState) { 521 | if (++this.y > 143 + INJURED_DELAY) { 522 | this.startTreeSpawn(gs); 523 | return; 524 | } 525 | } 526 | 527 | private startKnelling() { 528 | this.mainState = MainState.KNEELING; 529 | this.sprite = 5; 530 | this.kneeling = true; 531 | this.kneelingDelay = true; 532 | } 533 | 534 | rolled() { 535 | play('sfx/kneel.mp3', true); 536 | this.rollingDelay = 2; 537 | switch (this.mainState) { 538 | case MainState.STANDING: 539 | case MainState.KNEELING: 540 | this.startKnelling(); 541 | break; 542 | case MainState.CLIMBING: 543 | this.climbDownward(); 544 | break; 545 | } 546 | } 547 | 548 | skidded() { 549 | play('sfx/kneel.mp3', true); 550 | this.rollingDelay = 2; 551 | switch (this.mainState) { 552 | case MainState.STANDING: 553 | case MainState.SKIDDING: 554 | this.mainState = MainState.SKIDDING; 555 | this.sprite = 5; 556 | this.kneeling = true; 557 | this.kneelingDelay = true; 558 | break; 559 | } 560 | } 561 | 562 | private updateKneeling(gs: GameState) { 563 | if (this.kneelingDelay) { 564 | this.kneelingDelay = false; 565 | } else { 566 | this.mainState = MainState.STANDING; 567 | this.sprite = 0; 568 | this.kneeling = false; 569 | stop('sfx/kneel.mp3'); 570 | } 571 | } 572 | 573 | private updateSkidding(gs: GameState) { 574 | this.updateKneeling(gs); 575 | this.updateStanding(gs); 576 | } 577 | 578 | update(gs: GameState) { 579 | const state = this.mainState; 580 | switch (this.mainState) { 581 | case MainState.STANDING: 582 | this.updateStanding(gs); 583 | break; 584 | case MainState.FALLING: 585 | this.updateFalling(gs); 586 | break; 587 | case MainState.CLIMBING: 588 | this.updateClimbing(gs); 589 | break; 590 | case MainState.INJURED: 591 | this.updateInjured(gs); 592 | break; 593 | case MainState.SWINGING: 594 | this.updateSwinging(gs); 595 | break; 596 | case MainState.SINKING: 597 | this.updateSinking(gs); 598 | break; 599 | case MainState.KNEELING: 600 | this.updateKneeling(gs); 601 | break; 602 | case MainState.SKIDDING: 603 | this.updateSkidding(gs); 604 | break; 605 | } 606 | this.lastMainState = state; 607 | if (this.rollingDelay > 0 && --this.rollingDelay === 0) { 608 | stop('sfx/kneel.mp3'); 609 | } 610 | } 611 | 612 | render(gs: GameState, ctx: OffscreenCanvasRenderingContext2D, ox: number) { 613 | const sprite = harrySprites[this.dir][this.kneeling ? 5 : this.sprite]; 614 | const X = Math.floor(this.x) - 4 - ox; 615 | const Y = this.kneeling ? Y_UPPER_LEVEL - 17 : Math.floor(this.y) - 22; 616 | if (this.mainState === MainState.SINKING) { 617 | if (this.swallow) { 618 | if (Y < 121) { 619 | ctx.drawImage(sprite, 0, 0, 8, 121 - Y, X, Y, 8, 121 - Y); 620 | const crocImages = crocSprites[gs.sceneStates[this.scene].enteredLeft ? 0 : 1]; 621 | ctx.drawImage(crocImages[0], 0, 9, 8, 2, 44 - ox, 120, 8, 2); 622 | ctx.drawImage(crocImages[0], 0, 9, 8, 2, 60 - ox, 120, 8, 2); 623 | ctx.drawImage(crocImages[0], 0, 9, 8, 2, 76 - ox, 120, 8, 2); 624 | } 625 | } else if (Y < 119) { 626 | ctx.drawImage(sprite, 0, 0, 8, 119 - Y, X, Y, 8, 119 - Y); 627 | } 628 | } else if (this.tunnelSpawning && Y >= 127 && Y < 142) { 629 | ctx.drawImage(sprite, 0, 142 - Y, 8, Y - 100, X, 142, 8, Y - 100); 630 | } else if (Y < 101 || Y >= 127) { 631 | ctx.drawImage(sprite, X, Y); 632 | } else { 633 | if (Y < 122) { 634 | ctx.drawImage(sprite, 0, 0, 8, 122 - Y, X, Y, 8, 122 - Y); 635 | } 636 | if (Y > 106) { 637 | ctx.drawImage(sprite, 0, 127 - Y, 8, Y - 105, X, 127, 8, Y - 105); 638 | } 639 | } 640 | } 641 | } -------------------------------------------------------------------------------- /src/game/map.ts: -------------------------------------------------------------------------------- 1 | export const map = new Array(255); 2 | 3 | export enum PitType { 4 | TAR, 5 | QUICKSAND, 6 | CROCS, 7 | SHIFTING_TAR, 8 | SHIFTING_QUICKSAND, 9 | NONE, 10 | } 11 | 12 | export enum TreasureType { 13 | MONEY_BAG = 0, 14 | SILVER_BRICK = 1, 15 | GOLD_BRICK = 2, 16 | DIAMOND_RING = 3, 17 | NONE = 4, 18 | } 19 | 20 | export enum ObsticleType { 21 | ONE_ROLLING_LOG = 0, 22 | TWO_ROLLING_LOGS_NEAR = 1, 23 | TWO_ROLLING_LOGS_FAR = 2, 24 | THREE_ROLLING_LOGS = 3, 25 | ONE_STATIONARY_LOG = 4, 26 | THREE_STATIONARY_LOGS = 5, 27 | FIRE = 6, 28 | COBRA = 7, 29 | NONE = 8, 30 | } 31 | 32 | export enum WallType { 33 | LEFT = 0, 34 | RIGHT = 1, 35 | NONE = 2, 36 | } 37 | 38 | export class Scene { 39 | constructor(public trees: number, 40 | public ladder: boolean, 41 | public holes: boolean, 42 | public vine: boolean, 43 | public pit: PitType, 44 | public treasure: TreasureType, 45 | public obsticles: ObsticleType, 46 | public wall: WallType, 47 | public scorpion: boolean, 48 | public difficulty: number) { 49 | } 50 | } 51 | 52 | let seed = 0xC4; 53 | for (let i = 0; i < map.length; ++i) { 54 | let difficulty = 304; 55 | const trees = seed >> 6; 56 | let ladder = false; 57 | let holes = false; 58 | let vine = false; 59 | let pit = PitType.NONE; 60 | let treasure = TreasureType.NONE; 61 | let obsticles = ObsticleType.NONE; 62 | let wall = WallType.NONE; 63 | switch ((seed >> 3) & 7) { 64 | case 0: 65 | ladder = true; 66 | difficulty += 5; 67 | break; 68 | case 1: 69 | ladder = true; 70 | holes = true; 71 | difficulty += 10; 72 | break; 73 | case 2: 74 | vine = true; 75 | pit = PitType.TAR; 76 | difficulty += 143; 77 | break; 78 | case 3: 79 | vine = true; 80 | pit = PitType.QUICKSAND; 81 | difficulty += 143; 82 | break; 83 | case 4: 84 | vine = ((seed >> 1) & 1) === 1; 85 | pit = PitType.CROCS; 86 | difficulty += vine ? 143 : 240; 87 | break; 88 | case 5: 89 | pit = PitType.SHIFTING_TAR; 90 | treasure = seed & 3; 91 | difficulty += 111; 92 | break; 93 | case 6: 94 | pit = PitType.SHIFTING_TAR; 95 | vine = true; 96 | difficulty += 111; 97 | break; 98 | case 7: 99 | pit = PitType.SHIFTING_QUICKSAND; 100 | difficulty += 111; 101 | break; 102 | } 103 | if (treasure === TreasureType.NONE && pit !== PitType.CROCS) { 104 | obsticles = seed & 7; 105 | switch (obsticles) { 106 | case ObsticleType.ONE_STATIONARY_LOG: 107 | case ObsticleType.THREE_STATIONARY_LOGS: 108 | case ObsticleType.ONE_ROLLING_LOG: 109 | difficulty += 5; 110 | break; 111 | case ObsticleType.TWO_ROLLING_LOGS_FAR: 112 | difficulty += 10; 113 | break; 114 | case ObsticleType.TWO_ROLLING_LOGS_NEAR: 115 | difficulty += 15; 116 | break; 117 | case ObsticleType.THREE_ROLLING_LOGS: 118 | difficulty += 20; 119 | break; 120 | case ObsticleType.FIRE: 121 | case ObsticleType.COBRA: 122 | difficulty += 30; 123 | break; 124 | } 125 | } 126 | if (ladder) { 127 | wall = (seed >> 7) & 1; 128 | } 129 | map[i] = new Scene(trees, ladder, holes, vine, pit, treasure, obsticles, wall, !ladder, difficulty); 130 | 131 | seed = 0xFF & ((seed << 1) | (((seed >> 7) & 1) ^ ((seed >> 5) & 1) ^ ((seed >> 4) & 1) ^ ((seed >> 3) & 1))); 132 | } -------------------------------------------------------------------------------- /src/game/pit.ts: -------------------------------------------------------------------------------- 1 | import { GameState } from './game-state'; 2 | import { PitType, map } from './map'; 3 | import { pitSprites, crocSprites } from '@/graphics'; 4 | import { store, Difficulty } from '@/store'; 5 | 6 | const PIT_OPEN_FRAMES = 71; 7 | const PIT_CLOSED_FRAMES = 143; 8 | const PIT_SHIFT_FRAMES = 4; 9 | 10 | const CROC_CLOSED_FRAMES = 192; // 128 11 | const CROC_OPENED_FRAMES = 128; // 128 12 | 13 | const X_SHIFTS = [ 14 | [ 33, 95 ], 15 | [ 37, 91 ], 16 | [ 41, 87 ], 17 | [ 49, 79 ], 18 | [ 61, 67 ], 19 | ]; 20 | 21 | const X_CLOSED_CROCS = [ 22 | [ 33, 43 ], 23 | [ 53, 59 ], 24 | [ 69, 75 ], 25 | [ 85, 95 ], 26 | ]; 27 | 28 | const X_OPENED_CROCS_LEFT = [ 29 | [ 33, 48 ], 30 | [ 53, 64 ], 31 | [ 69, 80 ], 32 | [ 85, 95 ], 33 | ]; 34 | 35 | const X_OPENED_CROCS_RIGHT = [ 36 | [ 33, 43 ], 37 | [ 48, 59 ], 38 | [ 64, 75 ], 39 | [ 80, 95 ], 40 | ]; 41 | 42 | const X_CROCS = [ 43 | [ 45, 51 ], 44 | [ 61, 67 ], 45 | [ 77, 83 ], 46 | ]; 47 | 48 | enum PitState { 49 | OPENED, 50 | CLOSING, 51 | CLOSED, 52 | OPENING, 53 | } 54 | 55 | enum CrocState { 56 | OPENED, 57 | CLOSED, 58 | } 59 | 60 | export class Pit { 61 | 62 | pitState: PitState; 63 | pitOffset: number; 64 | pitCounter: number; 65 | 66 | crocState: CrocState; 67 | crocCounter: number; 68 | 69 | constructor(pit: { 70 | pitState: PitState; 71 | pitOffset: number; 72 | pitCounter: number; 73 | crocState: CrocState; 74 | crocCounter: number; 75 | } = { 76 | pitState: PitState.OPENED, 77 | pitOffset: 0, 78 | pitCounter: PIT_OPEN_FRAMES, 79 | crocState: CrocState.CLOSED, 80 | crocCounter: CROC_CLOSED_FRAMES, 81 | }) { 82 | this.pitState = pit.pitState; 83 | this.pitOffset = pit.pitOffset; 84 | this.pitCounter = pit.pitCounter; 85 | this.crocState = pit.crocState; 86 | this.crocCounter = pit.crocCounter; 87 | } 88 | 89 | private updatePitOpened(gs: GameState) { 90 | if (--this.pitCounter >= 0) { 91 | return; 92 | } 93 | this.pitState = PitState.CLOSING; 94 | this.pitCounter = PIT_SHIFT_FRAMES; 95 | ++this.pitOffset; 96 | } 97 | 98 | private updatePitClosing(gs: GameState) { 99 | if (--this.pitCounter >= 0) { 100 | return; 101 | } 102 | if (++this.pitOffset === 5) { 103 | this.pitState = PitState.CLOSED; 104 | this.pitCounter = PIT_CLOSED_FRAMES; 105 | return; 106 | } 107 | this.pitCounter = PIT_SHIFT_FRAMES; 108 | } 109 | 110 | private updatePitClosed(gs: GameState) { 111 | if (--this.pitCounter >= 0) { 112 | return; 113 | } 114 | this.pitState = PitState.OPENING; 115 | this.pitCounter = PIT_SHIFT_FRAMES; 116 | --this.pitOffset; 117 | } 118 | 119 | private updatePitOpening(gs: GameState) { 120 | if (--this.pitCounter >= 0) { 121 | return; 122 | } 123 | if (--this.pitOffset === 0) { 124 | this.pitState = PitState.OPENED; 125 | this.pitCounter = PIT_OPEN_FRAMES; 126 | return; 127 | } 128 | this.pitCounter = PIT_SHIFT_FRAMES; 129 | } 130 | 131 | private updateCrocOpened(gs: GameState) { 132 | if (--this.crocCounter === 0) { 133 | this.crocState = CrocState.CLOSED; 134 | this.crocCounter = CROC_CLOSED_FRAMES + 1; 135 | this.updateCrocClosed(gs); 136 | return; 137 | } 138 | 139 | const { harry, sceneStates } = gs; 140 | if (map[harry.scene].pit !== PitType.CROCS) { 141 | return; 142 | } 143 | 144 | const xOpenedCrocs = (sceneStates[harry.scene].enteredLeft) ? X_OPENED_CROCS_LEFT : X_OPENED_CROCS_RIGHT; 145 | for (let i = xOpenedCrocs.length - 1; i >= 0; --i) { 146 | const xCrocs = xOpenedCrocs[i]; 147 | if (harry.checkSink(xCrocs[0], xCrocs[1])) { 148 | for (let j = X_CROCS.length - 1; j >= 0; --j) { 149 | const xs = X_CROCS[j]; 150 | harry.checkSwallow(xs[0], xs[1]); 151 | } 152 | break; 153 | } 154 | } 155 | } 156 | 157 | private updateCrocClosed(gs: GameState) { 158 | if (--this.crocCounter === 0) { 159 | this.crocState = CrocState.OPENED; 160 | this.crocCounter = CROC_OPENED_FRAMES + 1; 161 | this.updateCrocOpened(gs); 162 | return; 163 | } 164 | 165 | const { harry } = gs; 166 | if (map[harry.scene].pit !== PitType.CROCS) { 167 | return; 168 | } 169 | 170 | for (let i = X_CLOSED_CROCS.length - 1; i >= 0; --i) { 171 | const xCrocs = X_CLOSED_CROCS[i]; 172 | if (harry.checkSink(xCrocs[0], xCrocs[1])) { 173 | break; 174 | } 175 | } 176 | } 177 | 178 | update(gs: GameState) { 179 | switch (this.pitState) { 180 | case PitState.OPENED: 181 | this.updatePitOpened(gs); 182 | break; 183 | case PitState.CLOSING: 184 | this.updatePitClosing(gs); 185 | break; 186 | case PitState.CLOSED: 187 | this.updatePitClosed(gs); 188 | break; 189 | case PitState.OPENING: 190 | this.updatePitOpening(gs); 191 | break; 192 | } 193 | 194 | switch (this.crocState) { 195 | case CrocState.OPENED: 196 | this.updateCrocOpened(gs); 197 | break; 198 | case CrocState.CLOSED: 199 | this.updateCrocClosed(gs); 200 | break; 201 | } 202 | 203 | const { harry } = gs; 204 | switch (map[harry.scene].pit) { 205 | case PitType.TAR: 206 | case PitType.QUICKSAND: 207 | harry.checkSink(X_SHIFTS[0][0], X_SHIFTS[0][1]); 208 | break; 209 | case PitType.SHIFTING_TAR: 210 | case PitType.SHIFTING_QUICKSAND: 211 | if (this.pitOffset < 5) { 212 | harry.checkSink(X_SHIFTS[this.pitOffset][0], X_SHIFTS[this.pitOffset][1]); 213 | } 214 | break; 215 | } 216 | } 217 | 218 | render(gs: GameState, ctx: OffscreenCanvasRenderingContext2D, scene: number, ox: number) { 219 | const { pit } = map[scene]; 220 | const sprites = pitSprites[(pit === PitType.TAR || pit == PitType.SHIFTING_TAR) ? 0 : 1]; 221 | if (pit === PitType.SHIFTING_TAR || pit === PitType.SHIFTING_QUICKSAND) { 222 | if (store.difficulty !== Difficulty.HARD && this.pitState !== PitState.OPENED) { 223 | ctx.drawImage(pitSprites[2][0], 32 - ox, 114); 224 | ctx.drawImage(pitSprites[2][1], 32 - ox, 119); 225 | } 226 | if (this.pitState !== PitState.CLOSED) { 227 | ctx.drawImage(sprites[0], 0, 0, 64, 5 - this.pitOffset, 32 - ox, 114 + this.pitOffset, 64, 228 | 5 - this.pitOffset); 229 | ctx.drawImage(sprites[1], 0, this.pitOffset, 64, 5 - this.pitOffset, 32 - ox, 119, 64, 230 | 5 - this.pitOffset); 231 | } 232 | } else { 233 | ctx.drawImage(sprites[0], 32 - ox, 114); 234 | ctx.drawImage(sprites[1], 32 - ox, 119); 235 | if (pit === PitType.CROCS) { 236 | const crocImages = crocSprites[gs.sceneStates[scene].enteredLeft ? 0 : 1]; 237 | const sprite = this.crocState === CrocState.OPENED ? 1 : 0; 238 | ctx.drawImage(crocImages[sprite], 44 - ox, 111); 239 | ctx.drawImage(crocImages[sprite], 60 - ox, 111); 240 | ctx.drawImage(crocImages[sprite], 76 - ox, 111); 241 | } 242 | } 243 | } 244 | } -------------------------------------------------------------------------------- /src/game/rattle.ts: -------------------------------------------------------------------------------- 1 | export class Rattle { 2 | 3 | seed: number; 4 | 5 | constructor(rattle: { 6 | seed: number; 7 | } = { 8 | seed: 1, 9 | }) { 10 | this.seed = rattle.seed; 11 | } 12 | 13 | update() { 14 | let a = this.seed; 15 | a <<= 1; 16 | a ^= this.seed; 17 | a <<= 1; 18 | this.seed = 0xFF & ((this.seed << 1) | ((a & 0x100) ? 1 : 0)); 19 | } 20 | 21 | getValue(): number { 22 | return (this.seed >> 4) & 1; 23 | } 24 | } -------------------------------------------------------------------------------- /src/game/rolling-log.ts: -------------------------------------------------------------------------------- 1 | import { logSprites, logMasks, Resolution } from '@/graphics'; 2 | import { GameState } from './game-state'; 3 | import { map, ObsticleType } from './map'; 4 | 5 | export class RollingLog { 6 | 7 | xCounter: number; 8 | spriteCounter: number; 9 | 10 | constructor(rollingLog: { 11 | xCounter: number; 12 | spriteCounter: number; 13 | } = { 14 | xCounter: 72, 15 | spriteCounter: 0, 16 | }) { 17 | this.xCounter = rollingLog.xCounter; 18 | this.spriteCounter = rollingLog.spriteCounter; 19 | } 20 | 21 | // When the scroll position changes, floor the rolling logs counter to prevent jittering. Jittering occurs when the 22 | // scroll position and the rolling logs change on alternate frames. 23 | sync() { 24 | this.xCounter = Math.floor(this.xCounter); 25 | } 26 | 27 | checkRolled(gs: GameState, x: number, y: number, sprite: number, offset: number, rollingRight: boolean) { 28 | const { harry } = gs; 29 | const X = (x + offset) % Resolution.WIDTH; 30 | if (this.computeFade(gs, X, offset, gs.harry.scene, rollingRight) === 1 31 | && harry.x >= X - 3 && harry.x <= X + 3 32 | && harry.intersects(logMasks[sprite], X - 4, y)) { 33 | gs.harry.rolled(); 34 | if (gs.score > 0) { 35 | --gs.score; 36 | } 37 | } 38 | } 39 | 40 | update(gs: GameState) { 41 | this.xCounter += .5; 42 | if (this.xCounter === Resolution.WIDTH) { 43 | this.xCounter = 0; 44 | } 45 | 46 | this.spriteCounter = (this.spriteCounter + 1) & 0xF; 47 | 48 | const { harry } = gs; 49 | const { obsticles } = map[harry.scene]; 50 | if (!harry.canBeHitByRollingLog() || obsticles > ObsticleType.THREE_ROLLING_LOGS) { 51 | return; 52 | } 53 | 54 | const rollingRight = gs.sceneStates[harry.scene].enteredLeft; 55 | const x = Math.floor(rollingRight ? this.xCounter : Resolution.WIDTH - .5 - this.xCounter); 56 | const s = this.spriteCounter >> 2; 57 | const sprite = s & 1; 58 | const y = 111 + ((s === 0) ? 1 : 0); 59 | 60 | this.checkRolled(gs, x, y, sprite, 0, rollingRight); 61 | switch (obsticles) { 62 | case ObsticleType.TWO_ROLLING_LOGS_NEAR: 63 | this.checkRolled(gs, x, y, sprite, 16, rollingRight); 64 | break; 65 | case ObsticleType.THREE_ROLLING_LOGS: 66 | this.checkRolled(gs, x, y, sprite, 64, rollingRight); 67 | // fall through to next case to check the third log 68 | case ObsticleType.TWO_ROLLING_LOGS_FAR: 69 | this.checkRolled(gs, x, y, sprite, 32, rollingRight); 70 | break; 71 | } 72 | } 73 | 74 | fadeLog(gs: GameState, scene: number, rollingRight: boolean, offset: number): boolean { 75 | if (gs.sceneStates[scene].enteredLeft !== rollingRight) { 76 | return true; 77 | } 78 | const { obsticles } = map[scene]; 79 | switch (offset) { 80 | case 0: 81 | if (obsticles > ObsticleType.THREE_ROLLING_LOGS) { 82 | return true; 83 | } 84 | break; 85 | case 16: 86 | if (obsticles !== ObsticleType.TWO_ROLLING_LOGS_NEAR) { 87 | return true; 88 | } 89 | break; 90 | case 32: 91 | if (obsticles !== ObsticleType.TWO_ROLLING_LOGS_FAR && obsticles !== ObsticleType.THREE_ROLLING_LOGS) { 92 | return true; 93 | } 94 | break; 95 | case 64: 96 | if (obsticles !== ObsticleType.THREE_ROLLING_LOGS) { 97 | return true; 98 | } 99 | break; 100 | } 101 | return false; 102 | } 103 | 104 | computeFade(gs: GameState, X: number, offset: number, scene: number, rollingRight: boolean): number { 105 | if (X <= 15) { 106 | let leftScene = scene - (gs.harry.isUnderground() ? 3 : 1); 107 | if (leftScene < 0) { 108 | leftScene += gs.sceneStates.length; 109 | } 110 | if (this.fadeLog(gs, leftScene, rollingRight, offset)) { 111 | return (X + 1) / 17; 112 | } 113 | } else if (X >= Resolution.WIDTH - 15) { 114 | let rightScene = scene + (gs.harry.isUnderground() ? 3 : 1); 115 | if (rightScene >= gs.sceneStates.length) { 116 | rightScene -= gs.sceneStates.length; 117 | } 118 | if (this.fadeLog(gs, rightScene, rollingRight, offset)) { 119 | return (Resolution.WIDTH - X + 1) / 17; 120 | } 121 | } 122 | return 1; 123 | } 124 | 125 | renderLog(gs: GameState, ctx: OffscreenCanvasRenderingContext2D, sprite: number, x: number, y: number, 126 | offset: number, scene: number, rollingRight: boolean, ox: number) { 127 | const X = (x + offset) % Resolution.WIDTH; 128 | ctx.globalAlpha = this.computeFade(gs, X, offset, scene, rollingRight); 129 | ctx.drawImage(logSprites[sprite], X - 4 - ox, y); 130 | ctx.globalAlpha = 1; 131 | } 132 | 133 | render(gs: GameState, ctx: OffscreenCanvasRenderingContext2D, scene: number, ox: number) { 134 | const rollingRight = gs.sceneStates[scene].enteredLeft; 135 | const x = Math.floor(rollingRight ? this.xCounter : Resolution.WIDTH - .5 - this.xCounter); 136 | const s = this.spriteCounter >> 2; 137 | const sprite = s & 1; 138 | const y = 111 + ((s === 0) ? 1 : 0); 139 | 140 | this.renderLog(gs, ctx, sprite, x, y, 0, scene, rollingRight, ox); 141 | switch (map[scene].obsticles) { 142 | case ObsticleType.TWO_ROLLING_LOGS_NEAR: 143 | this.renderLog(gs, ctx, sprite, x, y, 16, scene, rollingRight, ox); 144 | break; 145 | case ObsticleType.THREE_ROLLING_LOGS: 146 | this.renderLog(gs, ctx, sprite, x, y, 64, scene, rollingRight, ox); 147 | // fall through to next case to draw the third log 148 | case ObsticleType.TWO_ROLLING_LOGS_FAR: 149 | this.renderLog(gs, ctx, sprite, x, y, 32, scene, rollingRight, ox); 150 | break; 151 | } 152 | } 153 | } -------------------------------------------------------------------------------- /src/game/scorpion.ts: -------------------------------------------------------------------------------- 1 | import { scorpionMasks, sorpionSprites, Resolution } from '@/graphics'; 2 | import { GameState } from './game-state'; 3 | import { map } from './map'; 4 | 5 | const X_START = Resolution.WIDTH / 2; 6 | const X_MIN = 4; 7 | const X_MAX = Resolution.WIDTH - 4; 8 | const X_MARGIN = Resolution.WIDTH / 8; 9 | const FRAMES_PER_UPDATE = 8; 10 | 11 | export class Scorpion { 12 | x: number; 13 | X: number; 14 | dir: number; 15 | sprite: number; 16 | updateCounter: number; 17 | 18 | constructor(scorpion: { 19 | x: number; 20 | X: number; 21 | dir: number; 22 | sprite: number; 23 | updateCounter: number; 24 | } = { 25 | x: X_START, 26 | X: X_START, 27 | dir: 0, 28 | sprite: 0, 29 | updateCounter: FRAMES_PER_UPDATE, 30 | }) { 31 | this.x = scorpion.x; 32 | this.X = scorpion.X; 33 | this.dir = scorpion.dir; 34 | this.sprite = scorpion.sprite; 35 | this.updateCounter = scorpion.updateCounter; 36 | } 37 | 38 | update(gs: GameState) { 39 | const harryNearby = map[gs.harry.scene].scorpion && gs.harry.isUnderground(); 40 | if (harryNearby && gs.harry.intersects(scorpionMasks[this.dir][this.sprite], Math.floor(this.x) - 4, 158)) { 41 | gs.harry.injure(); 42 | return; 43 | } 44 | 45 | if (--this.updateCounter > 0) { 46 | return; 47 | } 48 | this.updateCounter = FRAMES_PER_UPDATE; 49 | 50 | this.sprite ^= 1; 51 | 52 | if (harryNearby && Math.abs(gs.harry.x - this.x) >= X_MARGIN) { 53 | const lastDir = this.dir; 54 | this.dir = (this.x > gs.harry.x) ? 1 : 0; 55 | if (lastDir !== this.dir) { 56 | return; 57 | } 58 | } 59 | 60 | if (this.dir === 0) { 61 | if (this.x >= X_MAX) { 62 | this.dir = 1; 63 | } else { 64 | ++this.x; 65 | } 66 | } else { 67 | if (this.x <= X_MIN) { 68 | this.dir = 0; 69 | } else { 70 | --this.x; 71 | } 72 | } 73 | } 74 | 75 | render(gs: GameState, ctx: OffscreenCanvasRenderingContext2D, ox: number) { 76 | ctx.drawImage(sorpionSprites[this.dir][this.sprite], Math.floor(this.x) - 4 - ox, 158); 77 | } 78 | } -------------------------------------------------------------------------------- /src/game/stationary-log.ts: -------------------------------------------------------------------------------- 1 | import { logSprites, logMasks, Resolution } from '@/graphics'; 2 | import { GameState } from './game-state'; 3 | import { map, ObsticleType } from './map'; 4 | 5 | export class StationaryLog { 6 | 7 | constructor(_: { } = { }) { 8 | } 9 | 10 | checkSkid(gs: GameState, x: number, y: number) { 11 | const { harry } = gs; 12 | if (harry.x >= x + 1 && harry.x <= x + 7 && harry.intersects(logMasks[1], x, y)) { 13 | harry.skidded(); 14 | if (gs.score > 0) { 15 | --gs.score; 16 | } 17 | } 18 | } 19 | 20 | update(gs: GameState) { 21 | switch (map[gs.harry.scene].obsticles) { 22 | case ObsticleType.THREE_STATIONARY_LOGS: 23 | this.checkSkid(gs, 12, 111); 24 | this.checkSkid(gs, 127, 111); 25 | // fall through to test to the final log 26 | case ObsticleType.ONE_STATIONARY_LOG: 27 | this.checkSkid(gs, 108, 111); 28 | break; 29 | } 30 | } 31 | 32 | render(gs: GameState, ctx: OffscreenCanvasRenderingContext2D, scene: number, ox: number) { 33 | switch (map[scene].obsticles) { 34 | case ObsticleType.THREE_STATIONARY_LOGS: 35 | ctx.drawImage(logSprites[1], 12 - ox, 111); 36 | ctx.drawImage(logSprites[1], 127 - ox, 111); 37 | // fall through to render to the final log 38 | case ObsticleType.ONE_STATIONARY_LOG: 39 | ctx.drawImage(logSprites[1], 108 - ox, 111); 40 | break; 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/game/treasure-map.ts: -------------------------------------------------------------------------------- 1 | import { map, TreasureType, WallType } from './map'; 2 | import { GameState } from './game-state'; 3 | import { dijkstra, Edge } from './dijkstra'; 4 | 5 | export enum Direction { 6 | RIGHT = 0, 7 | LEFT = 1, 8 | UP = 2, 9 | DOWN = 3, 10 | } 11 | 12 | export enum Tier { 13 | UPPER = 0, 14 | LOWER = 1, 15 | } 16 | 17 | class Node { 18 | constructor(public readonly scene: number, public readonly tier: Tier) { 19 | } 20 | } 21 | 22 | function createNodes(): Node[][] { 23 | const nodes = new Array(map.length); 24 | for (let scene = map.length - 1; scene >= 0; --scene) { 25 | nodes[scene] = [ new Node(scene, Tier.UPPER), new Node(scene, Tier.LOWER) ]; 26 | } 27 | return nodes; 28 | } 29 | 30 | function createGraph(nodes: Node[][]): Map[]> { 31 | 32 | const graph = new Map[]>(); 33 | for (let scene = map.length - 1; scene >= 0; --scene) { 34 | { 35 | const edges: Edge[] = []; 36 | if (map[scene].ladder) { 37 | edges.push({ 38 | node: nodes[scene][Tier.LOWER], 39 | weight: 165 + map[scene].difficulty, 40 | }); 41 | } 42 | let leftScene = scene - 1; 43 | if (leftScene < 0) { 44 | leftScene += map.length; 45 | } 46 | let rightScene = scene + 1; 47 | if (rightScene >= map.length) { 48 | rightScene -= map.length; 49 | } 50 | edges.push({ 51 | node: nodes[leftScene][Tier.UPPER], 52 | weight: map[scene].difficulty + map[leftScene].difficulty, 53 | }); 54 | edges.push({ 55 | node: nodes[rightScene][Tier.UPPER], 56 | weight: map[scene].difficulty + map[rightScene].difficulty, 57 | }); 58 | graph.set(nodes[scene][Tier.UPPER], edges); 59 | } 60 | 61 | { 62 | const edges: Edge[] = []; 63 | if (map[scene].ladder) { 64 | edges.push({ 65 | node: nodes[scene][Tier.UPPER], 66 | weight: 165 + map[scene].difficulty, 67 | }); 68 | } 69 | let leftScene = scene - 3; 70 | if (leftScene < 0) { 71 | leftScene += map.length; 72 | } 73 | let rightScene = scene + 3; 74 | if (rightScene >= map.length) { 75 | rightScene -= map.length; 76 | } 77 | if (map[scene].wall !== WallType.LEFT && map[leftScene].wall !== WallType.RIGHT) { 78 | edges.push({ 79 | node: nodes[leftScene][Tier.LOWER], 80 | weight: 10, 81 | }); 82 | } 83 | if (map[scene].wall !== WallType.RIGHT && map[rightScene].wall !== WallType.LEFT) { 84 | edges.push({ 85 | node: nodes[rightScene][Tier.LOWER], 86 | weight: 10, 87 | }); 88 | } 89 | graph.set(nodes[scene][Tier.LOWER], edges); 90 | } 91 | } 92 | return graph; 93 | } 94 | 95 | export class TreasureCell { 96 | constructor(public readonly direction: Direction) { 97 | } 98 | } 99 | 100 | export function updateTreasureMapIndex(gs: GameState) { 101 | // Foward route -- Not as short as the reverse route, but doable in 20 minutes. 102 | for (let i = 0; i < treasureIndices.length; ++i) { 103 | if (gs.sceneStates[treasureIndices[i]].treasure !== TreasureType.NONE) { 104 | gs.treasureMapIndex = i; 105 | break; 106 | } 107 | } 108 | 109 | // Reverse route 110 | // for (let i = treasureIndices.length - 1, j = 0; i >= 0; --i, --j) { 111 | // if (j < 0) { 112 | // j += treasureIndices.length; 113 | // } 114 | // if (gs.sceneStates[treasureIndices[j]].treasure !== TreasureType.NONE) { 115 | // gs.treasureMapIndex = j; 116 | // break; 117 | // } 118 | // } 119 | } 120 | 121 | export const treasureIndices = new Array(32); 122 | export const treasureCells = new Array(32); 123 | 124 | function initTreasureCells() { 125 | const nodes = createNodes(); 126 | const graph = createGraph(nodes); 127 | 128 | let treasureIndex = 0; 129 | for (let scene = 0; scene < map.length; ++scene) { 130 | if (map[scene].treasure === TreasureType.NONE) { 131 | continue; 132 | } 133 | treasureIndices[treasureIndex] = scene; 134 | const distLinks = dijkstra(graph, nodes[scene][Tier.UPPER]); 135 | const cells = treasureCells[treasureIndex++] = new Array(map.length); 136 | for (let i = 0; i < map.length; ++i) { 137 | cells[i] = new Array(2); 138 | { 139 | const distLink = distLinks.get(nodes[i][Tier.UPPER]); 140 | if (!distLink) { 141 | throw new Error('Missing upper distLink'); 142 | } 143 | const { link } = distLink; 144 | let direction = Direction.RIGHT; 145 | if (link) { 146 | if (link.tier === Tier.LOWER) { 147 | direction = Direction.DOWN; 148 | } else { 149 | let leftScene = i - 1; 150 | if (leftScene < 0) { 151 | leftScene += map.length; 152 | } 153 | direction = (link.scene === leftScene) ? Direction.LEFT : Direction.RIGHT; 154 | } 155 | } 156 | cells[i][Tier.UPPER] = new TreasureCell(direction); 157 | } 158 | { 159 | const distLink = distLinks.get(nodes[i][Tier.LOWER]); 160 | if (!distLink) { 161 | throw new Error('Missing lower distLink'); 162 | } 163 | const { link } = distLink; 164 | let direction = Direction.RIGHT; 165 | if (link) { 166 | if (link.tier === Tier.UPPER) { 167 | direction = Direction.UP; 168 | } else { 169 | let leftScene = i - 3; 170 | if (leftScene < 0) { 171 | leftScene += map.length; 172 | } 173 | direction = (link.scene === leftScene) ? Direction.LEFT : Direction.RIGHT; 174 | } 175 | } 176 | cells[i][Tier.LOWER] = new TreasureCell(direction); 177 | } 178 | } 179 | } 180 | } 181 | 182 | initTreasureCells(); -------------------------------------------------------------------------------- /src/game/treasure.ts: -------------------------------------------------------------------------------- 1 | import { GameState } from './game-state'; 2 | import { moneySprite, moneyMask, ringSprite, ringMask, goldSprites, goldMasks, silverSprites, silverMasks, Mask, 3 | Sprite } from '@/graphics'; 4 | import { TreasureType } from './map'; 5 | import { updateTreasureMapIndex } from './treasure-map'; 6 | import { play } from '@/audio'; 7 | 8 | export class Treasure { 9 | 10 | constructor(_: { } = { }) { 11 | } 12 | 13 | update(gs: GameState) { 14 | let mask: Mask; 15 | let points = 0; 16 | switch (gs.sceneStates[gs.harry.scene].treasure) { 17 | case TreasureType.DIAMOND_RING: 18 | mask = ringMask; 19 | points = 5000; 20 | break; 21 | case TreasureType.GOLD_BRICK: 22 | mask = goldMasks[gs.rattle.getValue()]; 23 | points = 4000; 24 | break; 25 | case TreasureType.SILVER_BRICK: 26 | mask = silverMasks[gs.rattle.getValue()]; 27 | points = 3000; 28 | break; 29 | case TreasureType.MONEY_BAG: 30 | mask = moneyMask; 31 | points = 2000; 32 | break; 33 | default: 34 | return; 35 | } 36 | 37 | if (gs.harry.intersects(mask, 108, 111)) { 38 | gs.sceneStates[gs.harry.scene].treasure = TreasureType.NONE; 39 | gs.score += points; 40 | if (++gs.treasureCount === 32) { 41 | gs.endGame(); 42 | } else { 43 | updateTreasureMapIndex(gs); 44 | } 45 | play('sfx/treasure.mp3'); 46 | } 47 | } 48 | 49 | render(gs: GameState, ctx: OffscreenCanvasRenderingContext2D, scene: number, ox: number) { 50 | let sprite: Sprite; 51 | switch (gs.sceneStates[scene].treasure) { 52 | case TreasureType.DIAMOND_RING: 53 | sprite = ringSprite; 54 | break; 55 | case TreasureType.GOLD_BRICK: 56 | sprite = goldSprites[gs.rattle.getValue()]; 57 | break; 58 | case TreasureType.SILVER_BRICK: 59 | sprite = silverSprites[gs.rattle.getValue()]; 60 | break; 61 | case TreasureType.MONEY_BAG: 62 | sprite = moneySprite; 63 | break; 64 | default: 65 | return; 66 | } 67 | 68 | ctx.drawImage(sprite, 108 - ox, 111); 69 | } 70 | } -------------------------------------------------------------------------------- /src/game/vine-state.ts: -------------------------------------------------------------------------------- 1 | export class VineState { 2 | constructor(public readonly x: number, public readonly y: number, public readonly vy: number) { 3 | } 4 | } -------------------------------------------------------------------------------- /src/game/vine.ts: -------------------------------------------------------------------------------- 1 | import { vineStates, vineSprites, vineMasks } from '@/graphics'; 2 | import { GameState } from './game-state'; 3 | import { map } from './map'; 4 | 5 | export class Vine { 6 | 7 | sprite: number; 8 | 9 | constructor(vine: { 10 | sprite: number; 11 | } = { 12 | sprite: Math.floor(vineStates.length / 2), 13 | }) { 14 | this.sprite = vine.sprite; 15 | } 16 | 17 | update(gs: GameState) { 18 | if (++this.sprite === vineStates.length) { 19 | this.sprite = 0; 20 | } 21 | 22 | const { harry } = gs; 23 | if (map[harry.scene].vine && !harry.releasedVine && harry.isFalling() 24 | && harry.intersects(vineMasks[this.sprite], 33 /* 31 */, 28)) { 25 | harry.swing(); 26 | } 27 | } 28 | 29 | render(gs: GameState, ctx: OffscreenCanvasRenderingContext2D, ox: number) { 30 | ctx.drawImage(vineSprites[this.sprite], 33 /* 31 */ - ox, 28); 31 | } 32 | } -------------------------------------------------------------------------------- /src/graphics.ts: -------------------------------------------------------------------------------- 1 | import { TAU } from '@/math'; 2 | import { VineState } from '@/game/vine-state'; 3 | 4 | class RGBColor { 5 | constructor(public readonly r: number, public readonly g: number, public readonly b: number) { 6 | } 7 | } 8 | 9 | export type Sprite = ImageBitmap; 10 | export type Mask = boolean[][]; 11 | 12 | export enum Resolution { 13 | WIDTH = 136, 14 | HEIGHT = 180, 15 | } 16 | 17 | export enum PhysicalDimensions { 18 | WIDTH = 4 * 136 / 160, 19 | HEIGHT = 3 * 180 / 228, 20 | } 21 | 22 | export enum Colors { 23 | BROWN = 0x12, 24 | YELLOW = 0x1e, 25 | ORANGE = 0x3e, 26 | RED = 0x48, 27 | OFF_GREEN = 0xd5, 28 | GREEN = 0xd6, 29 | BLUE = 0xa4, 30 | YELLOW_GREEN = 0xc8, 31 | PINK = 0x4a, 32 | BLACK = 0x00, 33 | OFF_BLACK = 0x02, 34 | GREY = 0x06, 35 | OFF_WHITE = 0x0c, 36 | WHITE = 0x0e, 37 | DARK_GREEN = 0xd2, 38 | DARK_RED = 0x42, 39 | DARK_YELLOW = 0x14, 40 | MID_YELLOW = 0x16, 41 | LIGHT_YELLOW = 0x18, 42 | DARK_BROWN = 0x10, 43 | } 44 | 45 | export const colors: string[] = new Array(256); 46 | 47 | export const harrySprites: Sprite[][] = new Array(2); // direction, sprite 48 | export const harryMasks: Mask[][] = new Array(2); // direction, mask 49 | 50 | export const cobraSprites: Sprite[][] = new Array(2); // direction, sprite 51 | export const cobraMasks: Mask[][] = new Array(2); // direction, mask 52 | 53 | export const crocSprites: Sprite[][] = new Array(2); // direction, sprite 54 | 55 | export const sorpionSprites: Sprite[][] = new Array(2); // direction, sprite 56 | export const scorpionMasks: Mask[][] = new Array(2); // direction, mask 57 | 58 | export const leavesSprites: Sprite[][] = new Array(2); // direction, sprite 59 | 60 | export const logSprites: Sprite[] = new Array(2); 61 | export const logMasks: Mask[] = new Array(2); 62 | 63 | export const fireSprites: Sprite[] = new Array(2); 64 | export const fireMasks: Mask[] = new Array(2); 65 | 66 | export const goldSprites: Sprite[] = new Array(2); 67 | export const goldMasks: Mask[] = new Array(2); 68 | 69 | export const silverSprites: Sprite[] = new Array(2); 70 | export const silverMasks: Mask[] = new Array(2); 71 | 72 | export let moneySprite: Sprite; 73 | export let moneyMask: Mask; 74 | 75 | export let ringSprite: Sprite; 76 | export let ringMask: Mask; 77 | 78 | export let wallSprite: Sprite; 79 | 80 | export let branchesSprite: Sprite; 81 | 82 | export const charSprites: Sprite[][] = new Array(256); // color, character 83 | 84 | export const pitSprites: Sprite[][] = new Array(3); // color(0=black,1=blue,2=yellow),sprite(0=bottom,1=top) 85 | 86 | export const VINE_PERIOD = 285; 87 | export const VINE_CX = 64; // 62 -- shifted vine 2 pixels right to better center it 88 | export const VINE_CY = 28; 89 | export const vineStates = new Array(VINE_PERIOD); 90 | export const vineSprites = new Array(VINE_PERIOD); 91 | export const vineMasks = new Array(VINE_PERIOD); 92 | 93 | export const arrowSprites: Sprite[][] = new Array(2); // color, direction (0=right, 1=left, 2=up, 3=down) 94 | 95 | function createVineSprites(palette: RGBColor[], promises: Promise[]) { 96 | const LENGTH = 73; 97 | const DISTORTION = 245 / 145; 98 | const MAX_ANGLE = Math.atan(1 / DISTORTION); 99 | 100 | let minX = VINE_CX; 101 | let minY = VINE_CY; 102 | let maxX = VINE_CX; 103 | let maxY = VINE_CY; 104 | for (let i = 0; i < VINE_PERIOD; ++i) { 105 | const t = TAU * i / VINE_PERIOD; 106 | const a = MAX_ANGLE * Math.sin(t); 107 | const p = new VineState(Math.round(VINE_CX + LENGTH * DISTORTION * Math.sin(a) / 2), 108 | Math.round(VINE_CY + LENGTH * Math.cos(a)), 109 | -LENGTH * MAX_ANGLE * TAU * Math.sin(a) * Math.cos(t) / VINE_PERIOD); 110 | vineStates[i] = p; 111 | minX = Math.min(minX, p.x); 112 | minY = Math.min(minY, p.y); 113 | maxX = Math.max(maxX, p.x); 114 | maxY = Math.max(maxY, p.y); 115 | } 116 | 117 | const color = palette[Colors.DARK_BROWN]; 118 | const width = maxX - minX + 1; 119 | const height = maxY - minY + 1; 120 | const imageData = new ImageData(width, height); 121 | for (let i = 0; i < vineStates.length; ++i) { 122 | const p = vineStates[i]; 123 | imageData.data.fill(0); 124 | plotLine(imageData, VINE_CX - minX, VINE_CY - minY, p.x - minX, p.y - minY, color); 125 | vineMasks[i] = createMask(imageData); 126 | promises.push(createImageBitmap(imageData).then(imageBitmap => vineSprites[i] = imageBitmap)); 127 | } 128 | } 129 | 130 | async function createSprite(width: number, height: number, callback: (imageData: ImageData) => void): 131 | Promise<{ imageBitmap: Sprite, imageData: ImageData }> { 132 | return new Promise(resolve => { 133 | const imageData = new ImageData(width, height); 134 | callback(imageData); 135 | createImageBitmap(imageData).then(imageBitmap => resolve({ imageBitmap, imageData })); 136 | }); 137 | } 138 | 139 | function createMask(imageData: ImageData): Mask { 140 | const mask = new Array(imageData.height); 141 | const { data } = imageData; 142 | for (let y = 0, i = 3; y < imageData.height; ++y) { 143 | mask[y] = new Array(imageData.width); 144 | for (let x = 0; x < imageData.width; ++x, i += 4) { 145 | mask[y][x] = data[i] !== 0; 146 | } 147 | } 148 | return mask; 149 | } 150 | 151 | function createSpriteAndMask(binStr: string, palette: RGBColor[], spriteOffset: number, colorOffset: number, 152 | height: number, flipped: boolean, spriteCallback: (sprite: Sprite) => void, 153 | maskCallback: ((mask: Mask) => void) | null, promises: Promise[]) { 154 | 155 | promises.push(createSprite(8, height, imageData => { 156 | if (flipped) { 157 | for (let y = 0; y < height; ++y) { 158 | const offset = height - 1 - y; 159 | const byte = binStr.charCodeAt(spriteOffset + offset); 160 | const color = palette[binStr.charCodeAt(colorOffset + offset)]; 161 | for (let x = 0, mask = 0x01; x < 8; ++x, mask <<= 1) { 162 | if ((byte & mask) !== 0) { 163 | setColor(imageData, x, y, color); 164 | } 165 | } 166 | } 167 | } else { 168 | for (let y = 0; y < height; ++y) { 169 | const offset = height - 1 - y; 170 | const byte = binStr.charCodeAt(spriteOffset + offset); 171 | const color = palette[binStr.charCodeAt(colorOffset + offset)]; 172 | for (let x = 0, mask = 0x80; x < 8; ++x, mask >>= 1) { 173 | if ((byte & mask) !== 0) { 174 | setColor(imageData, x, y, color); 175 | } 176 | } 177 | } 178 | } 179 | }).then(({ imageBitmap, imageData }) => { 180 | spriteCallback(imageBitmap); 181 | if (maskCallback) { 182 | maskCallback(createMask(imageData)); 183 | } 184 | })); 185 | } 186 | 187 | function extractPalette(): RGBColor[] { 188 | const palette = new Array(256); 189 | const binStr = atob('AAAAPz8+ZGRjhISDoqKhurq50tLR6urpPT0AXl4Ke3sVmZkgtLQqzc005uY+/f1IcSMAhj0LmVcYrW8mvYYyzZs+3LBJ6s' 190 | + 'JUhhUAmi8OrkgewGEv0Xc+4I1N76Jb/bVoigAAnhMSsSgnwj080lFQ4mRj73V0/YaFeQBYjRJuoCeEsTuYwE6q0GG83XHM6oLcRQB4XRKPci' 191 | + 'ekiDu5m07KrmHcv3Hs0IL7DgCFKROZQyitXT2/dFHQi2TfoXXutYb7AACKEhOdJCiwNz3BSVHRWmTganXueYb7ABV9EjGTJEynN2e7SYDMWp' 192 | + 'fdaq7tecL7ACdYEkV0JGKNN36nSZe+WrDUasfoed37ADUmEldCJHZdN5V2SbGOWsylauW7ef3PADkAE1sSKHknPZc8UbNQZM1jdeZ0hv2FDj' 193 | + 'IAK1QRR3MjY5M2fbBIlctZreVpwv14Jy4ARU4PYmshfogzl6NDsLxTx9Ri3epwPSMAXkINe18dmXsttJY7za9K5sdX/d1k'); 194 | for (let i = 0x00; i <= 0xFF; ++i) { 195 | const j = 3 * (i >> 1); 196 | const col = new RGBColor(binStr.charCodeAt(j), binStr.charCodeAt(j + 1), binStr.charCodeAt(j + 2)); 197 | palette[i] = col; 198 | colors[i] = `#${((col.r << 16) | (col.g << 8) | col.b).toString(16).padStart(6, '0')}`; 199 | } 200 | return palette; 201 | } 202 | 203 | function setColor(imageData: ImageData, x: number, y: number, color: RGBColor) { 204 | const offset = 4 * (y * imageData.width + x); 205 | const data = imageData.data; 206 | data[offset] = color.r; 207 | data[offset + 1] = color.g; 208 | data[offset + 2] = color.b; 209 | data[offset + 3] = 0xFF; 210 | } 211 | 212 | function plotLine(imageData: ImageData, x0: number, y0: number, x1: number, y1: number, color: RGBColor) { 213 | const dx = Math.abs(x1 - x0); 214 | const sx = x0 < x1 ? 1 : -1; 215 | const dy = -Math.abs(y1 - y0); 216 | const sy = y0 < y1 ? 1 : -1; 217 | let error = dx + dy 218 | 219 | while (true) { 220 | setColor(imageData, x0, y0, color); 221 | if (x0 === x1 && y0 === y1) { 222 | break; 223 | } 224 | const e2 = 2 * error; 225 | if (e2 >= dy) { 226 | error = error + dy; 227 | x0 = x0 + sx; 228 | } 229 | if (e2 <= dx) { 230 | error = error + dx; 231 | y0 = y0 + sy; 232 | } 233 | } 234 | } 235 | 236 | export async function init() { 237 | 238 | enum Offsets { 239 | CLIMBCOLTAB = 0, 240 | RUNCOLTAB = 22, 241 | LOGCOLOR = 43, 242 | FIRECOLOR = 59, 243 | COBRACOLOR = 75, 244 | CROCOCOLOR = 91, 245 | MONEYBAGCOLOR = 107, 246 | SCORPIONCOLOR = 123, 247 | WALLCOLOR = 139, 248 | RINGCOLOR = 155, 249 | GOLDBARCOLOR = 171, 250 | SILVERBARCOLOR = 187, 251 | LEAVESCOLOR = 203, 252 | BRANCHESCOLOR = 207, 253 | LEAVES0 = 215, 254 | LEAVES1 = 219, 255 | LEAVES2 = 223, 256 | LEAVES3 = 227, 257 | HARRY0 = 231, 258 | HARRY1 = 253, 259 | HARRY2 = 275, 260 | HARRY3 = 297, 261 | HARRY4 = 319, 262 | HARRY5 = 341, 263 | HARRY6 = 363, 264 | HARRY7 = 385, 265 | BRANCHES = 407, 266 | PIT = 415, 267 | LOG0 = 420, 268 | FIRE0 = 436, 269 | COBRA0 = 452, 270 | COBRA1 = 468, 271 | CROCO0 = 484, 272 | CROCO1 = 500, 273 | MONEYBAG = 516, 274 | SCORPION0 = 532, 275 | SCORPION1 = 548, 276 | WALL = 564, 277 | BAR0 = 580, 278 | BAR1 = 596, 279 | RING = 612, 280 | ZERO = 628, 281 | ONE = 636, 282 | TWO = 644, 283 | THREE = 652, 284 | FOUR = 660, 285 | SIX = 676, 286 | SEVEN = 684, 287 | EIGHT = 692, 288 | NINE = 700, 289 | COLON = 708, 290 | ARROWRIGHT = 716, 291 | ARROWLEFT = 732, 292 | ARROWUP = 748, 293 | ARROWDOWN = 764, 294 | } 295 | 296 | const palette = extractPalette(); 297 | 298 | const binStr = atob('0tLS0tLS0tLS0tLSyMjIyMjISkpKEtLS0tLS0tLS0tLIyMjIyMjISkpKEhISEhISEhISEhISEhISEhIQEBAQED4+Pi4uLi' 299 | + '4uLi4uAAAEAAQAAAAAAAAAAABCQtDQ0NDQ0NDQ0NDQ0NDQ0NAEBAQEBAQEBAQEBAQSBAQEDg4ODg4ODg4ODg4ODg4ODgZCQkIGQkJCBkJCQg' 300 | + 'ZCQkJCQh4eHh4eHh4eDg4ODg4OHh4eHh4eHh4ODg4ODg4ODgYGBgYGBgYODg4ODg4ODg7S0tLSEBAQEBAQEBABg8//ABg9fxi8/v8wePz+AA' 301 | + 'AAAAAzctoeHBhYWHw+GhgQGBgYAACAgMNiYjY+HBgYPD46OBgYEBgYGAAQICIkNDIWHhwYGBwcGBgYGBAYGBgADAgoKD4KDhwYGBwcGBgYGB' 302 | + 'gQGBgYAAACQ0R0FBwcGBgYPD46OBgYEBgYGAAYEBwYGBgYGBgYGBgcHhoYGBAYGBgAAAAAAAAAY/L23MDAwMDA8NCQ0NDAADAQEBAWFBQWEh' 303 | + 'YeHBg4ODweGgIYGBh+25mZmZmZmQABAw9/ABgkWlpaZn5edn5edjwYAADD5348GDx8fHg4ODAwEBAA/vn5+flgEAgMDAg4MEAAAP75+fr6YB' 304 | + 'AIDAwIODCAAAAAAAAA/6sDAwsuuuCAAAAAAAAAAP+rVf8GBAAAAAAAAD53d2N7Y29jNjYcCBw2AIUyPXj4xoKQiNhwAAAAAABJMzx4+sSSiN' 305 | + 'hwAAAAAAAA/rq6uv7u7u7+urq6/u7u7gD4/P7+fj4AEABUAJIAEAAA+Pz+/n4+AAAoAFQAEAAAAAA4bERERGw4EDh8OAAAADxmZmZmZmY8PB' 306 | + 'gYGBgYOBh+YGA8BgZGPDxGBgwMBkY8DAwMfkwsHAx8RgYGfGBgfjxmZmZ8YGI8GBgYGAwGQn48ZmY8PGZmPDxGBj5mZmY8ABgYAAAYGAAACA' 307 | + 'gMDP7+///+/gwMCAgAABAQMDB/f///f38wMBAQABAQODh8fP7+ODg4ODg4ODg4ODg4ODg4OP7+fHw4OBAQ'); 308 | 309 | const promises: Promise[] = []; 310 | 311 | for (let dir = 0; dir < 2; ++dir) { 312 | const flipped = dir === 1; 313 | 314 | // leaves 315 | leavesSprites[dir] = new Array(4); 316 | for (let i = 0; i < 4; ++i) { 317 | createSpriteAndMask(binStr, palette, Offsets.LEAVES0 + 4 * i, Offsets.LEAVESCOLOR, 4, flipped, 318 | sprite => leavesSprites[dir][i] = sprite, null, promises); 319 | } 320 | 321 | // harry 322 | harrySprites[dir] = new Array(8); 323 | harryMasks[dir] = new Array(8); 324 | for (let i = 0; i < 8; ++i) { 325 | const j = (i <= 5) ? 5 - i : i; 326 | createSpriteAndMask(binStr, palette, Offsets.HARRY0 + 22 * i, 327 | (i === 7) ? Offsets.CLIMBCOLTAB : Offsets.RUNCOLTAB, 22, flipped, 328 | sprite => harrySprites[dir][j] = sprite, mask => harryMasks[dir][j] = mask, promises); 329 | } 330 | 331 | // cobra 332 | cobraSprites[dir] = new Array(2); 333 | cobraMasks[dir] = new Array(2); 334 | createSpriteAndMask(binStr, palette, Offsets.COBRA1, Offsets.COBRACOLOR, 16, flipped, 335 | sprite => cobraSprites[dir][0] = sprite, mask => cobraMasks[dir][0] = mask, promises); 336 | createSpriteAndMask(binStr, palette, Offsets.COBRA0, Offsets.COBRACOLOR, 16, flipped, 337 | sprite => cobraSprites[dir][1] = sprite, mask => cobraMasks[dir][1] = mask, promises); 338 | 339 | // croc 340 | crocSprites[dir] = new Array(2); 341 | createSpriteAndMask(binStr, palette, Offsets.CROCO1, Offsets.CROCOCOLOR, 16, flipped, 342 | sprite => crocSprites[dir][0] = sprite, null, promises); 343 | createSpriteAndMask(binStr, palette, Offsets.CROCO0, Offsets.CROCOCOLOR, 16, flipped, 344 | sprite => crocSprites[dir][1] = sprite, null, promises); 345 | 346 | // sorpion 347 | sorpionSprites[dir] = new Array(2); 348 | scorpionMasks[dir] = new Array(2); 349 | createSpriteAndMask(binStr, palette, Offsets.SCORPION1, Offsets.SCORPIONCOLOR, 16, flipped, 350 | sprite => sorpionSprites[dir][0] = sprite, mask => scorpionMasks[dir][0] = mask, promises); 351 | createSpriteAndMask(binStr, palette, Offsets.SCORPION0, Offsets.SCORPIONCOLOR, 16, flipped, 352 | sprite => sorpionSprites[dir][1] = sprite, mask => scorpionMasks[dir][1] = mask, promises); 353 | } 354 | 355 | // log 356 | createSpriteAndMask(binStr, palette, Offsets.LOG0, Offsets.LOGCOLOR, 16, true, 357 | sprite => logSprites[0] = sprite, mask => logMasks[0] = mask, promises); 358 | createSpriteAndMask(binStr, palette, Offsets.LOG0, Offsets.LOGCOLOR, 16, false, 359 | sprite => logSprites[1] = sprite, mask => logMasks[1] = mask, promises); 360 | 361 | // fire 362 | createSpriteAndMask(binStr, palette, Offsets.FIRE0, Offsets.FIRECOLOR, 16, true, 363 | sprite => fireSprites[0] = sprite, mask => fireMasks[0] = mask, promises); 364 | createSpriteAndMask(binStr, palette, Offsets.FIRE0, Offsets.FIRECOLOR, 16, false, 365 | sprite => fireSprites[1] = sprite, mask => fireMasks[1] = mask, promises); 366 | 367 | // gold 368 | createSpriteAndMask(binStr, palette, Offsets.BAR1, Offsets.GOLDBARCOLOR, 16, false, 369 | sprite => goldSprites[0] = sprite, mask => goldMasks[0] = mask, promises); 370 | createSpriteAndMask(binStr, palette, Offsets.BAR0, Offsets.GOLDBARCOLOR, 16, false, 371 | sprite => goldSprites[1] = sprite, mask => goldMasks[1] = mask, promises); 372 | 373 | // silver 374 | createSpriteAndMask(binStr, palette, Offsets.BAR1, Offsets.SILVERBARCOLOR, 16, false, 375 | sprite => silverSprites[0] = sprite, mask => silverMasks[0] = mask, promises); 376 | createSpriteAndMask(binStr, palette, Offsets.BAR0, Offsets.SILVERBARCOLOR, 16, false, 377 | sprite => silverSprites[1] = sprite, mask => silverMasks[1] = mask, promises); 378 | 379 | // money 380 | createSpriteAndMask(binStr, palette, Offsets.MONEYBAG, Offsets.MONEYBAGCOLOR, 16, false, 381 | sprite => moneySprite = sprite, mask => moneyMask = mask, promises); 382 | 383 | // ring 384 | createSpriteAndMask(binStr, palette, Offsets.RING, Offsets.RINGCOLOR, 16, false, sprite => ringSprite = sprite, 385 | mask => ringMask = mask, promises); 386 | 387 | // wall 388 | createSpriteAndMask(binStr, palette, Offsets.WALL, Offsets.WALLCOLOR, 16, false, sprite => wallSprite = sprite, 389 | null, promises); 390 | 391 | // branches 392 | createSpriteAndMask(binStr, palette, Offsets.BRANCHES, Offsets.BRANCHESCOLOR, 8, false, 393 | sprite => branchesSprite = sprite, null, promises); 394 | 395 | // characters 396 | for (let color = 0; color < 256; ++color) { 397 | const charCol = palette[color]; 398 | charSprites[color] = new Array(11); 399 | for (let char = 0; char < 11; ++char) { 400 | promises.push(createSprite(8, 8, imageData => { 401 | const offset = Offsets.ZERO + 8 * (char + 1) - 1; 402 | for (let y = 0; y < 8; ++y) { 403 | const byte = binStr.charCodeAt(offset - y); 404 | for (let x = 0, mask = 0x80; x < 8; ++x, mask >>= 1) { 405 | if ((byte & mask) !== 0) { 406 | setColor(imageData, x, y, charCol); 407 | } 408 | } 409 | } 410 | }).then(({ imageBitmap }) => charSprites[color][char] = imageBitmap)); 411 | } 412 | } 413 | 414 | // pits 415 | const pitColors = [ Colors.BLACK, Colors.BLUE, Colors.MID_YELLOW ]; 416 | for (let color = 0; color < pitColors.length; ++color) { 417 | const pitCol = palette[pitColors[color]]; 418 | pitSprites[color] = new Array(2); 419 | for (let sprite = 0; sprite < 2; ++sprite) { 420 | promises.push(createSprite(64, 5, imageData => { 421 | for (let y = 0; y < 5; ++y) { 422 | const Y = (sprite === 0) ? 4 - y : y; 423 | const byte = binStr.charCodeAt(Offsets.PIT + y); 424 | for (let x = 0, mask = 0x80, x4 = 0; x < 8; ++x, mask >>= 1, x4 += 4) { 425 | if ((byte & mask) === 0) { 426 | for (let i = 0; i < 4; ++i) { 427 | const X = x4 + i; 428 | setColor(imageData, 31 - X, Y, pitCol); 429 | setColor(imageData, 32 + X, Y, pitCol); 430 | } 431 | } 432 | } 433 | } 434 | }).then(({ imageBitmap }) => pitSprites[color][sprite] = imageBitmap)); 435 | } 436 | } 437 | 438 | // vines 439 | createVineSprites(palette, promises); 440 | 441 | // arrows 442 | const arrowColors = [ Colors.OFF_GREEN, Colors.OFF_BLACK ]; 443 | for (let color = 0; color < arrowColors.length; ++color) { 444 | const arrowCol = palette[arrowColors[color]]; 445 | arrowSprites[color] = new Array(4); 446 | for (let sprite = 0; sprite < 4; ++sprite) { 447 | promises.push(createSprite(8, 16, imageData => { 448 | const offset = Offsets.ARROWRIGHT + 16 * sprite; 449 | for (let y = 0; y < 16; ++y) { 450 | const byte = binStr.charCodeAt(offset + y); 451 | for (let x = 0, mask = 0x80; x < 8; ++x, mask >>= 1) { 452 | if ((byte & mask) !== 0) { 453 | setColor(imageData, x, y, arrowCol); 454 | } 455 | } 456 | } 457 | }).then(({ imageBitmap }) => arrowSprites[color][sprite] = imageBitmap)); 458 | } 459 | } 460 | 461 | await Promise.all(promises); 462 | } 463 | 464 | export function printNumber(ctx: OffscreenCanvasRenderingContext2D, value: number, x: number, y: number, color: number, 465 | minDigits = 1) { 466 | 467 | const sprites = charSprites[color]; 468 | while (value > 0 || minDigits > 0) { 469 | ctx.drawImage(sprites[value % 10], x, y); 470 | value = Math.floor(value / 10); 471 | --minDigits; 472 | x -= 8; 473 | } 474 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | function onPlayButtonClicked() { 2 | window.location.href = 'app/app.html'; 3 | } 4 | 5 | export function init() { 6 | (document.getElementById('play-button') as HTMLButtonElement).addEventListener('click', onPlayButtonClicked); 7 | } 8 | 9 | document.addEventListener('DOMContentLoaded', init); -------------------------------------------------------------------------------- /src/input.ts: -------------------------------------------------------------------------------- 1 | import { exit } from '@/screen'; 2 | import { GameState } from './game/game-state'; 3 | 4 | const ANALOG_STICK_THRESHOLD = 0.5; 5 | 6 | export let leftPressed = false; 7 | export let rightPressed = false; 8 | export let upPressed = false; 9 | export let downPressed = false; 10 | export let jumpPressed = false; 11 | 12 | export let leftJustPressed = false; 13 | export let rightJustPressed = false; 14 | export let upJustPressed = false; 15 | export let downJustPressed = false; 16 | export let jumpJustPressed = false; 17 | 18 | export let leftJustReleased = false; 19 | export let rightJustReleased = false; 20 | export let upJustReleased = false; 21 | export let downJustReleased = false; 22 | export let jumpJustReleased = false; 23 | 24 | let lastLeftPressed = false; 25 | let lastRightPressed = false; 26 | let lastUpPressed = false; 27 | let lastDownPressed = false; 28 | let lastJumpPressed = false; 29 | 30 | let leftKeyPressed = 0; 31 | let rightKeyPressed = 0; 32 | let upKeyPressed = 0; 33 | let downKeyPressed = 0; 34 | let jumpKeyPressed = false; 35 | 36 | let leftScreenTouched = 0; 37 | let rightScreenTouched = 0; 38 | 39 | let hideCursorTimeoutId: number | null = null; 40 | let cursorHidden = false; 41 | 42 | class TouchData { 43 | timestampDown = 0; 44 | xDown = 0; 45 | yDown = 0; 46 | x = 0; 47 | y = 0; 48 | } 49 | 50 | const touchDatas: Map = new Map(); 51 | 52 | export function resetInput() { 53 | leftKeyPressed = 0; 54 | rightKeyPressed = 0; 55 | upKeyPressed = 0; 56 | downKeyPressed = 0; 57 | jumpKeyPressed = false; 58 | leftScreenTouched = 0; 59 | rightScreenTouched = 0; 60 | touchDatas.clear(); 61 | } 62 | 63 | export function startInput() { 64 | window.addEventListener('click', onClick); 65 | window.addEventListener('mousemove', resetHideCursorTimer); 66 | window.addEventListener('mouseenter', resetHideCursorTimer); 67 | window.addEventListener('mouseleave', cancelHideCursorTimer); 68 | resetHideCursorTimer(); 69 | 70 | window.addEventListener('touchstart', onTouch, { passive: false }); 71 | window.addEventListener('touchmove', onTouch, { passive: false }); 72 | window.addEventListener('touchend', onTouch, { passive: false }); 73 | window.addEventListener('touchcancel', onTouch, { passive: false }); 74 | 75 | window.addEventListener('keydown', onKeyDown); 76 | window.addEventListener('keyup', onKeyUp); 77 | 78 | resetInput(); 79 | } 80 | 81 | export function stopInput() { 82 | window.removeEventListener('click', onClick); 83 | window.removeEventListener('mousemove', resetHideCursorTimer); 84 | window.removeEventListener('mouseenter', resetHideCursorTimer); 85 | window.removeEventListener('mouseleave', cancelHideCursorTimer); 86 | cancelHideCursorTimer(); 87 | 88 | window.removeEventListener('keydown', onKeyDown); 89 | window.removeEventListener('keyup', onKeyUp); 90 | 91 | window.removeEventListener('touchstart', onTouch); 92 | window.removeEventListener('touchmove', onTouch); 93 | window.removeEventListener('touchend', onTouch); 94 | window.removeEventListener('touchcancel', onTouch); 95 | 96 | resetInput(); 97 | } 98 | 99 | export function updateInput(gs: GameState) { 100 | 101 | let touchLeft = false; 102 | let touchRight = false; 103 | let touchUp = false; 104 | let touchDown = false; 105 | let touchJump = false; 106 | if (gs.harry.canStartClimbingUp() && ((rightScreenTouched !== 0 && gs.harry.dir === 0) 107 | || (leftScreenTouched !== 0 && gs.harry.dir === 1))) { 108 | touchUp = true; 109 | } else if (gs.harry.isClimbing()) { 110 | if (leftScreenTouched > rightScreenTouched) { 111 | if (gs.harry.rightTouchMeansDown) { 112 | touchUp = true; 113 | } else { 114 | touchDown = true; 115 | } 116 | } else if (rightScreenTouched > leftScreenTouched) { 117 | if (gs.harry.rightTouchMeansDown) { 118 | touchDown = true; 119 | } else { 120 | touchUp = true; 121 | } 122 | } 123 | } else if (leftScreenTouched > rightScreenTouched) { 124 | if (rightScreenTouched > 0) { 125 | touchRight = true; 126 | touchJump = true; 127 | } else { 128 | touchLeft = true; 129 | } 130 | } else if (rightScreenTouched > leftScreenTouched) { 131 | if (leftScreenTouched > 0) { 132 | touchLeft = true; 133 | touchJump = true; 134 | } else { 135 | touchRight = true; 136 | } 137 | } 138 | 139 | let gamepadLeft = false; 140 | let gamepadRight = false; 141 | let gamepadUp = false; 142 | let gamepadDown = false; 143 | let gamepadJump = false; 144 | const gamepads = navigator.getGamepads(); 145 | if (gamepads) { 146 | for (let i = gamepads.length - 1; i >= 0; --i) { 147 | const gamepad = gamepads[i]; 148 | if (!gamepad) { 149 | continue; 150 | } 151 | 152 | // D-pad left or left bumper or left stick 153 | if (gamepad.buttons[14]?.pressed || gamepad.buttons[4]?.pressed || gamepad.buttons[10]?.pressed) { 154 | gamepadLeft = true; 155 | } 156 | 157 | // D-pad right or right bumper or right stick 158 | if (gamepad.buttons[15]?.pressed || gamepad.buttons[5]?.pressed || gamepad.buttons[11]?.pressed) { 159 | gamepadRight = true; 160 | } 161 | 162 | // D-pad up 163 | if (gamepad.buttons[12]?.pressed) { 164 | gamepadUp = true; 165 | } 166 | 167 | // D-pad down 168 | if (gamepad.buttons[13]?.pressed) { 169 | gamepadDown = true; 170 | } 171 | 172 | // Analog stick left and right 173 | const leftStickX = gamepad.axes[0]; 174 | const rightStickX = gamepad.axes[2]; 175 | if (leftStickX < -ANALOG_STICK_THRESHOLD || rightStickX < -ANALOG_STICK_THRESHOLD) { 176 | gamepadLeft = true; 177 | } else if (leftStickX > ANALOG_STICK_THRESHOLD || rightStickX > ANALOG_STICK_THRESHOLD) { 178 | gamepadRight = true; 179 | } 180 | 181 | // Analog stick up and down 182 | const leftStickY = gamepad.axes[1]; 183 | const rightStickY = gamepad.axes[3]; 184 | if (leftStickY < -ANALOG_STICK_THRESHOLD || rightStickY < -ANALOG_STICK_THRESHOLD) { 185 | gamepadUp = true; 186 | } else if (leftStickY > ANALOG_STICK_THRESHOLD || rightStickY > ANALOG_STICK_THRESHOLD) { 187 | gamepadDown = true; 188 | } 189 | 190 | // Non-directional buttons 191 | if (gamepad.buttons[0]?.pressed || gamepad.buttons[1]?.pressed || gamepad.buttons[2]?.pressed 192 | || gamepad.buttons[3]?.pressed || gamepad.buttons[6]?.pressed || gamepad.buttons[7]?.pressed 193 | || gamepad.buttons[8]?.pressed || gamepad.buttons[9]?.pressed) { 194 | gamepadJump = true; 195 | } 196 | } 197 | } 198 | 199 | leftPressed = touchLeft || gamepadLeft || leftKeyPressed > rightKeyPressed; 200 | rightPressed = touchRight || gamepadRight || rightKeyPressed > leftKeyPressed; 201 | upPressed = touchUp || gamepadUp || upKeyPressed > downKeyPressed; 202 | downPressed = touchDown || gamepadDown || downKeyPressed > upKeyPressed; 203 | jumpPressed = touchJump || gamepadJump || jumpKeyPressed; 204 | 205 | leftJustPressed = leftPressed && !lastLeftPressed; 206 | leftJustReleased = !leftPressed && lastLeftPressed; 207 | 208 | rightJustPressed = rightPressed && !lastRightPressed; 209 | rightJustReleased = !rightPressed && lastRightPressed; 210 | 211 | upJustPressed = upPressed && !lastUpPressed; 212 | upJustReleased = !upPressed && lastUpPressed; 213 | 214 | downJustPressed = downPressed && !lastDownPressed; 215 | downJustReleased = !downPressed && lastDownPressed; 216 | 217 | jumpJustPressed = jumpPressed && !lastJumpPressed; 218 | jumpJustReleased = !jumpPressed && lastJumpPressed; 219 | 220 | lastLeftPressed = leftPressed; 221 | lastRightPressed = rightPressed; 222 | lastUpPressed = upPressed; 223 | lastDownPressed = downPressed; 224 | lastJumpPressed = jumpPressed; 225 | } 226 | 227 | function cancelHideCursorTimer() { 228 | if (hideCursorTimeoutId !== null) { 229 | clearTimeout(hideCursorTimeoutId); 230 | hideCursorTimeoutId = null; 231 | } 232 | 233 | if (cursorHidden) { 234 | document.body.style.cursor = 'default'; 235 | cursorHidden = false; 236 | } 237 | } 238 | 239 | function resetHideCursorTimer() { 240 | cancelHideCursorTimer(); 241 | 242 | hideCursorTimeoutId = window.setTimeout(() => { 243 | document.body.style.cursor = 'none'; 244 | cursorHidden = true; 245 | }, 3000); 246 | } 247 | 248 | function onTouch(e: TouchEvent) { 249 | e.preventDefault(); 250 | 251 | const innerWidth = window.innerWidth; 252 | const innerHeight = window.innerHeight; 253 | const landscape = innerWidth >= innerHeight; 254 | 255 | for (let i = e.changedTouches.length - 1; i >= 0; --i) { 256 | const t = e.changedTouches[i]; 257 | let x: number; 258 | let y: number; 259 | if (landscape) { 260 | x = t.clientX; 261 | y = t.clientY; 262 | } else { 263 | x = innerHeight - 1 - t.clientY; 264 | y = t.clientX; 265 | } 266 | switch (e.type) { 267 | case 'touchstart': { 268 | const touchData = new TouchData(); 269 | touchData.timestampDown = Date.now(); 270 | touchData.xDown = touchData.x = x; 271 | touchData.yDown = touchData.y = y; 272 | touchDatas.set(t.identifier, touchData); 273 | break; 274 | } 275 | case 'touchmove': { 276 | resetHideCursorTimer(); 277 | const touchData = touchDatas.get(t.identifier); 278 | if (touchData) { 279 | touchData.x = x; 280 | touchData.y = y; 281 | } 282 | break; 283 | } 284 | case 'touchend': 285 | case 'touchcancel': { 286 | const touchData = touchDatas.get(t.identifier); 287 | if (touchData) { 288 | if (x < 64 && y < 64 && touchData.xDown < 64 && touchData.yDown < 64) { 289 | exit(); 290 | } 291 | touchDatas.delete(t.identifier); 292 | } 293 | break; 294 | } 295 | } 296 | } 297 | 298 | const halfInnerWidth = innerWidth / 2; 299 | leftScreenTouched = rightScreenTouched = 0; 300 | for (const [ identifier, touchData ] of Array.from(touchDatas)) { 301 | if (touchData.x < halfInnerWidth) { 302 | if (touchData.x >= 64 || touchData.y >= 64) { // excluding the hamburger 303 | leftScreenTouched = touchData.timestampDown; 304 | } 305 | } else { 306 | rightScreenTouched = touchData.timestampDown; 307 | } 308 | outer: { 309 | for (let i = e.touches.length - 1; i >= 0; --i) { 310 | const t = e.touches[i]; 311 | if (t.identifier === identifier) { 312 | break outer; 313 | } 314 | } 315 | touchDatas.delete(identifier); 316 | } 317 | } 318 | } 319 | 320 | function onClick(e: MouseEvent) { 321 | if (!(e.clientX && e.clientY)) { 322 | return; 323 | } 324 | 325 | const innerWidth = window.innerWidth; 326 | const innerHeight = window.innerHeight; 327 | let x: number; 328 | let y: number; 329 | if (innerWidth >= innerHeight) { 330 | x = e.clientX; 331 | y = e.clientY; 332 | } else { 333 | x = innerHeight - 1 - e.clientY; 334 | y = e.clientX; 335 | } 336 | 337 | if (x < 64 && y < 64) { // hamburger 338 | exit(); 339 | } 340 | } 341 | 342 | function onKeyDown(e: KeyboardEvent) { 343 | switch (e.code) { 344 | case 'KeyA': 345 | case 'ArrowLeft': 346 | leftKeyPressed = rightKeyPressed + 1; 347 | break; 348 | case 'KeyD': 349 | case 'ArrowRight': 350 | rightKeyPressed = leftKeyPressed + 1; 351 | break; 352 | case 'KeyW': 353 | case 'ArrowUp': 354 | upKeyPressed = downKeyPressed + 1; 355 | break; 356 | case 'KeyS': 357 | case 'ArrowDown': 358 | downKeyPressed = upKeyPressed + 1; 359 | break; 360 | case 'Escape': 361 | exit(); 362 | break; 363 | default: 364 | jumpKeyPressed = true; 365 | break; 366 | } 367 | 368 | // touch testing 369 | // switch (e.code) { 370 | // case 'KeyA': 371 | // leftScreenTouched = rightScreenTouched + 1; 372 | // break; 373 | // case 'Quote': 374 | // rightScreenTouched = leftScreenTouched + 1; 375 | // break; 376 | // } 377 | } 378 | 379 | function onKeyUp(e: KeyboardEvent) { 380 | switch (e.code) { 381 | case 'KeyA': 382 | case 'ArrowLeft': 383 | leftKeyPressed = 0; 384 | break; 385 | case 'KeyD': 386 | case 'ArrowRight': 387 | rightKeyPressed = 0; 388 | break; 389 | case 'KeyW': 390 | case 'ArrowUp': 391 | upKeyPressed = 0; 392 | break; 393 | case 'KeyS': 394 | case 'ArrowDown': 395 | downKeyPressed = 0; 396 | break; 397 | case 'Escape': 398 | break; 399 | default: 400 | jumpKeyPressed = false; 401 | break; 402 | } 403 | 404 | // touch testing 405 | // switch (e.code) { 406 | // case 'KeyA': 407 | // leftScreenTouched = 0; 408 | // break; 409 | // case 'Quote': 410 | // rightScreenTouched = 0; 411 | // break; 412 | // } 413 | } -------------------------------------------------------------------------------- /src/math.ts: -------------------------------------------------------------------------------- 1 | import { Mask } from './graphics'; 2 | 3 | export const TAU = 2 * Math.PI; 4 | 5 | export function clamp(value: number, min: number, max: number) { 6 | return (value < min) ? min : (value > max) ? max : value; 7 | } 8 | 9 | export function mod(n: number, m: number): number { 10 | return ((n % m) + m) % m; 11 | } 12 | 13 | export function spritesIntersect( 14 | mask0: Mask, x0: number, y0: number, 15 | mask1: Mask, x1: number, y1: number): boolean { 16 | 17 | x0 = Math.floor(x0); 18 | y0 = Math.floor(y0); 19 | const width0 = mask0[0].length; 20 | const height0 = mask0.length; 21 | const xMax0 = width0 - 1; 22 | const yMax0 = height0 - 1; 23 | 24 | x1 = Math.floor(x1) - x0; 25 | y1 = Math.floor(y1) - y0; 26 | const width1 = mask1[0].length; 27 | const height1 = mask1.length; 28 | const xMax1 = x1 + width1 - 1; 29 | const yMax1 = y1 + height1 - 1; 30 | 31 | if (yMax1 < 0 || yMax0 < y1 || xMax1 < 0 || xMax0 < x1) { 32 | return false; 33 | } 34 | 35 | const xMin = Math.max(0, x1); 36 | const xMax = Math.min(xMax0, xMax1); 37 | const yMin = Math.max(0, y1); 38 | const yMax = Math.min(yMax0, yMax1); 39 | for (let y = yMin; y <= yMax; ++y) { 40 | for (let x = xMin; x <= xMax; ++x) { 41 | if (mask0[y][x] && mask1[y - y1][x - x1]) { 42 | return true; 43 | } 44 | } 45 | } 46 | 47 | return false; 48 | } -------------------------------------------------------------------------------- /src/no-param-void-func.ts: -------------------------------------------------------------------------------- 1 | export type NoParamVoidFunc = () => void; -------------------------------------------------------------------------------- /src/progress.ts: -------------------------------------------------------------------------------- 1 | import { download } from "./download"; 2 | import { decodeAudioData, waitForDecodes } from "./audio"; 3 | import { enter as enterStart } from "./start"; 4 | import { loadStore } from "./store"; 5 | import { init as initGraphics } from './graphics'; 6 | 7 | let landscape = false; 8 | let progressBar: HTMLProgressElement; 9 | 10 | export function enter() { 11 | window.addEventListener('resize', windowResized); 12 | window.addEventListener('touchmove', onTouchMove, { passive: false }); 13 | 14 | const mainElement = document.getElementById('main-content') as HTMLElement; 15 | mainElement.innerHTML = ` 16 |
17 |
18 | 19 |
20 |
`; 21 | progressBar = document.getElementById('loading-progress') as HTMLProgressElement; 22 | 23 | if ('serviceWorker' in navigator) { 24 | navigator.serviceWorker.addEventListener('message', messageReceived); 25 | } 26 | 27 | windowResized(); 28 | 29 | download('resources.zip', frac => { 30 | progressBar.value = 100 * frac; 31 | setProgressBarColor('#0075FF'); 32 | }).then(onDownload); 33 | } 34 | 35 | export function exit() { 36 | window.removeEventListener('resize', windowResized); 37 | window.removeEventListener('touchmove', onTouchMove); 38 | 39 | if ('serviceWorker' in navigator) { 40 | navigator.serviceWorker.removeEventListener('message', messageReceived); 41 | } 42 | } 43 | 44 | function setProgressBarColor(color: string) { 45 | 46 | if (progressBar) { 47 | if (color === progressBar.style.color) { 48 | return; 49 | } 50 | progressBar.style.color = color; 51 | } 52 | 53 | const styleId: string = 'progress-bar-style'; 54 | 55 | let styleSheet: HTMLStyleElement | null = document.getElementById(styleId) as HTMLStyleElement; 56 | 57 | if (!styleSheet) { 58 | styleSheet = document.createElement("style"); 59 | styleSheet.id = styleId; 60 | document.head.appendChild(styleSheet); 61 | } 62 | 63 | styleSheet.innerText = ` 64 | #loading-progress::-webkit-progress-value { 65 | background-color: ${color} !important; 66 | } 67 | #loading-progress::-moz-progress-bar { 68 | background-color: ${color} !important; 69 | } 70 | `; 71 | } 72 | 73 | function messageReceived(e: MessageEvent) { 74 | if (progressBar) { 75 | progressBar.value = 100 * e.data; 76 | setProgressBarColor('#48D800'); 77 | } 78 | } 79 | 80 | function onDownload(arrayBuffer: Uint8Array) { 81 | import(/* webpackChunkName: "jszip" */ 'jszip').then(({ default: JSZip }) => { 82 | new JSZip().loadAsync(arrayBuffer).then(zip => Object.entries(zip.files).forEach(entry => { 83 | const [ filename, fileData ] = entry; 84 | if (fileData.dir) { 85 | return; 86 | } 87 | if (filename.endsWith('.mp3')) { 88 | decodeAudioData(filename, fileData); 89 | } 90 | })); 91 | }); 92 | 93 | waitForDecodes().then(() => { 94 | (document.getElementById('loading-progress') as HTMLProgressElement).value = 100; 95 | setTimeout(() => { 96 | loadStore(); 97 | initGraphics().then(() => { 98 | exit(); 99 | enterStart(); 100 | }); 101 | }, 10); 102 | }); 103 | } 104 | 105 | function onTouchMove(e: TouchEvent) { 106 | e.preventDefault(); 107 | } 108 | 109 | function windowResized() { 110 | const progressContainer = document.getElementById('progress-container') as HTMLDivElement; 111 | const progressDiv = document.getElementById('progress-div') as HTMLDivElement; 112 | 113 | progressContainer.style.width = progressContainer.style.height = ''; 114 | progressContainer.style.left = progressContainer.style.top = ''; 115 | progressContainer.style.display = 'none'; 116 | 117 | progressDiv.style.top = progressDiv.style.left = progressDiv.style.transform = ''; 118 | progressDiv.style.display = 'none'; 119 | 120 | const innerWidth = window.innerWidth; 121 | const innerHeight = window.innerHeight; 122 | landscape = (innerWidth >= innerHeight); 123 | 124 | progressContainer.style.left = '0px'; 125 | progressContainer.style.top = '0px'; 126 | progressContainer.style.width = `${innerWidth}px`; 127 | progressContainer.style.height = `${innerHeight}px`; 128 | progressContainer.style.display = 'block'; 129 | 130 | progressDiv.style.display = 'flex'; 131 | 132 | if (landscape) { 133 | const rect = progressDiv.getBoundingClientRect(); 134 | progressDiv.style.left = `${(innerWidth - rect.width) / 2}px` 135 | progressDiv.style.top = `${(innerHeight - rect.height) / 2}px`; 136 | } else { 137 | progressDiv.style.transform = 'rotate(-90deg)'; 138 | const rect = progressDiv.getBoundingClientRect(); 139 | progressDiv.style.left = `${(innerWidth - rect.height) / 2}px` 140 | progressDiv.style.top = `${(innerHeight - rect.width) / 2}px`; 141 | } 142 | } -------------------------------------------------------------------------------- /src/screen.ts: -------------------------------------------------------------------------------- 1 | import { startAnimation, stopAnimation } from './animate'; 2 | import { acquireWakeLock, releaseWakeLock } from './wake-lock'; 3 | import { NoParamVoidFunc } from './no-param-void-func'; 4 | import { enter as enterStart } from './start'; 5 | import { PhysicalDimensions, Resolution } from './graphics'; 6 | import { startInput, stopInput, resetInput } from './input'; 7 | import { renderScreen, initGame } from './game/game'; 8 | import { saveStore } from './store'; 9 | import { stopAll } from './audio'; 10 | 11 | export let dpr: number; 12 | 13 | let mainCanvas: HTMLCanvasElement; 14 | let mainCtx: CanvasRenderingContext2D | null; 15 | let mainCanvasWidth: number; 16 | let mainCanvasHeight: number; 17 | 18 | let screenCanvas: OffscreenCanvas; 19 | let ctx: OffscreenCanvasRenderingContext2D | null; 20 | 21 | let removeMediaEventListener: NoParamVoidFunc | null = null; 22 | let exiting = false; 23 | 24 | let screenWidth: number; 25 | let screenHeight: number; 26 | let screenX: number; 27 | let screenY: number; 28 | 29 | function updatePixelRatio() { 30 | if (removeMediaEventListener !== null) { 31 | removeMediaEventListener(); 32 | removeMediaEventListener = null; 33 | } 34 | 35 | if (exiting) { 36 | return; 37 | } 38 | 39 | const media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); 40 | media.addEventListener("change", updatePixelRatio); 41 | removeMediaEventListener = () => media.removeEventListener("change", updatePixelRatio); 42 | 43 | onWindowResized(); 44 | }; 45 | 46 | export function enter() { 47 | exiting = false; 48 | 49 | initGame(); 50 | 51 | document.body.style.backgroundColor = '#C2BCB1'; 52 | 53 | screenCanvas = new OffscreenCanvas(Resolution.WIDTH, Resolution.HEIGHT); 54 | ctx = screenCanvas.getContext('2d'); 55 | 56 | const mainElement = document.getElementById("main-content") as HTMLElement; 57 | mainElement.innerHTML = ``; 58 | mainCanvas = document.getElementById("main-canvas") as HTMLCanvasElement; 59 | mainCanvas.style.touchAction = 'none'; 60 | 61 | window.addEventListener('beforeunload', onBeforeUnload); 62 | window.addEventListener('resize', onWindowResized); 63 | window.addEventListener('focus', onVisibilityChanged); 64 | window.addEventListener('blur', onVisibilityChanged); 65 | document.addEventListener('visibilitychange', onVisibilityChanged); 66 | 67 | acquireWakeLock(); 68 | updatePixelRatio(); 69 | startInput(); 70 | startAnimation(); 71 | } 72 | 73 | function cleanUp() { 74 | if (exiting) { 75 | return; 76 | } 77 | 78 | exiting = true; 79 | stopAnimation(); 80 | stopInput(); 81 | stopAll(); 82 | releaseWakeLock(); 83 | 84 | window.removeEventListener('beforeunload', onBeforeUnload); 85 | window.removeEventListener('resize', onWindowResized); 86 | window.removeEventListener('focus', onVisibilityChanged); 87 | window.removeEventListener('blur', onVisibilityChanged); 88 | document.removeEventListener('visibilitychange', onVisibilityChanged); 89 | 90 | if (removeMediaEventListener !== null) { 91 | removeMediaEventListener(); 92 | removeMediaEventListener = null; 93 | } 94 | 95 | saveStore(); 96 | } 97 | 98 | export function exit() { 99 | cleanUp(); 100 | enterStart(); 101 | } 102 | 103 | export function render() { 104 | if (!mainCtx) { 105 | onWindowResized(); 106 | return; 107 | } 108 | if (!ctx) { 109 | return; 110 | } 111 | 112 | mainCtx.imageSmoothingEnabled = false; 113 | mainCtx.fillStyle = '#0F0F0F'; 114 | mainCtx.fillRect(0, 0, mainCanvasWidth, mainCanvasHeight); 115 | 116 | ctx.imageSmoothingEnabled = false; 117 | renderScreen(ctx); 118 | 119 | mainCtx.drawImage(screenCanvas, screenX, screenY, screenWidth, screenHeight); 120 | 121 | // hamburger icon 122 | mainCtx.imageSmoothingEnabled = true; 123 | mainCtx.fillStyle = '#FFFFFF'; 124 | mainCtx.fillRect(27, 21, 18, 1); 125 | mainCtx.fillRect(27, 27, 18, 1); 126 | mainCtx.fillRect(27, 33, 18, 1); 127 | } 128 | 129 | function onWindowResized() { 130 | 131 | if (exiting) { 132 | return; 133 | } 134 | 135 | mainCtx = null; 136 | mainCanvas = document.getElementById("main-canvas") as HTMLCanvasElement; 137 | mainCanvas.style.display = 'none'; 138 | 139 | const innerWidth = window.innerWidth; 140 | const innerHeight = window.innerHeight; 141 | 142 | mainCanvas.style.display = 'block'; 143 | mainCanvas.style.width = `${innerWidth}px`; 144 | mainCanvas.style.height = `${innerHeight}px`; 145 | mainCanvas.style.position = 'absolute'; 146 | mainCanvas.style.left = '0px'; 147 | mainCanvas.style.top = '0px'; 148 | 149 | dpr = window.devicePixelRatio || 1; 150 | mainCanvas.width = Math.floor(dpr * innerWidth); 151 | mainCanvas.height = Math.floor(dpr * innerHeight); 152 | 153 | const transform = new DOMMatrix(); 154 | if (innerWidth >= innerHeight) { 155 | // Landscape mode 156 | mainCanvasWidth = innerWidth; 157 | mainCanvasHeight = innerHeight; 158 | transform.a = transform.d = dpr; 159 | transform.b = transform.c = transform.e = transform.f = 0; 160 | } else { 161 | // Portrait mode 162 | mainCanvasWidth = innerHeight; 163 | mainCanvasHeight = innerWidth; 164 | transform.a = transform.d = transform.e = 0; 165 | transform.c = dpr; 166 | transform.b = -transform.c; 167 | transform.f = dpr * innerHeight; 168 | } 169 | 170 | mainCtx = mainCanvas.getContext('2d'); 171 | if (!mainCtx) { 172 | return; 173 | } 174 | mainCtx.setTransform(transform); 175 | 176 | screenHeight = mainCanvasHeight; 177 | screenWidth = screenHeight * PhysicalDimensions.WIDTH / PhysicalDimensions.HEIGHT; 178 | if (screenWidth > mainCanvasWidth) { 179 | screenWidth = mainCanvasWidth; 180 | screenHeight = screenWidth * PhysicalDimensions.HEIGHT / PhysicalDimensions.WIDTH; 181 | screenX = 0; 182 | screenY = Math.round((mainCanvasHeight - screenHeight) / 2); 183 | } else { 184 | screenX = Math.round((mainCanvasWidth - screenWidth) / 2); 185 | screenY = 0; 186 | } 187 | 188 | render(); 189 | } 190 | 191 | function onVisibilityChanged() { 192 | if (!exiting && document.visibilityState === 'visible' && document.hasFocus()) { 193 | acquireWakeLock(); 194 | resetInput(); 195 | startAnimation(); 196 | } else { 197 | stopAnimation(); 198 | stopAll(); 199 | } 200 | } 201 | 202 | function onBeforeUnload() { 203 | cleanUp(); 204 | } -------------------------------------------------------------------------------- /src/start.ts: -------------------------------------------------------------------------------- 1 | import { setVolume } from './audio'; 2 | import { enter as enterGame } from './screen'; 3 | import { store, saveStore, Difficulty } from './store'; 4 | 5 | let landscape = false; 6 | 7 | let dropdownToggleListener: () => void; 8 | let dropdownCloseListener: (event: MouseEvent) => void; 9 | const optionListners = new Array<() => void>(3); 10 | let selectedDifficulty: number; 11 | 12 | export function enter() { 13 | selectedDifficulty = store.difficulty; 14 | 15 | document.body.style.backgroundColor = '#0F0F0F'; 16 | 17 | window.addEventListener('resize', windowResized); 18 | window.addEventListener('touchmove', onTouchMove, { passive: false }); 19 | 20 | const mainElement = document.getElementById('main-content') as HTMLElement; 21 | mainElement.innerHTML = ` 22 |
23 |
24 |
High Score: ${store.highScores[selectedDifficulty]}
25 |
26 | volume_mute 28 | 29 | 100 30 |
31 |
32 | 33 |
34 | 35 | 40 |
41 |
42 |
${store.gameState 43 | ? '' 44 | : ''} 45 |
46 |
47 |
`; 48 | 49 | const highScore = document.getElementById('high-score-div') as HTMLDivElement; 50 | 51 | const volumeInput = document.getElementById('volume-input') as HTMLInputElement; 52 | volumeInput.addEventListener('input', volumeChanged); 53 | volumeInput.value = String(store.volume); 54 | 55 | const startButton = document.getElementById('start-button') as HTMLButtonElement; 56 | startButton.addEventListener('click', startButtonClicked); 57 | 58 | const continueButton = document.getElementById('continue-button') as HTMLButtonElement | null; 59 | continueButton?.addEventListener('click', continueButtonClicked); 60 | 61 | const dropdown = document.getElementById('custom-dropdown') as HTMLDivElement; 62 | const selected = dropdown.querySelector('.dropdown-selected') as HTMLDivElement; 63 | const optionItems = dropdown.querySelectorAll('.dropdown-option'); 64 | 65 | // Toggle dropdown open/closed on click 66 | dropdownToggleListener = () => dropdown.classList.toggle('open'); 67 | selected.addEventListener('click', dropdownToggleListener); 68 | 69 | // Close if clicked outside 70 | dropdownCloseListener = event => { 71 | if (!dropdown.contains(event.target as Node)) { 72 | dropdown.classList.remove('open'); 73 | } 74 | }; 75 | document.addEventListener('click', dropdownCloseListener); 76 | 77 | // Handle clicks on each option 78 | optionItems.forEach(option => { 79 | const difficulty = Number(option.getAttribute('data-value')); 80 | optionListners[difficulty] = () => { 81 | selectedDifficulty = difficulty; 82 | highScore.textContent = `High Score: ${store.highScores[selectedDifficulty]}`; 83 | selected.textContent = option.textContent; // Update the "selected" text 84 | dropdown.classList.remove('open'); // Close dropdown 85 | }; 86 | option.addEventListener('click', optionListners[difficulty]); 87 | }); 88 | 89 | windowResized(); 90 | } 91 | 92 | export function exit() { 93 | window.removeEventListener('resize', windowResized); 94 | window.removeEventListener('touchmove', onTouchMove); 95 | 96 | const volumeInput = document.getElementById('volume-input') as HTMLInputElement; 97 | volumeInput.removeEventListener('input', volumeChanged); 98 | 99 | const startButton = document.getElementById('start-button') as HTMLButtonElement; 100 | startButton.removeEventListener('click', startButtonClicked); 101 | 102 | const continueButton = document.getElementById('continue-button') as HTMLButtonElement | null; 103 | continueButton?.removeEventListener('click', continueButtonClicked); 104 | 105 | const dropdown = document.getElementById('custom-dropdown') as HTMLDivElement; 106 | document.removeEventListener('click', dropdownCloseListener); 107 | 108 | const selected = dropdown.querySelector('.dropdown-selected') as HTMLDivElement; 109 | selected.removeEventListener('click', dropdownToggleListener); 110 | 111 | const optionItems = dropdown.querySelectorAll('.dropdown-option'); 112 | optionItems.forEach(option => option.removeEventListener('click', 113 | optionListners[Number(option.getAttribute('data-value'))])); 114 | 115 | saveStore(); 116 | } 117 | 118 | function getDifficultyName() { 119 | switch (store.difficulty) { 120 | case Difficulty.EASY: 121 | return "Easy"; 122 | case Difficulty.NORMAL: 123 | return "Normal"; 124 | default: 125 | return "Hard"; 126 | } 127 | } 128 | 129 | function startButtonClicked() { 130 | store.gameState = undefined; 131 | store.difficulty = selectedDifficulty; 132 | continueButtonClicked(); 133 | } 134 | 135 | function continueButtonClicked() { 136 | setVolume(store.volume); 137 | exit(); 138 | enterGame(); 139 | } 140 | 141 | function onTouchMove(e: TouchEvent) { 142 | let target = e.target as HTMLElement | null; 143 | while (target !== null) { 144 | if (target.id === 'volume-input') { 145 | if (landscape) { 146 | return; 147 | } 148 | 149 | const volumeInput = target as HTMLInputElement; 150 | const max = parseFloat(volumeInput.max); 151 | const min = parseFloat(volumeInput.min); 152 | const rect = volumeInput.getBoundingClientRect(); 153 | const value = (1 - ((e.touches[0].clientY - rect.top) / rect.height)) * (max - min) + min; 154 | volumeInput.value = value.toString(); 155 | volumeInput.dispatchEvent(new Event('input')); 156 | return; 157 | } 158 | target = target.parentElement; 159 | } 160 | e.preventDefault(); 161 | } 162 | 163 | function volumeChanged() { 164 | const leftVolumeSpan = document.getElementById('left-volume-span') as HTMLSpanElement; 165 | const volumeInput = document.getElementById('volume-input') as HTMLInputElement; 166 | const rightVolumeSpan = document.getElementById('right-volume-span') as HTMLSpanElement; 167 | 168 | store.volume = 100 * (+volumeInput.value - +volumeInput.min) / (+volumeInput.max - +volumeInput.min); 169 | volumeInput.style.setProperty('--thumb-position', `${store.volume}%`); 170 | 171 | if (store.volume === 0) { 172 | leftVolumeSpan.textContent = 'volume_off'; 173 | } else if (store.volume < 33) { 174 | leftVolumeSpan.textContent = 'volume_mute'; 175 | } else if (store.volume < 66) { 176 | leftVolumeSpan.textContent = 'volume_down'; 177 | } else { 178 | leftVolumeSpan.textContent = 'volume_up'; 179 | } 180 | 181 | rightVolumeSpan.textContent = String(Math.round(store.volume)); 182 | } 183 | 184 | function windowResized() { 185 | const startContainer = document.getElementById('start-container') as HTMLDivElement; 186 | const startDiv = document.getElementById('start-div') as HTMLDivElement; 187 | const leftVolumeSpan = document.getElementById('left-volume-span') as HTMLSpanElement; 188 | const rightVolumeSpan = document.getElementById('right-volume-span') as HTMLSpanElement; 189 | 190 | startContainer.style.width = startContainer.style.height = ''; 191 | startContainer.style.left = startContainer.style.top = ''; 192 | startContainer.style.display = 'none'; 193 | 194 | startDiv.style.left = startDiv.style.top = startDiv.style.transform = ''; 195 | startDiv.style.display = 'none'; 196 | 197 | const innerWidth = window.innerWidth; 198 | const innerHeight = window.innerHeight; 199 | landscape = (innerWidth >= innerHeight); 200 | 201 | startContainer.style.left = '0px'; 202 | startContainer.style.top = '0px'; 203 | startContainer.style.width = `${innerWidth}px`; 204 | startContainer.style.height = `${innerHeight}px`; 205 | startContainer.style.display = 'block'; 206 | 207 | startDiv.style.display = 'flex'; 208 | 209 | leftVolumeSpan.style.width = ''; 210 | leftVolumeSpan.style.display = 'inline-block'; 211 | leftVolumeSpan.style.textAlign = 'center'; 212 | leftVolumeSpan.textContent = '\u{1F507}'; 213 | leftVolumeSpan.style.transform = ''; 214 | 215 | rightVolumeSpan.style.width = ''; 216 | rightVolumeSpan.style.display = 'inline-block'; 217 | rightVolumeSpan.style.textAlign = 'center'; 218 | rightVolumeSpan.textContent = '100'; 219 | 220 | if (landscape) { 221 | const leftVolumeSpanWidth = leftVolumeSpan.getBoundingClientRect().width; 222 | leftVolumeSpan.style.width = `${leftVolumeSpanWidth}px`; 223 | 224 | const rightVolumeSpanWidth = rightVolumeSpan.getBoundingClientRect().width; 225 | rightVolumeSpan.style.width = `${rightVolumeSpanWidth}px`; 226 | 227 | const rect = startDiv.getBoundingClientRect(); 228 | startDiv.style.left = `${(innerWidth - rect.width) / 2}px` 229 | startDiv.style.top = `${(innerHeight - rect.height) / 2}px`; 230 | } else { 231 | const leftVolumeSpanHeight = leftVolumeSpan.getBoundingClientRect().height; 232 | leftVolumeSpan.style.width = `${leftVolumeSpanHeight}px`; 233 | 234 | const rightVolumeSpanHeight = rightVolumeSpan.getBoundingClientRect().height; 235 | rightVolumeSpan.style.width = `${rightVolumeSpanHeight}px`; 236 | 237 | startDiv.style.transform = 'rotate(-90deg)'; 238 | const rect = startDiv.getBoundingClientRect(); 239 | startDiv.style.left = `${(innerWidth - rect.height) / 2}px` 240 | startDiv.style.top = `${(innerHeight - rect.width) / 2}px`; 241 | } 242 | rightVolumeSpan.textContent = String(store.volume); 243 | 244 | volumeChanged(); 245 | } -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { GameState } from './game/game-state'; 2 | 3 | export const LOCAL_STORAGE_KEY = 'pitfall-store'; 4 | 5 | export enum Difficulty { 6 | EASY = 0, 7 | NORMAL = 1, 8 | HARD = 2, 9 | } 10 | 11 | export class Store { 12 | highScores = [ 0, 0, 0]; 13 | volume = 10; 14 | difficulty = 0; 15 | gameState: GameState | undefined = undefined; 16 | } 17 | 18 | export let store: Store; 19 | 20 | export function saveStore() { 21 | if (store.gameState?.gameOver) { 22 | store.gameState = undefined; 23 | } 24 | localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(store)); 25 | } 26 | 27 | export function loadStore() { 28 | if (store) { 29 | return; 30 | } 31 | 32 | const str = localStorage.getItem(LOCAL_STORAGE_KEY); 33 | if (str) { 34 | try { 35 | store = JSON.parse(str) as Store; 36 | store.gameState = (store.gameState && !store.gameState.gameOver) 37 | ? new GameState(store.gameState) : undefined; 38 | } catch { 39 | store = new Store(); 40 | } 41 | } else { 42 | store = new Store(); 43 | } 44 | } -------------------------------------------------------------------------------- /src/sw.ts: -------------------------------------------------------------------------------- 1 | declare const self: ServiceWorkerGlobalScope; 2 | 3 | export const CACHE_NAME = 'pitfall-cache-2025-01-25'; 4 | 5 | const MAX_FETCH_RETRIES = 5; 6 | 7 | async function fetchWithRetry(request: Request, options: RequestInit = {}) { 8 | 9 | for (let i = MAX_FETCH_RETRIES - 1; i >= 0; --i) { 10 | try { 11 | const response = await fetch(request, options); 12 | if (!response.ok) { 13 | continue; 14 | } 15 | 16 | const contentLengthStr = response.headers.get('Content-Length'); 17 | const contentLength = contentLengthStr ? parseInt(contentLengthStr, 10) : 0; 18 | const postStatus = contentLength > 0 && request.url.includes('resources.zip'); 19 | 20 | const body = response.body; 21 | if (body === null) { 22 | continue; 23 | } 24 | 25 | const reader = body.getReader(); 26 | const chunks = []; 27 | let bytesReceived = 0; 28 | while (true) { 29 | const { done, value: chunk } = await reader.read(); 30 | if (done) { 31 | break; 32 | } 33 | chunks.push(chunk); 34 | bytesReceived += chunk.length; 35 | if (postStatus) { 36 | self.clients.matchAll().then(clients => { 37 | clients.forEach(client => { 38 | client.postMessage(bytesReceived / contentLength); 39 | }); 40 | }); 41 | } 42 | } 43 | 44 | const uint8Array = new Uint8Array(bytesReceived); 45 | let position = 0; 46 | chunks.forEach(chunk => { 47 | uint8Array.set(chunk, position); 48 | position += chunk.length; 49 | }); 50 | 51 | return new Response(uint8Array, { 52 | status: 200, 53 | statusText: 'OK', 54 | headers: response.headers 55 | }); 56 | } catch (error) { 57 | if (i === 0) { 58 | throw error; 59 | } 60 | } 61 | } 62 | throw new Error("Failed to fetch."); 63 | } 64 | 65 | self.addEventListener('activate', e => { 66 | e.waitUntil( 67 | caches.keys().then(cacheNames => { 68 | return Promise.all(cacheNames.filter(cacheName => cacheName !== CACHE_NAME) 69 | .map(cacheName => caches.delete(cacheName))); 70 | }).then(() => self.clients.claim()) 71 | ); 72 | }); 73 | 74 | self.addEventListener('fetch', e => { 75 | 76 | if (!e.request.url.startsWith('http')) { 77 | return; 78 | } 79 | 80 | e.respondWith( 81 | caches.open(CACHE_NAME).then(cache => { 82 | return cache.match(e.request).then(cachedResponse => { 83 | if (cachedResponse) { 84 | return cachedResponse; 85 | } 86 | const fetchOptions: RequestInit = (new URL(e.request.url).hostname !== self.location.hostname) 87 | ? { mode: 'cors', credentials: 'omit' } : {}; 88 | return fetchWithRetry(e.request, fetchOptions).then(fetchResponse => { 89 | cache.put(e.request, fetchResponse.clone()).then(_ => {}); 90 | return fetchResponse; 91 | }); 92 | }); 93 | }).catch(() => new Response('Service Unavailable', { status: 503 })) 94 | ); 95 | }); 96 | 97 | self.addEventListener('install', () => self.skipWaiting()); -------------------------------------------------------------------------------- /src/wake-lock.ts: -------------------------------------------------------------------------------- 1 | let wakeLock: WakeLockSentinel | null = null; 2 | let acquiringWaitLock = false; 3 | 4 | export function acquireWakeLock() { 5 | if (!acquiringWaitLock && wakeLock === null && 'wakeLock' in navigator) { 6 | acquiringWaitLock = true; 7 | navigator.wakeLock.request('screen') 8 | .then(w => { 9 | if (acquiringWaitLock) { 10 | wakeLock = w; 11 | wakeLock.addEventListener("release", () => { 12 | if (!acquiringWaitLock) { 13 | wakeLock = null; 14 | } 15 | }); 16 | } 17 | }).catch(_ => { 18 | }).finally(() => acquiringWaitLock = false); 19 | } 20 | } 21 | 22 | export function releaseWakeLock() { 23 | if (wakeLock !== null && 'wakeLock' in navigator) { 24 | acquiringWaitLock = false; 25 | wakeLock.release() 26 | .then(() => { 27 | if (!acquiringWaitLock) { 28 | wakeLock = null; 29 | } 30 | }).catch(_ => { 31 | }); 32 | } 33 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "strict": true, 6 | "jsx": "react", 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "sourceMap": true, 11 | "baseUrl": ".", 12 | "paths": { 13 | "@/*": ["src/*"], 14 | }, 15 | "rootDir": "./src", 16 | "outDir": "./public_html/app/app/scripts", 17 | "moduleResolution": "Node", 18 | "lib": [ 19 | "DOM", 20 | "ESNext", 21 | "WebWorker" 22 | ] 23 | }, 24 | 25 | "include": [ 26 | "src/**/*" 27 | ], 28 | 29 | "exclude": [ 30 | "node_modules" 31 | ] 32 | } -------------------------------------------------------------------------------- /webpack.config.bootstrap.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | 4 | const __filename = fileURLToPath(import.meta.url); 5 | 6 | export default (env, argv) => { 7 | const isDevelopment = argv.mode === 'development'; 8 | 9 | return { 10 | target: 'web', 11 | mode: isDevelopment ? 'development' : 'production', 12 | entry: { 13 | bootstrap: './src/bootstrap.ts', 14 | }, 15 | output: { 16 | filename: '[name].bundle.js', 17 | chunkFilename: '[name].bundle.js', 18 | path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'public_html/app/scripts'), 19 | clean: true, 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.tsx?$/, 25 | exclude: /node_modules/, 26 | use: [ 27 | { 28 | loader: 'ts-loader', 29 | options: { 30 | transpileOnly: false, 31 | }, 32 | }, 33 | ], 34 | }, 35 | ], 36 | }, 37 | resolve: { 38 | extensions: ['.tsx', '.ts', '.js'], 39 | alias: { 40 | '@': path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'src'), 41 | }, 42 | }, 43 | cache: { 44 | type: 'filesystem', 45 | buildDependencies: { 46 | config: [__filename], 47 | }, 48 | }, 49 | devtool: isDevelopment ? 'source-map' : false, 50 | optimization: { 51 | splitChunks: { 52 | chunks: 'all', // Split both synchronous and asynchronous chunks 53 | cacheGroups: { 54 | vendors: { 55 | test: /[\\/]node_modules[\\/]/, 56 | name: 'vendors', 57 | chunks: 'all', 58 | }, 59 | }, 60 | }, 61 | minimize: !isDevelopment, 62 | }, 63 | bail: true, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /webpack.config.index.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | 4 | const __filename = fileURLToPath(import.meta.url); 5 | 6 | export default (env, argv) => { 7 | const isDevelopment = argv.mode === 'development'; 8 | 9 | return { 10 | target: 'web', 11 | mode: isDevelopment ? 'development' : 'production', 12 | entry: { 13 | index: './src/index.ts', 14 | }, 15 | output: { 16 | filename: '[name].bundle.js', 17 | chunkFilename: '[name].bundle.js', 18 | path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'public_html/scripts'), 19 | clean: true, 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.tsx?$/, 25 | exclude: /node_modules/, 26 | use: [ 27 | { 28 | loader: 'ts-loader', 29 | options: { 30 | transpileOnly: false, 31 | }, 32 | }, 33 | ], 34 | }, 35 | ], 36 | }, 37 | resolve: { 38 | extensions: ['.tsx', '.ts', '.js'], 39 | alias: { 40 | '@': path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'src'), 41 | }, 42 | }, 43 | cache: { 44 | type: 'filesystem', 45 | buildDependencies: { 46 | config: [__filename], 47 | }, 48 | }, 49 | devtool: isDevelopment ? 'source-map' : false, 50 | optimization: { 51 | splitChunks: { 52 | chunks: 'all', // Split both synchronous and asynchronous chunks 53 | cacheGroups: { 54 | vendors: { 55 | test: /[\\/]node_modules[\\/]/, 56 | name: 'vendors', 57 | chunks: 'all', 58 | }, 59 | }, 60 | }, 61 | minimize: !isDevelopment, 62 | }, 63 | bail: true, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /webpack.config.sw.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | 4 | const __filename = fileURLToPath(import.meta.url); 5 | 6 | export default (env, argv) => { 7 | const isDevelopment = argv.mode === 'development'; 8 | 9 | return { 10 | target: 'web', 11 | mode: isDevelopment ? 'development' : 'production', 12 | entry: { 13 | sw: './src/sw.ts', 14 | }, 15 | output: { 16 | filename: '[name].bundle.js', 17 | chunkFilename: '[name].bundle.js', 18 | path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'public_html/app'), 19 | clean: false, // Do not delete files in public_html/app 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.tsx?$/, 25 | exclude: /node_modules/, 26 | use: [ 27 | { 28 | loader: 'ts-loader', 29 | options: { 30 | transpileOnly: false, 31 | }, 32 | }, 33 | ], 34 | }, 35 | ], 36 | }, 37 | resolve: { 38 | extensions: ['.tsx', '.ts', '.js'], 39 | alias: { 40 | '@': path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'src'), 41 | }, 42 | }, 43 | cache: { 44 | type: 'filesystem', 45 | buildDependencies: { 46 | config: [__filename], 47 | }, 48 | }, 49 | devtool: isDevelopment ? 'source-map' : false, 50 | optimization: { 51 | splitChunks: { 52 | chunks: 'all', // Split both synchronous and asynchronous chunks 53 | cacheGroups: { 54 | vendors: { 55 | test: /[\\/]node_modules[\\/]/, 56 | name: 'vendors', 57 | chunks: 'all', 58 | }, 59 | }, 60 | }, 61 | minimize: !isDevelopment, 62 | }, 63 | bail: true, 64 | }; 65 | }; 66 | --------------------------------------------------------------------------------