Up-in-the-Air – blob

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