Up-in-the-Air – blob

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