From b3a6398b03839d9a9c5f2a1a9bac144867e283a6 Mon Sep 17 00:00:00 2001 From: Florrie Date: Wed, 16 Jan 2019 14:37:45 -0400 Subject: Initial commit --- main.js | 419 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 main.js (limited to 'main.js') diff --git a/main.js b/main.js new file mode 100644 index 0000000..97966f5 --- /dev/null +++ b/main.js @@ -0,0 +1,419 @@ +/** + * + * IDEAS LOL + * + * - make the game, dummy + * + * - hide the image, or the name -- i.e. force the user to guess based on one + * or the other, not both + * + * - pick __ HARD __ selections of choices, not just totally random ones + * + */ + +// initializy stuff --------------------------------------------------------ooo + +'use strict'; + +// sorry this is cursed +const { gameData } = window; + +document.body.classList.add('js-enabled'); + +// resource loading --------------------------------------------------------o.o + +const resources = { + imageMap: new Map(), + soundMap: new Map() +}; + +let loadTotal = 0; +let loadSoFar = 0; + +function loadResources() { + // 2 assets for each character: image, sound. + loadTotal = gameData.characters.length * 2; + + return Promise.all([ + loadImages(), + loadSounds() + ]); +} + +async function loadImages() { + for (const character of gameData.characters) { + const image = new Image(); + resources.imageMap.set(character, image); + + const path = `faces/${getCharacterPath(character)}.png`; + image.src = path; + + await new Promise((resolve, reject) => { + image.addEventListener('load', () => resolve()); + + image.addEventListener('error', () => { + reject(new Error('Failed to load image from path ' + path)); + }); + }); + + loadSoFar++; + updateLoadProgress(); + } +} + +async function loadSounds() { + for (const character of gameData.characters) { + const audio = new Audio(); + resources.soundMap.set(character, audio); + + const path = `sounds/${getCharacterPath(character)}.wav`; + audio.src = path; + + await new Promise((resolve, reject) => { + audio.addEventListener('canplaythrough', () => resolve()); + + audio.addEventListener('error', () => { + reject(new Error('Failed to load audio from path ' + path)); + }); + }); + + loadSoFar++; + updateLoadProgress(); + } +} + +function updateLoadProgress() { + const el = document.getElementById('load-progress'); + if (loadSoFar < loadTotal) { + el.innerHTML = Math.floor(loadSoFar / loadTotal * 100); + } else { + el.parentElement.innerHTML = 'Loaded!'; + } +} + +function getCharacterPath(character) { + let path = ''; + if (character.games.length >= 2) { + path += 'common/'; + } else if (character.games.length === 1) { + path += character.games[0] + '/'; + } else { + // uhhhh what + throw new error(character.name + " isn't actually part of any games??"); + } + path += character.file; + return path; +} + +// talky talky -------------------------------------------------------------oWo + +function wrapMessage(msg, length = 23) { + // so h*ck, apparently word wrapping is Not Automatic in undertale + // best we just specify word-wraps in the unmodified message text. + // keeping this function around just in case though. + const words = msg.split(' '); + const lines = [[]]; + let col = 0; + for (let i = 0; i < words.length; i++) { + const word = words[i]; + col += word.length + 1; + if (col > length) { + lines.push([word]); + col = 0; + } else { + lines[lines.length - 1].push(word); + } + } + return lines.map(l => l.join(' ')).join('\n'); +} + +const audioCtx = new AudioContext(); +const analyser = audioCtx.createAnalyser(); +analyser.fftSize = 256; +analyser.connect(audioCtx.destination); + +async function doSpeech(character, msg) { + const audio = resources.soundMap.get(character); + if (!audio) { + return; + } + + for (let i = 0; i < msg.length; i++) { + const char = msg[i]; + // Yeah all these timings are wrong but they're decent estimations. + if (char === ',' || char === '.') { + await delay(300); + while (msg[i + 1] === ',') i++; // Deal with ellipsis + } else if (char === '\n') { + await delay(80); + } else if (char === ' ') { + await delay(30); + } else { + const a = audio.cloneNode(); + a.volume = character.volume || 0.5; + + const source = audioCtx.createMediaElementSource(a); + source.connect(analyser); + + a.play(); + await delay(character.speed || 50); + } + } +} + +const canvas = document.getElementById('canvas'); +const streamData = new Uint8Array(128); + +function sampleAudioStream() { + analyser.getByteFrequencyData(streamData); + + const rect = document.body.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + let lastTop = null; + for (let i = 0; i < 60; i++) { // i < N is arbitrary, this value seems pretty good for most voices + const w = canvas.width / 60; + const x = w * i; + const h = (0.9 * canvas.height / 255) * streamData[i]; + ctx.fillStyle = 'white'; + + const top = h / 2; + if (lastTop) ctx.fillRect(x, canvas.height / 2 + top, 1, lastTop - top); + ctx.fillRect(x, canvas.height / 2 + top, w, 2); + if (lastTop) ctx.fillRect(x, canvas.height / 2 - top, 1, top - lastTop); + ctx.fillRect(x, canvas.height / 2 - top, w, 2); + lastTop = top; + } + + requestAnimationFrame(sampleAudioStream); +} + +requestAnimationFrame(sampleAudioStream); + +// display choices ---------------------------------------------------------www + +function displayChoices(characters, confirmCharacterCb) { + return new Promise(async resolve => { + const container = document.getElementById('choice-container'); + while (container.firstChild) { + container.removeChild(container.firstChild); + } + container.classList.remove('hide'); + + let selected = false; + for (const character of characters) { + const el = document.createElement('a'); + el.href = '#'; + el.classList.add('character-choice'); + el.appendChild(resources.imageMap.get(character)); + const span = document.createElement('span'); + span.appendChild(document.createTextNode(character.name)); + el.appendChild(span); + container.appendChild(el); + + el.addEventListener('click', () => { + if (!selected) { + selected = true; + resolve(character); + } + }); + } + + container.children[0].focus(); + for (const el of container.children) { + el.classList.add('visible'); + await delay(800 / container.children.length); + } + }); +} + +// utilitylol --------------------------------------------------------------uwu + +function characterWithName(name) { + return gameData.characters.find(chr => chr.name === name); +} + +function pickRandom(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +function spliceRandom(array) { + const val = pickRandom(array); + array.splice(array.indexOf(val), 1); + return val; +} + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function waitForEvent(el, eventName) { + return new Promise(resolve => { + el.addEventListener(eventName, function cb(evt) { + el.removeEventListener(eventName, cb); + resolve(evt); + }); // if only {once: true} were "more supported" + // as if that' s something I care about in this game + }); +} + +// scorekeeper -------------------------------------------------------------*** + +function setupScorekeeper(numCharacters) { + const container = document.getElementById('scorekeeper'); + for (let i = 0; i < numCharacters; i++) { + const el = document.createElement('div') + el.classList.add('marker'); + el.classList.add('unset'); + container.appendChild(el); + } +} + +function markScorekeeper(cls, character) { + const el = scorekeeper.querySelector('.unset'); + el.classList.remove('unset'); + el.classList.add(cls); + el.appendChild(resources.imageMap.get(character).cloneNode()); + el.title = character.name +} + +// uh start it now ---------------------------------------------------------aA! + +async function mainLol() { + const testChar = 'Noellef'; + + await loadResources(); + + document.body.addEventListener('keydown', evt => { + const choiceContainer = document.getElementById('choice-container'); + if (!choiceContainer.classList.contains('hide')) { + if (/^[0-9]$/.test(evt.key)) { + const el = choiceContainer.children[evt.key - 1]; + if (document.activeElement === el) { + el.click(); + } else if (el) { + el.focus(); + } + } else if (event.key === 'ArrowRight') { + const el = document.activeElement; + if (el.parentElement === choiceContainer && el.nextSibling) { + el.nextSibling.focus(); + } + } else if (event.key === 'ArrowLeft') { + const el = document.activeElement; + if (el.parentElement === choiceContainer && el.previousSibling) { + el.previousSibling.focus(); + } + } + } + + if (event.which === 90) { // Z + if (document.activeElement.click) { + document.activeElement.click(); + } + } + }); + + const gameEl = document.getElementById('game'); + + const playLink = document.getElementById('play-link'); + playLink.focus(); + await waitForEvent(playLink, 'click'); + + document.body.classList.add('game'); + + const deckGames = ['deltarune']; + const hardMode = false; + + const characterDeck = gameData.characters.filter(chr => chr.games.some(g => deckGames.includes(g))); + + setupScorekeeper(characterDeck.length); + + while (characterDeck.length) { + gameEl.classList.remove('correct'); + gameEl.classList.remove('incorrect'); + + const correctCharacter = characterWithName(testChar) || spliceRandom(characterDeck); + + const numChoices = 6; + const potentialOptions = characterDeck.slice().filter(chr => chr !== correctCharacter); + const correctIndex = Math.min(Math.floor(Math.random() * numChoices), potentialOptions.length); + const allCharacters = []; + for (let i = 0; i < numChoices; i++) { + if (i === correctIndex) { + allCharacters.push(correctCharacter); + } else if (potentialOptions.length) { + let choice; + allCharacters.push(spliceRandom(potentialOptions)); + } else { + break + } + } + + let msg; + if (hardMode) { + msg = pickRandom(['Oh?', 'Hello!']); + } else { + msg = pickRandom(correctCharacter.messages); + } + + await doSpeech(correctCharacter, msg); + await delay(500); + + const choice = await displayChoices(allCharacters); + + const container = document.getElementById('choice-container'); + for (let i = 0; i < allCharacters.length; i++) { + const el = container.children[i]; + if (i === correctIndex) { + el.classList.add('correct'); + } else { + el.classList.add('incorrect'); + } + if (i === allCharacters.indexOf(choice)) { + el.classList.add('chosen'); + } else { + el.classList.add('not-chosen'); + } + } + + if (choice === correctCharacter) { + markScorekeeper('correct', correctCharacter); + } else { + markScorekeeper('incorrect', correctCharacter); + } + + await delay(200); + + if (choice === correctCharacter) { + gameEl.classList.add('correct'); + } else { + gameEl.classList.add('incorrect'); + const el = document.getElementById('correct-character'); + el.innerHTML = ''; + el.appendChild(document.createTextNode(correctCharacter.name)); + } + + document.getElementById('results').classList.remove('hide'); + + const nextLink = document.getElementById('next-link'); + nextLink.focus(); + await waitForEvent(nextLink, 'click'); + + document.getElementById('choice-container').classList.add('hide'); + document.getElementById('results').classList.add('hide'); + + await delay(400); + } +} + +mainLol().catch(error => { + document.body.classList.add('crashed'); + document.getElementById('crash-trace').appendChild(document.createTextNode( + error.message + '\n' + + error.stack + )); +}); -- cgit 1.3.0-6-gf8a5