Up-in-the-Air – blob

You can use Git to clone the repository via the web URL. Download snapshot (zip)
070ad0129ba5c51b04ec90e6191e2c47109b8813
[Up-in-the-Air] / main.js
1 "use strict";
2 // SPDX-License-Identifier: GPL-3.0-or-later
3 /**
4  * Up in the Air
5  * – a browser game created for FediJam 2024 –
6  * https://fietkau.media/up_in_the_air
7  *
8  * Copyright (c) Julian Fietkau
9  * See README.txt for details.
10  *
11  *******************************************************************************
12  *
13  * This program is free software: you can redistribute it and/or modify
14  * it under the terms of the GNU General Public License as published by
15  * the Free Software Foundation, either version 3 of the License, or
16  * (at your option) any later version.
17  *
18  * This program is distributed in the hope that it will be useful,
19  * but WITHOUT ANY WARRANTY; without even the implied warranty of
20  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21  * GNU General Public License for more details.
22  *
23  * You should have received a copy of the GNU General Public License
24  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
25  *
26  */
28 import * as THREE from 'three';
29 import { FontLoader } from 'three/addons/loaders/FontLoader.js';
30 import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
33 window['startUpInTheAirGame'] = (game) => {
35 if(!game.hasOwnProperty('deploymentOptions')) {
36   game['deploymentOptions'] = {};
37 }
38 const deploymentDefaults = {
39   'assetUrlPrefix': '',
40 };
41 for(let k in deploymentDefaults) {
42   if(!game['deploymentOptions'].hasOwnProperty(k)) {
43     game['deploymentOptions'][k] = deploymentDefaults[k];
44   }
45 }
47 game['fn'] = {};
49 game['fn'].playRandomSound = () => {
50   if(!game.view || !game.view.audioListener) {
51     return;
52   }
53   if(!game.view.lastSoundsCache) {
54     game.view.lastSoundsCache = [];
55   }
56   let index;
57   // We remember the last two notes played and make sure not to repeat one of those.
58   do {
59     index = 1 + Math.floor(Math.random() * 5);
60   } while(game.view.lastSoundsCache.includes(index));
61   game.view.lastSoundsCache.push(index);
62   if(game.view.lastSoundsCache.length > 2) {
63     game.view.lastSoundsCache.splice(0, 1);
64   }
65   let sound = new THREE.Audio(game.view.audioListener);
66   sound.setBuffer(game.assets['audio']['sound' + index + '-' + game.settings['audio']['theme']]);
67   sound.setVolume(game.settings['audio']['sounds']);
68   if(!game.view.muted && game.settings['audio']['sounds'] > 0) {
69     sound.play();
70   }
71 }
73 game['fn'].easeInOut = (val) => {
74   return -0.5 * Math.cos(val * Math.PI) + 0.5;
75 }
77 game['fn'].lerp = (start, end, progress) => {
78   return (1.0 - progress) * start + progress * end;
79 }
81 game['fn'].loadAllAssets = (renderProgressCallback) => {
82   game.assets = {};
83   game.assets.words = {
84     'thanks': ['thank you', 'thanks'],
85     'sorry': ['sorry', 'apologize'],
86     'emotion': ['blessed', 'fortunate', 'glad', 'happy', 'joyous', 'lucky', 'overjoyed', 'thankful'],
87     'verb_general': ['adore', 'appreciate', 'cherish', 'enjoy', 'like', 'love', 'treasure', 'value'],
88     'verb_person': ['admire', 'honor', 'love', 'respect', 'treasure', 'value'],
89     'trait': ['amazing', 'compassionate', 'delightful', 'genuine', 'generous', 'incredible', 'joyful', 'kind', 'passionate', 'patient', 'principled', 'refreshing', 'sweet'],
90   };
91   game.assets.wordList = [...new Set([].concat.apply([], Object.values(game.assets.words)))]; // no need to be sorted
92   game.assets.sentences = [
93     '{thanks} for always listening.',
94     '{thanks} for being there.',
95     '{thanks} for helping me when I needed it most.',
96     '{thanks} for being with me.',
97     '{thanks} for believing in me.',
98     '{thanks} for not giving up.',
99     '{thanks} for believing in me when I myself couldn’t.',
100     '{thanks} for standing by my side.',
101     '{sorry} for what I said.',
102     '{sorry} for not being there.',
103     '{sorry} for forgetting.',
104     '{sorry} for not telling you.',
105     '{sorry} for what I did.',
106     '{sorry} for back then.',
107     '{sorry} for not being honest.',
108     'Just being around you makes me feel {emotion}.',
109     'I have no words for how {emotion} you make me feel.',
110     'I always feel {emotion} in your presence.',
111     'I’m honestly {emotion}.',
112     'I feel {emotion} just for knowing you.',
113     'Every moment with you makes me feel {emotion}.',
114     'I {verb_person} you.',
115     'I {verb_person} you more than anything.',
116     'I deeply {verb_person} you.',
117     'I honestly {verb_person} you.',
118     'I really do {verb_person} you.',
119     'I {verb_general} every moment with you.',
120     'I {verb_general} the way you see the world.',
121     'I {verb_general} you the way you are.',
122     'I {verb_general} how {trait} you are.',
123     'I {verb_general} how {trait} you are.',
124     'I {verb_general} how {trait} you are.',
125     'I always {verb_general} how {trait} you are.',
126     'I deeply {verb_general} how {trait} you are.',
127     'Thinking about how {trait} you are always improves my mood.',
128     'You are the most {trait} person I know.',
129     'You are the most {trait} person I know.',
130     'Your {trait} personality always makes my day.',
131     'Your {trait} personality is my sunshine.',
132     'Your {trait} personality gives me strength.',
133     'I’m astonished how {trait} you are.',
134     'I hope I can learn to be as {trait} as you.',
135   ];
136   return new Promise((resolve, reject) => {
137     let todoList = {
138       'audio/wind.ogg': 72482,
139       'fonts/cookie.json': 37866,
140       'textures/cloud0a.png': 568,
141       'textures/cloud0b.png': 569,
142       'textures/cloud0c.png': 568,
143       'textures/cloud1a.png': 6932,
144       'textures/cloud1b.png': 6932,
145       'textures/cloud1c.png': 6933,
146       'textures/cloud2a.png': 4365,
147       'textures/cloud2b.png': 4364,
148       'textures/cloud2c.png': 4365,
149       'textures/cloud3a.png': 4000,
150       'textures/cloud3b.png': 3999,
151       'textures/cloud3c.png': 4001,
152       'textures/cloud4a.png': 3183,
153       'textures/cloud4b.png': 3182,
154       'textures/cloud4c.png': 3184,
155       'textures/cloud5a.png': 2066,
156       'textures/cloud5b.png': 2065,
157       'textures/cloud5c.png': 2066,
158       'textures/feather-black.png': 1026,
159       'textures/feather-blue.png': 1026,
160       'textures/feather-brown.png': 1027,
161       'textures/feather-green.png': 1028,
162       'textures/feather-orange.png': 1028,
163       'textures/feather-purple.png': 1028,
164       'textures/feather-red.png': 1024,
165       'textures/highcontrast-backdrop.png': 500,
166       'textures/pinwheel.png': 904,
167       'textures/house-day-1.png': 17819,
168       'textures/house-day-2.png': 598,
169       'textures/house-day-3.png': 646,
170       'textures/house-evening-1.png': 16939,
171       'textures/house-evening-2.png': 597,
172       'textures/house-evening-3.png': 646,
173     };
174     for(let unlockable of game.settings['unlocks']) {
175       if(unlockable == 'golden') {
176         todoList['textures/feather-golden.png'] = 1027;
177       } else if(unlockable == 'ghost') {
178         todoList['textures/feather-ghost.png'] = 1023;
179       } else {
180         let unlock = game['fn'].unlockWithKey('NIbp2kW5' + unlockable + 'e2ZDFl5Y');
181         if(unlock && unlock['type'] == 'feather') {
182           todoList['data:textures/feather-' + unlock['name']] = unlock['url'];
183         }
184       }
185     }
186     game.assets.audiothemes = [];
187     const audioThemes = {
188       'classical': [1636930, 34002, 34629, 25399, 16426, 26122],
189     }
190     for(let theme of Object.keys(audioThemes)) {
191       todoList['audio/music-' + theme + '.ogg'] = audioThemes[theme][0];
192       todoList['audio/sound1-' + theme + '.ogg'] = audioThemes[theme][1];
193       todoList['audio/sound2-' + theme + '.ogg'] = audioThemes[theme][2];
194       todoList['audio/sound3-' + theme + '.ogg'] = audioThemes[theme][3];
195       todoList['audio/sound4-' + theme + '.ogg'] = audioThemes[theme][4];
196       todoList['audio/sound5-' + theme + '.ogg'] = audioThemes[theme][5];
197       game.assets.audiothemes.push(theme);
198     }
199     let total = Object.keys(todoList).filter(k => !k.startsWith('data:')).map(k => todoList[k]).reduce((a, b) => a + b, 0);
200     let progress = {};
201     const loader = {
202       'audio': new THREE.AudioLoader(),
203       'fonts': new FontLoader(),
204       'textures': new THREE.TextureLoader(),
205     };
206     for(let todo in todoList) {
207       let isDataUri = todo.startsWith('data:');
208       if(isDataUri) {
209         todo = todo.slice(5);
210       } else {
211         progress[todo] = 0;
212       }
213       let segments = todo.split('/');
214       if(!(segments[0] in game.assets)) {
215         game.assets[segments[0]] = {};
216       }
217       if(!(segments[0] in loader)) {
218         reject('Unsupported resource: ' + todo);
219       }
220       let url = todo;
221       if(isDataUri) {
222         url = todoList['data:' + todo];
223       } else {
224         url = game['deploymentOptions']['assetUrlPrefix'] + url;
225       }
226       loader[segments[0]].load(url, (result) => {
227         if(segments[0] == 'textures') {
228           result.colorSpace = THREE.SRGBColorSpace;
229           result.minFilter = THREE.NearestFilter;
230           result.magFilter = THREE.NearestFilter;
231           if(segments[1].split('.')[0].startsWith('feather-')) {
232             result.repeat = new THREE.Vector2(1, -1);
233             result.wrapT = THREE.RepeatWrapping;
234           } else if(segments[1].split('.')[0] == 'highcontrast-backdrop') {
235             result.repeat = new THREE.Vector2(25, 25);
236             result.wrapS = THREE.RepeatWrapping;
237             result.wrapT = THREE.RepeatWrapping;
238           }
239         }
240         game.assets[segments[0]][segments[1].split('.')[0]] = result;
241         if(todo in progress) {
242           progress[todo] = todoList[todo];
243           if(renderProgressCallback) {
244             renderProgressCallback(Object.keys(progress).map(k => progress[k]).reduce((a, b) => a + b, 0) / total);
245           }
246         }
247       }, (xhr) => {
248         if(todo in progress) {
249           progress[todo] = xhr.loaded;
250           if(renderProgressCallback) {
251             renderProgressCallback(Object.keys(progress).map(k => progress[k]).reduce((a, b) => a + b, 0) / total);
252           }
253         }
254       }, (err) => {
255         reject('Error while loading ' + todo + ': ' + err);
256       });
257     }
258     const loadingHeartbeat = () => {
259       let totalProgress = Object.keys(progress).map(k => progress[k]).reduce((a, b) => a + b, 0);
260       if(totalProgress == total) {
261         resolve(totalProgress);
262       } else {
263         setTimeout(loadingHeartbeat, 100);
264       }
265     };
266     setTimeout(loadingHeartbeat, 100);
267   });
270 game['fn'].applyForceToFeather = (vector) => {
271   game.objects.feather.speed.add(vector);
274 game['fn'].initializeGame = (canvas) => {
275   game.timeProgress = 0;
276   game.timeTotal = 258;
277   game.courseRadius = 50;
279   game.objects = {};
280   game.view = {};
281   game.view.muted = false;
282   game.view.canvas = canvas;
283   game.ui.virtualInput = canvas.closest('.upInTheAirGame').querySelector('.virtual-input-widget');
285   const scene = new THREE.Scene();
286   game.view.camera = new THREE.PerspectiveCamera(75, canvas.width / canvas.height, 0.1, 1000);
287   game.view.camera.position.z = 5;
288   game.view.ambientLight = new THREE.AmbientLight(0xffffff, 2);
289   scene.add(game.view.ambientLight);
290   game.view.directionalLight1 = new THREE.DirectionalLight(0xffffff, 1);
291   game.view.directionalLight1.position.set(1, 1, 1);
292   scene.add(game.view.directionalLight1);
293   game.view.directionalLight2 = new THREE.DirectionalLight(0xffffff, 1);
294   game.view.directionalLight2.position.set(-1, -1, 1);
295   scene.add(game.view.directionalLight2);
296   game.view.directionalLight3 = new THREE.DirectionalLight(0xffffff, 1);
297   game.view.directionalLight3.position.set(0, -1, 1);
298   scene.add(game.view.directionalLight3);
299   game.view.renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: false });
300   game.view.renderer.setSize(canvas.width, canvas.height);
301   game.view.renderer.setClearColor(0x808080, 1);
302   let resolution = Math.round(3200 / Math.pow(2, game.settings['graphics']));
303   game.view.canvas.width = resolution;
304   game.view.canvas.height = resolution;
305   game.view.camera.updateProjectionMatrix();
306   game.view.renderer.setSize(game.view.canvas.width, game.view.canvas.height);
307   game.view.clock = new THREE.Clock();
308   game.view.clock.previousTime = 0;
309   game.view.clock.getDeltaTime = () => {
310     const elapsedTime = game.view.clock.getElapsedTime();
311     const deltaTime = elapsedTime - game.view.clock.previousTime;
312     game.view.clock.previousTime = elapsedTime;
313     return deltaTime;
314   };
316   const pinwheelGeometry = new THREE.BoxGeometry(.9, .9, 0.01);
317   const pinwheelMaterial = new THREE.MeshPhongMaterial({
318     map: game.assets.textures.pinwheel,
319     transparent: true,
320     alphaTest: 0.5,
321     opacity: 0.0,
322   });
323   game.objects.pinwheel = new THREE.Mesh(pinwheelGeometry, [null, null, null, null, pinwheelMaterial, null]);
324   game.objects.pinwheel.position.setY(2);
325   game.objects.pinwheel.position.setZ(-1);
326   game.objects.pinwheel.opacity = 0;
327   scene.add(game.objects.pinwheel);
329   for(let time of ['day', 'evening']) {
330     game.objects[time + 'House'] = new THREE.Group();
331     for(let layer of [1, 2, 3]) {
332       let material = new THREE.MeshBasicMaterial({
333         map: game.assets['textures']['house-' + time + '-' + layer],
334         transparent: true,
335         alphaTest: 0.5,
336       });
337       let dimensions;
338       if(layer == 1) {
339         dimensions = [14, 14, 0, 0, -2];
340       } else if(layer == 2) {
341         dimensions = [6, 3.4455, -1.4, -3.6, -3];
342       } else if(layer == 3) {
343         dimensions = [10, 10, -4, -2, -6];
344       }
345       let mesh = new THREE.Mesh(new THREE.PlaneGeometry(dimensions[0], dimensions[1]), material);
346       mesh.position.set(dimensions[2], dimensions[3], dimensions[4]);
347       if(time == 'evening') {
348         mesh.position.setX(-mesh.position.x);
349       }
350       game.objects[time + 'House'].add(mesh);
351     }
352     game.objects[time + 'House'].position.set(-11.5, -game.courseRadius - 4, -7);
353     if(time == 'evening') {
354       game.objects[time + 'House'].position.x *= -1;
355     }
356   }
358   game.view.camera.position.set(-5, -game.courseRadius, game.view.camera.position.z);
359   game.view.scene = scene;
361   game['fn'].createMeshes();
362   game['fn'].createFeather();
363   game['fn'].reset();
365   function pinwheelPositionUpdate(game, viewportX, viewportY) {
366     const vFOV = THREE.MathUtils.degToRad(game.view.camera.fov);
367     const viewportHeight = 2 * Math.tan(vFOV / 2) * (game.view.camera.position.z - game.objects.pinwheel.position.z);
368     game.controls.positionX = viewportHeight * viewportX;
369     game.controls.positionY = - viewportHeight * viewportY;
370   }
372   function cursorMoveEvent(game, target, viewportLocalX, viewportLocalY, pressed) {
373     if(game.settings['controls'] == 'mouse' || game.settings['controls'] == 'touchpad') {
374       let sensorElem = game.view.canvas;
375       if(game.settings['controls'] == 'touchpad') {
376         sensorElem = game.ui.virtualInput;
377       }
378       let bbox = sensorElem.getBoundingClientRect();
379       // Intentional division by height instead of width in the following line, since
380       // three.js controls the vertical FOV. So if we ever change the aspect ratio from 1:1,
381       // y will still be in range (-0.5, 0.5), but the range for x will be smaller or larger.
382       let x = (viewportLocalX - bbox.x - (bbox.width / 2)) / bbox.height;
383       let y = (viewportLocalY - bbox.y - (bbox.height / 2)) / bbox.height;
384       if(game.settings['controls'] == 'touchpad') {
385         sensorElem.children[0].style.left = ((0.5 + x) * 100) + '%';
386         sensorElem.children[0].style.top = ((0.5 + y) * 100) + '%';
387       }
388       if(game.settings['controls'] == 'touchpad') {
389         x *= 1.05;
390         y *= 1.05;
391       }
392       // The pinwheel gets to go a little bit past the edge of the playing field.
393       const maxDist = 0.55;
394       if(game.settings['controls'] == 'mouse' || (pressed && Math.abs(x) <= maxDist && Math.abs(y) <= maxDist)) {
395         pinwheelPositionUpdate(game, x, y);
396       }
397     }
398     if(game.settings['controls'] == 'thumbstick') {
399       if(!game.ui.virtualInput.inProgress) {
400         return;
401       }
402       let bbox = game.ui.virtualInput.getBoundingClientRect();
403       let x, y;
404       if(pressed) {
405         x = (viewportLocalX - bbox.x - (bbox.width / 2)) / bbox.height;
406         y = (viewportLocalY - bbox.y - (bbox.height / 2)) / bbox.height;
407         let vLen = Math.sqrt(4 * x * x + 4 * y * y);
408         x = x / Math.max(vLen, 0.6);
409         y = y / Math.max(vLen, 0.6);
410       } else {
411         x = 0;
412         y = 0;
413       }
414       let speedScale = 7.0;
415       let deadZone = 0.2;
416       let speedX = x * 2;
417       if(Math.abs(speedX) < deadZone) {
418         speedX = 0.0;
419       }
420       let speedY = y * 2;
421       if(Math.abs(speedY) < deadZone) {
422         speedY = 0.0;
423       }
424       game.controls.speedX = speedScale * speedX;
425       game.controls.speedY = -1 * speedScale * speedY;
426       x *= 0.6;
427       y *= 0.6;
428       game.ui.virtualInput.children[0].style.left = ((0.5 + x) * 100) + '%';
429       game.ui.virtualInput.children[0].style.top = ((0.5 + y) * 100) + '%';
430     }
431   }
433   function keyboardEvent(game, key, motion) {
434     if(game.settings['controls'] != 'keyboard') {
435       return;
436     }
437     if(motion == 'down') {
438       if(game.controls.heldKeys.includes(key)) {
439         return;
440       }
441       game.controls.heldKeys.push(key);
442     } else {
443       if(game.controls.heldKeys.includes(key)) {
444         game.controls.heldKeys.splice(game.controls.heldKeys.indexOf(key), 1);
445       }
446     }
447     if(game.settings['keyboard']['tapmode']) {
448       if(motion != 'down') {
449         return;
450       }
451       if(game.settings['keyboard']['up'].includes(key)) {
452         game.controls.speedY = Math.max(0.0, game.controls.speedY + 2.0);
453       }
454       if(game.settings['keyboard']['down'].includes(key)) {
455         game.controls.speedY = Math.min(0.0, game.controls.speedY - 2.0);
456       }
457       if(game.settings['keyboard']['right'].includes(key)) {
458         game.controls.speedX = Math.max(0.0, game.controls.speedX + 2.0);
459       }
460       if(game.settings['keyboard']['left'].includes(key)) {
461         game.controls.speedX = Math.min(0.0, game.controls.speedX - 2.0);
462       }
463       return;
464     }
465     if(motion == 'down' && game.settings['keyboard']['up'].includes(key)) {
466       game.controls.accelY = 15.0;
467       game.controls.speedY = 0.0;
468     }
469     if(motion == 'down' && game.settings['keyboard']['down'].includes(key)) {
470       game.controls.accelY = -15.0;
471       game.controls.speedY = 0.0;
472     }
473     if(motion == 'down' && game.settings['keyboard']['right'].includes(key)) {
474       game.controls.accelX = 15.0;
475       game.controls.speedX = 0.0;
476     }
477     if(motion == 'down' && game.settings['keyboard']['left'].includes(key)) {
478       game.controls.accelX = -15.0;
479       game.controls.speedX = 0.0;
480     }
481     if(motion == 'up' && game.settings['keyboard']['up'].includes(key)) {
482       game.controls.accelY = Math.min(0.0, game.controls.accelY);
483       game.controls.speedY = Math.min(0.0, game.controls.speedY);
484     }
485     if(motion == 'up' && game.settings['keyboard']['down'].includes(key)) {
486       game.controls.accelY = Math.max(0.0, game.controls.accelY);
487       game.controls.speedY = Math.max(0.0, game.controls.speedY);
488     }
489     if(motion == 'up' && game.settings['keyboard']['right'].includes(key)) {
490       game.controls.accelX = Math.min(0.0, game.controls.accelX);
491       game.controls.speedX = Math.min(0.0, game.controls.speedX);
492     }
493     if(motion == 'up' && game.settings['keyboard']['left'].includes(key)) {
494       game.controls.accelX = Math.max(0.0, game.controls.accelX);
495       game.controls.speedX = Math.max(0.0, game.controls.speedX);
496     }
497   }
499   document.body.addEventListener('mousemove', e => cursorMoveEvent(game, e.target, e.clientX, e.clientY, (e.buttons % 2 == 1)));
500   document.body.addEventListener('mousedown', e => {
501     if(game.settings['controls'] == 'touchpad' || game.settings['controls'] == 'thumbstick') {
502       if(e.target.closest('.virtual-input-widget') == game.ui.virtualInput) {
503         game.ui.virtualInput.inProgress = true;
504         game.ui.virtualInput.children[0].style.display = 'block';
505         e.preventDefault();
506       } else {
507         game.ui.virtualInput.inProgress = false;
508         if(game.settings['controls'] == 'touchpad') {
509           game.ui.virtualInput.children[0].style.display = 'none';
510         }
511       }
512     }
513     cursorMoveEvent(game, e.target, e.clientX, e.clientY, (e.buttons % 2 == 1));
514   });
515   document.body.addEventListener('mouseup', e => {
516     cursorMoveEvent(game, e.target, e.clientX, e.clientY, (e.buttons % 2 == 1));
517     if(game.settings['controls'] == 'touchpad' || game.settings['controls'] == 'thumbstick') {
518       game.ui.virtualInput.inProgress = false;
519       if(game.settings['controls'] == 'touchpad') {
520         game.ui.virtualInput.children[0].style.display = 'none';
521       } else {
522         game.ui.virtualInput.children[0].style.transitionDuration = '50ms';
523         setTimeout(() => { game.ui.virtualInput.children[0].style.transitionDuration = '0ms'; }, 75);
524       }
525       cursorMoveEvent(game, e.target, 0, 0, false);
526     }
527   });
528   document.body.addEventListener('touchmove', e => cursorMoveEvent(game, e.target, e.touches[0].clientX, e.touches[0].clientY, true));
529   document.body.addEventListener('touchstart', e => {
530     if(game.settings['controls'] == 'touchpad' || game.settings['controls'] == 'thumbstick') {
531       if(e.target.closest('.virtual-input-widget') == game.ui.virtualInput) {
532         game.ui.virtualInput.inProgress = true;
533         game.ui.virtualInput.children[0].style.display = 'block';
534         e.preventDefault();
535       } else {
536         game.ui.virtualInput.inProgress = false;
537         if(game.settings['controls'] == 'touchpad') {
538           game.ui.virtualInput.children[0].style.display = 'none';
539         }
540       }
541     }
542     cursorMoveEvent(game, e.target, e.touches[0].clientX, e.touches[0].clientY, true);
543   });
544   document.body.addEventListener('touchend', e => {
545     if(e.target.closest('.ui-container') && game.settings['controls'] != 'mouse' && ['gameplay', 'openingcutscene', 'endingcutscene'].includes(game.ui.currentPage)) {
546       game['fn'].moveToPage('pause', true);
547       e.preventDefault();
548     }
549     if(game.settings['controls'] == 'touchpad' || game.settings['controls'] == 'thumbstick') {
550       game.ui.virtualInput.inProgress = false;
551       if(game.settings['controls'] == 'touchpad') {
552         game.ui.virtualInput.children[0].style.display = 'none';
553       } else {
554         game.ui.virtualInput.children[0].style.transitionDuration = '50ms';
555         setTimeout(() => { game.ui.virtualInput.children[0].style.transitionDuration = '0ms'; }, 75);
556       }
557       cursorMoveEvent(game, e.target, 0, 0, false);
558     }
559   });
560   document.body.addEventListener('keydown', e => keyboardEvent(game, e.key, 'down'));
561   document.body.addEventListener('keyup', e => keyboardEvent(game, e.key, 'up'));
563   // All vectors used by the game loop (no allocations inside)
564   game.var = {};
565   game.var.featherLocalPos = new THREE.Vector3();
566   game.var.featherBorderForce = new THREE.Vector3();
567   game.var.pinwheelDistance = new THREE.Vector3();
568   game.var.pinwheelRotationSpeed = 0;
569   game.var.notCollectedPos = new THREE.Vector3();
570   game.var.collectedPos = new THREE.Vector3();
571   game.var.endingEntryTrajectory = new THREE.Vector3();
572   game.var.endingExitTrajectory = new THREE.Vector3();
573   game.var.endingEntryRotation = new THREE.Vector3();
574   game.var.endingExitRotation = new THREE.Vector3();
575   game.view.renderer.setAnimationLoop(() => { game['fn'].animate(scene); });
578 game['fn'].prepareWordMesh = (word) => {
579   while(word.children.length > 0) {
580     word.remove(word.children[0]);
581   }
582   if(game.settings['graphics'] <= 2 && !game.settings['highcontrast']) {
583     for(let letter of word.text) {
584       let geometry = game.assets.fonts.geometry[letter];
585       let material = game.view.materials.letter;
586       if(geometry.customMaterial) {
587         material = geometry.customMaterial;
588       }
589       let mesh = new THREE.Mesh(geometry, material);
590       // We wrap each letter in a surrounding group in order to move the center point
591       // from the corner of the letter to its center. This makes rotations easier.
592       let container = new THREE.Group();
593       mesh.position.set(-game.assets.fonts.geometry[letter].dx, -game.assets.fonts.geometry[letter].dy, 0);
594       container.add(mesh);
595       word.add(container);
596     }
597   } else if(game.settings['graphics'] == 3 || game.settings['highcontrast']) {
598     let mesh = new THREE.Mesh(Object.values(game.assets.fonts.geometry)[0], game.view.materials.letter);
599     word.add(mesh);
600   }
603 game['fn'].reset = () => {
604   game.controls = {};
605   game.controls.positionX = 0;
606   game.controls.positionY = 0;
607   game.controls.speedX = 0;
608   game.controls.speedY = 0;
609   game.controls.accelX = 0;
610   game.controls.accelY = 0;
611   game.controls.heldKeys = [];
612   game.ui.reachedEnd = false;
613   if(game.settings['graphics'] <= 2 && !game.settings['highcontrast']) {
614     for(let i = 0; i < 6; i++) {
615      game.view.materials['cloud' + i].uniforms.lerp.value = 0.0;
616     }
617   }
618   game.view.scene.add(game.objects.dayHouse);
619   game.view.scene.remove(game.objects.eveningHouse);
620   game.objects.feather.position.set(-11.45, -game.courseRadius - 4.2, -9.9);
621   game.objects.feather.rotation.set(Math.PI, 0, Math.PI / 2.1);
622   game.objects.pinwheel.material[4].opacity = 0.0;
623   setTimeout(() => {
624     game.ui.root.querySelector('.ui-page.title').classList.remove('end');
625   }, 500);
627   if(game.objects.words) {
628     for(let word of game.objects.words) {
629       game.view.scene.remove(word);
630     }
631   }
632   game.objects.words = [];
633   game.objects.words.collectedCount = 0;
634   const interWordDistance = new THREE.Vector3();
635   let placementSuccess;
636   let wordList = [];
637   for(let i = 0; i < 100; i++) {
638     let angleInCourse;
639     let clusteringFunction = (val) => Math.max(0.15 + val / 2, 0.7 - 3 * Math.pow(0.75 - 2 * val, 2), 1 - 3 * Math.pow(1 - val, 2));
640     do {
641       angleInCourse = Math.random();
642     } while(clusteringFunction(angleInCourse) < Math.random());
643     angleInCourse = (0.08 + 0.87 * angleInCourse);
644     if(i == 0) {
645       angleInCourse = 0.05;
646     }
647     let randomCameraX = game.courseRadius * Math.sin(angleInCourse * 2 * Math.PI);
648     let randomCameraY = game.courseRadius * -Math.cos(angleInCourse * 2 * Math.PI);
649     let word = new THREE.Group();
650     if(wordList.length == 0) {
651       wordList.push(...game.assets.wordList);
652     }
653     let wordIndex = Math.floor(Math.random() * wordList.length);
654     word.text = wordList.splice(wordIndex, 1)[0];
655     game['fn'].prepareWordMesh(word);
656     word.randomAnimOffset = Math.random();
657     const vFOV = THREE.MathUtils.degToRad(game.view.camera.fov);
658     let attempts = 0;
659     do {
660       let randomPlacementRadius = Math.min(0.8, angleInCourse) * Math.tan(vFOV / 2) * Math.abs(word.position.z - game.view.camera.position.z);
661       if(i == 0) {
662         randomPlacementRadius = 0;
663       }
664       let randomPlacementAngle = Math.random() * 2 * Math.PI;
665       let randomPlacementX = Math.sin(randomPlacementAngle) * randomPlacementRadius;
666       let randomPlacementY = Math.cos(randomPlacementAngle) * randomPlacementRadius;
667       word.position.set(randomCameraX + randomPlacementX, randomCameraY + randomPlacementY, 0);
668       placementSuccess = true;
669       for(let j = 0; j < i; j++) {
670         if(interWordDistance.subVectors(word.position, game.objects.words[j].position).length() <= 1.2) {
671           placementSuccess = false;
672           break;
673         }
674       }
675       attempts += 1;
676       if(attempts >= 10) {
677         angleInCourse = 0.04 + 0.92 * Math.random();
678         attempts = 0;
679       }
680     } while(!placementSuccess);
681     game.view.scene.add(word);
682     game.objects.words.push(word);
683   }
686 game['fn'].animate = (scene) => {
687   if(!('startTime' in game)) {
688     game.startTime = game.view.clock.getElapsedTime();
689   }
690   if(game.ui.currentPage == 'pause') {
691     return;
692   }
693   let delta = Math.min(game.view.clock.getDeltaTime(), 1 / 12);
694   if(game.ui.currentPage == 'gameplay') {
695     delta = delta * (game.settings['difficulty']['speed'] / 100);
696   }
697   game.timeProgress = (game.timeProgress + delta);
699   if(game.settings['controls'] == 'gamepad') {
700     let speedScale = 7.0;
701     let deadZone = 0.2;
702     let speedX = [];
703     let speedY = [];
704     for(let gamepad of game.ui.gamepads) {
705       let sx = gamepad.axes[0];
706       if(Math.abs(sx) < deadZone) {
707         sx = 0.0;
708       }
709       speedX.push(sx);
710       let sy = gamepad.axes[1];
711       if(Math.abs(sy) < deadZone) {
712         sy = 0.0;
713       }
714       speedY.push(sy);
715     }
716     if(speedX.length > 0) {
717       speedX = speedX.reduce((s, a) => s + a, 0) / speedX.length;
718     } else {
719       speedX = 0;
720     }
721     if(speedY.length > 0) {
722       speedY = speedY.reduce((s, a) => s + a, 0) / speedY.length;
723     } else {
724       speedY = 0;
725     }
726     game.controls.speedX = speedScale * speedX;
727     game.controls.speedY = -1 * speedScale * speedY;
728   }
730   const maxPinwheelSpeed = 8.0;
731   const maxPinwheelDistance = 5.0;
732   game.controls.speedX += delta * game.controls.accelX;
733   game.controls.speedY += delta * game.controls.accelY;
734   game.controls.speedX = Math.min(maxPinwheelSpeed, Math.max(-maxPinwheelSpeed, game.controls.speedX));
735   game.controls.speedY = Math.min(maxPinwheelSpeed, Math.max(-maxPinwheelSpeed, game.controls.speedY));
736   game.controls.positionX += delta * game.controls.speedX;
737   game.controls.positionY += delta * game.controls.speedY;
738   if(game.controls.positionX > maxPinwheelDistance) {
739     game.controls.positionX = maxPinwheelDistance;
740     game.controls.speedX = Math.max(0.0, game.controls.speedX);
741     game.controls.accelX = Math.max(0.0, game.controls.accelX);
742   } else if(game.controls.positionX < -maxPinwheelDistance) {
743     game.controls.positionX = -maxPinwheelDistance;
744     game.controls.speedX = Math.min(0.0, game.controls.speedX);
745     game.controls.accelX = Math.min(0.0, game.controls.accelX);
746   }
747   if(game.controls.positionY > maxPinwheelDistance) {
748     game.controls.positionY = maxPinwheelDistance;
749     game.controls.speedY = Math.max(0.0, game.controls.speedY);
750     game.controls.accelY = Math.max(0.0, game.controls.accelY);
751   } else if(game.controls.positionY < -maxPinwheelDistance) {
752     game.controls.positionY = -maxPinwheelDistance;
753     game.controls.speedY = Math.min(0.0, game.controls.speedY);
754     game.controls.accelY = Math.min(0.0, game.controls.accelY);
755   }
757   if(game.ui.currentPage != 'gameplay') {
758     let cameraSwayFactor = 1;
759     if(game.settings['graphics'] == 3) {
760       cameraSwayFactor = 0;
761     }
762     let cameraX, cameraY;
763     if(['options', 'credits', 'outro', 'unlock'].includes(game.ui.currentPage)) {
764       cameraX = game.ui.reachedEnd ? 5 : -5;
765       cameraY = 0;
766     }
767     if(game.ui.currentPage == 'title') {
768       cameraX = -5;
769       cameraY = 0;
770       if(!game.ui.reachedEnd) {
771         game.objects.feather.position.set(cameraX - 8.45, -game.courseRadius - 6.4, -9.9);
772         game.objects.feather.rotation.set(Math.PI, 0, Math.PI / 2.1);
773       } else {
774         cameraX = 5;
775       }
776       if(!game.ui.reachedStart) {
777         if(game.timeProgress < 1) {
778           game.ui.root.querySelector('.ui-page.title h1').style.opacity = '0';
779           game.ui.root.querySelectorAll('.ui-page.title > button').forEach((btn) => {
780             btn.disabled = true;
781             btn.style.position = 'relative';
782             btn.style.left = '10em';
783             btn.style.opacity = '0';
784           });
785           game.ui.root.querySelector('.ui-page.title .footer').style.opacity = '0';
786           game.ui.root.querySelector('.ui-page.title .system-buttons').style.opacity = '0';
787           cameraX += Math.max(0.0, 1 - game['fn'].easeInOut(0.5 + game.timeProgress / 2));
788           cameraY += Math.max(0.0, 10 * Math.pow(0.5 - game.timeProgress / 2, 2));
789         } else if(game.timeProgress >= 1.0 && game.timeProgress <= 2.1) {
790           game.ui.root.querySelector('.ui-page.title h1').style.opacity = Math.min(1.0, game.timeProgress - 1.0).toFixed(2);
791         }
792         if(game.timeProgress >= 1.5 && game.timeProgress <= 3.0) {
793           game.ui.root.querySelectorAll('.ui-page.title > button').forEach((btn) => {
794             let timeOffset = Array.from(btn.parentNode.children).indexOf(btn) - 2;
795             btn.style.left = 10 * game['fn'].easeInOut(Math.max(0.0, Math.min(1.0, 0.3 * timeOffset + 2.5 - game.timeProgress))).toFixed(2) + 'em';
796             let opacity = game['fn'].easeInOut(Math.max(0.0, Math.min(1.0, -0.3 * timeOffset + game.timeProgress - 1.5)));
797             btn.style.opacity = opacity.toFixed(2);
798             if(opacity == 1.0) {
799               btn.disabled = false;
800             }
801           });
802         }
803         if(game.timeProgress >= 3.0 && game.timeProgress <= 4.0) {
804           game.ui.root.querySelector('.ui-page.title .footer').style.opacity = game['fn'].easeInOut(Math.max(0.0, Math.min(1.0, game.timeProgress - 3.0))).toFixed(2);
805           game.ui.root.querySelector('.ui-page.title .system-buttons').style.opacity = game['fn'].easeInOut(Math.max(0.0, Math.min(1.0, game.timeProgress - 3.0))).toFixed(2);
806         }
807         if(game.timeProgress > 4.0 && !game.ui.reachedStart) {
808           game.ui.root.querySelector('.ui-page.title h1').removeAttribute('style');
809           game.ui.root.querySelectorAll('.ui-page.title > button').forEach(btn => { btn.disabled = false; btn.removeAttribute('style'); });
810           game.ui.root.querySelector('.ui-page.title .footer').removeAttribute('style');
811           game.ui.root.querySelector('.ui-page.title .system-buttons').removeAttribute('style');
812           game.ui.reachedStart = true;
813         }
814       }
815     } else if(game.ui.currentPage == 'openingcutscene') {
816       cameraX = -5;
817       cameraY = 0;
818       if(game.ui.reachedEnd) {
819         game['fn'].reset();
820       }
821       if(game.timeProgress < 0.1 && !game.view.windSound.isPlaying) {
822         game.view.windSound.stop();
823         game.objects.feather.position.set(cameraX - 8.45, -game.courseRadius - 6.4, -9.9);
824         game.objects.feather.rotation.set(Math.PI, 0, Math.PI / 2.1);
825       }
826       if(game.timeProgress > 1.0 && game.timeProgress <= 1.1 && !game.ui.root.querySelector('.gameplay p')) {
827         const lines = ['Why are the simplest words…', '…often the most difficult to write?'];
828         for(let line of lines) {
829           let elem = document.createElement('p');
830           elem.innerText = line;
831           elem.style.left = (1.5 + 2 * lines.indexOf(line)) + 'em';
832           elem.style.top = (1 + 1.2 * lines.indexOf(line)) + 'em';
833           elem.style.opacity = '0';
834           document.querySelector('.ui-page.gameplay').appendChild(elem);
835         }
836         if(!game.view.windSound.isPlaying) {
837           game.view.windSound.setVolume(game.settings['audio']['sounds']);
838           game.view.windSound.offset = 0;
839           if(!game.view.muted) {
840             game.view.windSound.play();
841           }
842         }
843       }
844       if(game.timeProgress > 1.0 && game.timeProgress <= 5.0) {
845         game.ui.root.querySelectorAll('.ui-page.gameplay p').forEach((elem) => {
846           let opacity = Math.max(0.0, Math.min(1.0, game.timeProgress - (elem.nextSibling ? 1.0 : 3.5)));
847           elem.style.transform = 'translateX(' + (Math.pow(1.0 - opacity, 2) * 10).toFixed(2) + 'em)';
848           elem.style.opacity = opacity.toFixed(2);
849         });
850       }
851       if(game.timeProgress >= 1.0 && game.timeProgress <= 3.0) {
852         let windStrength = 0.5 * (1 + Math.cos((game.timeProgress - 2.0) * Math.PI));
853         game.objects.feather.position.x = cameraX - 8.45 + 0.15 * windStrength;
854         game.objects.feather.rotation.z = Math.PI / 2.1 - 0.2 * windStrength;
855       }
856       if(game.timeProgress >= 3.0) {
857         let windStrength = Math.max(0.5 * (1 + Math.cos((game.timeProgress - 4.0) * Math.PI)), Math.min(2 * (game.timeProgress - 4.2), 1.2));
858         game.objects.feather.position.x = cameraX - 8.45 + 0.15 * windStrength + 13.45 * Math.min(1.0, Math.max(0.0, 1 - Math.pow(Math.max(0.0, 6.0 - game.timeProgress), 2)));
859         game.objects.feather.position.y = -game.courseRadius - 6.4 + 6.4 * game['fn'].easeInOut(Math.min(1,Math.max(0, game.timeProgress - 5.0) / 2));
860         game.objects.feather.position.y += (Math.cos((Math.max(5.0, Math.min(8.0, game.timeProgress)) - 5.0) * 2 * Math.PI / 3) - 1) * 2 * Math.sin(game.timeProgress * 2);
861         game.objects.feather.position.z = -9.9 + 9.9 * Math.min(1.0, Math.max(0.0, 1 - Math.pow(Math.max(0.0, 6.0 - game.timeProgress), 2)));
862         game.objects.feather.rotation.z = Math.PI / 2.1 - 0.2 * windStrength + 1.45 * Math.max(0.0, game.timeProgress - 5.0);
863         game.objects.feather.rotation.x = Math.PI + Math.max(0.0, game.timeProgress - 4.5) * Math.sin(Math.pow(game.timeProgress - 4.5, 2));
864       }
865       if(game.timeProgress > 7.0) {
866         game.ui.root.querySelectorAll('.ui-page.gameplay p').forEach((elem) => {
867           let opacity = Math.max(0.0, Math.min(1.0, 8.0 - game.timeProgress));
868           elem.style.opacity = opacity.toFixed(2);
869         });
870       }
871       cameraSwayFactor = cameraSwayFactor * (1 - (game.timeProgress / 8));
872       cameraX = -5 + Math.pow(Math.max(0, game.timeProgress - 3) / 5, 1.6) * 5;
874       if(game.timeProgress > 6.0 && game.timeProgress < 7.0 && game.settings['controls'] != 'mouse') {
875         game.controls.positionX = 0;
876         game.controls.positionY = -3;
877       }
878       game.objects.pinwheel.material[4].opacity = game['fn'].easeInOut(Math.max(0, (game.timeProgress - 7)));
879       if(game.timeProgress >= 8.0) {
880         game.ui.root.querySelectorAll('.ui-page.gameplay p').forEach((elem) => {
881           let opacity = Math.max(0.0, Math.min(1.0, 8.0 - game.timeProgress));
882           elem.style.opacity = opacity.toFixed(2);
883         });
884         game.view.music.offset = 0;
885         if(!game.view.muted) {
886           game.view.music.play();
887         }
888         game['fn'].moveToPage('gameplay', true);
889       }
890     } else if(game.ui.currentPage == 'endingcutscene') {
891       cameraX = -5;
892       cameraY = 0;
893       cameraSwayFactor = cameraSwayFactor * game.timeProgress / 8;
894       cameraX = 5 - Math.pow(Math.max(0, 5 - game.timeProgress) / 5, 1.6) * 5;
895       let trajectoryLerpValue = game['fn'].easeInOut(Math.min(6.0, game.timeProgress) / 6);
896       game.var.endingEntryTrajectory.addScaledVector(game.objects.feather.speed, delta);
897       game.var.endingExitTrajectory.setX(11.2 * game['fn'].easeInOut(Math.min(1, 1 - Math.max(0, 4 - game.timeProgress) / 4)));
898       game.var.endingExitTrajectory.setY(-game.courseRadius - 7.6 * game['fn'].easeInOut(Math.min(1, 1 - Math.max(0, 4 - game.timeProgress) / 4)));
899       game.var.endingExitTrajectory.setZ(-8.9 * game['fn'].easeInOut(Math.min(1, 1 - Math.max(0, 4 - game.timeProgress) / 4)));
900       game.objects.feather.rotation.x = game['fn'].lerp(game.var.endingEntryRotation.x, game.var.endingExitRotation.x, trajectoryLerpValue);
901       game.objects.feather.rotation.y = game['fn'].lerp(game.var.endingEntryRotation.y, game.var.endingExitRotation.y, trajectoryLerpValue);
902       game.objects.feather.rotation.z = game['fn'].lerp(game.var.endingEntryRotation.z, game.var.endingExitRotation.z, trajectoryLerpValue);
903       game.objects.feather.position.lerpVectors(game.var.endingEntryTrajectory, game.var.endingExitTrajectory, trajectoryLerpValue);
904       game.objects.pinwheel.material[4].opacity = game['fn'].easeInOut(Math.max(0, (1 - game.timeProgress)));
905       if(!game.settings['highcontrast']) {
906         let letterScale = game['fn'].lerp(0.3, 0.0, game['fn'].easeInOut(Math.max(0, Math.min(1, game.timeProgress - 6))));
907         for(let i = 0; i < game.objects.words.length; i++) {
908           let word = game.objects.words[i];
909           if(!word.collected) {
910             continue;
911           }
912           let x, y, z;
913           let collectionAnimationDuration = 1.0;
914           for(let j = 0; j < word.children.length; j++) {
915             let letter = word.children[j];
916             let animationProgress = (((game.timeProgress + 5 * word.randomAnimOffset) % 5) / 5 + j / 37) % 1;
917             x = game.objects.feather.scale.x * 0.5 * Math.cos(animationProgress * 1 * Math.PI * 2);
918             y = 0.2 * Math.sin(animationProgress * 7 * Math.PI * 2);
919             z = 0.2 * Math.cos(animationProgress * 7 * Math.PI * 2);
920             x = x * Math.cos(game.objects.feather.rotation.z) - y * Math.sin(game.objects.feather.rotation.z);
921             y = y * Math.cos(game.objects.feather.rotation.z) + x * Math.sin(game.objects.feather.rotation.z);
922             x += game.objects.feather.position.x - word.position.x;
923             y += game.objects.feather.position.y - word.position.y;
924             z += game.objects.feather.position.z - word.position.z;
925             if(i == 0 && !word.collected) {
926               // If we don't catch this edge case here, the manually placed first word might be visible
927               // in the closing cutscene if it is not collected.
928               x = 9999;
929             }
930             letter.position.set(x, y, z);
931             let rotation = (game.timeProgress * 3 + 2 * Math.PI * word.randomAnimOffset) % (2 * Math.PI);
932             letter.rotation.set(rotation + j, rotation + 2*j, rotation + 3*j);
933             letter.scale.set(letterScale, letterScale, letterScale);
934           }
935         }
936       }
937       if(game.timeProgress >= 8) {
938         game.ui.root.querySelector('.ui-page.title').classList.add('end');
939         game['fn'].moveToPage('outro', true);
940       }
941     } else if(game.ui.reachedEnd) {
942       cameraX = 5;
943       cameraY = 0;
944     }
945     if(typeof(cameraX) == 'number' && typeof(cameraY) == 'number') {
946       game.view.camera.position.setY(cameraY - game.courseRadius + 0.07 * cameraSwayFactor * Math.sin(game.view.clock.getElapsedTime() * 0.5));
947       game.view.camera.position.setX(cameraX + 0.05 * cameraSwayFactor * Math.sin(game.view.clock.getElapsedTime() * 0.7));
948     }
949     game.view.renderer.render(scene, game.view.camera);
950     return;
951   }
952   if(game.ui.root.querySelector('.ui-page.gameplay p')) {
953     game.ui.root.querySelectorAll('.ui-page.gameplay p').forEach(elem => elem.remove());
954   }
955   if(game.timeProgress / game.timeTotal >= 1.0) {
956     game.ui.reachedEnd = true;
957     game.var.endingEntryTrajectory.set(game.objects.feather.position.x, game.objects.feather.position.y, game.objects.feather.position.z);
958     game.var.endingEntryRotation.x = game.objects.feather.rotation.x;
959     game.var.endingEntryRotation.y = game.objects.feather.rotation.y;
960     game.var.endingEntryRotation.z = game.objects.feather.rotation.z;
961     game.var.endingExitRotation.set(-0.2, 0, -0.2);
962     game['fn'].moveToPage('endingcutscene', true);
963   }
965   if(game.settings['audio']['music'] > 0.0 && game.view.music && !game.view.music.isPlaying) {
966     const remainingRealTime = (game.timeTotal - game.timeProgress) / (game.settings['difficulty']['speed'] / 100);
967     if(remainingRealTime >= game.assets['audio']['music-' + game.settings['audio']['theme']].duration - 2) {
968       game.view.music.offset = 0;
969       if(!game.view.muted) {
970         game.view.music.play();
971       }
972     }
973   }
975   const angle = 2 * Math.PI * (game.timeProgress / game.timeTotal);
976   game.view.camera.position.x = game.courseRadius * Math.sin(angle);
977   game.view.camera.position.y = - game.courseRadius * Math.cos(angle);
979   if(!game.settings['highcontrast']) {
980     let sunsetValue = 2.0;
981     if(!game.ui.reachedEnd) {
982       sunsetValue = sunsetValue * game['fn'].easeInOut(Math.min(1, Math.max(0, ((game.timeProgress / game.timeTotal) - 0.3) / 0.6)));
983     }
984     if(game.settings['graphics'] <= 2) {
985       for(let i = 0; i < 6; i++) {
986         game.view.materials['cloud' + i].uniforms.lerp.value = sunsetValue;
987       }
988     } else {
989       let cloudMaterialVariant = null;
990       if(sunsetValue < 0.5 && !game.objects.clouds.children[0].material.name.endsWith('a')) {
991         cloudMaterialVariant = 'a';
992       } else if(sunsetValue >= 0.5 && sunsetValue < 1.5 && !game.objects.clouds.children[0].material.name.endsWith('b')) {
993         cloudMaterialVariant = 'b';
994       } else if(sunsetValue >= 1.5 && !game.objects.clouds.children[0].material.name.endsWith('c')) {
995         cloudMaterialVariant = 'c';
996       }
997       if(cloudMaterialVariant) {
998         for(let cloud of game.objects.clouds.children) {
999           cloud.material = game.view.materials[cloud.material.name.slice(0, -1) + cloudMaterialVariant];
1000         }
1001         game.objects.backdrop.material = game.view.materials['cloud0' + cloudMaterialVariant];
1002       }
1003     }
1004   }
1006   if(game.timeProgress / game.timeTotal > 0.5 && game.objects.dayHouse.parent == scene) {
1007     scene.remove(game.objects.dayHouse);
1008     scene.add(game.objects.eveningHouse);
1009   }
1011   game.var.featherLocalPos.subVectors(game.objects.feather.position, game.view.camera.position).setZ(0);
1012   game.var.featherBorderForce.set(0, 0, 0);
1013   for(let coord of [0, 1]) {
1014     if(Math.abs(game.var.featherLocalPos.getComponent(coord)) > 3) {
1015       game.var.featherBorderForce.setComponent(coord, 3 * Math.sign(game.var.featherLocalPos.getComponent(coord)) - game.var.featherLocalPos.getComponent(coord));
1016     }
1017   }
1018   game['fn'].applyForceToFeather(game.var.featherBorderForce);
1019   const tiltedGravity = game.gravity.clone();
1020   game.var.pinwheelDistance.subVectors(game.objects.feather.position, game.objects.pinwheel.position).setZ(0);
1022   const pinwheelRotationTargetSpeed = 1 + Math.max(0, 2 - game.var.pinwheelDistance.length());
1023   game.var.pinwheelRotationSpeed = Math.max(1, pinwheelRotationTargetSpeed, game.var.pinwheelRotationSpeed - 2 * delta);
1024   game.objects.pinwheel.rotation.z -= game.var.pinwheelRotationSpeed * 5 * delta;
1025   game.objects.pinwheel.position.x = game.view.camera.position.x + game.controls.positionX;
1026   game.objects.pinwheel.position.y = game.view.camera.position.y + game.controls.positionY;
1028   const pinwheelForce = 0.5 * Math.max(0, Math.pow(game.var.pinwheelDistance.length(), - 0.5) - 0.5);
1029   game['fn'].applyForceToFeather(game.var.pinwheelDistance.normalize().multiplyScalar(pinwheelForce));
1030   if(pinwheelForce < 0.2) {
1031     if(game.objects.feather.swayDirection > 0 && game.objects.feather.speed.x > 1.5) {
1032       game.objects.feather.swayDirection *= -1;
1033     } else if(game.objects.feather.swayDirection < 0 && game.objects.feather.speed.x < -1.5) {
1034       game.objects.feather.swayDirection *= -1;
1035     }
1036     tiltedGravity.x += game.objects.feather.swayDirection;
1037   }
1038   if(game.objects.feather.speed.y > -1) {
1039     game['fn'].applyForceToFeather(tiltedGravity);
1040   }
1041   game.objects.feather.rotation.z = -0.1 * game.objects.feather.speed.x * game.settings['difficulty']['speed'] / 100;
1042   game.objects.feather.position.addScaledVector(game.objects.feather.speed, delta * game.settings['difficulty']['speed'] / 100);
1044   if(pinwheelForce > 0.2) {
1045     if(game.objects.feather.twistSpeed < 0.0001) {
1046       game.objects.feather.twistSpeed = (Math.random() - 0.5) * 0.01;
1047     }
1048     game.objects.feather.twistSpeed = Math.sign(game.objects.feather.twistSpeed) * 0.1 * game.objects.feather.speed.length();
1049   } else {
1050     game.objects.feather.twistSpeed = 0.98 * game.objects.feather.twistSpeed;
1051     if(Math.abs(game.objects.feather.twistSpeed < 0.1)) {
1052       let rotationDelta = game.objects.feather.rotation.x;
1053       if(rotationDelta >= Math.PI) {
1054         rotationDelta -= 2 * Math.PI;
1055       }
1056       game.objects.feather.twistSpeed -= rotationDelta * 0.02;
1057     }
1058   }
1060   game.objects.feather.twistSpeed = Math.min(0.13, game.objects.feather.twistSpeed) * game.settings['difficulty']['speed'] / 100;
1061   game.objects.feather.rotation.x = (game.objects.feather.rotation.x + game.objects.feather.twistSpeed) % (2 * Math.PI);
1063   let collectedScale = 0.0;
1064   if(!game.settings['highcontrast'] && game.settings['graphics'] <= 2) {
1065     collectedScale = game['fn'].lerp(0.6, 0.3, 1 - Math.pow(1 - game.objects.words.collectedCount / game.objects.words.length, 2));
1066   } else if(!game.settings['highcontrast'] && game.settings['graphics'] == 3) {
1067     collectedScale = 0.3;
1068   }
1069   const collectingRadius = - 0.5 + 1.5 * game.settings['difficulty']['collectingradius'];
1070   for(let i = 0; i < game.objects.words.length; i++) {
1071     let word = game.objects.words[i];
1072     if(!word.collected && new THREE.Vector3().subVectors(word.position, game.objects.feather.position).length() < collectingRadius) {
1073       word.collected = game.view.clock.getElapsedTime();
1074       game.objects.words.collectedCount += 1;
1075       game['fn'].playRandomSound();
1076     }
1077     if(word.parent != game.view.scene) {
1078       // All that happens in here is the positional animation for the word, which
1079       // we can skip if it is no longer visible.
1080       continue;
1081     }
1082     let x, y, z;
1083     let collectionAnimationDuration = 1.0;
1084     for(let j = 0; j < word.children.length; j++) {
1085       game.var.notCollectedPos.set(0, 0, 0);
1086       game.var.collectedPos.set(0, 0, 0);
1087       let letter = word.children[j];
1088       let animationProgress = (((game.timeProgress + 5 * word.randomAnimOffset) % 5) / 5 + j / 37) % 1;
1089       if(!word.collected || game.view.clock.getElapsedTime() - word.collected <= collectionAnimationDuration) {
1090         x = word.position.x;
1091         y = word.position.y;
1092         z = 0;
1093         if(game.settings['graphics'] <= 2 && !game.settings['highcontrast']) {
1094           const wordAnimationRadius = 0.2;
1095           x += wordAnimationRadius * Math.cos(animationProgress * 5 * Math.PI * 2);
1096           y += wordAnimationRadius * Math.sin(animationProgress * 4 * Math.PI * 2);
1097           z += wordAnimationRadius * Math.sin(animationProgress * 6 * Math.PI * 2);
1098         }
1099         game.var.notCollectedPos.set(x, y, z);
1100       }
1101       if(word.collected) {
1102         if(!game.settings['highcontrast']) {
1103           x = game.objects.feather.scale.x * 0.5 * Math.cos(animationProgress * 1 * Math.PI * 2);
1104           y = 0.2 * Math.sin(animationProgress * 7 * Math.PI * 2);
1105           z = 0.2 * Math.cos(animationProgress * 7 * Math.PI * 2);
1106           x = x * Math.cos(game.objects.feather.rotation.z) - y * Math.sin(game.objects.feather.rotation.z);
1107           y = y * Math.cos(game.objects.feather.rotation.z) + x * Math.sin(game.objects.feather.rotation.z);
1108           x += game.objects.feather.position.x;
1109           y += game.objects.feather.position.y;
1110           z += game.objects.feather.position.z;
1111         } else {
1112           x = game.objects.feather.position.x;
1113           y = game.objects.feather.position.y;
1114           z = game.objects.feather.position.z;
1115         }
1116         game.var.collectedPos.set(x, y, z);
1117       }
1118       if(game.var.notCollectedPos.length() > 0 && game.var.collectedPos.length() > 0) {
1119         let collectingProgress = game['fn'].easeInOut(Math.max(0.0, Math.min(1.0, (game.view.clock.getElapsedTime() - word.collected) / collectionAnimationDuration)));
1120         letter.position.lerpVectors(game.var.notCollectedPos, game.var.collectedPos, collectingProgress);
1121         let scale = game['fn'].lerp(1.0, collectedScale, collectingProgress);
1122         letter.scale.set(scale, scale, scale);
1123       } else if(game.var.notCollectedPos.length() > 0) {
1124         letter.position.set(game.var.notCollectedPos.x, game.var.notCollectedPos.y, game.var.notCollectedPos.z);
1125       } else if(game.var.collectedPos.length() > 0) {
1126         letter.position.set(game.var.collectedPos.x, game.var.collectedPos.y, game.var.collectedPos.z);
1127         if(game.settings['highcontrast']) {
1128           // Special case because in high contrast mode, collected words vanish entirely.
1129           game.view.scene.remove(word);
1130         }
1131       }
1132       letter.position.sub(word.position);
1133       if(!game.settings['highcontrast']) {
1134         let rotation = (game.timeProgress * 3 + 2 * Math.PI * word.randomAnimOffset) % (2 * Math.PI);
1135         letter.rotation.set(rotation + j, rotation + 2*j, rotation + 3*j);
1136       }
1137     }
1138   }
1140   game.view.renderer.render(scene, game.view.camera);
1143 game['fn'].loadSettings = () => {
1144   let settings = {
1145     'controls': null, // set during first time launch depending on device
1146     'virtualinputleft': false,
1147     'graphics': 1,
1148     'audio': {
1149       'music': 0.5,
1150       'sounds': 0.5,
1151       'theme': null, // actual default value determined after asset loading
1152     },
1153     'feather': 'blue',
1154     'unlocks': [],
1155     'highcontrast': false,
1156     'font': 'standard',
1157     'difficulty': {
1158       'collectingradius': 1,
1159       'speed': 100,
1160     },
1161     'keyboard': {
1162       'up': ['ArrowUp', 'w'],
1163       'right': ['ArrowRight', 'd'],
1164       'down': ['ArrowDown', 's'],
1165       'left': ['ArrowLeft', 'a'],
1166       'tapmode': false,
1167     },
1168   };
1169   let stored;
1170   try {
1171     stored = window['localStorage'].getItem('upInTheAirGameSettings');
1172     game.ui.root.querySelector('.ui-page.options .warning.storage').style.display = 'none';
1173   } catch(error) {
1174     game.ui.root.querySelector('.ui-page.options .warning.storage').style.display = 'block';
1175   }
1176   if(stored) {
1177     let merge = (source, target) => {
1178       for(let k of Object.keys(source)) {
1179         if(source[k] != null && typeof(source[k]) == 'object' && typeof(target[k]) == 'object' && !Array.isArray(target[k])) {
1180           merge(source[k], target[k]);
1181         } else if(k in target) {
1182           target[k] = source[k];
1183         }
1184       }
1185     };
1186     stored = JSON.parse(stored);
1187     merge(stored, settings);
1188   }
1189   const ui = game.ui.root.querySelector('.ui-page.options');
1190   if(settings['controls']) {
1191     ui.querySelector('.controls input[value="' + settings['controls'] + '"]').checked = true;
1192     ui.querySelector('.controls .leftside input').checked = settings['virtualinputleft'];
1193     ui.querySelector('.controls .leftside').style.display = (['touchpad', 'thumbstick'].includes(settings['controls'])) ? 'block' : 'none';
1194     ui.querySelectorAll('.controls p span:not(.' + settings['controls'] + ')').forEach(span => span.style.display = 'none');
1195     ui.querySelector('.controls span.' + settings['controls']).style.display = 'block';
1196   }
1197   ui.querySelector('.graphics input[value="' + settings['graphics'] + '"]').checked = true;
1198   for(let audioCategory of ['music', 'sounds']) {
1199     let newValue = Math.max(0, Math.min(100, Math.round(100 * settings['audio'][audioCategory])));
1200     game.ui.root.querySelectorAll('.ui-page .audio input[type=range].' + audioCategory).forEach((elem) => {
1201       elem.value = newValue;
1202       elem.parentNode.nextElementSibling.innerText = newValue;
1203     });
1204   }
1205   let audioThemeRadio = ui.querySelector('.audiotheme input[value="' + settings['audio']['theme'] + '"]');
1206   if(audioThemeRadio) {
1207     audioThemeRadio.checked = true;
1208   }
1209   // Custom hash function that ensures our unlockables get stored in the same order,
1210   // regardless of the order in which they get unlocked.
1211   let miniHash = (input) => {
1212     return 4 * input.charCodeAt(0) + 0.1 * input.charCodeAt(1) + 3 * input.charCodeAt(2) + 2 * input.charCodeAt(3);
1213   }
1214   settings['unlocks'].sort((u1, u2) => miniHash(u1) > miniHash(u2));
1215   for(let unlockedFeather of settings['unlocks']) {
1216     if(!game.ui.root.querySelector('.ui-page.options .feather input[value="' + unlockedFeather + '"]')) {
1217       let radio = document.createElement('input');
1218       radio.type = 'radio';
1219       radio.name = 'upInTheAirGame-feather';
1220       radio.value = unlockedFeather;
1221       let img = document.createElement('img');
1222       if(unlockedFeather == 'golden' || unlockedFeather == 'ghost') {
1223         img.src = game['deploymentOptions']['assetUrlPrefix'] + 'textures/feather-' + unlockedFeather + '.png';
1224       } else {
1225         let unlock = game['fn'].unlockWithKey('NIbp2kW5' + unlockedFeather + 'e2ZDFl5Y');
1226         if(unlock && unlock['type'] == 'feather') {
1227           img.src = unlock['url'];
1228         } else {
1229           continue;
1230         }
1231       }
1232       img.alt = unlockedFeather[0].toUpperCase() + unlockedFeather.slice(1) + ' feather';
1233       let label = document.createElement('label');
1234       label.appendChild(radio);
1235       label.appendChild(img);
1236       game.ui.root.querySelector('.ui-page.options .feather').appendChild(label);
1237     }
1238   }
1239   if(!ui.querySelector('.feather input[value="' + settings['feather'] + '"]')) {
1240     settings['feather'] = 'blue';
1241   }
1242   ui.querySelector('.feather input[value=' + settings['feather'] + ']').checked = true;
1243   ui.querySelector('input[value="highcontrast"]').checked = !!settings['highcontrast'];
1244   ui.querySelector('.font input[value=' + settings['font'] + ']').checked = true;
1245   ui.querySelector('.difficulty select.collectingradius option[value="' + settings['difficulty']['collectingradius'] + '"]').selected = true;
1246   ui.querySelector('.difficulty select.speed option[value="' + settings['difficulty']['speed'] + '"]').selected = true;
1247   for(let direction of ['up', 'right', 'down', 'left']) {
1248     let keys = settings['keyboard'][direction];
1249     let btn = ui.querySelector('.keyboard button.' + direction);
1250     btn.value = keys.join('|');
1251     keys = keys.map(k => {
1252       if(k.length == 1 && k != 'ß') {
1253         k = k.toUpperCase();
1254       }
1255       switch(k) {
1256         case 'ArrowUp': return '🠕';
1257         case 'ArrowRight': return '🠖';
1258         case 'ArrowDown': return '🠗';
1259         case 'ArrowLeft': return '🠔';
1260         case ' ': return 'Space';
1261         default: return k;
1262       }
1263     });
1264     btn.innerText = keys.join(' or ');
1265   }
1266   ui.querySelector('input[value="tapmode"]').checked = !!settings['keyboard']['tapmode'];
1267   game.settings = settings;
1270 game['fn'].applySettings = () => {
1271   const ui = game.ui.root.querySelector('.ui-page.options');
1272   if(ui.querySelector('input[name="upInTheAirGame-controls"]:checked')) {
1273     game.settings['controls'] = ui.querySelector('input[name="upInTheAirGame-controls"]:checked').value;
1274   }
1275   game.settings['virtualinputleft'] = ui.querySelector('.controls .leftside input').checked;
1276   if(game.settings['virtualinputleft']) {
1277     game.ui.root.parentNode.classList.add('virtual-input-left');
1278   } else {
1279     game.ui.root.parentNode.classList.remove('virtual-input-left');
1280   }
1281   const virtualInput = game.ui.root.parentNode.querySelector('.virtual-input-widget');
1282   virtualInput.children[0].style.display = 'block';
1283   virtualInput.children[0].style.left = '50%';
1284   virtualInput.children[0].style.top = '50%';
1285   delete virtualInput.inProgress;
1286   if(game.settings['controls'] == 'touchpad') {
1287     virtualInput.classList.remove('thumbstick');
1288     virtualInput.classList.add('touchpad');
1289     game.ui.root.classList.remove('control-mouse', 'control-thumbstick');
1290     game.ui.root.classList.add('control-touchpad');
1291     virtualInput.children[0].style.display = 'none';
1292   } else if(game.settings['controls'] == 'thumbstick') {
1293     virtualInput.classList.remove('touchpad');
1294     virtualInput.classList.add('thumbstick');
1295     game.ui.root.classList.remove('control-mouse', 'control-touchpad');
1296     game.ui.root.classList.add('control-thumbstick');
1297   } else if(game.settings['controls'] == 'mouse') {
1298     virtualInput.classList.remove('touchpad', 'thumbstick');
1299     game.ui.root.classList.remove('control-touchpad', 'control-thumbstick');
1300     game.ui.root.classList.add('control-mouse');
1301   } else {
1302     virtualInput.classList.remove('touchpad', 'thumbstick');
1303     game.ui.root.classList.remove('control-mouse', 'control-touchpad', 'control-thumbstick');
1304   }
1305   for(let timeout of [10, 100, 1000]) {
1306     setTimeout(() => { game.ui.root.style.fontSize = (game.ui.root.clientWidth / 48) + 'px'; }, timeout);
1307   }
1308   game.settings['graphics'] = parseInt(ui.querySelector('input[name="upInTheAirGame-graphics"]:checked').value, 10);
1309   if(game.view) {
1310     let resolution = Math.round(3200 / Math.pow(2, game.settings['graphics']));
1311     game.view.canvas.width = resolution;
1312     game.view.canvas.height = resolution;
1313     game.view.camera.updateProjectionMatrix();
1314     game.view.renderer.setSize(game.view.canvas.width, game.view.canvas.height);
1315   }
1316   for(let audioCategory of ['music', 'sounds']) {
1317     game.settings['audio'][audioCategory] = parseInt(ui.querySelector('.audio input[type=range].' + audioCategory).value, 10) / 100;
1318   }
1319   let audioThemeRadio = ui.querySelector('.audiotheme input[name="upInTheAirGame-audiotheme"]:checked');
1320   if(audioThemeRadio) {
1321     game.settings['audio']['theme'] = audioThemeRadio.value;
1322   }
1323   game.settings['feather'] = ui.querySelector('input[name="upInTheAirGame-feather"]:checked').value;
1324   game.settings['highcontrast'] = ui.querySelector('input[value="highcontrast"]').checked;
1325   game.settings['font'] = ui.querySelector('input[name="upInTheAirGame-font"]:checked').value;
1326   game.settings['difficulty']['collectingradius'] = parseInt(ui.querySelector('.difficulty select.collectingradius').value, 10);
1327   game.settings['difficulty']['speed'] = parseInt(ui.querySelector('.difficulty select.speed').value, 10);
1328   for(let direction of ['up', 'right', 'down', 'left']) {
1329     game.settings['keyboard'][direction] = ui.querySelector('.keyboard button.' + direction).value.split('|');
1330   }
1331   game.settings['keyboard']['tapmode'] = ui.querySelector('input[value="tapmode"]').checked;
1332   try {
1333     window['localStorage'].setItem('upInTheAirGameSettings', JSON.stringify(game.settings));
1334     game.ui.root.querySelector('.ui-page.options .warning.storage').style.display = 'none';
1335   } catch(error) {
1336     game.ui.root.querySelector('.ui-page.options .warning.storage').style.display = 'block';
1337   }
1339   for(let audioCategory of ['music', 'sounds']) {
1340     game.settings['audio'][audioCategory] = parseInt(ui.querySelector('.audio input[type=range].' + audioCategory).value, 10) / 100;
1341     let value = Math.round(100 * game.settings['audio'][audioCategory]);
1342     game.ui.root.querySelectorAll('.ui-page .audio input[type=range].' + audioCategory).forEach((elem) => {
1343       elem.value = value;
1344       elem.parentNode.nextElementSibling.innerText = value;
1345     });
1346     if(audioCategory == 'music' && game.view && game.view.music) {
1347       game.view.music.setVolume(game.settings['audio'][audioCategory]);
1348     }
1349   }
1350   game.ui.root.classList.remove('font-atkinson', 'font-opendyslexic');
1351   if(game.settings['font'] != 'standard') {
1352     game.ui.root.classList.add('font-' + game.settings['font']);
1353   }
1356 game['fn'].createFeather = () => {
1357   let position, rotation;
1358   if(game.objects.feather) {
1359     position = game.objects.feather.position;
1360     rotation = game.objects.feather.rotation;
1361     game.objects.feather.geometry.dispose();
1362     game.objects.feather.material.dispose();
1363     game.view.scene.remove(game.objects.feather);
1364     delete game.objects.feather;
1365   }
1367   const featherGeometry = new THREE.PlaneGeometry(1.6, 0.5);
1368   let options = {
1369     map: game.assets.textures['feather-' + game.settings['feather']],
1370     transparent: true,
1371     alphaTest: 0.01,
1372     side: THREE.DoubleSide,
1373   }
1374   if(game.settings['feather'] == 'golden') {
1375     options.color = 0xffffff;
1376     options.emissive = 0x644a1e;
1377     options.roughness = 0.5;
1378     options.metalness = 0.4;
1379   }
1380   if(game.settings['feather'] == 'ghost') {
1381     options.opacity = 0.7;
1382   }
1383   game.view.materials.feather = new THREE.MeshStandardMaterial(options);
1384   game.objects.feather = new THREE.Mesh(featherGeometry, game.view.materials.feather);
1385   game.objects.feather.rotation.order = 'ZXY';
1386   if(position) {
1387     game.objects.feather.position.set(position.x, position.y, position.z);
1388   }
1389   if(rotation) {
1390     game.objects.feather.rotation.set(rotation.x, rotation.y, rotation.z);
1391   }
1392   game.view.scene.add(game.objects.feather);
1393   game.objects.feather.speed = new THREE.Vector3(0, 0, 0);
1394   game.gravity = new THREE.Vector3(0, -0.1, 0);
1395   game.objects.feather.swayDirection = 0.2;
1396   game.objects.feather.twistSpeed = 0.1;
1399 game['fn'].createMeshes = () => {
1400   if(game.objects.clouds && game.objects.clouds.parent == game.view.scene) {
1401     game.view.scene.remove(game.objects.clouds);
1402     if(game.objects.clouds.children.length > 0) {
1403       game.objects.clouds.children[0].geometry.dispose();
1404     }
1405     for(let mKey in game.view.materials) {
1406       game.view.materials[mKey].dispose();
1407     }
1408     delete game.objects.clouds;
1409   }
1410   if(game.objects.backdrop && game.objects.backdrop.parent == game.view.scene) {
1411     game.view.scene.remove(game.objects.backdrop);
1412     game.objects.backdrop.material.dispose();
1413     game.objects.backdrop.geometry.dispose();
1414   }
1415   if(game.assets.fonts.geometry) {
1416     for(let geom of Object.values(game.assets.fonts.geometry)) {
1417       if(geom.customMaterial) {
1418         geom.customMaterial.dispose();
1419       }
1420       geom.dispose();
1421     }
1422     delete game.assets.fonts.geometry;
1423   }
1424   if(game.view.materials) {
1425     for(let material of Object.values(game.view.materials)) {
1426       material.dispose();
1427     }
1428     delete game.view.materials;
1429   }
1431   if(game.view.materials && game.view.materials.feather) {
1432     game.view.materials = {
1433       'feather': game.view.materials['feather'],
1434     };
1435   } else {
1436     game.view.materials = {};
1437   }
1439   if(!game.settings['highcontrast']) {
1440     let cloudShaders;
1441     let cloudGeometry = new THREE.PlaneGeometry(1, 200 / 350);
1442     if(game.settings['graphics'] <= 2) {
1443       cloudShaders = {
1444         vertexShader:
1445        `
1446        precision highp float;
1447        precision highp int;
1448        varying vec2 vUv;
1449        void main() {
1450          vUv = uv;
1451          gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1452        }
1453          `,
1454         fragmentShader:
1455          `
1456        precision mediump float;
1457        uniform sampler2D texture1;
1458        uniform sampler2D texture2;
1459        uniform sampler2D texture3;
1460        uniform float lerp;
1461        varying vec2 vUv;
1462        void main() {
1463          if(lerp > 1.0) {
1464            vec4 col1 = texture2D(texture2, vUv);
1465            vec4 col2 = texture2D(texture3, vUv);
1466            gl_FragColor = mix(col1, col2, lerp - 1.0);
1467          } else {
1468            vec4 col1 = texture2D(texture1, vUv);
1469            vec4 col2 = texture2D(texture2, vUv);
1470            gl_FragColor = mix(col1, col2, lerp);
1471          }
1472          // I don't know how GLSL works: why do I need to do this to match the textures?
1473          gl_FragColor = mix(gl_FragColor, vec4(1.0, 1.0, 1.0, gl_FragColor.a), 0.5);
1474        }
1475          `
1476       };
1477     }
1478     for(let i = 0; i < 6; i++) {
1479       if(game.settings['graphics'] <= 2) {
1480         game.view.materials['cloud' + i] = new THREE.ShaderMaterial({
1481           uniforms: THREE.UniformsUtils.merge([{
1482             texture1: null,
1483             texture2: null,
1484             texture3: null,
1485             lerp: null,
1486           }]),
1487           ...cloudShaders,
1488         });
1489         game.view.materials['cloud' + i].uniforms.texture1.value = game.assets.textures['cloud' + i + 'a'];
1490         game.view.materials['cloud' + i].uniforms.texture2.value = game.assets.textures['cloud' + i + 'b'];
1491         game.view.materials['cloud' + i].uniforms.texture3.value = game.assets.textures['cloud' + i + 'c'];
1492         if(game.ui.reachedEnd) {
1493           game.view.materials['cloud' + i].uniforms.lerp.value = 2.0;
1494         } else {
1495           game.view.materials['cloud' + i].uniforms.lerp.value = 0.0;
1496         }
1497         game.view.materials['cloud' + i].transparent = true;
1498       } else {
1499         for(let variant of ['a', 'b', 'c']) {
1500           game.view.materials['cloud' + i + variant] = new THREE.MeshBasicMaterial({
1501             map: game.assets.textures['cloud' + i + variant],
1502             transparent: true,
1503             alphaTest: 0.5,
1504           });
1505           game.view.materials['cloud' + i + variant].name = 'cloud' + i + variant;
1506         }
1507       }
1508     }
1509     game.objects.clouds = new THREE.Group();
1510     let textureVariantSuffix = '';
1511     if(game.settings['graphics'] > 2) {
1512       if(game.ui.reachedEnd) {
1513         textureVariantSuffix = 'c';
1514       } else {
1515         textureVariantSuffix = 'a';
1516       }
1517     }
1518     game.view.materials.letter = new THREE.MeshStandardMaterial({
1519       color: 0xffffff,
1520       emissive: 0x606060,
1521       roughness: 0.4,
1522       metalness: 1,
1523     });
1524     if(game.settings['graphics'] <= 2) {
1525       game.assets.fonts.geometry = {};
1526       for(let letter of [...new Set(game.assets.wordList.join(''))]) {
1527         if(game.settings['graphics'] == 1) {
1528           game.assets.fonts.geometry[letter] = new TextGeometry(letter, {
1529             font: game.assets.fonts.cookie,
1530             size: 0.2,
1531             depth: 0.03,
1532             curveSegments: 2,
1533             bevelEnabled: false,
1534           });
1535           game.assets.fonts.geometry[letter].computeBoundingBox();
1536           let bbox = game.assets.fonts.geometry[letter].boundingBox;
1537           // Add these to local 0,0 later to get the letter's center rotation point
1538           game.assets.fonts.geometry[letter].dx = (bbox.max.x - bbox.min.x) / 2;
1539           game.assets.fonts.geometry[letter].dy = (bbox.max.y - bbox.min.y) / 2;
1540         } else {
1541           let letterCanvas = document.createElement('canvas');
1542           letterCanvas.width = 64;
1543           letterCanvas.height = 64;
1544           let letterCanvasContext = letterCanvas.getContext('2d');
1545           letterCanvasContext.font = '60px Cookie';
1546           letterCanvasContext.fillStyle = '#000';
1547           letterCanvasContext.fillRect(0, 0, letterCanvas.width, letterCanvas.height);
1548           letterCanvasContext.fillStyle = '#fff';
1549           let bbox = letterCanvasContext.measureText(letter);
1550           let vOffset = bbox.actualBoundingBoxAscent - 0.5 * (bbox.actualBoundingBoxAscent + bbox.actualBoundingBoxDescent);
1551           letterCanvasContext.fillText(letter, Math.round((letterCanvas.width - bbox.width) / 2), (letterCanvas.height / 2) + vOffset);
1552           let alphaMap = new THREE.CanvasTexture(letterCanvas);
1553           alphaMap.needsUpdate = true;
1554           let letterMaterial = new THREE.MeshStandardMaterial({
1555             color: game.view.materials.letter.color,
1556             emissive: 0x303030,
1557             roughness: game.view.materials.letter.roughness,
1558             metalness: game.view.materials.letter.metalness,
1559             side: THREE.DoubleSide,
1560             alphaMap: alphaMap,
1561             transparent: true,
1562           });
1563           game.assets.fonts.geometry[letter] = new THREE.PlaneGeometry(0.3, 0.3);
1564           game.assets.fonts.geometry[letter].dx = 0;
1565           game.assets.fonts.geometry[letter].dy = 0;
1566           game.assets.fonts.geometry[letter].customMaterial = letterMaterial;
1567         }
1568       }
1569       let numClouds = 300;
1570       if(game.settings['graphics'] == 2) {
1571         numClouds = 75;
1572       }
1573       for(let i = 0; i < numClouds; i++) {
1574         let randomAngle = Math.random() * 2 * Math.PI;
1575         let randomCameraX = game.courseRadius * Math.sin(randomAngle);
1576         let randomCameraY = game.courseRadius * Math.cos(randomAngle);
1577         let cloud = new THREE.Mesh(cloudGeometry, game.view.materials['cloud' + (i % 5 + 1) + textureVariantSuffix]);
1578         cloud.position.z = -15 - Math.round(Math.random() * 40);
1579         const vFOV = THREE.MathUtils.degToRad(game.view.camera.fov);
1580         let maxCameraDistance = 2 * Math.tan(vFOV / 2) * Math.abs(cloud.position.z - game.view.camera.position.z);
1581         cloud.position.x = randomCameraX + maxCameraDistance * 2 * (Math.random() - 0.5);
1582         cloud.position.y = randomCameraY + maxCameraDistance * 2 * (Math.random() - 0.5);
1583         let scale = 21 + (Math.random() * 0.5 + 0.5) * Math.abs(cloud.position.z);
1584         cloud.scale.set(scale, scale, scale);
1585         game.objects.clouds.add(cloud);
1586       }
1587     } else {
1588       const minimalLetterGeometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
1589       minimalLetterGeometry.dx = 0;
1590       minimalLetterGeometry.dy = 0;
1591       game.assets.fonts.geometry = {};
1592       for(let letter of [...new Set(game.assets.wordList.join(''))]) {
1593         game.assets.fonts.geometry[letter] = minimalLetterGeometry;
1594       }
1595       for(let r = 0; r < game.courseRadius / 3; r++) {
1596         let angle = THREE.MathUtils.degToRad(360 * 3 * r / game.courseRadius);
1597         let cameraX = game.courseRadius * Math.sin(angle);
1598         let cameraY = game.courseRadius * Math.cos(angle);
1599         const vFOV = THREE.MathUtils.degToRad(game.view.camera.fov);
1600         let z = -15;
1601         let maxCameraDistance = Math.tan(vFOV / 2) * Math.abs(z - game.view.camera.position.z);
1602         let axis = [-1, 0, 1];
1603         if(r % 2 == 0) {
1604           axis = [-0.5, 0.5];
1605         }
1606         for(let step of axis) {
1607           let shape = 1 + Math.floor(Math.random() * 5);
1608           let cloud = new THREE.Mesh(cloudGeometry, game.view.materials['cloud' + shape + textureVariantSuffix]);
1609           cloud.position.x = cameraX + step * 1.2 * maxCameraDistance * Math.sin(angle);
1610           cloud.position.y = cameraY + step * maxCameraDistance * Math.cos(angle);
1611           cloud.position.z = z;
1612           let scale = 15 + 0.5 * Math.abs(cloud.position.z);
1613           cloud.scale.set(scale, scale, scale);
1614           game.objects.clouds.add(cloud);
1615         }
1616       }
1617     }
1618     game.view.scene.add(game.objects.clouds);
1619     game.objects.backdrop = new THREE.Mesh(new THREE.PlaneGeometry(350, 350), game.view.materials['cloud0' + textureVariantSuffix]);
1620     game.objects.backdrop.position.setZ(-100);
1621   } else {
1622     game.view.materials.letter = new THREE.MeshStandardMaterial({
1623       color: 0x00ff00,
1624       emissive: 0x00ff00,
1625     });
1626     const highcontrastLetterGeometry = new THREE.SphereGeometry(0.1, 16, 16);
1627     highcontrastLetterGeometry.dx = 0;
1628     highcontrastLetterGeometry.dy = 0;
1629     game.assets.fonts.geometry = {};
1630     for(let letter of [...new Set(game.assets.wordList.join(''))]) {
1631       game.assets.fonts.geometry[letter] = highcontrastLetterGeometry;
1632     }
1633     const highContrastBackdropMaterial = new THREE.MeshBasicMaterial({
1634       map: game.assets.textures['highcontrast-backdrop'],
1635     });
1636     game.objects.backdrop = new THREE.Mesh(new THREE.PlaneGeometry(150, 150), highContrastBackdropMaterial);
1637     game.objects.backdrop.position.setZ(-10);
1638   }
1639   if(game.objects.words) {
1640     for(let word of game.objects.words) {
1641       game['fn'].prepareWordMesh(word);
1642     }
1643   }
1644   game.view.scene.add(game.objects.backdrop);
1647 game['fn'].unlockFeather = (feather, url) => {
1648   if(game.settings['unlocks'].includes(feather)) {
1649     return false;
1650   }
1651   game.settings['unlocks'].push(feather);
1652   if(!url) {
1653     url = game['deploymentOptions']['assetUrlPrefix'] + 'textures/feather-' + feather + '.png';
1654   } else if(url.startsWith('textures/')) {
1655     url = game['deploymentOptions']['assetUrlPrefix'] + url;
1656   }
1658   if(!game.assets['textures']['feather-' + feather]) {
1659     (new THREE.TextureLoader()).load(url, (result) => {
1660       result.colorSpace = THREE.SRGBColorSpace;
1661       result.minFilter = THREE.NearestFilter;
1662       result.magFilter = THREE.NearestFilter;
1663       result.repeat = new THREE.Vector2(1, -1);
1664       result.wrapT = THREE.RepeatWrapping;
1665       game.assets['textures']['feather-' + feather] = result;
1666     }, () => {}, (err) => {
1667       console.error('Error while loading ' + feather + ' feather texture: ' + err);
1668     });
1669   }
1670   // Custom hash function that ensures our unlockables get stored in the same order,
1671   // regardless of the order in which they get unlocked.
1672   let miniHash = (input) => {
1673     return 4 * input.charCodeAt(0) + 0.1 * input.charCodeAt(1) + 3 * input.charCodeAt(2) + 2 * input.charCodeAt(3);
1674   }
1675   game.settings['unlocks'].sort((u1, u2) => miniHash(u1) > miniHash(u2));
1676   let insertAfterFeather = 'purple';
1677   if(game.settings['unlocks'].indexOf(feather) >= 1) {
1678     insertAfterFeather = game.settings['unlocks'][game.settings['unlocks'].indexOf(feather) - 1];
1679   }
1680   let radio = document.createElement('input');
1681   radio.type = 'radio';
1682   radio.name = 'upInTheAirGame-feather';
1683   radio.value = feather;
1684   radio.addEventListener('change', () => {
1685     game['fn'].applySettings();
1686     game['fn'].createFeather();
1687   });
1688   let img = document.createElement('img');
1689   img.src = url;
1690   img.alt = feather[0].toUpperCase() + feather.slice(1) + ' feather';
1691   let label = document.createElement('label');
1692   label.appendChild(radio);
1693   label.appendChild(img);
1694   game.ui.root.querySelector('.ui-page.options .feather input[value="' + insertAfterFeather + '"]').parentNode.after(label);
1695   game['fn'].applySettings();
1696   let ui = game.ui.root.querySelector('.ui-page.unlock');
1697   let img2 = ui.querySelector('img');
1698   img2.src = img.src;
1699   img2.alt = img.alt;
1700   ui.querySelector('p.name').innerText = img2.alt;
1701   return true;
1704 game['fn'].moveToPage = (target, skipFade = false) => {
1705   let fadeDuration = 250;
1706   if(skipFade) {
1707     fadeDuration = 0;
1708   }
1709   // After the gameplay page is shown for the first time, always keep it around as a backdrop
1710   game.ui.root.querySelectorAll('.ui-page:not(.' + target + '):not(.gameplay)').forEach((page) => {
1711     page.style.opacity = '0';
1712     setTimeout((page) => {
1713       page.style.display = 'none';
1714     }, fadeDuration, page);
1715   });
1716   if(game.ui.currentPage == 'title' && !game.ui.reachedStart) {
1717     setTimeout(() => {
1718       game.ui.root.querySelector('.ui-page.title h1').removeAttribute('style');
1719       game.ui.root.querySelectorAll('.ui-page.title button').forEach(btn => { btn.disabled = false; btn.removeAttribute('style'); });
1720       game.ui.root.querySelector('.ui-page.title .footer').removeAttribute('style');
1721       game.ui.root.querySelector('.ui-page.title .system-buttons').removeAttribute('style');
1722       game.ui.reachedStart = true;
1723     }, fadeDuration);
1724   }
1725   if(target == 'title' && game.view) {
1726     game.view.cheatBuffer = '';
1727   }
1728   if(target == 'title' && (!game.ui.currentPage || ['loading', 'controls'].includes(game.ui.currentPage))) {
1729     game['fn'].initializeGame(game.ui.root.querySelector('canvas'));
1730   }
1731   if(target == 'title' && game.ui.root.querySelector('.ui-page.gameplay p')) {
1732     game.ui.root.querySelectorAll('.ui-page.gameplay p').forEach(elem => elem.remove());
1733   }
1734   if(target == 'title' && game.view && game.view.windSound && game.view.windSound.isPlaying) {
1735     game.view.windSound.stop();
1736   }
1737   if(target == 'title' && game.view && game.view.music && game.view.music.isPlaying) {
1738     game.view.music.stop();
1739     if(game.view.music.timeoutID) {
1740       clearTimeout(game.view.music.timeoutID);
1741       delete game.view.music.timeoutID;
1742     }
1743   }
1744   if(target == 'outro') {
1745     if(game.view.music.isPlaying) {
1746       game.view.music.stop();
1747     }
1748     let collectedWords = game.objects.words.filter(w => w.collected).map(w => w.text);
1749     game.ui.root.querySelector('.ui-page.outro .count').innerText = game.objects.words.collectedCount;
1750     game.ui.root.querySelector('.ui-page.outro .optionalPlural').innerText = 'word' + ((game.objects.words.collectedCount == 1) ? '' : 's') + '.';
1751     let ratingElem = game.ui.root.querySelector('.ui-page.outro .rating');
1752     let exampleElems = game.ui.root.querySelectorAll('.ui-page.outro .examples');
1753     let finalParagraph = game.ui.root.querySelector('.ui-page.outro .area > p:last-child');
1754     ratingElem.style.display = 'none';
1755     exampleElems.forEach(elem => { elem.style.display = 'none'; });
1756     let returnButton = game.ui.root.querySelector('.ui-page.outro button.goto');
1757     if(game.objects.words.collectedCount == 100 || game.objects.words.collectedCount == 0) {
1758       finalParagraph.style.display = 'none';
1759       let neededUnlocking = false;
1760       if(game.objects.words.collectedCount == 100) {
1761         neededUnlocking = game['fn'].unlockFeather('golden');
1762       } else {
1763         neededUnlocking = game['fn'].unlockFeather('ghost');
1764       }
1765       if(neededUnlocking) {
1766         returnButton.innerText = 'Continue';
1767         returnButton.classList.remove('title');
1768         returnButton.classList.add('unlock');
1769       } else {
1770         returnButton.innerText = 'Return to Title Screen';
1771         returnButton.classList.remove('unlock');
1772         returnButton.classList.add('title');
1773       }
1774     } else {
1775       finalParagraph.style.display = 'block';
1776       returnButton.innerText = 'Return to Title Screen';
1777       returnButton.classList.remove('unlock');
1778       returnButton.classList.add('title');
1779     }
1780     if(game.objects.words.collectedCount > 0) {
1781       if(game.objects.words.collectedCount == 100) {
1782         ratingElem.style.display = 'block';
1783         ratingElem.innerText = 'Wow, you managed to collect all of them. Congratulations!';
1784       } else {
1785         let generateExampleSentences = (wordList) => {
1786           let container = game.ui.root.querySelector('.ui-page.outro div.examples');
1787           while(container.children.length > 0) {
1788             container.children[0].remove();
1789           }
1790           let words = {};
1791           for(let category of Object.keys(game.assets.words)) {
1792             words[category] = [];
1793             for(let word of game.assets.words[category]) {
1794               if(wordList.includes(word)) {
1795                 words[category].push(word);
1796               }
1797             }
1798           }
1799           let result = [];
1800           let failedAttempts = 0;
1801           while(result.length < 3 && failedAttempts < 1000) {
1802             let sentence = game.assets.sentences[Math.floor(Math.random() * game.assets.sentences.length)];
1803             while(sentence.indexOf('{') > -1) {
1804               let areWeStuck = true;
1805               for(let category of Object.keys(words)) {
1806                 if(sentence.includes('{' + category + '}')) {
1807                   if(words[category].length == 0) {
1808                     break;
1809                   }
1810                   let choice = words[category][Math.floor(Math.random() * words[category].length)];
1811                   if(category == 'sorry') {
1812                     if(choice == 'sorry') {
1813                       sentence = sentence.replace('{sorry}', 'I’m {sorry}');
1814                     }
1815                     if(choice == 'apologize') {
1816                       sentence = sentence.replace('{sorry}', 'I {sorry}');
1817                     }
1818                   }
1819                   if(sentence.indexOf('{' + category + '}') == 0) {
1820                     choice = choice[0].toUpperCase() + choice.slice(1);
1821                   }
1822                   sentence = sentence.replace('{' + category + '}', '<strong>' + choice + '</strong>');
1823                   words[category].splice(words[category].indexOf(choice), 1);
1824                   areWeStuck = false;
1825                 }
1826               }
1827               if(areWeStuck) {
1828                 break;
1829               }
1830             }
1831             if(sentence.indexOf('{') == -1 && !result.includes(sentence)) {
1832               result.push(sentence);
1833               failedAttempts = 0;
1834             }
1835             failedAttempts += 1;
1836           }
1837           for(let sentence of result) {
1838             let elem = document.createElement('p');
1839             elem.innerHTML = sentence;
1840             container.appendChild(elem);
1841           }
1842         };
1843         generateExampleSentences(collectedWords);
1844         game.ui.root.querySelector('.ui-page.outro button.examples').addEventListener('click', () => { generateExampleSentences(collectedWords); });
1845         exampleElems.forEach(elem => { elem.style.display = 'flex'; });
1846       }
1847     } else {
1848       ratingElem.style.display = 'block';
1849       ratingElem.innerText = 'You completed the course while dodging every word. That’s an achievement all on its own. Respect!';
1850     }
1851   }
1852   if(target == 'options') {
1853     game.ui.root.querySelectorAll('.options .areatabs button').forEach((btn) => {
1854       if(btn.classList.contains('general')) {
1855         btn.classList.add('active');
1856       } else {
1857         btn.classList.remove('active');
1858       }
1859     });
1860     game.ui.root.querySelectorAll('.options > div.area.twocol').forEach((area) => {
1861       if(area.classList.contains('general')) {
1862         area.style.display = 'flex';
1863       } else {
1864         area.style.display = 'none';
1865       }
1866     });
1867   }
1868   const targetElems = [game.ui.root.querySelector('.ui-page.' + target + '')];
1869   if(game.ui.root.querySelector('.ui-page.gameplay').style.opacity != '1' && target == 'title') {
1870     targetElems.push(game.ui.root.querySelector('.ui-page.gameplay'));
1871   }
1872   for(let targetElem of targetElems) {
1873     if(!targetElem.classList.contains('gameplay')) {
1874       targetElem.style.opacity = '0';
1875     }
1876     targetElem.style.display = 'flex';
1877     setTimeout((targetElem) => {
1878       targetElem.style.opacity = '1';
1879       if(target == 'credits') {
1880         targetElem.querySelector('.area').scrollTop = 0;
1881       }
1882     }, fadeDuration, targetElem);
1883   }
1884   if(target != 'pause' && game.ui.currentPage != 'pause') {
1885     game.timeProgress = 0;
1886   }
1887   if(target == 'pause') {
1888     game.view.music.stop();
1889     game.view.windSound.stop();
1890   } else if(game.ui.currentPage == 'pause' && target == 'openingcutscene') {
1891     if(game.timeProgress >= 1.0) {
1892       game.view.windSound.offset = game.timeProgress - 1.0;
1893       game.view.windSound.setVolume(game.settings['audio']['sounds']);
1894       if(!game.view.muted) {
1895         game.view.windSound.play();
1896       }
1897     }
1898   } else if(game.ui.currentPage == 'pause' && target == 'gameplay') {
1899     game.view.music.offset = (game.timeProgress / (game.settings['difficulty']['speed'] / 100)) % game.assets['audio']['music-' + game.settings['audio']['theme']].duration;
1900     if(!game.view.muted) {
1901       game.view.music.play();
1902     }
1903   }
1904   game.ui.previousPage = game.ui.currentPage;
1905   game.ui.currentPage = target;
1906   if(game.view) {
1907     game.startTime = game.view.clock.getElapsedTime();
1908   }
1911 game['fn'].unlockWithKey = (input) => {
1912   input = 'aBYPmb2xCwF2ilfD'+ input + 'PNHFwI2zKZejUv6c';
1913   let hash = (input) => {
1914     // Adapted with appreciation from bryc:
1915     // https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
1916     let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
1917     for(let i = 0, ch; i < input.length; i++) {
1918       ch = input.charCodeAt(i);
1919       h1 = Math.imul(h1 ^ ch, 2654435761);
1920       h2 = Math.imul(h2 ^ ch, 1597334677);
1921     }
1922     h1  = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
1923     h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
1924     h2  = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
1925     h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
1926     return (h2 >>> 0).toString(16).padStart(8, 0) + (h1 >>> 0).toString(16).padStart(8, 0);
1927   }
1928   for(let unlockable of game.unlockables) {
1929     if(unlockable.accessKey == hash(input)) {
1930       let key = hash('UZx2jWen9w5jm0FB' + input + '7DZpEq4OOwv2kiJ1');
1931       let seed = parseInt(key.slice(12), 16);
1932       let prng = () => {
1933         seed |= 0;
1934         seed = seed + 0x9e3779b9 | 0;
1935         let t = seed ^ seed >>> 16;
1936         t = Math.imul(t, 0x21f0aaad);
1937         t = t ^ t >>> 15;
1938         t = Math.imul(t, 0x735a2d97);
1939         return ((t = t ^ t >>> 15) >>> 0);
1940       };
1941       let data = Uint8Array.from(atob(unlockable.payload), (c) => c.codePointAt(0));
1942       let pad;
1943       for(let i = 0; i < data.length; i++) {
1944         if(i % 4 == 0) {
1945           pad = prng();
1946           pad = [pad % 256, (pad >> 8) % 256, (pad >> 16) % 256, (pad >> 24) % 256];
1947         }
1948         data[i] = data[i] ^ pad[i % 4];
1949       }
1950       data = new TextDecoder().decode(data);
1951       let result = JSON.parse(data);
1952       if(result['type'] == 'redirect') {
1953         return game['fn'].unlockWithKey(result['target']);
1954       } else {
1955         return result;
1956       }
1957     }
1958   }
1959   return null;
1960 };
1962 game['fn'].start = () => {
1963   game.ui = {
1964     root: document.querySelector('.upInTheAirGame .ui-container'),
1965     gamepads: [],
1966   };
1967   game.settings = {};
1968   // If you're looking at the source code and the following seems scary, don't worry, it's just a few
1969   // unlockable feather textures. I liked easter eggs and cheat codes when I was young, and I didn't
1970   // want these to be trivially bypassable for people who can read the code.
1971   game.unlockables = [
1972     {
1973       'accessKey': '5b32eb7ad08488f4',
1974       'payload': 'k4JPu3sWfEhcgleieVGghixKSI10qdRRC5tAl39Tzy1U7Rx9EEEYbLx9wCcxAf7wC8r9mJZCOj8bNa7grMbUmTeCeWWPAg==',
1975     },
1976     {
1977       'accessKey': '6273da5894b2dd8b',
1978       'payload': 'XAFLhAjcTzsWgGb7DAMCKwHXjoyUg/yxkIUyLcV/7PpktgW3MHtXhh4OkVeANr52RfdbwfVpgO4dxuyYPaFZ4x4JDmI=',
1979     },
1980     {
1981       'accessKey': 'fb8b533451a6dd68',
1982       'payload': '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',
1983     },
1984     {
1985       'accessKey': '3c78e25c7b1106b6',
1986       'payload': 'dnbO28tXI2i2+o+etuRc0/Cfxd4UpdG1IRgqB0fTwJE1xCoK+TtB/JVGTquaD/stnIkKiA==',
1987     },
1988     {
1989       'accessKey': '5f79141f861a146a',
1990       'payload': 'IO59sEgiVtdv68PW1n+NdmYY6sGhOdG6BHA4BwVErVCIu25lohqBbDKgI+gWyCPVFUaFk76yW1Ji09i/n+swIQK5ayyfCeRoruxz2lA/Eoe0LTPqy2CPShxD0Pfi66AdgzQ+jMvHOi9Stw/t0XGGpLjrQfghJDnZpuEgipYG3PpiZbcbj3BVUuw7fV6idwGPBXoqagwXvscfjQppTq8LvuqgHYgSM6CXlKr95uJL0zsnqvtL9DPQgmZZTdHOZcwn/AXJFT94VIctGtgs81J4zwNCelbdD0dnhoFdJSTNhEhHH6vVMblNnz6wr48kOGI88yJf158ByR4ic3+Q0T7azpOSPGqWQkrsWN4u+gAprAhUeGrsL/7FopiSdRW3/iLDsXW31omQu6iPpLiP6KcvAOsJKYQdow1uVptFbfvA3cdONWL49XsIEgDFWtAb0XckN/xaerhYN50Vlw9bZW6t+P1/EZwgpqMLgBVwYHnQjthYJUw8bgmMtF4Jos7/0XDrVGE9EmNA0bCPS+h492zQ2S20kwLrncJJocyxVsEc9Nzu7L6JNtstzYEN8lLMcXedGZwvHoki+/q1DcJI8MMi2meb9JoQZE7a8KChtPssbuu2rPbZpodK99Q9AqaFeFu/5XtRsnVHY0SIBTyr66jP0LhNwhPzjz/+sDMAPc/6JkV7MNSxP7OS1VB2gaX5Xr6+fA4c+c+e4oVgx6xtp8ENxmGyVfjEvkkFD5Md6ARJ1Fznw/TWfZyGEQMj3WFsE+YYVKcCN5plCJ0f8ZpzrBvPCvViNbOW85DXavXy05NV/+F54OXx+DnPfWvvtaKSehUKLxzNrbqedtdqweMZyg70LNxJIzhVV1LsxqaLx9Q9L3TFbE8IV0ZJSin8n1Vzq/woFlP3brIwq0bVXUPTsrD/Q2oJTM/JN5OZLyDtdqU5r7dc/r3ZRPKeOZhMiauZS8QGu4JTbkbUHJVKe81gpi/nBDdX+2PoNJJy3nuoIyhtu57e8VfQKxUPhpxY+hiv2ETT8ZTF/SmHoC+VLVaDDN9Om4dW4XdQqO2Ab+XjovK30UK2Cc5SKK7AYtbX8HdzOp6rG5uRsNllWl28MD4gsXxkrQolsFbei0uasztUuZrZ0vOS4D0pPgxjPJ3e6iVlqgcd0ioa89Fl5MEiDRmy2QakXGQ/G6HWMNEUq0Ue9kHkyShi59TLrjeiRdw1ijojQ14eLHJgtCYfKIn9wxFTZfKJtlMgDyY+bca73BuCnXbbHAOr3pMtgdCOLVj8E0+RW4uB0VW3+mZwLvEZ3BNmYuf8J+sMbOkLrZAlgYJaxQWHiiAghyshoN5Vw+6XJGxe3obLuvDBDReGxmiehzX97bH+5cmIP0S2bD06+K5Yqsay5Gnvmjtj4+R+MHYEdlXQu6wCelP34YIeBnFz//1p13vQg/gsugpGBIh0lOeolEmyekpBRIKtkaDiPf5Sem1zeqmtLtrPNU3iP+EbjE1Vp41CWkTe/zZI4syUHTEKTpJpTLT7htM38YCAZ7z7EYjLeBjWjKtew+yN5pDLl9MX0pE2PgWHCdCHcHHtQW43W1qI+XzGJmuKiNfyz6kWb7SeGaFVue5iayagFwNuFO5EWNS33dgHvlkxU3pLnorbht1l4IsqCIQJnTWTXdhmdUvMCcVecQ3ZZiIhpj6o+ZWkmpq2fmNRBIRPvvoEwn7KZ+8IWDGU+BhhEDC75wI0iIsVLH9u5i2KwV2swqC+3YM07HzLY9Yb2Sphdz/stTsuiBGrGeL1sxvbMcdG4Yenp9peYRnm6NfZ+zvx4N0P2gtG39+YSF9mHNfaKBhQdzMlVO/KrIR4oK3gq6lnsPFbEAFuITVyTqXWccYMoFhRctUF0hACqgE4aZWx2vnMwYEAogPVVmyEjRJ+hbdVybkhyOK8TTxKh1McUt8bGU22ykNZokuXxqiu2mOFfnOiEwVMTvt8P3uMDAJ0swpeGwSbRvEk5DZ1j0mY34G0gaEyMkzDSfsFGlFb+PDenq7Zz9zb6+8K6gI3DrB6piB1ZN1qsgTHi/SaOCyFCdDtaSHDCd0hWtdv9lu8ul78fNhAHV0THNagsSORQeOTmq9b3AzqB1r+PnsndH1S/qFlsBZddUihMuPttDTbsQ1z+UdzgeWs',
1991     },
1992     {
1993       'accessKey': 'c2cfaaebb10f00ab',
1994       'payload': 'CAVH/4AwEJzNt950XEAZP3Q92fMNasIje6K5FSBgjqchkxmrFxjlNHjadnrHWdqM+zrB',
1995     },
1996     {
1997       'accessKey': '130b037dadbe1d7a',
1998       'payload': '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',
1999     },
2000     {
2001       'accessKey': 'd8e8dd84f4b0c103',
2002       'payload': 'EGKsJYSjVVaxCBWPRUGjWuLMl3k7fB/7uKYp8wz28r/5XTaOJF7LnbPMpBwysAR8IR/whArG',
2003     },
2004   ];
2006   game['fn'].loadSettings();
2007   game['fn'].applySettings();
2009   if(game.ui.root.querySelectorAll('.ui-page.credits .area h3').length > 3) {
2010     // If the credits have more than three third-level headers, that means we are
2011     // in the freeware version and can make the CSS adjustments it needs.
2012     let css = document.styleSheets[0];
2013     css.insertRule('.upInTheAirGame .ui-page.credits .person { position: relative; height: 4em; padding-left: calc(4em + 1ex); display: flex; flex-direction: column; justify-content: center; }');
2014     css.insertRule('.upInTheAirGame .ui-page.credits .person::before { content: " "; position: absolute; left: 0; box-sizing: border-box; width: 4em; height: 4em; background-size: contain; border-radius: .6em; border: .1em solid #d53c59; }');
2015     game.ui.root.querySelectorAll('.ui-page.credits .area .person').forEach((person) => {
2016       let personName = Array.from(person.classList).filter(c => c != 'person')[0];
2017       let imageFormat = (personName == 'nina') ? 'png' : 'jpg';
2018       css.insertRule('.upInTheAirGame .ui-page.credits .person.' + personName + '::before { background-image: url("' + game['deploymentOptions']['assetUrlPrefix'] + 'textures/person-' + personName + '.' + imageFormat + '"); }');
2019     });
2020   }
2022   game.ui.root.querySelectorAll('button.goto').forEach((btn) => {
2023     btn.addEventListener('click', (e) => {
2024       if(game.view && !game.view.music) {
2025         game.view.audioListener = new THREE.AudioListener();
2026         game.view.camera.add(game.view.audioListener);
2027         game.view.music = new THREE.Audio(game.view.audioListener);
2028         game.view.music.setBuffer(game.assets['audio']['music-' + game.settings['audio']['theme']]);
2029         game.view.music.setVolume(game.settings['audio']['music']);
2030         game.view.windSound = new THREE.Audio(game.view.audioListener);
2031         game.view.windSound.setBuffer(game.assets['audio']['wind']);
2032         game.view.windSound.setVolume(game.settings['audio']['sounds']);
2033         game.view.windSound.setLoop(false);
2034       }
2035       let btn = e.target.closest('button');
2036       let target = Array.from(btn.classList).filter(c => c != 'goto')[0];
2037       if(target == 'previous') {
2038         target = game.ui.previousPage;
2039       }
2040       game['fn'].moveToPage(target);
2041     });
2042   });
2044   game.ui.root.querySelectorAll('.options .controls input, .options .graphics input, .options .feather input, .options .accessibility input, .options .accessibility select').forEach((elem) => {
2045     elem.addEventListener('change', () => {
2046       game['fn'].applySettings();
2047       if(elem.name == 'upInTheAirGame-controls') {
2048         game.ui.root.querySelector('.controls .leftside').style.display = (['touchpad', 'thumbstick'].includes(game.settings['controls'])) ? 'block' : 'none';
2049         game.ui.root.querySelectorAll('.options .controls p span:not(.' + game.settings['controls'] + ')').forEach(span => span.style.display = 'none');
2050         game.ui.root.querySelector('.options .controls span.' + game.settings['controls']).style.display = 'block';
2051       } else if(elem.value == 'highcontrast' || elem.name == 'upInTheAirGame-graphics') {
2052         game['fn'].createMeshes();
2053       } else if(elem.name == 'upInTheAirGame-feather') {
2054         game['fn'].createFeather();
2055       }
2056     });
2057   });
2059   game.ui.root.querySelector('.ui-page.title .system-buttons input').addEventListener('change', (e) => {
2060     game.view.muted = e.target.checked;
2061   });
2063   game.ui.root.querySelector('.ui-page.title .system-buttons button').addEventListener('click', (e) => {
2064     if(document.fullscreenElement == game.ui.root.parentNode) {
2065       document.exitFullscreen();
2066     } else {
2067       game.ui.root.parentNode.requestFullscreen();
2068     }
2069   });
2071   game.ui.root.querySelectorAll('.ui-page .audio input[type=range]').forEach((elem) => {
2072     elem.addEventListener('input', (e) => {
2073       let audioCategory = Array.from(e.target.classList).filter(v => ['music', 'sounds'].includes(v))[0];
2074       game.ui.root.querySelector('.ui-page.options .audio input[type=range].' + audioCategory).value = e.target.value;
2075       game['fn'].applySettings();
2076     });
2077   });
2079   game.ui.root.querySelectorAll('.options .audio button').forEach((btn) => {
2080     btn.addEventListener('click', (e) => {
2081       if(e.target.classList.contains('music')) {
2082         if(game.view.music.isPlaying) {
2083           game.view.music.stop();
2084           if(game.view.music.timeoutID) {
2085             clearTimeout(game.view.music.timeoutID);
2086             delete game.view.music.timeoutID;
2087           }
2088         } else {
2089           game.view.music.offset = 36;
2090           if(!game.view.muted) {
2091             game.view.music.play();
2092           }
2093           game.view.music.timeoutID = setTimeout(() => {
2094             game.view.music.stop();
2095           }, 6000);
2096         }
2097       } else if(e.target.classList.contains('sounds')) {
2098         game['fn'].playRandomSound();
2099       }
2100     });
2101   });
2103   game.ui.root.querySelectorAll('.options .keyboard label button').forEach((btn) => {
2104     btn.addEventListener('click', () => {
2105       if(game.ui.root.querySelector('.ui-page.keyboard-modal')) {
2106         return;
2107       }
2108       const keyboardModal = document.createElement('div');
2109       keyboardModal.classList.add('ui-page', 'keyboard-modal');
2110       const instruction = document.createElement('span');
2111       const direction = btn.classList[0];
2112       keyboardModal.classList.add(direction);
2113       instruction.innerText = 'Please press the key for “' + direction[0].toUpperCase() + direction.slice(1) + '”';
2114       keyboardModal.appendChild(instruction);
2115       game.ui.root.appendChild(keyboardModal);
2116     });
2117   });
2119   game.ui.root.querySelector('.options .keyboard button[value="reset"]').addEventListener('click', (e) => {
2120     const container = e.target.parentNode;
2121     container.querySelector('button.up').value = 'ArrowUp|w';
2122     container.querySelector('button.right').value = 'ArrowRight|d';
2123     container.querySelector('button.down').value = 'ArrowDown|s';
2124     container.querySelector('button.left').value = 'ArrowLeft|a';
2125     game['fn'].applySettings();
2126     game['fn'].loadSettings();
2127   });
2129   game.ui.root.querySelectorAll('.ui-page .areatabs button').forEach((btn) => {
2130     btn.addEventListener('click', (e) => {
2131       btn.parentNode.querySelectorAll('button').forEach((otherBtn) => {
2132         otherBtn.classList.remove('active');
2133         let val = otherBtn.classList[0];
2134         otherBtn.closest('.ui-page').querySelector('div.' + val).style.display = 'none';
2135       });
2136       btn.classList.add('active');
2137       let val = Array.from(btn.classList).filter(c => c != 'active')[0];
2138       btn.closest('.ui-page').querySelector('div.' + val).style.display = 'flex';
2139     });
2140   });
2142   game.ui.root.style.fontSize = (game.ui.root.clientWidth / 48) + 'px';
2143   window.addEventListener('resize', () => {
2144     game.ui.root.style.fontSize = (game.ui.root.clientWidth / 48) + 'px';
2145   });
2147   window.addEventListener('scroll', () => {
2148     if(!['gameplay', 'openingcutscene', 'endingcutscene'].includes(game.ui.currentPage)) {
2149       return;
2150     }
2151     let bbox = game.ui.root.querySelector('canvas').getBoundingClientRect();
2152     if(bbox.bottom < -100 || bbox.top - bbox.height > 100 || bbox.left + bbox.width < -100 || bbox.left - window.innerWidth > 100) {
2153       game['fn'].moveToPage('pause', true);
2154     }
2155   });
2156   window.addEventListener('blur', () => {
2157     if(['gameplay', 'openingcutscene', 'endingcutscene'].includes(game.ui.currentPage)) {
2158       game['fn'].moveToPage('pause', true);
2159     }
2160   });
2162   document.addEventListener('keydown', (e) => {
2163     const keyboardModal = game.ui.root.querySelector('.keyboard-modal');
2164     if(keyboardModal) {
2165       const direction = [...keyboardModal.classList].filter(c => c != 'ui-page' && c != 'keyboard-modal')[0];
2166       if(e.key != 'Escape') {
2167         game.ui.root.querySelector('.options .keyboard label button.' + direction).value = e.key;
2168         game['fn'].applySettings();
2169         game['fn'].loadSettings();
2170       }
2171       keyboardModal.remove();
2172       e.preventDefault();
2173       e.stopPropagation();
2174       return;
2175     }
2176     if(game.ui.currentPage == 'title' && e.key.match(/[a-z]/)) {
2177       game.view.cheatBuffer = (game.view.cheatBuffer + e.key).slice(-25);
2178       for(let len = 10; len <= 25; len++) {
2179         if(game.view.cheatBuffer.length < len) {
2180           break;
2181         }
2182         let unlock = game['fn'].unlockWithKey(game.view.cheatBuffer.slice(-len));
2183         if(unlock && unlock['type'] == 'feather' && !game.settings['unlocks'].includes(unlock['name'])) {
2184           game['fn'].playRandomSound();
2185           game['fn'].unlockFeather(unlock['name'], unlock['url']);
2186           game['fn'].moveToPage('unlock');
2187         }
2188       }
2189       return;
2190     }
2191     if(e.key == 'Escape') {
2192       if(['gameplay', 'openingcutscene', 'endingcutscene'].includes(game.ui.currentPage)) {
2193         game['fn'].moveToPage('pause', true);
2194       } else if(game.ui.currentPage == 'pause') {
2195         game['fn'].moveToPage(game.ui.previousPage, true);
2196       }
2197     }
2198   });
2200   window.addEventListener('gamepadconnected', (e) => {
2201     game.ui.gamepads.push(e.gamepad);
2202   });
2203   window.addEventListener('gamepaddisconnected', (e) => {
2204     if(game.ui.gamepads.includes(e.gamepad)) {
2205       game.ui.gamepads.splice(game.ui.gamepads.indexOf(e.gamepad), 1);
2206     }
2207   });
2209   game.ui.root.querySelector('.ui-page.pause button.title').addEventListener('click', () => {
2210     game['fn'].reset();
2211   });
2213   game['fn'].loadAllAssets((progress) => {
2214     let percentage = Math.floor(100 * progress);
2215     game.ui.root.querySelector('.ui-page.loading progress').value = percentage;
2216     game.ui.root.querySelector('.ui-page.loading span').innerText = percentage;
2217   }).then(() => {
2218     if(window.location.hostname == 'fietkau.media' && window.location.pathname == '/up_in_the_air') {
2219       game.ui.root.querySelector('.ui-page.title .footer span:last-child').remove();
2220     }
2221     let controlsInterstitial = false;
2222     if(!game.settings['controls']) {
2223       controlsInterstitial = true;
2224       let control;
2225       if(matchMedia('(hover: hover)').matches) {
2226         control = 'mouse';
2227       } else {
2228         control = 'touchpad';
2229       }
2230       game.ui.root.querySelector('.controls input[value="' + control + '"]').checked = true;
2231       game['fn'].applySettings();
2232       game['fn'].loadSettings();
2233       game.ui.root.querySelectorAll('.ui-page.controls .' + ((control == 'mouse') ? 'touchpad' : 'mouse')).forEach(elem => elem.remove());
2234     }
2235     if(!game.assets.audiothemes.includes(game.settings['audio']['theme'])) {
2236       game.settings['audio']['theme'] = game.assets.audiothemes[0];
2237     }
2238     if(game.assets.audiothemes.length == 1) {
2239       game.ui.root.querySelector('.ui-page.options .audiotheme').style.display = 'none';
2240     }
2241     let container = game.ui.root.querySelector('.ui-page.options .audiotheme');
2242     for(let audioTheme of game.assets.audiothemes) {
2243       let snippet = container.children[0].content.cloneNode(true).children[0];
2244       snippet.children[0].value = audioTheme;
2245       if(audioTheme == game.settings['audio']['theme']) {
2246         snippet.children[0].checked = true;
2247       }
2248       snippet.children[0].addEventListener('change', () => {
2249         game['fn'].applySettings();
2250         game.view.music.setBuffer(game.assets['audio']['music-' + game.settings['audio']['theme']]);
2251       });
2252       snippet.childNodes[1].textContent = ' ' + audioTheme[0].toUpperCase() + audioTheme.slice(1);
2253       container.appendChild(snippet);
2254     }
2255     if(controlsInterstitial) {
2256       game['fn'].moveToPage('controls');
2257     } else {
2258       game['fn'].moveToPage('title');
2259     }
2260   }, (err) => {
2261     console.error(err);
2262   });
2264 };
2266 // Set up name mirrors for each function that should survive most minifiers and transpilers
2267 game['fn'].animate['friendlyName'] = 'animate';
2268 game['fn'].applyForceToFeather['friendlyName'] = 'applyForceToFeather';
2269 game['fn'].applySettings['friendlyName'] = 'applySettings';
2270 game['fn'].createFeather['friendlyName'] = 'createFeather';
2271 game['fn'].createMeshes['friendlyName'] = 'createMeshes';
2272 game['fn'].easeInOut['friendlyName'] = 'easeInOut';
2273 game['fn'].initializeGame['friendlyName'] = 'initializeGame';
2274 game['fn'].lerp['friendlyName'] = 'lerp';
2275 game['fn'].loadAllAssets['friendlyName'] = 'loadAllAssets';
2276 game['fn'].loadSettings['friendlyName'] = 'loadSettings';
2277 game['fn'].moveToPage['friendlyName'] = 'moveToPage';
2278 game['fn'].playRandomSound['friendlyName'] = 'playRandomSound';
2279 game['fn'].prepareWordMesh['friendlyName'] = 'prepareWordMesh';
2280 game['fn'].reset['friendlyName'] = 'reset';
2281 game['fn'].start['friendlyName'] = 'start';
2282 game['fn'].unlockFeather['friendlyName'] = 'unlockFeather';
2283 game['fn'].unlockWithKey['friendlyName'] = 'unlockWithKey';
2285 game['fn'].start();