2 // SPDX-License-Identifier: GPL-3.0-or-later
5 * – a browser game created for FediJam 2024 –
6 * https://fietkau.media/up_in_the_air
8 * Copyright (c) Julian Fietkau
9 * See README.txt for details.
11 *******************************************************************************
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.
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.
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/>.
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'] = {};
38 const deploymentDefaults = {
41 for(let k in deploymentDefaults) {
42 if(!game['deploymentOptions'].hasOwnProperty(k)) {
43 game['deploymentOptions'][k] = deploymentDefaults[k];
49 game['fn'].playRandomSound = () => {
50 if(!game.view || !game.view.audioListener) {
53 if(!game.view.lastSoundsCache) {
54 game.view.lastSoundsCache = [];
57 // We remember the last two notes played and make sure not to repeat one of those.
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);
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) {
73 game['fn'].easeInOut = (val) => {
74 return -0.5 * Math.cos(val * Math.PI) + 0.5;
77 game['fn'].lerp = (start, end, progress) => {
78 return (1.0 - progress) * start + progress * end;
81 game['fn'].loadAllAssets = (renderProgressCallback) => {
84 'thanks': ['thank you', 'thanks'],
85 'sorry': ['sorry', 'apologize'],
86 'emotion': ['blessed', 'contented', 'fortunate', 'fulfilled', 'glad', 'happy', 'joyful', 'joyous', 'lucky', 'overjoyed', 'thankful'],
87 'verb_general': ['adore', 'appreciate', 'applaud', 'cherish', 'enjoy', 'like', 'love', 'treasure', 'value'],
88 'verb_person': ['admire', 'honor', 'love', 'respect', 'treasure', 'value'],
89 'verb_together': ['chat', 'dance', 'hang out', 'meet', 'sing', 'speak', 'talk'],
90 'verb_communication': ['convey to', 'say to', 'show', 'tell'],
91 'trait': ['amazing', 'compassionate', 'delightful', 'genuine', 'generous', 'incredible', 'joyful', 'kind', 'passionate', 'patient', 'principled', 'refreshing', 'sweet'],
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 'You’re so {trait} whenever we {verb_together}.',
139 'When we {verb_together}, you’re always {trait}.',
140 'It’s hard to {verb_communication} you what you mean to me.',
141 'It’s hard to {verb_communication} you how {trait} you are.',
142 'It’s hard to {verb_communication} you how {trait} you are.',
143 'I want to {verb_communication} you how {emotion} you make me feel.',
144 'I want to {verb_communication} you how {emotion} I feel around you.',
145 'Thinking about how {trait} you are always improves my mood.',
146 'You are the most {trait} person I know.',
147 'You are the most {trait} person I know.',
148 'Your {trait} personality always makes my day.',
149 'Your {trait} personality is my sunshine.',
150 'Your {trait} personality gives me strength.',
151 'I’m astonished how {trait} you are.',
152 'I hope I can learn to be as {trait} as you.',
154 return new Promise((resolve, reject) => {
156 'audio/wind.ogg': 72482,
157 'fonts/cookie.json': 37866,
158 'textures/cloud0a.png': 568,
159 'textures/cloud0b.png': 569,
160 'textures/cloud0c.png': 568,
161 'textures/cloud1a.png': 6932,
162 'textures/cloud1b.png': 6932,
163 'textures/cloud1c.png': 6933,
164 'textures/cloud2a.png': 4365,
165 'textures/cloud2b.png': 4364,
166 'textures/cloud2c.png': 4365,
167 'textures/cloud3a.png': 4000,
168 'textures/cloud3b.png': 3999,
169 'textures/cloud3c.png': 4001,
170 'textures/cloud4a.png': 3183,
171 'textures/cloud4b.png': 3182,
172 'textures/cloud4c.png': 3184,
173 'textures/cloud5a.png': 2066,
174 'textures/cloud5b.png': 2065,
175 'textures/cloud5c.png': 2066,
176 'textures/feather-black.png': 1026,
177 'textures/feather-blue.png': 1026,
178 'textures/feather-brown.png': 1027,
179 'textures/feather-green.png': 1028,
180 'textures/feather-orange.png': 1028,
181 'textures/feather-purple.png': 1028,
182 'textures/feather-red.png': 1024,
183 'textures/highcontrast-backdrop.png': 500,
184 'textures/pinwheel.png': 904,
185 'textures/house-day-1.png': 17819,
186 'textures/house-day-2.png': 598,
187 'textures/house-day-3.png': 646,
188 'textures/house-evening-1.png': 16939,
189 'textures/house-evening-2.png': 597,
190 'textures/house-evening-3.png': 646,
192 for(let unlockable of game.settings['unlocks']) {
193 if(unlockable == 'golden') {
194 todoList['textures/feather-golden.png'] = 1027;
195 } else if(unlockable == 'ghost') {
196 todoList['textures/feather-ghost.png'] = 8810;
198 let unlock = game['fn'].unlockWithKey('NIbp2kW5' + unlockable + 'e2ZDFl5Y');
199 if(unlock && unlock['type'] == 'feather') {
200 todoList['data:textures/feather-' + unlock['name']] = unlock['url'];
204 game.assets.audiothemes = [];
205 const audioThemes = {
206 'classical': [1636930, 34002, 34629, 25399, 16426, 26122],
208 for(let theme of Object.keys(audioThemes)) {
209 todoList['audio/music-' + theme + '.ogg'] = audioThemes[theme][0];
210 todoList['audio/sound1-' + theme + '.ogg'] = audioThemes[theme][1];
211 todoList['audio/sound2-' + theme + '.ogg'] = audioThemes[theme][2];
212 todoList['audio/sound3-' + theme + '.ogg'] = audioThemes[theme][3];
213 todoList['audio/sound4-' + theme + '.ogg'] = audioThemes[theme][4];
214 todoList['audio/sound5-' + theme + '.ogg'] = audioThemes[theme][5];
215 game.assets.audiothemes.push(theme);
217 let total = Object.keys(todoList).filter(k => !k.startsWith('data:')).map(k => todoList[k]).reduce((a, b) => a + b, 0);
220 'audio': new THREE.AudioLoader(),
221 'fonts': new FontLoader(),
222 'textures': new THREE.TextureLoader(),
224 for(let todo in todoList) {
225 let isDataUri = todo.startsWith('data:');
227 todo = todo.slice(5);
231 let segments = todo.split('/');
232 if(!(segments[0] in game.assets)) {
233 game.assets[segments[0]] = {};
235 if(!(segments[0] in loader)) {
236 reject('Unsupported resource: ' + todo);
240 url = todoList['data:' + todo];
242 url = game['deploymentOptions']['assetUrlPrefix'] + url;
244 loader[segments[0]].load(url, (result) => {
245 if(segments[0] == 'textures') {
247 result.colorSpace = THREE.SRGBColorSpace;
248 result.minFilter = THREE.NearestFilter;
249 result.magFilter = THREE.NearestFilter;
250 if(segments[1].split('.')[0].startsWith('feather-')) {
251 result.repeat = new THREE.Vector2(1, -1);
252 result.wrapT = THREE.RepeatWrapping;
253 } else if(segments[1].split('.')[0] == 'highcontrast-backdrop') {
254 result.repeat = new THREE.Vector2(25, 25);
255 result.wrapS = THREE.RepeatWrapping;
256 result.wrapT = THREE.RepeatWrapping;
259 game.assets[segments[0]][segments[1].split('.')[0]] = result;
260 if(todo in progress) {
261 progress[todo] = todoList[todo];
262 if(renderProgressCallback) {
263 renderProgressCallback(Object.keys(progress).map(k => progress[k]).reduce((a, b) => a + b, 0) / total);
267 if(todo in progress) {
268 progress[todo] = xhr.loaded;
269 if(renderProgressCallback) {
270 renderProgressCallback(Object.keys(progress).map(k => progress[k]).reduce((a, b) => a + b, 0) / total);
274 reject('Error while loading ' + todo + ': ' + err);
277 const loadingHeartbeat = () => {
278 let totalProgress = Object.keys(progress).map(k => progress[k]).reduce((a, b) => a + b, 0);
279 if(totalProgress == total) {
280 resolve(totalProgress);
282 setTimeout(loadingHeartbeat, 100);
285 setTimeout(loadingHeartbeat, 100);
289 game['fn'].applyForceToFeather = (vector) => {
290 game.objects.feather.speed.add(vector);
293 game['fn'].initializeGame = (canvas) => {
294 game.timeProgress = 0;
295 game.timeTotal = 258;
296 game.courseRadius = 50;
300 game.view.muted = false;
301 game.view.canvas = canvas;
302 game.view.audioListener = new THREE.AudioListener();
303 game.ui.virtualInput = canvas.closest('.upInTheAirGame').querySelector('.virtual-input-widget');
305 const scene = new THREE.Scene();
306 game.view.camera = new THREE.PerspectiveCamera(75, canvas.width / canvas.height, 0.1, 1000);
307 game.view.camera.position.z = 5;
308 game.view.camera.add(game.view.audioListener);
309 game.view.ambientLight = new THREE.AmbientLight(0xffffff, 2);
310 scene.add(game.view.ambientLight);
311 game.view.directionalLight1 = new THREE.DirectionalLight(0xffffff, 1);
312 game.view.directionalLight1.position.set(1, 1, 1);
313 scene.add(game.view.directionalLight1);
314 game.view.directionalLight2 = new THREE.DirectionalLight(0xffffff, 1);
315 game.view.directionalLight2.position.set(-1, -1, 1);
316 scene.add(game.view.directionalLight2);
317 game.view.directionalLight3 = new THREE.DirectionalLight(0xffffff, 1);
318 game.view.directionalLight3.position.set(0, -1, 1);
319 scene.add(game.view.directionalLight3);
320 game.view.renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: false });
321 game.view.renderer.setSize(canvas.width, canvas.height);
322 game.view.renderer.setClearColor(0x808080, 1);
323 let resolution = Math.round(3200 / Math.pow(2, game.settings['graphics']));
324 game.view.canvas.width = resolution;
325 game.view.canvas.height = resolution;
326 game.view.camera.updateProjectionMatrix();
327 game.view.renderer.setSize(game.view.canvas.width, game.view.canvas.height);
328 game.view.clock = new THREE.Clock();
329 game.view.clock.previousTime = 0;
330 game.view.clock.getDeltaTime = () => {
331 const elapsedTime = game.view.clock.getElapsedTime();
332 const deltaTime = elapsedTime - game.view.clock.previousTime;
333 game.view.clock.previousTime = elapsedTime;
337 const pinwheelGeometry = new THREE.BoxGeometry(.9, .9, 0.01);
338 const pinwheelMaterial = new THREE.MeshPhongMaterial({
339 map: game.assets.textures.pinwheel,
344 game.objects.pinwheel = new THREE.Mesh(pinwheelGeometry, [null, null, null, null, pinwheelMaterial, null]);
345 game.objects.pinwheel.position.setY(2);
346 game.objects.pinwheel.position.setZ(-1);
347 game.objects.pinwheel.opacity = 0;
348 scene.add(game.objects.pinwheel);
350 for(let time of ['day', 'evening']) {
351 game.objects[time + 'House'] = new THREE.Group();
352 for(let layer of [1, 2, 3]) {
353 let material = new THREE.MeshBasicMaterial({
354 map: game.assets['textures']['house-' + time + '-' + layer],
360 dimensions = [14, 14, 0, 0, -2];
361 } else if(layer == 2) {
362 dimensions = [6, 3.4455, -1.4, -3.6, -3];
363 } else if(layer == 3) {
364 dimensions = [10, 10, -4, -2, -6];
366 let mesh = new THREE.Mesh(new THREE.PlaneGeometry(dimensions[0], dimensions[1]), material);
367 mesh.position.set(dimensions[2], dimensions[3], dimensions[4]);
368 if(time == 'evening') {
369 mesh.position.setX(-mesh.position.x);
371 game.objects[time + 'House'].add(mesh);
373 game.objects[time + 'House'].position.set(-11.5, -game.courseRadius - 4, -7);
374 if(time == 'evening') {
375 game.objects[time + 'House'].position.x *= -1;
379 game.view.camera.position.set(-5, -game.courseRadius, game.view.camera.position.z);
380 game.view.scene = scene;
382 game['fn'].createMeshes();
383 game['fn'].createFeather();
386 function pinwheelPositionUpdate(game, viewportX, viewportY) {
387 const vFOV = THREE.MathUtils.degToRad(game.view.camera.fov);
388 const viewportHeight = 2 * Math.tan(vFOV / 2) * (game.view.camera.position.z - game.objects.pinwheel.position.z);
389 game.controls.positionX = viewportHeight * viewportX;
390 game.controls.positionY = - viewportHeight * viewportY;
393 function cursorMoveEvent(game, target, viewportLocalX, viewportLocalY, pressed) {
394 if(game.settings['controls'] == 'mouse' || game.settings['controls'] == 'touchpad') {
395 let sensorElem = game.view.canvas;
396 if(game.settings['controls'] == 'touchpad') {
397 sensorElem = game.ui.virtualInput;
399 let bbox = sensorElem.getBoundingClientRect();
400 // Intentional division by height instead of width in the following line, since
401 // three.js controls the vertical FOV. So if we ever change the aspect ratio from 1:1,
402 // y will still be in range (-0.5, 0.5), but the range for x will be smaller or larger.
403 let x = (viewportLocalX - bbox.x - (bbox.width / 2)) / bbox.height;
404 let y = (viewportLocalY - bbox.y - (bbox.height / 2)) / bbox.height;
405 if(game.settings['controls'] == 'touchpad') {
406 sensorElem.children[0].style.left = ((0.5 + x) * 100) + '%';
407 sensorElem.children[0].style.top = ((0.5 + y) * 100) + '%';
409 if(game.settings['controls'] == 'touchpad') {
413 // The pinwheel gets to go a little bit past the edge of the playing field.
414 const maxDist = 0.55;
415 if(game.settings['controls'] == 'mouse' || (pressed && Math.abs(x) <= maxDist && Math.abs(y) <= maxDist)) {
416 pinwheelPositionUpdate(game, x, y);
419 if(game.settings['controls'] == 'thumbstick') {
420 if(!game.ui.virtualInput.inProgress) {
423 let bbox = game.ui.virtualInput.getBoundingClientRect();
426 x = (viewportLocalX - bbox.x - (bbox.width / 2)) / bbox.height;
427 y = (viewportLocalY - bbox.y - (bbox.height / 2)) / bbox.height;
428 let vLen = Math.sqrt(4 * x * x + 4 * y * y);
429 x = x / Math.max(vLen, 0.6);
430 y = y / Math.max(vLen, 0.6);
435 let speedScale = 7.0;
438 if(Math.abs(speedX) < deadZone) {
442 if(Math.abs(speedY) < deadZone) {
445 game.controls.speedX = speedScale * speedX;
446 game.controls.speedY = -1 * speedScale * speedY;
449 game.ui.virtualInput.children[0].style.left = ((0.5 + x) * 100) + '%';
450 game.ui.virtualInput.children[0].style.top = ((0.5 + y) * 100) + '%';
454 function keyboardEvent(game, key, motion) {
455 if(game.settings['controls'] != 'keyboard') {
458 if(motion == 'down') {
459 if(game.controls.heldKeys.includes(key)) {
462 game.controls.heldKeys.push(key);
464 if(game.controls.heldKeys.includes(key)) {
465 game.controls.heldKeys.splice(game.controls.heldKeys.indexOf(key), 1);
468 if(game.settings['keyboard']['tapmode']) {
469 if(motion != 'down') {
472 if(game.settings['keyboard']['up'].includes(key)) {
473 game.controls.speedY = Math.max(0.0, game.controls.speedY + 2.0);
475 if(game.settings['keyboard']['down'].includes(key)) {
476 game.controls.speedY = Math.min(0.0, game.controls.speedY - 2.0);
478 if(game.settings['keyboard']['right'].includes(key)) {
479 game.controls.speedX = Math.max(0.0, game.controls.speedX + 2.0);
481 if(game.settings['keyboard']['left'].includes(key)) {
482 game.controls.speedX = Math.min(0.0, game.controls.speedX - 2.0);
486 if(motion == 'down' && game.settings['keyboard']['up'].includes(key)) {
487 game.controls.accelY = 15.0;
488 game.controls.speedY = 0.0;
490 if(motion == 'down' && game.settings['keyboard']['down'].includes(key)) {
491 game.controls.accelY = -15.0;
492 game.controls.speedY = 0.0;
494 if(motion == 'down' && game.settings['keyboard']['right'].includes(key)) {
495 game.controls.accelX = 15.0;
496 game.controls.speedX = 0.0;
498 if(motion == 'down' && game.settings['keyboard']['left'].includes(key)) {
499 game.controls.accelX = -15.0;
500 game.controls.speedX = 0.0;
502 if(motion == 'up' && game.settings['keyboard']['up'].includes(key)) {
503 game.controls.accelY = Math.min(0.0, game.controls.accelY);
504 game.controls.speedY = Math.min(0.0, game.controls.speedY);
506 if(motion == 'up' && game.settings['keyboard']['down'].includes(key)) {
507 game.controls.accelY = Math.max(0.0, game.controls.accelY);
508 game.controls.speedY = Math.max(0.0, game.controls.speedY);
510 if(motion == 'up' && game.settings['keyboard']['right'].includes(key)) {
511 game.controls.accelX = Math.min(0.0, game.controls.accelX);
512 game.controls.speedX = Math.min(0.0, game.controls.speedX);
514 if(motion == 'up' && game.settings['keyboard']['left'].includes(key)) {
515 game.controls.accelX = Math.max(0.0, game.controls.accelX);
516 game.controls.speedX = Math.max(0.0, game.controls.speedX);
520 document.body.addEventListener('mousemove', e => cursorMoveEvent(game, e.target, e.clientX, e.clientY, (e.buttons % 2 == 1)));
521 document.body.addEventListener('mousedown', e => {
522 if(game.settings['controls'] == 'touchpad' || game.settings['controls'] == 'thumbstick') {
523 if(e.target.closest('.virtual-input-widget') == game.ui.virtualInput) {
524 game.ui.virtualInput.inProgress = true;
525 game.ui.virtualInput.children[0].style.display = 'block';
528 game.ui.virtualInput.inProgress = false;
529 if(game.settings['controls'] == 'touchpad') {
530 game.ui.virtualInput.children[0].style.display = 'none';
534 cursorMoveEvent(game, e.target, e.clientX, e.clientY, (e.buttons % 2 == 1));
536 document.body.addEventListener('mouseup', e => {
537 cursorMoveEvent(game, e.target, e.clientX, e.clientY, (e.buttons % 2 == 1));
538 if(game.settings['controls'] == 'touchpad' || game.settings['controls'] == 'thumbstick') {
539 game.ui.virtualInput.inProgress = false;
540 if(game.settings['controls'] == 'touchpad') {
541 game.ui.virtualInput.children[0].style.display = 'none';
543 game.ui.virtualInput.children[0].style.transitionDuration = '50ms';
544 setTimeout(() => { game.ui.virtualInput.children[0].style.transitionDuration = '0ms'; }, 75);
546 cursorMoveEvent(game, e.target, 0, 0, false);
549 document.body.addEventListener('touchmove', e => cursorMoveEvent(game, e.target, e.touches[0].clientX, e.touches[0].clientY, true));
550 document.body.addEventListener('touchstart', e => {
551 if(game.settings['controls'] == 'touchpad' || game.settings['controls'] == 'thumbstick') {
552 if(e.target.closest('.virtual-input-widget') == game.ui.virtualInput) {
553 game.ui.virtualInput.inProgress = true;
554 game.ui.virtualInput.children[0].style.display = 'block';
557 game.ui.virtualInput.inProgress = false;
558 if(game.settings['controls'] == 'touchpad') {
559 game.ui.virtualInput.children[0].style.display = 'none';
563 cursorMoveEvent(game, e.target, e.touches[0].clientX, e.touches[0].clientY, true);
565 document.body.addEventListener('touchend', e => {
566 if(e.target.closest('.ui-container') && game.settings['controls'] != 'mouse' && ['gameplay', 'openingcutscene', 'endingcutscene'].includes(game.ui.currentPage)) {
567 game['fn'].moveToPage('pause', true);
570 if(game.settings['controls'] == 'touchpad' || game.settings['controls'] == 'thumbstick') {
571 game.ui.virtualInput.inProgress = false;
572 if(game.settings['controls'] == 'touchpad') {
573 game.ui.virtualInput.children[0].style.display = 'none';
575 game.ui.virtualInput.children[0].style.transitionDuration = '50ms';
576 setTimeout(() => { game.ui.virtualInput.children[0].style.transitionDuration = '0ms'; }, 75);
578 cursorMoveEvent(game, e.target, 0, 0, false);
581 document.body.addEventListener('keydown', e => keyboardEvent(game, e.key, 'down'));
582 document.body.addEventListener('keyup', e => keyboardEvent(game, e.key, 'up'));
584 // All vectors used by the game loop (no allocations inside)
586 game.var.featherLocalPos = new THREE.Vector3();
587 game.var.featherBorderForce = new THREE.Vector3();
588 game.var.pinwheelDistance = new THREE.Vector3();
589 game.var.pinwheelRotationSpeed = 0;
590 game.var.notCollectedPos = new THREE.Vector3();
591 game.var.collectedPos = new THREE.Vector3();
592 game.var.endingEntryTrajectory = new THREE.Vector3();
593 game.var.endingExitTrajectory = new THREE.Vector3();
594 game.var.endingEntryRotation = new THREE.Vector3();
595 game.var.endingExitRotation = new THREE.Vector3();
596 game.view.renderer.setAnimationLoop(() => { game['fn'].animate(scene); });
599 game['fn'].prepareWordMesh = (word) => {
600 while(word.children.length > 0) {
601 word.remove(word.children[0]);
603 if(game.settings['graphics'] <= 2 && !game.settings['highcontrast']) {
604 for(let letter of word.text.split(' ')[0]) {
605 let geometry = game.assets.fonts.geometry[letter];
606 let material = game.view.materials.letter;
607 if(geometry.customMaterial) {
608 material = geometry.customMaterial;
610 let mesh = new THREE.Mesh(geometry, material);
611 // We wrap each letter in a surrounding group in order to move the center point
612 // from the corner of the letter to its center. This makes rotations easier.
613 let container = new THREE.Group();
614 mesh.position.set(-game.assets.fonts.geometry[letter].dx, -game.assets.fonts.geometry[letter].dy, 0);
618 } else if(game.settings['graphics'] == 3 || game.settings['highcontrast']) {
619 let mesh = new THREE.Mesh(Object.values(game.assets.fonts.geometry)[0], game.view.materials.letter);
624 game['fn'].reset = () => {
626 game.controls.positionX = 0;
627 game.controls.positionY = 0;
628 game.controls.speedX = 0;
629 game.controls.speedY = 0;
630 game.controls.accelX = 0;
631 game.controls.accelY = 0;
632 game.controls.heldKeys = [];
633 game.ui.reachedEnd = false;
634 if(game.settings['graphics'] <= 2 && !game.settings['highcontrast']) {
635 for(let i = 0; i < 6; i++) {
636 game.view.materials['cloud' + i].uniforms.lerp.value = 0.0;
639 game.view.scene.add(game.objects.dayHouse);
640 game.view.scene.remove(game.objects.eveningHouse);
641 game.objects.feather.position.set(-11.45, -game.courseRadius - 4.2, -9.9);
642 game.objects.feather.rotation.set(Math.PI, 0, Math.PI / 2.1);
643 game.objects.pinwheel.material[4].opacity = 0.0;
645 game.ui.root.querySelector('.ui-page.title').classList.remove('end');
648 if(game.objects.words) {
649 for(let word of game.objects.words) {
650 game.view.scene.remove(word);
653 game.objects.words = [];
654 game.objects.words.collectedCount = 0;
655 game.ui.hud.children[1].innerText = '0';
656 const interWordDistance = new THREE.Vector3();
657 let placementSuccess;
659 for(let i = 0; i < 100; i++) {
661 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));
663 angleInCourse = Math.random();
664 } while(clusteringFunction(angleInCourse) < Math.random());
665 angleInCourse = (0.08 + 0.87 * angleInCourse);
667 angleInCourse = 0.05;
669 let randomCameraX = game.courseRadius * Math.sin(angleInCourse * 2 * Math.PI);
670 let randomCameraY = game.courseRadius * -Math.cos(angleInCourse * 2 * Math.PI);
671 let word = new THREE.Group();
672 if(wordList.length == 0) {
673 wordList.push(...game.assets.wordList);
675 let wordIndex = Math.floor(Math.random() * wordList.length);
676 word.text = wordList.splice(wordIndex, 1)[0];
677 game['fn'].prepareWordMesh(word);
678 word.randomAnimOffset = Math.random();
679 const vFOV = THREE.MathUtils.degToRad(game.view.camera.fov);
682 let randomPlacementRadius = Math.min(0.8, angleInCourse) * Math.tan(vFOV / 2) * Math.abs(word.position.z - game.view.camera.position.z);
684 randomPlacementRadius = 0;
686 let randomPlacementAngle = Math.random() * 2 * Math.PI;
687 let randomPlacementX = Math.sin(randomPlacementAngle) * randomPlacementRadius;
688 let randomPlacementY = Math.cos(randomPlacementAngle) * randomPlacementRadius;
689 word.position.set(randomCameraX + randomPlacementX, randomCameraY + randomPlacementY, 0);
690 placementSuccess = true;
691 for(let j = 0; j < i; j++) {
692 if(interWordDistance.subVectors(word.position, game.objects.words[j].position).length() <= 1.2) {
693 placementSuccess = false;
699 angleInCourse = 0.04 + 0.92 * Math.random();
702 } while(!placementSuccess);
703 game.view.scene.add(word);
704 game.objects.words.push(word);
708 game['fn'].animate = (scene) => {
709 if(!('startTime' in game)) {
710 game.startTime = game.view.clock.getElapsedTime();
712 if(game.ui.currentPage == 'pause') {
715 let delta = Math.min(game.view.clock.getDeltaTime(), 1 / 12);
716 if(game.ui.currentPage == 'gameplay') {
717 delta = delta * (game.settings['difficulty']['speed'] / 100);
719 game.timeProgress = (game.timeProgress + delta);
721 if(game.settings['controls'] == 'gamepad') {
722 let speedScale = 7.0;
726 for(let gamepad of game.ui.gamepads) {
727 let sx = gamepad.axes[0];
728 if(Math.abs(sx) < deadZone) {
732 let sy = gamepad.axes[1];
733 if(Math.abs(sy) < deadZone) {
738 if(speedX.length > 0) {
739 speedX = speedX.reduce((s, a) => s + a, 0) / speedX.length;
743 if(speedY.length > 0) {
744 speedY = speedY.reduce((s, a) => s + a, 0) / speedY.length;
748 game.controls.speedX = speedScale * speedX;
749 game.controls.speedY = -1 * speedScale * speedY;
752 const maxPinwheelSpeed = 8.0;
753 const maxPinwheelDistance = 5.0;
754 game.controls.speedX += delta * game.controls.accelX;
755 game.controls.speedY += delta * game.controls.accelY;
756 game.controls.speedX = Math.min(maxPinwheelSpeed, Math.max(-maxPinwheelSpeed, game.controls.speedX));
757 game.controls.speedY = Math.min(maxPinwheelSpeed, Math.max(-maxPinwheelSpeed, game.controls.speedY));
758 game.controls.positionX += delta * game.controls.speedX;
759 game.controls.positionY += delta * game.controls.speedY;
760 if(game.controls.positionX > maxPinwheelDistance) {
761 game.controls.positionX = maxPinwheelDistance;
762 game.controls.speedX = Math.max(0.0, game.controls.speedX);
763 game.controls.accelX = Math.max(0.0, game.controls.accelX);
764 } else if(game.controls.positionX < -maxPinwheelDistance) {
765 game.controls.positionX = -maxPinwheelDistance;
766 game.controls.speedX = Math.min(0.0, game.controls.speedX);
767 game.controls.accelX = Math.min(0.0, game.controls.accelX);
769 if(game.controls.positionY > maxPinwheelDistance) {
770 game.controls.positionY = maxPinwheelDistance;
771 game.controls.speedY = Math.max(0.0, game.controls.speedY);
772 game.controls.accelY = Math.max(0.0, game.controls.accelY);
773 } else if(game.controls.positionY < -maxPinwheelDistance) {
774 game.controls.positionY = -maxPinwheelDistance;
775 game.controls.speedY = Math.min(0.0, game.controls.speedY);
776 game.controls.accelY = Math.min(0.0, game.controls.accelY);
779 if(game.ui.currentPage != 'gameplay') {
780 let cameraSwayFactor = 1;
781 if(game.settings['graphics'] == 3) {
782 cameraSwayFactor = 0;
784 let cameraX, cameraY;
785 if(['options', 'credits', 'outro', 'unlock'].includes(game.ui.currentPage)) {
786 cameraX = game.ui.reachedEnd ? 5 : -5;
789 if(game.ui.currentPage == 'title') {
792 if(!game.ui.reachedEnd) {
793 game.objects.feather.position.set(cameraX - 8.45, -game.courseRadius - 6.4, -9.9);
794 game.objects.feather.rotation.set(Math.PI, 0, Math.PI / 2.1);
798 if(!game.ui.reachedStart) {
799 if(game.timeProgress < 1) {
800 game.ui.root.querySelector('.ui-page.title h1').style.opacity = '0';
801 game.ui.root.querySelectorAll('.ui-page.title > button').forEach((btn) => {
803 btn.style.position = 'relative';
804 btn.style.left = '10em';
805 btn.style.opacity = '0';
807 game.ui.root.querySelector('.ui-page.title .footer').style.opacity = '0';
808 game.ui.root.querySelector('.ui-page.title .system-buttons').style.opacity = '0';
809 cameraX += Math.max(0.0, 1 - game['fn'].easeInOut(0.5 + game.timeProgress / 2));
810 cameraY += Math.max(0.0, 10 * Math.pow(0.5 - game.timeProgress / 2, 2));
811 } else if(game.timeProgress >= 1.0 && game.timeProgress <= 2.1) {
812 game.ui.root.querySelector('.ui-page.title h1').style.opacity = Math.min(1.0, game.timeProgress - 1.0).toFixed(2);
814 if(game.timeProgress >= 1.5 && game.timeProgress <= 3.0) {
815 game.ui.root.querySelectorAll('.ui-page.title > button').forEach((btn) => {
816 let timeOffset = Array.from(btn.parentNode.children).indexOf(btn) - 2;
817 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';
818 let opacity = game['fn'].easeInOut(Math.max(0.0, Math.min(1.0, -0.3 * timeOffset + game.timeProgress - 1.5)));
819 btn.style.opacity = opacity.toFixed(2);
821 btn.disabled = false;
825 if(game.timeProgress >= 3.0 && game.timeProgress <= 4.0) {
826 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);
827 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);
829 if(game.timeProgress > 4.0 && !game.ui.reachedStart) {
830 game.ui.root.querySelector('.ui-page.title h1').removeAttribute('style');
831 game.ui.root.querySelectorAll('.ui-page.title > button').forEach(btn => { btn.disabled = false; btn.removeAttribute('style'); });
832 game.ui.root.querySelector('.ui-page.title .footer').removeAttribute('style');
833 game.ui.root.querySelector('.ui-page.title .system-buttons').removeAttribute('style');
834 game.ui.reachedStart = true;
837 } else if(game.ui.currentPage == 'openingcutscene') {
840 if(game.ui.reachedEnd) {
843 if(game.timeProgress < 0.1 && !game.view.windSound.isPlaying) {
844 game.view.windSound.stop();
845 game.objects.feather.position.set(cameraX - 8.45, -game.courseRadius - 6.4, -9.9);
846 game.objects.feather.rotation.set(Math.PI, 0, Math.PI / 2.1);
848 if(game.timeProgress > 1.0 && game.timeProgress <= 1.1 && !game.ui.root.querySelector('.gameplay p')) {
849 const lines = ['Why are the simplest words…', '…often the most difficult to write?'];
850 for(let line of lines) {
851 let elem = document.createElement('p');
852 elem.innerText = line;
853 elem.style.left = (1.5 + 2 * lines.indexOf(line)) + 'em';
854 elem.style.top = (1 + 1.2 * lines.indexOf(line)) + 'em';
855 elem.style.opacity = '0';
856 document.querySelector('.ui-page.gameplay').appendChild(elem);
858 if(!game.view.windSound.isPlaying) {
859 game.view.windSound.setVolume(game.settings['audio']['sounds']);
860 game.view.windSound.offset = 0;
861 if(!game.view.muted) {
862 game.view.windSound.play();
866 if(game.timeProgress > 1.0 && game.timeProgress <= 5.0) {
867 game.ui.root.querySelectorAll('.ui-page.gameplay p').forEach((elem) => {
868 let opacity = Math.max(0.0, Math.min(1.0, game.timeProgress - (elem.nextSibling ? 1.0 : 3.5)));
869 elem.style.transform = 'translateX(' + (Math.pow(1.0 - opacity, 2) * 10).toFixed(2) + 'em)';
870 elem.style.opacity = opacity.toFixed(2);
873 if(game.timeProgress >= 1.0 && game.timeProgress <= 3.0) {
874 let windStrength = 0.5 * (1 + Math.cos((game.timeProgress - 2.0) * Math.PI));
875 game.objects.feather.position.x = cameraX - 8.45 + 0.15 * windStrength;
876 game.objects.feather.rotation.z = Math.PI / 2.1 - 0.2 * windStrength;
878 if(game.timeProgress >= 3.0) {
879 let windStrength = Math.max(0.5 * (1 + Math.cos((game.timeProgress - 4.0) * Math.PI)), Math.min(2 * (game.timeProgress - 4.2), 1.2));
880 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)));
881 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));
882 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);
883 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)));
884 game.objects.feather.rotation.z = Math.PI / 2.1 - 0.2 * windStrength + 1.45 * Math.max(0.0, game.timeProgress - 5.0);
885 game.objects.feather.rotation.x = Math.PI + Math.max(0.0, game.timeProgress - 4.5) * Math.sin(Math.pow(game.timeProgress - 4.5, 2));
887 if(game.timeProgress > 7.0) {
888 game.ui.root.querySelectorAll('.ui-page.gameplay p').forEach((elem) => {
889 let opacity = Math.max(0.0, Math.min(1.0, 8.0 - game.timeProgress));
890 elem.style.opacity = opacity.toFixed(2);
893 cameraSwayFactor = cameraSwayFactor * (1 - (game.timeProgress / 8));
894 cameraX = -5 + Math.pow(Math.max(0, game.timeProgress - 3) / 5, 1.6) * 5;
896 if(game.timeProgress > 6.0 && game.timeProgress < 7.0 && game.settings['controls'] != 'mouse') {
897 game.controls.positionX = 0;
898 game.controls.positionY = -3;
900 game.objects.pinwheel.material[4].opacity = game['fn'].easeInOut(Math.max(0, (game.timeProgress - 7)));
901 if(game.timeProgress >= 8.0) {
902 game.ui.root.querySelectorAll('.ui-page.gameplay p').forEach((elem) => {
903 let opacity = Math.max(0.0, Math.min(1.0, 8.0 - game.timeProgress));
904 elem.style.opacity = opacity.toFixed(2);
906 game.view.music.offset = 0;
907 if(!game.view.muted) {
908 game.view.music.play();
910 game['fn'].moveToPage('gameplay', true);
912 } else if(game.ui.currentPage == 'endingcutscene') {
915 cameraSwayFactor = cameraSwayFactor * game.timeProgress / 8;
916 cameraX = 5 - Math.pow(Math.max(0, 5 - game.timeProgress) / 5, 1.6) * 5;
917 let trajectoryLerpValue = game['fn'].easeInOut(Math.min(6.0, game.timeProgress) / 6);
918 game.var.endingEntryTrajectory.addScaledVector(game.objects.feather.speed, delta);
919 game.var.endingExitTrajectory.setX(11.2 * game['fn'].easeInOut(Math.min(1, 1 - Math.max(0, 4 - game.timeProgress) / 4)));
920 game.var.endingExitTrajectory.setY(-game.courseRadius - 7.6 * game['fn'].easeInOut(Math.min(1, 1 - Math.max(0, 4 - game.timeProgress) / 4)));
921 game.var.endingExitTrajectory.setZ(-8.9 * game['fn'].easeInOut(Math.min(1, 1 - Math.max(0, 4 - game.timeProgress) / 4)));
922 game.objects.feather.rotation.x = game['fn'].lerp(game.var.endingEntryRotation.x, game.var.endingExitRotation.x, trajectoryLerpValue);
923 game.objects.feather.rotation.y = game['fn'].lerp(game.var.endingEntryRotation.y, game.var.endingExitRotation.y, trajectoryLerpValue);
924 game.objects.feather.rotation.z = game['fn'].lerp(game.var.endingEntryRotation.z, game.var.endingExitRotation.z, trajectoryLerpValue);
925 game.objects.feather.position.lerpVectors(game.var.endingEntryTrajectory, game.var.endingExitTrajectory, trajectoryLerpValue);
926 game.objects.pinwheel.material[4].opacity = game['fn'].easeInOut(Math.max(0, (1 - game.timeProgress)));
927 if(!game.settings['highcontrast']) {
928 let letterScale = game['fn'].lerp(0.3, 0.0, game['fn'].easeInOut(Math.max(0, Math.min(1, game.timeProgress - 6))));
929 for(let i = 0; i < game.objects.words.length; i++) {
930 let word = game.objects.words[i];
931 if(!word.collected) {
935 let collectionAnimationDuration = 1.0;
936 for(let j = 0; j < word.children.length; j++) {
937 let letter = word.children[j];
938 let animationProgress = (((game.timeProgress + 5 * word.randomAnimOffset) % 5) / 5 + j / 37) % 1;
939 x = game.objects.feather.scale.x * 0.5 * Math.cos(animationProgress * 1 * Math.PI * 2);
940 y = 0.2 * Math.sin(animationProgress * 7 * Math.PI * 2);
941 z = 0.2 * Math.cos(animationProgress * 7 * Math.PI * 2);
942 x = x * Math.cos(game.objects.feather.rotation.z) - y * Math.sin(game.objects.feather.rotation.z);
943 y = y * Math.cos(game.objects.feather.rotation.z) + x * Math.sin(game.objects.feather.rotation.z);
944 x += game.objects.feather.position.x - word.position.x;
945 y += game.objects.feather.position.y - word.position.y;
946 z += game.objects.feather.position.z - word.position.z;
947 if(i == 0 && !word.collected) {
948 // If we don't catch this edge case here, the manually placed first word might be visible
949 // in the closing cutscene if it is not collected.
952 letter.position.set(x, y, z);
953 let rotation = (game.timeProgress * 3 + 2 * Math.PI * word.randomAnimOffset) % (2 * Math.PI);
954 letter.rotation.set(rotation + j, rotation + 2*j, rotation + 3*j);
955 letter.scale.set(letterScale, letterScale, letterScale);
959 if(game.timeProgress >= 8) {
960 game.ui.root.querySelector('.ui-page.title').classList.add('end');
961 game['fn'].moveToPage('outro', true);
963 } else if(game.ui.reachedEnd && ['title', 'options', 'credits', 'unlock'].includes(game.ui.currentPage)) {
967 if(typeof(cameraX) == 'number' && typeof(cameraY) == 'number') {
968 game.view.camera.position.setY(cameraY - game.courseRadius + 0.07 * cameraSwayFactor * Math.sin(game.view.clock.getElapsedTime() * 0.5));
969 game.view.camera.position.setX(cameraX + 0.05 * cameraSwayFactor * Math.sin(game.view.clock.getElapsedTime() * 0.7));
971 game.view.renderer.render(scene, game.view.camera);
974 if(game.settings['enablehud']) {
975 if(game.timeProgress < 0.5) {
976 game.ui.hud.style.opacity = Math.min(1, game.timeProgress * 4).toFixed(2);
978 const failsafe = 0.0001;
979 const radiusOuter = 0.5;
980 const radiusInner = 0.4;
981 const progress = Math.max(failsafe, Math.min(1 - failsafe, game.timeProgress / game.timeTotal));
982 const xOuter = (0.5 + radiusOuter * Math.sin(progress * 2 * Math.PI)).toFixed(4);
983 const yOuter = (0.5 + radiusOuter * Math.cos(progress * 2 * Math.PI)).toFixed(4);
984 const xInner = (0.5 + radiusInner * Math.sin(progress * 2 * Math.PI)).toFixed(4);
985 const yInner = (0.5 + radiusInner * Math.cos(progress * 2 * Math.PI)).toFixed(4);
986 for(let segment of [0, 1]) {
987 let p = 'M' + xOuter + ',' + yOuter;
988 p += 'A' + radiusOuter + ',' + radiusOuter + ' 0 ' + ((progress >= 0.5) ? (1 - segment) : segment) + ' ' + (1 - segment) + ' .5,' + (0.5 + radiusOuter);
989 p += 'V' + (0.5 + radiusInner);
990 p += 'A' + radiusInner + ',' + radiusInner + ' 0 ' + ((progress >= 0.5) ? (1 - segment) : segment) + ' ' + segment + ' ' + xInner + ',' + yInner;
992 game.ui.hud.children[0].children[segment].setAttribute('d', p);
994 if(game.timeProgress > game.timeTotal - 0.5) {
995 game.ui.hud.style.opacity = Math.max(0, (game.timeTotal - game.timeProgress - 0.25) * 4).toFixed(2);
998 if(game.ui.root.querySelector('.ui-page.gameplay p')) {
999 game.ui.root.querySelectorAll('.ui-page.gameplay p').forEach(elem => elem.remove());
1001 if(game.timeProgress / game.timeTotal >= 1.0) {
1002 game.ui.reachedEnd = true;
1003 game.var.endingEntryTrajectory.set(game.objects.feather.position.x, game.objects.feather.position.y, game.objects.feather.position.z);
1004 game.var.endingEntryRotation.x = game.objects.feather.rotation.x;
1005 game.var.endingEntryRotation.y = game.objects.feather.rotation.y;
1006 game.var.endingEntryRotation.z = game.objects.feather.rotation.z;
1007 game.var.endingExitRotation.set(-0.2, 0, -0.2);
1008 game['fn'].moveToPage('endingcutscene', true);
1011 if(game.settings['audio']['music'] > 0.0 && game.view.music && !game.view.music.isPlaying) {
1012 const remainingRealTime = (game.timeTotal - game.timeProgress) / (game.settings['difficulty']['speed'] / 100);
1013 if(remainingRealTime >= game.assets['audio']['music-' + game.settings['audio']['theme']].duration - 2) {
1014 game.view.music.offset = 0;
1015 if(!game.view.muted) {
1016 game.view.music.play();
1021 const angle = 2 * Math.PI * (game.timeProgress / game.timeTotal);
1022 game.view.camera.position.x = game.courseRadius * Math.sin(angle);
1023 game.view.camera.position.y = - game.courseRadius * Math.cos(angle);
1025 if(!game.settings['highcontrast']) {
1026 let sunsetValue = 2.0;
1027 if(!game.ui.reachedEnd) {
1028 sunsetValue = sunsetValue * game['fn'].easeInOut(Math.min(1, Math.max(0, ((game.timeProgress / game.timeTotal) - 0.3) / 0.6)));
1030 if(game.settings['graphics'] <= 2) {
1031 for(let i = 0; i < 6; i++) {
1032 game.view.materials['cloud' + i].uniforms.lerp.value = sunsetValue;
1035 let cloudMaterialVariant = null;
1036 if(sunsetValue < 0.5 && !game.objects.clouds.children[0].material.name.endsWith('a')) {
1037 cloudMaterialVariant = 'a';
1038 } else if(sunsetValue >= 0.5 && sunsetValue < 1.5 && !game.objects.clouds.children[0].material.name.endsWith('b')) {
1039 cloudMaterialVariant = 'b';
1040 } else if(sunsetValue >= 1.5 && !game.objects.clouds.children[0].material.name.endsWith('c')) {
1041 cloudMaterialVariant = 'c';
1043 if(cloudMaterialVariant) {
1044 for(let cloud of game.objects.clouds.children) {
1045 cloud.material = game.view.materials[cloud.material.name.slice(0, -1) + cloudMaterialVariant];
1047 game.objects.backdrop.material = game.view.materials['cloud0' + cloudMaterialVariant];
1052 if(game.timeProgress / game.timeTotal > 0.5 && game.objects.dayHouse.parent == scene) {
1053 scene.remove(game.objects.dayHouse);
1054 scene.add(game.objects.eveningHouse);
1057 game.var.featherLocalPos.subVectors(game.objects.feather.position, game.view.camera.position).setZ(0);
1058 game.var.featherBorderForce.set(0, 0, 0);
1059 for(let coord of [0, 1]) {
1060 if(Math.abs(game.var.featherLocalPos.getComponent(coord)) > 3) {
1061 game.var.featherBorderForce.setComponent(coord, 3 * Math.sign(game.var.featherLocalPos.getComponent(coord)) - game.var.featherLocalPos.getComponent(coord));
1064 game['fn'].applyForceToFeather(game.var.featherBorderForce);
1065 const tiltedGravity = game.gravity.clone();
1066 game.var.pinwheelDistance.subVectors(game.objects.feather.position, game.objects.pinwheel.position).setZ(0);
1068 const pinwheelRotationTargetSpeed = 1 + Math.max(0, 2 - game.var.pinwheelDistance.length());
1069 game.var.pinwheelRotationSpeed = Math.max(1, pinwheelRotationTargetSpeed, game.var.pinwheelRotationSpeed - 2 * delta);
1070 game.objects.pinwheel.rotation.z -= game.var.pinwheelRotationSpeed * 5 * delta;
1071 game.objects.pinwheel.position.x = game.view.camera.position.x + game.controls.positionX;
1072 game.objects.pinwheel.position.y = game.view.camera.position.y + game.controls.positionY;
1074 const pinwheelForce = 0.5 * Math.max(0, Math.pow(game.var.pinwheelDistance.length(), - 0.5) - 0.5);
1075 game['fn'].applyForceToFeather(game.var.pinwheelDistance.normalize().multiplyScalar(pinwheelForce));
1076 if(pinwheelForce < 0.2) {
1077 if(game.objects.feather.swayDirection > 0 && game.objects.feather.speed.x > 1.5) {
1078 game.objects.feather.swayDirection *= -1;
1079 } else if(game.objects.feather.swayDirection < 0 && game.objects.feather.speed.x < -1.5) {
1080 game.objects.feather.swayDirection *= -1;
1082 tiltedGravity.x += game.objects.feather.swayDirection;
1084 if(game.objects.feather.speed.y > -1) {
1085 game['fn'].applyForceToFeather(tiltedGravity);
1087 game.objects.feather.rotation.z = -0.1 * game.objects.feather.speed.x * game.settings['difficulty']['speed'] / 100;
1088 game.objects.feather.position.addScaledVector(game.objects.feather.speed, delta * game.settings['difficulty']['speed'] / 100);
1090 if(pinwheelForce > 0.2) {
1091 if(game.objects.feather.twistSpeed < 0.0001) {
1092 game.objects.feather.twistSpeed = (Math.random() - 0.5) * 0.01;
1094 game.objects.feather.twistSpeed = Math.sign(game.objects.feather.twistSpeed) * 0.1 * game.objects.feather.speed.length();
1096 game.objects.feather.twistSpeed = 0.98 * game.objects.feather.twistSpeed;
1097 if(Math.abs(game.objects.feather.twistSpeed < 0.1)) {
1098 let rotationDelta = game.objects.feather.rotation.x;
1099 if(rotationDelta >= Math.PI) {
1100 rotationDelta -= 2 * Math.PI;
1102 game.objects.feather.twistSpeed -= rotationDelta * 0.02;
1106 game.objects.feather.twistSpeed = Math.min(0.13, game.objects.feather.twistSpeed) * game.settings['difficulty']['speed'] / 100;
1107 game.objects.feather.rotation.x = (game.objects.feather.rotation.x + game.objects.feather.twistSpeed) % (2 * Math.PI);
1109 let collectedScale = 0.0;
1110 if(!game.settings['highcontrast'] && game.settings['graphics'] <= 2) {
1111 collectedScale = game['fn'].lerp(0.6, 0.3, 1 - Math.pow(1 - game.objects.words.collectedCount / game.objects.words.length, 2));
1112 } else if(!game.settings['highcontrast'] && game.settings['graphics'] == 3) {
1113 collectedScale = 0.3;
1115 const collectingRadius = - 0.5 + 1.5 * game.settings['difficulty']['collectingradius'];
1116 for(let i = 0; i < game.objects.words.length; i++) {
1117 let word = game.objects.words[i];
1118 if(!word.collected && new THREE.Vector3().subVectors(word.position, game.objects.feather.position).length() < collectingRadius) {
1119 word.collected = game.view.clock.getElapsedTime();
1120 game.objects.words.collectedCount += 1;
1121 game.ui.hud.children[1].innerText = game.objects.words.collectedCount;
1122 game['fn'].playRandomSound();
1124 if(word.parent != game.view.scene) {
1125 // All that happens in here is the positional animation for the word, which
1126 // we can skip if it is no longer visible.
1130 let collectionAnimationDuration = 1.0;
1131 for(let j = 0; j < word.children.length; j++) {
1132 game.var.notCollectedPos.set(0, 0, 0);
1133 game.var.collectedPos.set(0, 0, 0);
1134 let letter = word.children[j];
1135 let animationProgress = (((game.timeProgress + 5 * word.randomAnimOffset) % 5) / 5 + j / 37) % 1;
1136 if(!word.collected || game.view.clock.getElapsedTime() - word.collected <= collectionAnimationDuration) {
1137 x = word.position.x;
1138 y = word.position.y;
1140 if(game.settings['graphics'] <= 2 && !game.settings['highcontrast']) {
1141 const wordAnimationRadius = 0.2;
1142 x += wordAnimationRadius * Math.cos(animationProgress * 5 * Math.PI * 2);
1143 y += wordAnimationRadius * Math.sin(animationProgress * 4 * Math.PI * 2);
1144 z += wordAnimationRadius * Math.sin(animationProgress * 6 * Math.PI * 2);
1146 game.var.notCollectedPos.set(x, y, z);
1148 if(word.collected) {
1149 if(!game.settings['highcontrast']) {
1150 x = game.objects.feather.scale.x * 0.5 * Math.cos(animationProgress * 1 * Math.PI * 2);
1151 y = 0.2 * Math.sin(animationProgress * 7 * Math.PI * 2);
1152 z = 0.2 * Math.cos(animationProgress * 7 * Math.PI * 2);
1153 x = x * Math.cos(game.objects.feather.rotation.z) - y * Math.sin(game.objects.feather.rotation.z);
1154 y = y * Math.cos(game.objects.feather.rotation.z) + x * Math.sin(game.objects.feather.rotation.z);
1155 x += game.objects.feather.position.x;
1156 y += game.objects.feather.position.y;
1157 z += game.objects.feather.position.z;
1159 x = game.objects.feather.position.x;
1160 y = game.objects.feather.position.y;
1161 z = game.objects.feather.position.z;
1163 game.var.collectedPos.set(x, y, z);
1165 if(game.var.notCollectedPos.length() > 0 && game.var.collectedPos.length() > 0) {
1166 let collectingProgress = game['fn'].easeInOut(Math.max(0.0, Math.min(1.0, (game.view.clock.getElapsedTime() - word.collected) / collectionAnimationDuration)));
1167 letter.position.lerpVectors(game.var.notCollectedPos, game.var.collectedPos, collectingProgress);
1168 let scale = game['fn'].lerp(1.0, collectedScale, collectingProgress);
1169 letter.scale.set(scale, scale, scale);
1170 } else if(game.var.notCollectedPos.length() > 0) {
1171 letter.position.set(game.var.notCollectedPos.x, game.var.notCollectedPos.y, game.var.notCollectedPos.z);
1172 } else if(game.var.collectedPos.length() > 0) {
1173 letter.position.set(game.var.collectedPos.x, game.var.collectedPos.y, game.var.collectedPos.z);
1174 if(game.settings['highcontrast']) {
1175 // Special case because in high contrast mode, collected words vanish entirely.
1176 game.view.scene.remove(word);
1179 letter.position.sub(word.position);
1180 if(!game.settings['highcontrast']) {
1181 let rotation = (game.timeProgress * 3 + 2 * Math.PI * word.randomAnimOffset) % (2 * Math.PI);
1182 letter.rotation.set(rotation + j, rotation + 2*j, rotation + 3*j);
1187 game.view.renderer.render(scene, game.view.camera);
1190 game['fn'].loadSettings = () => {
1192 'controls': null, // set during first time launch depending on device
1193 'virtualinputleft': false,
1199 'theme': null, // actual default value determined after asset loading
1203 'highcontrast': false,
1206 'collectingradius': 1,
1210 'up': ['ArrowUp', 'w'],
1211 'right': ['ArrowRight', 'd'],
1212 'down': ['ArrowDown', 's'],
1213 'left': ['ArrowLeft', 'a'],
1219 stored = window['localStorage'].getItem('upInTheAirGameSettings');
1220 game.ui.root.querySelector('.ui-page.options .warning.storage').style.display = 'none';
1222 game.ui.root.querySelector('.ui-page.options .warning.storage').style.display = 'block';
1225 let merge = (source, target) => {
1226 for(let k of Object.keys(source)) {
1227 if(source[k] != null && typeof(source[k]) == 'object' && typeof(target[k]) == 'object' && !Array.isArray(target[k])) {
1228 merge(source[k], target[k]);
1229 } else if(k in target) {
1230 target[k] = source[k];
1234 stored = JSON.parse(stored);
1235 merge(stored, settings);
1237 const ui = game.ui.root.querySelector('.ui-page.options');
1238 if(settings['controls']) {
1239 ui.querySelector('.controls input[value="' + settings['controls'] + '"]').checked = true;
1240 ui.querySelector('.controls .leftside input').checked = settings['virtualinputleft'];
1241 ui.querySelector('.controls .leftside').style.display = (['touchpad', 'thumbstick'].includes(settings['controls'])) ? 'block' : 'none';
1242 ui.querySelectorAll('.controls p span:not(.' + settings['controls'] + ')').forEach(span => span.style.display = 'none');
1243 ui.querySelector('.controls span.' + settings['controls']).style.display = 'block';
1245 ui.querySelector('.hud input[name="upInTheAirGame-hud"]').checked = settings['enablehud'];
1246 game.ui.hud.style.display = settings['enablehud'] ? 'flex' : 'none';
1247 ui.querySelector('.graphics input[value="' + settings['graphics'] + '"]').checked = true;
1248 for(let audioCategory of ['music', 'sounds']) {
1249 let newValue = Math.max(0, Math.min(100, Math.round(100 * settings['audio'][audioCategory])));
1250 game.ui.root.querySelectorAll('.ui-page .audio input[type=range].' + audioCategory).forEach((elem) => {
1251 elem.value = newValue;
1252 elem.parentNode.nextElementSibling.innerText = newValue;
1255 let audioThemeRadio = ui.querySelector('.audiotheme input[value="' + settings['audio']['theme'] + '"]');
1256 if(audioThemeRadio) {
1257 audioThemeRadio.checked = true;
1259 // Custom hash function that ensures our unlockables get stored in the same order,
1260 // regardless of the order in which they get unlocked.
1261 let miniHash = (input) => {
1262 return 4 * input.charCodeAt(0) + 0.1 * input.charCodeAt(1) + 3 * input.charCodeAt(2) + 2 * input.charCodeAt(3);
1264 settings['unlocks'].sort((u1, u2) => miniHash(u1) > miniHash(u2));
1265 for(let unlockedFeather of settings['unlocks']) {
1266 if(!game.ui.root.querySelector('.ui-page.options .feather input[value="' + unlockedFeather + '"]')) {
1267 let radio = document.createElement('input');
1268 radio.type = 'radio';
1269 radio.name = 'upInTheAirGame-feather';
1270 radio.value = unlockedFeather;
1271 let img = document.createElement('img');
1272 if(unlockedFeather == 'golden' || unlockedFeather == 'ghost') {
1273 img.src = game['deploymentOptions']['assetUrlPrefix'] + 'textures/feather-' + unlockedFeather + '.png';
1275 let unlock = game['fn'].unlockWithKey('NIbp2kW5' + unlockedFeather + 'e2ZDFl5Y');
1276 if(unlock && unlock['type'] == 'feather') {
1277 img.src = unlock['url'];
1282 img.alt = unlockedFeather[0].toUpperCase() + unlockedFeather.slice(1) + ' feather';
1283 let label = document.createElement('label');
1284 label.appendChild(radio);
1285 label.appendChild(img);
1286 game.ui.root.querySelector('.ui-page.options .feather').appendChild(label);
1289 if(!ui.querySelector('.feather input[value="' + settings['feather'] + '"]')) {
1290 settings['feather'] = 'blue';
1292 ui.querySelector('.feather input[value=' + settings['feather'] + ']').checked = true;
1293 ui.querySelector('input[value="highcontrast"]').checked = !!settings['highcontrast'];
1294 ui.querySelector('.font input[value=' + settings['font'] + ']').checked = true;
1295 ui.querySelector('.difficulty select.collectingradius option[value="' + settings['difficulty']['collectingradius'] + '"]').selected = true;
1296 ui.querySelector('.difficulty select.speed option[value="' + settings['difficulty']['speed'] + '"]').selected = true;
1297 for(let direction of ['up', 'right', 'down', 'left']) {
1298 let keys = settings['keyboard'][direction];
1299 let btn = ui.querySelector('.keyboard button.' + direction);
1300 btn.value = keys.join('|');
1301 keys = keys.map(k => {
1302 if(k.length == 1 && k != 'ß') {
1303 k = k.toUpperCase();
1306 case 'ArrowUp': return '🠕';
1307 case 'ArrowRight': return '🠖';
1308 case 'ArrowDown': return '🠗';
1309 case 'ArrowLeft': return '🠔';
1310 case ' ': return 'Space';
1314 btn.innerText = keys.join(' or ');
1316 ui.querySelector('input[value="tapmode"]').checked = !!settings['keyboard']['tapmode'];
1317 game.settings = settings;
1320 game['fn'].applySettings = () => {
1321 const ui = game.ui.root.querySelector('.ui-page.options');
1322 if(ui.querySelector('input[name="upInTheAirGame-controls"]:checked')) {
1323 game.settings['controls'] = ui.querySelector('input[name="upInTheAirGame-controls"]:checked').value;
1325 game.settings['virtualinputleft'] = ui.querySelector('.controls .leftside input').checked;
1326 if(game.settings['virtualinputleft']) {
1327 game.ui.root.parentNode.classList.add('virtual-input-left');
1329 game.ui.root.parentNode.classList.remove('virtual-input-left');
1331 const virtualInput = game.ui.root.parentNode.querySelector('.virtual-input-widget');
1332 virtualInput.children[0].style.display = 'block';
1333 virtualInput.children[0].style.left = '50%';
1334 virtualInput.children[0].style.top = '50%';
1335 delete virtualInput.inProgress;
1336 if(game.settings['controls'] == 'touchpad') {
1337 virtualInput.classList.remove('thumbstick');
1338 virtualInput.classList.add('touchpad');
1339 game.ui.root.classList.remove('control-mouse', 'control-thumbstick');
1340 game.ui.root.classList.add('control-touchpad');
1341 virtualInput.children[0].style.display = 'none';
1342 } else if(game.settings['controls'] == 'thumbstick') {
1343 virtualInput.classList.remove('touchpad');
1344 virtualInput.classList.add('thumbstick');
1345 game.ui.root.classList.remove('control-mouse', 'control-touchpad');
1346 game.ui.root.classList.add('control-thumbstick');
1347 } else if(game.settings['controls'] == 'mouse') {
1348 virtualInput.classList.remove('touchpad', 'thumbstick');
1349 game.ui.root.classList.remove('control-touchpad', 'control-thumbstick');
1350 game.ui.root.classList.add('control-mouse');
1352 virtualInput.classList.remove('touchpad', 'thumbstick');
1353 game.ui.root.classList.remove('control-mouse', 'control-touchpad', 'control-thumbstick');
1355 for(let timeout of [10, 100, 1000]) {
1356 setTimeout(() => { game.ui.root.style.fontSize = (game.ui.root.clientWidth / 48) + 'px'; }, timeout);
1358 game.settings['graphics'] = parseInt(ui.querySelector('input[name="upInTheAirGame-graphics"]:checked').value, 10);
1360 let resolution = Math.round(3200 / Math.pow(2, game.settings['graphics']));
1361 game.view.canvas.width = resolution;
1362 game.view.canvas.height = resolution;
1363 game.view.camera.updateProjectionMatrix();
1364 game.view.renderer.setSize(game.view.canvas.width, game.view.canvas.height);
1366 game.settings['enablehud'] = ui.querySelector('.hud input[name="upInTheAirGame-hud"]').checked;
1367 for(let audioCategory of ['music', 'sounds']) {
1368 game.settings['audio'][audioCategory] = parseInt(ui.querySelector('.audio input[type=range].' + audioCategory).value, 10) / 100;
1370 let audioThemeRadio = ui.querySelector('.audiotheme input[name="upInTheAirGame-audiotheme"]:checked');
1371 if(audioThemeRadio) {
1372 game.settings['audio']['theme'] = audioThemeRadio.value;
1374 game.settings['feather'] = ui.querySelector('input[name="upInTheAirGame-feather"]:checked').value;
1375 game.settings['highcontrast'] = ui.querySelector('input[value="highcontrast"]').checked;
1376 game.settings['font'] = ui.querySelector('input[name="upInTheAirGame-font"]:checked').value;
1377 game.settings['difficulty']['collectingradius'] = parseInt(ui.querySelector('.difficulty select.collectingradius').value, 10);
1378 game.settings['difficulty']['speed'] = parseInt(ui.querySelector('.difficulty select.speed').value, 10);
1379 for(let direction of ['up', 'right', 'down', 'left']) {
1380 game.settings['keyboard'][direction] = ui.querySelector('.keyboard button.' + direction).value.split('|');
1382 game.settings['keyboard']['tapmode'] = ui.querySelector('input[value="tapmode"]').checked;
1384 window['localStorage'].setItem('upInTheAirGameSettings', JSON.stringify(game.settings));
1385 game.ui.root.querySelector('.ui-page.options .warning.storage').style.display = 'none';
1387 game.ui.root.querySelector('.ui-page.options .warning.storage').style.display = 'block';
1390 for(let audioCategory of ['music', 'sounds']) {
1391 game.settings['audio'][audioCategory] = parseInt(ui.querySelector('.audio input[type=range].' + audioCategory).value, 10) / 100;
1392 let value = Math.round(100 * game.settings['audio'][audioCategory]);
1393 game.ui.root.querySelectorAll('.ui-page .audio input[type=range].' + audioCategory).forEach((elem) => {
1395 elem.parentNode.nextElementSibling.innerText = value;
1397 if(audioCategory == 'music' && game.view && game.view.music) {
1398 game.view.music.setVolume(game.settings['audio'][audioCategory]);
1401 game.ui.root.classList.remove('font-atkinson', 'font-opendyslexic');
1402 if(game.settings['font'] != 'standard') {
1403 game.ui.root.classList.add('font-' + game.settings['font']);
1407 game['fn'].createFeather = () => {
1408 let position, rotation;
1409 if(game.objects.feather) {
1410 position = game.objects.feather.position;
1411 rotation = game.objects.feather.rotation;
1412 game.objects.feather.geometry.dispose();
1413 game.objects.feather.material.dispose();
1414 game.view.scene.remove(game.objects.feather);
1415 delete game.objects.feather;
1418 const featherGeometry = new THREE.PlaneGeometry(1.6, 0.5);
1420 map: game.assets.textures['feather-' + game.settings['feather']],
1423 side: THREE.DoubleSide,
1425 if(game.settings['feather'] == 'golden') {
1426 options.color = 0xffffff;
1427 options.emissive = 0x644a1e;
1428 options.roughness = 0.5;
1429 options.metalness = 0.4;
1431 game.view.materials.feather = new THREE.MeshStandardMaterial(options);
1432 game.objects.feather = new THREE.Mesh(featherGeometry, game.view.materials.feather);
1433 game.objects.feather.rotation.order = 'ZXY';
1435 game.objects.feather.position.set(position.x, position.y, position.z);
1438 game.objects.feather.rotation.set(rotation.x, rotation.y, rotation.z);
1440 game.view.scene.add(game.objects.feather);
1441 game.objects.feather.speed = new THREE.Vector3(0, 0, 0);
1442 game.gravity = new THREE.Vector3(0, -0.1, 0);
1443 game.objects.feather.swayDirection = 0.2;
1444 game.objects.feather.twistSpeed = 0.1;
1447 game['fn'].createMeshes = () => {
1448 if(game.objects.clouds && game.objects.clouds.parent == game.view.scene) {
1449 game.view.scene.remove(game.objects.clouds);
1450 if(game.objects.clouds.children.length > 0) {
1451 game.objects.clouds.children[0].geometry.dispose();
1453 for(let mKey in game.view.materials) {
1454 game.view.materials[mKey].dispose();
1456 delete game.objects.clouds;
1458 if(game.objects.backdrop && game.objects.backdrop.parent == game.view.scene) {
1459 game.view.scene.remove(game.objects.backdrop);
1460 game.objects.backdrop.material.dispose();
1461 game.objects.backdrop.geometry.dispose();
1463 if(game.assets.fonts.geometry) {
1464 for(let geom of Object.values(game.assets.fonts.geometry)) {
1465 if(geom.customMaterial) {
1466 geom.customMaterial.dispose();
1470 delete game.assets.fonts.geometry;
1472 if(game.view.materials) {
1473 for(let material of Object.values(game.view.materials)) {
1476 delete game.view.materials;
1479 if(game.view.materials && game.view.materials.feather) {
1480 game.view.materials = {
1481 'feather': game.view.materials['feather'],
1484 game.view.materials = {};
1487 if(!game.settings['highcontrast']) {
1489 let cloudGeometry = new THREE.PlaneGeometry(1, 200 / 350);
1490 if(game.settings['graphics'] <= 2) {
1494 precision highp float;
1495 precision highp int;
1499 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1504 precision mediump float;
1505 uniform sampler2D texture1;
1506 uniform sampler2D texture2;
1507 uniform sampler2D texture3;
1512 vec4 col1 = texture2D(texture2, vUv);
1513 vec4 col2 = texture2D(texture3, vUv);
1514 gl_FragColor = mix(col1, col2, lerp - 1.0);
1516 vec4 col1 = texture2D(texture1, vUv);
1517 vec4 col2 = texture2D(texture2, vUv);
1518 gl_FragColor = mix(col1, col2, lerp);
1520 // I don't know how GLSL works: why do I need to do this to match the textures?
1521 gl_FragColor = mix(gl_FragColor, vec4(1.0, 1.0, 1.0, gl_FragColor.a), 0.5);
1526 for(let i = 0; i < 6; i++) {
1527 if(game.settings['graphics'] <= 2) {
1528 game.view.materials['cloud' + i] = new THREE.ShaderMaterial({
1529 uniforms: THREE.UniformsUtils.merge([{
1537 game.view.materials['cloud' + i].uniforms.texture1.value = game.assets.textures['cloud' + i + 'a'];
1538 game.view.materials['cloud' + i].uniforms.texture2.value = game.assets.textures['cloud' + i + 'b'];
1539 game.view.materials['cloud' + i].uniforms.texture3.value = game.assets.textures['cloud' + i + 'c'];
1540 if(game.ui.reachedEnd) {
1541 game.view.materials['cloud' + i].uniforms.lerp.value = 2.0;
1543 game.view.materials['cloud' + i].uniforms.lerp.value = 0.0;
1545 game.view.materials['cloud' + i].transparent = true;
1547 for(let variant of ['a', 'b', 'c']) {
1548 game.view.materials['cloud' + i + variant] = new THREE.MeshBasicMaterial({
1549 map: game.assets.textures['cloud' + i + variant],
1553 game.view.materials['cloud' + i + variant].name = 'cloud' + i + variant;
1557 game.objects.clouds = new THREE.Group();
1558 let textureVariantSuffix = '';
1559 if(game.settings['graphics'] > 2) {
1560 if(game.ui.reachedEnd) {
1561 textureVariantSuffix = 'c';
1563 textureVariantSuffix = 'a';
1566 game.view.materials.letter = new THREE.MeshStandardMaterial({
1572 if(game.settings['graphics'] <= 2) {
1573 game.assets.fonts.geometry = {};
1574 for(let letter of [...new Set(game.assets.wordList.join(''))]) {
1575 if(game.settings['graphics'] == 1) {
1576 game.assets.fonts.geometry[letter] = new TextGeometry(letter, {
1577 font: game.assets.fonts.cookie,
1581 bevelEnabled: false,
1583 game.assets.fonts.geometry[letter].computeBoundingBox();
1584 let bbox = game.assets.fonts.geometry[letter].boundingBox;
1585 // Add these to local 0,0 later to get the letter's center rotation point
1586 game.assets.fonts.geometry[letter].dx = (bbox.max.x - bbox.min.x) / 2;
1587 game.assets.fonts.geometry[letter].dy = (bbox.max.y - bbox.min.y) / 2;
1589 let letterCanvas = document.createElement('canvas');
1590 letterCanvas.width = 64;
1591 letterCanvas.height = 64;
1592 let letterCanvasContext = letterCanvas.getContext('2d');
1593 letterCanvasContext.font = '60px Cookie';
1594 letterCanvasContext.fillStyle = '#000';
1595 letterCanvasContext.fillRect(0, 0, letterCanvas.width, letterCanvas.height);
1596 letterCanvasContext.fillStyle = '#fff';
1597 let bbox = letterCanvasContext.measureText(letter);
1598 let vOffset = bbox.actualBoundingBoxAscent - 0.5 * (bbox.actualBoundingBoxAscent + bbox.actualBoundingBoxDescent);
1599 letterCanvasContext.fillText(letter, Math.round((letterCanvas.width - bbox.width) / 2), (letterCanvas.height / 2) + vOffset);
1600 let alphaMap = new THREE.CanvasTexture(letterCanvas);
1601 alphaMap.needsUpdate = true;
1602 let letterMaterial = new THREE.MeshStandardMaterial({
1603 color: game.view.materials.letter.color,
1605 roughness: game.view.materials.letter.roughness,
1606 metalness: game.view.materials.letter.metalness,
1607 side: THREE.DoubleSide,
1611 game.assets.fonts.geometry[letter] = new THREE.PlaneGeometry(0.3, 0.3);
1612 game.assets.fonts.geometry[letter].dx = 0;
1613 game.assets.fonts.geometry[letter].dy = 0;
1614 game.assets.fonts.geometry[letter].customMaterial = letterMaterial;
1617 let numClouds = 300;
1618 if(game.settings['graphics'] == 2) {
1621 for(let i = 0; i < numClouds; i++) {
1622 let randomAngle = Math.random() * 2 * Math.PI;
1623 let randomCameraX = game.courseRadius * Math.sin(randomAngle);
1624 let randomCameraY = game.courseRadius * Math.cos(randomAngle);
1625 let cloud = new THREE.Mesh(cloudGeometry, game.view.materials['cloud' + (i % 5 + 1) + textureVariantSuffix]);
1626 cloud.position.z = -15 - Math.round(Math.random() * 40);
1627 const vFOV = THREE.MathUtils.degToRad(game.view.camera.fov);
1628 let maxCameraDistance = 2 * Math.tan(vFOV / 2) * Math.abs(cloud.position.z - game.view.camera.position.z);
1629 cloud.position.x = randomCameraX + maxCameraDistance * 2 * (Math.random() - 0.5);
1630 cloud.position.y = randomCameraY + maxCameraDistance * 2 * (Math.random() - 0.5);
1631 let scale = 21 + (Math.random() * 0.5 + 0.5) * Math.abs(cloud.position.z);
1632 cloud.scale.set(scale, scale, scale);
1633 game.objects.clouds.add(cloud);
1636 const minimalLetterGeometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
1637 minimalLetterGeometry.dx = 0;
1638 minimalLetterGeometry.dy = 0;
1639 game.assets.fonts.geometry = {};
1640 for(let letter of [...new Set(game.assets.wordList.join(''))]) {
1641 game.assets.fonts.geometry[letter] = minimalLetterGeometry;
1643 for(let r = 0; r < game.courseRadius / 3; r++) {
1644 let angle = THREE.MathUtils.degToRad(360 * 3 * r / game.courseRadius);
1645 let cameraX = game.courseRadius * Math.sin(angle);
1646 let cameraY = game.courseRadius * Math.cos(angle);
1647 const vFOV = THREE.MathUtils.degToRad(game.view.camera.fov);
1649 let maxCameraDistance = Math.tan(vFOV / 2) * Math.abs(z - game.view.camera.position.z);
1650 let axis = [-1, 0, 1];
1654 for(let step of axis) {
1655 let shape = 1 + Math.floor(Math.random() * 5);
1656 let cloud = new THREE.Mesh(cloudGeometry, game.view.materials['cloud' + shape + textureVariantSuffix]);
1657 cloud.position.x = cameraX + step * 1.2 * maxCameraDistance * Math.sin(angle);
1658 cloud.position.y = cameraY + step * maxCameraDistance * Math.cos(angle);
1659 cloud.position.z = z;
1660 let scale = 15 + 0.5 * Math.abs(cloud.position.z);
1661 cloud.scale.set(scale, scale, scale);
1662 game.objects.clouds.add(cloud);
1666 game.view.scene.add(game.objects.clouds);
1667 game.objects.backdrop = new THREE.Mesh(new THREE.PlaneGeometry(350, 350), game.view.materials['cloud0' + textureVariantSuffix]);
1668 game.objects.backdrop.position.setZ(-100);
1670 game.view.materials.letter = new THREE.MeshStandardMaterial({
1674 const highcontrastLetterGeometry = new THREE.SphereGeometry(0.1, 16, 16);
1675 highcontrastLetterGeometry.dx = 0;
1676 highcontrastLetterGeometry.dy = 0;
1677 game.assets.fonts.geometry = {};
1678 for(let letter of [...new Set(game.assets.wordList.join(''))]) {
1679 game.assets.fonts.geometry[letter] = highcontrastLetterGeometry;
1681 const highContrastBackdropMaterial = new THREE.MeshBasicMaterial({
1682 map: game.assets.textures['highcontrast-backdrop'],
1684 game.objects.backdrop = new THREE.Mesh(new THREE.PlaneGeometry(150, 150), highContrastBackdropMaterial);
1685 game.objects.backdrop.position.setZ(-10);
1687 if(game.objects.words) {
1688 for(let word of game.objects.words) {
1689 game['fn'].prepareWordMesh(word);
1692 game.view.scene.add(game.objects.backdrop);
1695 game['fn'].unlockFeather = (feather, url) => {
1696 if(game.settings['unlocks'].includes(feather)) {
1699 game.settings['unlocks'].push(feather);
1701 url = game['deploymentOptions']['assetUrlPrefix'] + 'textures/feather-' + feather + '.png';
1702 } else if(url.startsWith('textures/')) {
1703 url = game['deploymentOptions']['assetUrlPrefix'] + url;
1706 if(!game.assets['textures']['feather-' + feather]) {
1707 (new THREE.TextureLoader()).load(url, (result) => {
1708 result.colorSpace = THREE.SRGBColorSpace;
1709 result.minFilter = THREE.NearestFilter;
1710 result.magFilter = THREE.NearestFilter;
1711 result.repeat = new THREE.Vector2(1, -1);
1712 result.wrapT = THREE.RepeatWrapping;
1713 game.assets['textures']['feather-' + feather] = result;
1714 }, () => {}, (err) => {
1715 console.error('Error while loading ' + feather + ' feather texture: ' + err);
1718 // Custom hash function that ensures our unlockables get stored in the same order,
1719 // regardless of the order in which they get unlocked.
1720 let miniHash = (input) => {
1721 return 4 * input.charCodeAt(0) + 0.1 * input.charCodeAt(1) + 3 * input.charCodeAt(2) + 2 * input.charCodeAt(3);
1723 game.settings['unlocks'].sort((u1, u2) => miniHash(u1) > miniHash(u2));
1724 let insertAfterFeather = 'purple';
1725 if(game.settings['unlocks'].indexOf(feather) >= 1) {
1726 insertAfterFeather = game.settings['unlocks'][game.settings['unlocks'].indexOf(feather) - 1];
1728 let radio = document.createElement('input');
1729 radio.type = 'radio';
1730 radio.name = 'upInTheAirGame-feather';
1731 radio.value = feather;
1732 radio.addEventListener('change', () => {
1733 game['fn'].applySettings();
1734 game['fn'].createFeather();
1736 let img = document.createElement('img');
1738 img.alt = feather[0].toUpperCase() + feather.slice(1) + ' feather';
1739 let label = document.createElement('label');
1740 label.appendChild(radio);
1741 label.appendChild(img);
1742 game.ui.root.querySelector('.ui-page.options .feather input[value="' + insertAfterFeather + '"]').parentNode.after(label);
1743 game['fn'].applySettings();
1744 let ui = game.ui.root.querySelector('.ui-page.unlock');
1745 let img2 = ui.querySelector('img');
1748 ui.querySelector('p.name').innerText = img2.alt;
1752 game['fn'].moveToPage = (target, skipFade = false) => {
1753 let fadeDuration = 250;
1757 // After the gameplay page is shown for the first time, always keep it around as a backdrop
1758 game.ui.root.querySelectorAll('.ui-page:not(.' + target + '):not(.gameplay)').forEach((page) => {
1759 page.style.opacity = '0';
1760 setTimeout((page) => {
1761 page.style.display = 'none';
1762 }, fadeDuration, page);
1764 if(game.ui.currentPage == 'title' && !game.ui.reachedStart) {
1766 game.ui.root.querySelector('.ui-page.title h1').removeAttribute('style');
1767 game.ui.root.querySelectorAll('.ui-page.title button').forEach(btn => { btn.disabled = false; btn.removeAttribute('style'); });
1768 game.ui.root.querySelector('.ui-page.title .footer').removeAttribute('style');
1769 game.ui.root.querySelector('.ui-page.title .system-buttons').removeAttribute('style');
1770 game.ui.reachedStart = true;
1773 if(target == 'title' && game.view) {
1774 game.view.cheatBuffer = '';
1776 if(target == 'title' && (!game.ui.currentPage || ['loading', 'controls'].includes(game.ui.currentPage))) {
1777 game['fn'].initializeGame(game.ui.root.querySelector('canvas'));
1779 if(target == 'title' && game.ui.root.querySelector('.ui-page.gameplay p')) {
1780 game.ui.root.querySelectorAll('.ui-page.gameplay p').forEach(elem => elem.remove());
1782 if(target == 'title' && game.view && game.view.windSound && game.view.windSound.isPlaying) {
1783 game.view.windSound.stop();
1785 if(target == 'title' && game.view && game.view.music && game.view.music.isPlaying) {
1786 game.view.music.stop();
1787 if(game.view.music.timeoutID) {
1788 clearTimeout(game.view.music.timeoutID);
1789 delete game.view.music.timeoutID;
1792 if((target != 'pause' && game.ui.currentPage != 'pause') || target == 'title') {
1793 game.ui.hud.style.opacity = '0';
1795 if(target == 'outro') {
1796 if(game.view.music.isPlaying) {
1797 game.view.music.stop();
1799 let collectedWords = game.objects.words.filter(w => w.collected).map(w => w.text);
1800 game.ui.root.querySelector('.ui-page.outro .count').innerText = game.objects.words.collectedCount;
1801 game.ui.root.querySelector('.ui-page.outro .optionalPlural').innerText = 'word' + ((game.objects.words.collectedCount == 1) ? '' : 's') + '.';
1802 let ratingElem = game.ui.root.querySelector('.ui-page.outro .rating');
1803 let exampleElems = game.ui.root.querySelectorAll('.ui-page.outro .examples');
1804 let finalParagraph = game.ui.root.querySelector('.ui-page.outro .area > p:last-child');
1805 ratingElem.style.display = 'none';
1806 exampleElems.forEach(elem => { elem.style.display = 'none'; });
1807 let returnButton = game.ui.root.querySelector('.ui-page.outro button.goto');
1808 if(game.objects.words.collectedCount == 100 || game.objects.words.collectedCount == 0) {
1809 finalParagraph.style.display = 'none';
1810 let neededUnlocking = false;
1811 if(game.objects.words.collectedCount == 100) {
1812 neededUnlocking = game['fn'].unlockFeather('golden');
1814 neededUnlocking = game['fn'].unlockFeather('ghost');
1816 if(neededUnlocking) {
1817 returnButton.innerText = 'Continue';
1818 returnButton.classList.remove('title');
1819 returnButton.classList.add('unlock');
1821 returnButton.innerText = 'Return to Title Screen';
1822 returnButton.classList.remove('unlock');
1823 returnButton.classList.add('title');
1826 finalParagraph.style.display = 'block';
1827 returnButton.innerText = 'Return to Title Screen';
1828 returnButton.classList.remove('unlock');
1829 returnButton.classList.add('title');
1831 if(game.objects.words.collectedCount > 0) {
1832 if(game.objects.words.collectedCount == 100) {
1833 ratingElem.style.display = 'block';
1834 ratingElem.innerText = 'Wow, you managed to collect all of them. Congratulations!';
1836 let generateExampleSentences = (wordList) => {
1837 let container = game.ui.root.querySelector('.ui-page.outro div.examples');
1838 while(container.children.length > 0) {
1839 container.children[0].remove();
1842 for(let category of Object.keys(game.assets.words)) {
1843 words[category] = [];
1844 for(let word of game.assets.words[category]) {
1845 if(wordList.includes(word)) {
1846 words[category].push(word);
1851 let failedAttempts = 0;
1852 while(result.length < 3 && failedAttempts < 1000) {
1853 let sentence = game.assets.sentences[Math.floor(Math.random() * game.assets.sentences.length)];
1854 while(sentence.indexOf('{') > -1) {
1855 let areWeStuck = true;
1856 for(let category of Object.keys(words)) {
1857 if(sentence.includes('{' + category + '}')) {
1858 if(words[category].length == 0) {
1861 let choice = words[category][Math.floor(Math.random() * words[category].length)];
1862 if(category == 'sorry') {
1863 if(choice == 'sorry') {
1864 sentence = sentence.replace('{sorry}', 'I’m {sorry}');
1866 if(choice == 'apologize') {
1867 sentence = sentence.replace('{sorry}', 'I {sorry}');
1870 if(sentence.indexOf('{' + category + '}') == 0) {
1871 choice = choice[0].toUpperCase() + choice.slice(1);
1873 sentence = sentence.replace('{' + category + '}', '<strong>' + choice + '</strong>');
1874 words[category].splice(words[category].indexOf(choice), 1);
1882 if(sentence.indexOf('{') == -1 && !result.includes(sentence)) {
1883 result.push(sentence);
1886 failedAttempts += 1;
1888 for(let sentence of result) {
1889 let elem = document.createElement('p');
1890 elem.innerHTML = sentence;
1891 container.appendChild(elem);
1894 generateExampleSentences(collectedWords);
1895 game.ui.root.querySelector('.ui-page.outro button.examples').addEventListener('click', () => { generateExampleSentences(collectedWords); });
1896 exampleElems.forEach(elem => { elem.style.display = 'flex'; });
1899 ratingElem.style.display = 'block';
1900 ratingElem.innerText = 'You completed the course while dodging every word. That’s an achievement all on its own. Respect!';
1903 if(target == 'options') {
1904 game.ui.root.querySelectorAll('.options .areatabs button').forEach((btn) => {
1905 if(btn.classList.contains('general')) {
1906 btn.classList.add('active');
1908 btn.classList.remove('active');
1911 game.ui.root.querySelectorAll('.options > div.area.twocol').forEach((area) => {
1912 if(area.classList.contains('general')) {
1913 area.style.display = 'flex';
1915 area.style.display = 'none';
1919 const targetElems = [game.ui.root.querySelector('.ui-page.' + target + '')];
1920 if(game.ui.root.querySelector('.ui-page.gameplay').style.opacity != '1' && target == 'title') {
1921 targetElems.push(game.ui.root.querySelector('.ui-page.gameplay'));
1923 for(let targetElem of targetElems) {
1924 if(!targetElem.classList.contains('gameplay')) {
1925 targetElem.style.opacity = '0';
1927 targetElem.style.display = 'flex';
1928 setTimeout((targetElem) => {
1929 targetElem.style.opacity = '1';
1930 if(target == 'credits') {
1931 targetElem.querySelector('.area').scrollTop = 0;
1933 }, fadeDuration, targetElem);
1935 if(target != 'pause' && game.ui.currentPage != 'pause') {
1936 game.timeProgress = 0;
1938 if(target == 'pause') {
1939 game.view.music.stop();
1940 game.view.windSound.stop();
1941 } else if(game.ui.currentPage == 'pause' && target == 'openingcutscene') {
1942 if(game.timeProgress >= 1.0) {
1943 game.view.windSound.offset = game.timeProgress - 1.0;
1944 game.view.windSound.setVolume(game.settings['audio']['sounds']);
1945 if(!game.view.muted) {
1946 game.view.windSound.play();
1949 } else if(game.ui.currentPage == 'pause' && target == 'gameplay') {
1950 game.view.music.offset = (game.timeProgress / (game.settings['difficulty']['speed'] / 100)) % game.assets['audio']['music-' + game.settings['audio']['theme']].duration;
1951 if(!game.view.muted) {
1952 game.view.music.play();
1955 game.ui.previousPage = game.ui.currentPage;
1956 game.ui.currentPage = target;
1958 game.startTime = game.view.clock.getElapsedTime();
1962 game['fn'].unlockWithKey = (input) => {
1963 input = 'aBYPmb2xCwF2ilfD'+ input + 'PNHFwI2zKZejUv6c';
1964 let hash = (input) => {
1965 // Adapted with appreciation from bryc:
1966 // https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
1967 let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
1968 for(let i = 0, ch; i < input.length; i++) {
1969 ch = input.charCodeAt(i);
1970 h1 = Math.imul(h1 ^ ch, 2654435761);
1971 h2 = Math.imul(h2 ^ ch, 1597334677);
1973 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
1974 h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
1975 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
1976 h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
1977 return (h2 >>> 0).toString(16).padStart(8, 0) + (h1 >>> 0).toString(16).padStart(8, 0);
1979 for(let unlockable of game.unlockables) {
1980 if(unlockable.accessKey == hash(input)) {
1981 let key = hash('UZx2jWen9w5jm0FB' + input + '7DZpEq4OOwv2kiJ1');
1982 let seed = parseInt(key.slice(12), 16);
1985 seed = seed + 0x9e3779b9 | 0;
1986 let t = seed ^ seed >>> 16;
1987 t = Math.imul(t, 0x21f0aaad);
1989 t = Math.imul(t, 0x735a2d97);
1990 return ((t = t ^ t >>> 15) >>> 0);
1992 let data = Uint8Array.from(atob(unlockable['payload']), (c) => c.codePointAt(0));
1994 for(let i = 0; i < data.length; i++) {
1997 pad = [pad % 256, (pad >> 8) % 256, (pad >> 16) % 256, (pad >> 24) % 256];
1999 data[i] = data[i] ^ pad[i % 4];
2001 data = new TextDecoder().decode(data);
2002 let result = JSON.parse(data);
2003 if(result['type'] == 'redirect') {
2004 return game['fn'].unlockWithKey(result['target']);
2013 game['fn'].start = () => {
2015 root: document.querySelector('.upInTheAirGame .ui-container'),
2016 hud: document.querySelector('.upInTheAirGame .gameplay .hud'),
2020 // If you're looking at the source code and the following seems scary, don't worry, it's just a few
2021 // unlockable feather textures. I liked easter eggs and cheat codes when I was young, and I didn't
2022 // want these to be trivially bypassable for people who can read the code.
2023 game.unlockables = [
2025 'accessKey': '5b32eb7ad08488f4',
2026 'payload': 'k4JPu3sWfEhcgleieVGghixKSI10qdRRC5tAl39Tzy1U7Rx9EEEYbLx9wCcxAf7wC8r9mJZCOj8bNa7grMbUmTeCeWWPAg==',
2029 'accessKey': '6273da5894b2dd8b',
2030 'payload': 'XAFLhAjcTzsWgGb7DAMCKwHXjoyUg/yxkIUyLcV/7PpktgW3MHtXhh4OkVeANr52RfdbwfVpgO4dxuyYPaFZ4x4JDmI=',
2033 'accessKey': 'fb8b533451a6dd68',
2034 'payload': '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',
2037 'accessKey': '3c78e25c7b1106b6',
2038 'payload': 'dnbO28tXI2i2+o+etuRc0/Cfxd4UpdG1IRgqB0fTwJE1xCoK+TtB/JVGTquaD/stnIkKiA==',
2041 'accessKey': '5f79141f861a146a',
2042 'payload': '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',
2045 'accessKey': 'c2cfaaebb10f00ab',
2046 'payload': 'CAVH/4AwEJzNt950XEAZP3Q92fMNasIje6K5FSBgjqchkxmrFxjlNHjadnrHWdqM+zrB',
2049 'accessKey': '130b037dadbe1d7a',
2050 'payload': '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',
2053 'accessKey': 'd8e8dd84f4b0c103',
2054 'payload': 'EGKsJYSjVVaxCBWPRUGjWuLMl3k7fB/7uKYp8wz28r/5XTaOJF7LnbPMpBwysAR8IR/whArG',
2057 'accessKey': 'd1f0242fccc5692d',
2058 'payload': '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',
2061 'accessKey': '452d9211d2fba161',
2062 'payload': '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',
2066 game['fn'].loadSettings();
2067 game['fn'].applySettings();
2069 if(game.ui.root.querySelectorAll('.ui-page.credits .area h3').length > 3) {
2070 // If the credits have more than three third-level headers, that means we are
2071 // in the freeware version and can make the CSS adjustments it needs.
2072 let css = document.styleSheets[0];
2073 css.insertRule('.upInTheAirGame .ui-page.credits .person { position: relative; height: 4em; padding-left: calc(4em + 1ex); display: flex; flex-direction: column; justify-content: center; }');
2074 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; }');
2075 game.ui.root.querySelectorAll('.ui-page.credits .area .person').forEach((person) => {
2076 let personName = Array.from(person.classList).filter(c => c != 'person')[0];
2077 let imageFormat = (personName == 'nina') ? 'png' : 'jpg';
2078 css.insertRule('.upInTheAirGame .ui-page.credits .person.' + personName + '::before { background-image: url("' + game['deploymentOptions']['assetUrlPrefix'] + 'textures/person-' + personName + '.' + imageFormat + '"); }');
2082 game.ui.root.querySelectorAll('button.goto').forEach((btn) => {
2083 btn.addEventListener('click', (e) => {
2084 if(game.view && !game.view.music) {
2085 game.view.music = new THREE.Audio(game.view.audioListener);
2086 game.view.music.setBuffer(game.assets['audio']['music-' + game.settings['audio']['theme']]);
2087 game.view.music.setVolume(game.settings['audio']['music']);
2088 game.view.windSound = new THREE.Audio(game.view.audioListener);
2089 game.view.windSound.setBuffer(game.assets['audio']['wind']);
2090 game.view.windSound.setVolume(game.settings['audio']['sounds']);
2091 game.view.windSound.setLoop(false);
2093 let btn = e.target.closest('button');
2094 let target = Array.from(btn.classList).filter(c => c != 'goto')[0];
2095 if(target == 'previous') {
2096 target = game.ui.previousPage;
2098 game['fn'].moveToPage(target);
2102 game.ui.root.querySelectorAll('.options .controls input, .options .graphics input, .options .hud input, .options .feather input, .options .accessibility input, .options .accessibility select').forEach((elem) => {
2103 elem.addEventListener('change', () => {
2104 game['fn'].applySettings();
2105 if(elem.name == 'upInTheAirGame-controls') {
2106 game.ui.root.querySelector('.controls .leftside').style.display = (['touchpad', 'thumbstick'].includes(game.settings['controls'])) ? 'block' : 'none';
2107 game.ui.root.querySelectorAll('.options .controls p span:not(.' + game.settings['controls'] + ')').forEach(span => span.style.display = 'none');
2108 game.ui.root.querySelector('.options .controls span.' + game.settings['controls']).style.display = 'block';
2109 } else if(elem.value == 'highcontrast' || elem.name == 'upInTheAirGame-graphics') {
2110 game['fn'].createMeshes();
2111 } else if(elem.name == 'upInTheAirGame-feather') {
2112 game['fn'].createFeather();
2117 game.ui.root.querySelector('.ui-page.title .system-buttons input').addEventListener('change', (e) => {
2118 game.view.muted = e.target.checked;
2121 game.ui.root.querySelector('.ui-page.title .system-buttons button').addEventListener('click', (e) => {
2122 if(document.fullscreenElement == game.ui.root.parentNode) {
2123 document.exitFullscreen();
2125 game.ui.root.parentNode.requestFullscreen();
2129 game.ui.root.querySelectorAll('.ui-page .audio input[type=range]').forEach((elem) => {
2130 elem.addEventListener('input', (e) => {
2131 let audioCategory = Array.from(e.target.classList).filter(v => ['music', 'sounds'].includes(v))[0];
2132 game.ui.root.querySelector('.ui-page.options .audio input[type=range].' + audioCategory).value = e.target.value;
2133 game['fn'].applySettings();
2137 game.ui.root.querySelectorAll('.options .audio button').forEach((btn) => {
2138 btn.addEventListener('click', (e) => {
2139 if(e.target.classList.contains('music')) {
2140 if(game.view.music.isPlaying) {
2141 game.view.music.stop();
2142 if(game.view.music.timeoutID) {
2143 clearTimeout(game.view.music.timeoutID);
2144 delete game.view.music.timeoutID;
2147 game.view.music.offset = 36;
2148 if(!game.view.muted) {
2149 game.view.music.play();
2151 game.view.music.timeoutID = setTimeout(() => {
2152 game.view.music.stop();
2155 } else if(e.target.classList.contains('sounds')) {
2156 game['fn'].playRandomSound();
2161 game.ui.root.querySelectorAll('.options .keyboard label button').forEach((btn) => {
2162 btn.addEventListener('click', () => {
2163 if(game.ui.root.querySelector('.ui-page.keyboard-modal')) {
2166 const keyboardModal = document.createElement('div');
2167 keyboardModal.classList.add('ui-page', 'keyboard-modal');
2168 const instruction = document.createElement('span');
2169 const direction = btn.classList[0];
2170 keyboardModal.classList.add(direction);
2171 instruction.innerText = 'Please press the key for “' + direction[0].toUpperCase() + direction.slice(1) + '”';
2172 keyboardModal.appendChild(instruction);
2173 game.ui.root.appendChild(keyboardModal);
2177 game.ui.root.querySelector('.options .keyboard button[value="reset"]').addEventListener('click', (e) => {
2178 const container = e.target.parentNode;
2179 container.querySelector('button.up').value = 'ArrowUp|w';
2180 container.querySelector('button.right').value = 'ArrowRight|d';
2181 container.querySelector('button.down').value = 'ArrowDown|s';
2182 container.querySelector('button.left').value = 'ArrowLeft|a';
2183 game['fn'].applySettings();
2184 game['fn'].loadSettings();
2187 game.ui.root.querySelectorAll('.ui-page .areatabs button').forEach((btn) => {
2188 btn.addEventListener('click', (e) => {
2189 btn.parentNode.querySelectorAll('button').forEach((otherBtn) => {
2190 otherBtn.classList.remove('active');
2191 let val = otherBtn.classList[0];
2192 otherBtn.closest('.ui-page').querySelector('div.' + val).style.display = 'none';
2194 btn.classList.add('active');
2195 let val = Array.from(btn.classList).filter(c => c != 'active')[0];
2196 btn.closest('.ui-page').querySelector('div.' + val).style.display = 'flex';
2200 game.ui.root.style.fontSize = (game.ui.root.clientWidth / 48) + 'px';
2201 window.addEventListener('resize', () => {
2202 game.ui.root.style.fontSize = (game.ui.root.clientWidth / 48) + 'px';
2205 window.addEventListener('scroll', () => {
2206 if(!['gameplay', 'openingcutscene', 'endingcutscene'].includes(game.ui.currentPage)) {
2209 let bbox = game.ui.root.querySelector('canvas').getBoundingClientRect();
2210 if(bbox.bottom < -100 || bbox.top - bbox.height > 100 || bbox.left + bbox.width < -100 || bbox.left - window.innerWidth > 100) {
2211 game['fn'].moveToPage('pause', true);
2214 window.addEventListener('blur', () => {
2215 if(['gameplay', 'openingcutscene', 'endingcutscene'].includes(game.ui.currentPage)) {
2216 game['fn'].moveToPage('pause', true);
2220 document.addEventListener('keydown', (e) => {
2221 const keyboardModal = game.ui.root.querySelector('.keyboard-modal');
2223 const direction = [...keyboardModal.classList].filter(c => c != 'ui-page' && c != 'keyboard-modal')[0];
2224 if(e.key != 'Escape') {
2225 game.ui.root.querySelector('.options .keyboard label button.' + direction).value = e.key;
2226 game['fn'].applySettings();
2227 game['fn'].loadSettings();
2229 keyboardModal.remove();
2231 e.stopPropagation();
2234 if(game.ui.currentPage == 'title' && e.key.match(/[a-z]/)) {
2235 game.view.cheatBuffer = (game.view.cheatBuffer + e.key).slice(-25);
2236 for(let len = 10; len <= 25; len++) {
2237 if(game.view.cheatBuffer.length < len) {
2240 let unlock = game['fn'].unlockWithKey(game.view.cheatBuffer.slice(-len));
2241 if(unlock && unlock['type'] == 'feather' && !game.settings['unlocks'].includes(unlock['name'])) {
2242 game['fn'].playRandomSound();
2243 game['fn'].unlockFeather(unlock['name'], unlock['url']);
2244 game['fn'].moveToPage('unlock');
2245 } else if(unlock && unlock['type'] == 'custom') {
2246 if(!game.var.sessionUnlocks) {
2247 game.var.sessionUnlocks = [];
2249 if(!game.var.sessionUnlocks.includes(unlock['name'])) {
2250 game['fn'].playRandomSound();
2251 let action = new Function(unlock['action']);
2252 action.call(null, game, THREE);
2253 game.var.sessionUnlocks.push(unlock['name']);
2259 if(e.key == 'Escape') {
2260 if(['gameplay', 'openingcutscene', 'endingcutscene'].includes(game.ui.currentPage)) {
2261 game['fn'].moveToPage('pause', true);
2262 } else if(game.ui.currentPage == 'pause') {
2263 game['fn'].moveToPage(game.ui.previousPage, true);
2268 window.addEventListener('gamepadconnected', (e) => {
2269 game.ui.gamepads.push(e.gamepad);
2271 window.addEventListener('gamepaddisconnected', (e) => {
2272 if(game.ui.gamepads.includes(e.gamepad)) {
2273 game.ui.gamepads.splice(game.ui.gamepads.indexOf(e.gamepad), 1);
2277 game.ui.root.querySelector('.ui-page.pause button.title').addEventListener('click', () => {
2281 game['fn'].loadAllAssets((progress) => {
2282 let percentage = Math.floor(100 * progress);
2283 game.ui.root.querySelector('.ui-page.loading progress').value = percentage;
2284 game.ui.root.querySelector('.ui-page.loading span').innerText = percentage;
2286 if(window.location.hostname == 'fietkau.media' && window.location.pathname == '/up_in_the_air') {
2287 game.ui.root.querySelector('.ui-page.title .footer span:last-child').remove();
2289 let controlsInterstitial = false;
2290 if(!game.settings['controls']) {
2291 controlsInterstitial = true;
2293 if(matchMedia('(hover: hover)').matches) {
2296 control = 'touchpad';
2298 game.ui.root.querySelector('.controls input[value="' + control + '"]').checked = true;
2299 game['fn'].applySettings();
2300 game['fn'].loadSettings();
2301 game.ui.root.querySelectorAll('.ui-page.controls .' + ((control == 'mouse') ? 'touchpad' : 'mouse')).forEach(elem => elem.remove());
2303 if(!game.assets.audiothemes.includes(game.settings['audio']['theme'])) {
2304 game.settings['audio']['theme'] = game.assets.audiothemes[0];
2306 if(game.assets.audiothemes.length == 1) {
2307 game.ui.root.querySelector('.ui-page.options .audiotheme').style.display = 'none';
2309 let container = game.ui.root.querySelector('.ui-page.options .audiotheme');
2310 for(let audioTheme of game.assets.audiothemes) {
2311 let snippet = container.children[0].content.cloneNode(true).children[0];
2312 snippet.children[0].value = audioTheme;
2313 if(audioTheme == game.settings['audio']['theme']) {
2314 snippet.children[0].checked = true;
2316 snippet.children[0].addEventListener('change', () => {
2317 game['fn'].applySettings();
2318 game.view.music.setBuffer(game.assets['audio']['music-' + game.settings['audio']['theme']]);
2320 snippet.childNodes[1].textContent = ' ' + audioTheme[0].toUpperCase() + audioTheme.slice(1);
2321 container.appendChild(snippet);
2323 if(controlsInterstitial) {
2324 game['fn'].moveToPage('controls');
2326 game['fn'].moveToPage('title');
2334 // Set up name mirrors for each function that should survive most minifiers and transpilers
2335 game['fn'].animate['friendlyName'] = 'animate';
2336 game['fn'].applyForceToFeather['friendlyName'] = 'applyForceToFeather';
2337 game['fn'].applySettings['friendlyName'] = 'applySettings';
2338 game['fn'].createFeather['friendlyName'] = 'createFeather';
2339 game['fn'].createMeshes['friendlyName'] = 'createMeshes';
2340 game['fn'].easeInOut['friendlyName'] = 'easeInOut';
2341 game['fn'].initializeGame['friendlyName'] = 'initializeGame';
2342 game['fn'].lerp['friendlyName'] = 'lerp';
2343 game['fn'].loadAllAssets['friendlyName'] = 'loadAllAssets';
2344 game['fn'].loadSettings['friendlyName'] = 'loadSettings';
2345 game['fn'].moveToPage['friendlyName'] = 'moveToPage';
2346 game['fn'].playRandomSound['friendlyName'] = 'playRandomSound';
2347 game['fn'].prepareWordMesh['friendlyName'] = 'prepareWordMesh';
2348 game['fn'].reset['friendlyName'] = 'reset';
2349 game['fn'].start['friendlyName'] = 'start';
2350 game['fn'].unlockFeather['friendlyName'] = 'unlockFeather';
2351 game['fn'].unlockWithKey['friendlyName'] = 'unlockWithKey';