diff --git a/docs/index.9d14e10e.js b/docs/index.9d14e10e.js new file mode 100644 index 0000000..21cc87e --- /dev/null +++ b/docs/index.9d14e10e.js @@ -0,0 +1,6327 @@ +// modules are defined as an array +// [ module function, map of requires ] +// +// map of requires is short require name -> numeric require +// +// anything defined in a previous bundle is accessed via the +// orig method which is the require for previous bundles + +(function (modules, entry, mainEntry, parcelRequireName, globalName) { + /* eslint-disable no-undef */ + var globalObject = + typeof globalThis !== 'undefined' + ? globalThis + : typeof self !== 'undefined' + ? self + : typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : {}; + /* eslint-enable no-undef */ + + // Save the require from previous bundle to this closure if any + var previousRequire = + typeof globalObject[parcelRequireName] === 'function' && + globalObject[parcelRequireName]; + + var cache = previousRequire.cache || {}; + // Do not use `require` to prevent Webpack from trying to bundle this call + var nodeRequire = + typeof module !== 'undefined' && + typeof module.require === 'function' && + module.require.bind(module); + + function newRequire(name, jumped) { + if (!cache[name]) { + if (!modules[name]) { + // if we cannot find the module within our internal map or + // cache jump to the current global require ie. the last bundle + // that was added to the page. + var currentRequire = + typeof globalObject[parcelRequireName] === 'function' && + globalObject[parcelRequireName]; + if (!jumped && currentRequire) { + return currentRequire(name, true); + } + + // If there are other bundles on this page the require from the + // previous one is saved to 'previousRequire'. Repeat this as + // many times as there are bundles until the module is found or + // we exhaust the require chain. + if (previousRequire) { + return previousRequire(name, true); + } + + // Try the node require function if it exists. + if (nodeRequire && typeof name === 'string') { + return nodeRequire(name); + } + + var err = new Error("Cannot find module '" + name + "'"); + err.code = 'MODULE_NOT_FOUND'; + throw err; + } + + localRequire.resolve = resolve; + localRequire.cache = {}; + + var module = (cache[name] = new newRequire.Module(name)); + + modules[name][0].call( + module.exports, + localRequire, + module, + module.exports, + this + ); + } + + return cache[name].exports; + + function localRequire(x) { + var res = localRequire.resolve(x); + return res === false ? {} : newRequire(res); + } + + function resolve(x) { + var id = modules[name][1][x]; + return id != null ? id : x; + } + } + + function Module(moduleName) { + this.id = moduleName; + this.bundle = newRequire; + this.exports = {}; + } + + newRequire.isParcelRequire = true; + newRequire.Module = Module; + newRequire.modules = modules; + newRequire.cache = cache; + newRequire.parent = previousRequire; + newRequire.register = function (id, exports) { + modules[id] = [ + function (require, module) { + module.exports = exports; + }, + {}, + ]; + }; + + Object.defineProperty(newRequire, 'root', { + get: function () { + return globalObject[parcelRequireName]; + }, + }); + + globalObject[parcelRequireName] = newRequire; + + for (var i = 0; i < entry.length; i++) { + newRequire(entry[i]); + } + + if (mainEntry) { + // Expose entry point to Node, AMD or browser globals + // Based on https://github.com/ForbesLindesay/umd/blob/master/template.js + var mainExports = newRequire(mainEntry); + + // CommonJS + if (typeof exports === 'object' && typeof module !== 'undefined') { + module.exports = mainExports; + + // RequireJS + } else if (typeof define === 'function' && define.amd) { + define(function () { + return mainExports; + }); + + // + + Scaledle + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..83489e6 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + + Scaledle + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..2f27c8b --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "dependencies": { + "mithril": "^2.2.2", + "tonal": "^5.0.0" + }, + "devDependencies": { + "@parcel/transformer-sass": "2.10.2", + "parcel": "^2.10.2" + } +} diff --git a/src/App.ts b/src/App.ts new file mode 100644 index 0000000..f8debc9 --- /dev/null +++ b/src/App.ts @@ -0,0 +1,8 @@ +import m from "mithril"; +import MainPage from "./components/MainPage"; + +// Set up routing by connecting components to routes +m.route(document.body, "/", { + "/": MainPage, +}); + diff --git a/src/Util.ts b/src/Util.ts new file mode 100644 index 0000000..626767f --- /dev/null +++ b/src/Util.ts @@ -0,0 +1,132 @@ +import { Note, Scale } from "tonal"; + +export default class Util { + static readonly SCALE_TYPES = [ + "major", + "minor", + "harmonic minor", + "melodic minor", + "dorian", + "phrygian", + "lydian", + "mixolydian", + "locrian", + "pentatonic", + "whole-half diminished", + "half-whole diminished" + ] + + static readonly ROOTS = [ + "A", + "Bb", + "B", + "C", + "C#", + "D", + "Eb", + "E", + "F", + "F#", + "G", + "Ab", + ]; + + static readonly BLACK_NOTES = ["C#", "D#", "F#", "G#", "A#"]; + static readonly BLACK_NOTES_FLAT = ["Db", "Eb", "Gb", "Ab", "Bb"]; + static readonly WHITE_NOTES = ["C", "D", "E", "F", "G", "A", "B"]; + + static getRandomScaleNumber() { + // TODO: use the proper algorithm rather than assuming 12 roots and scale types + return Math.floor(Math.random() * 144); + } + + static getScaleByNumber(i: number) { + // TODO: use the proper algorithm rather than assuming 12 roots and scale types + const root = this.ROOTS[i%12]; + const scaleType = this.SCALE_TYPES[Math.floor(i/12)]; + + const octave = 5; // Doesn't really matter + return Scale.get(`${root}${octave} ${scaleType}`); + } + + // Get midi array + static toMidiArray(notes) { + return notes.map((noteName) => Note.get(noteName).midi % 12); + } + + // Returns an array of results: + // "C": correct, + // "W": wrong spot, + // "X": does not exist + static calculateGuess(guess, rawAnswer) { + // Normalize correct to be same length as guess + const answer = rawAnswer.concat(rawAnswer).slice(0, guess.length); + + // Make map of answer to track note usage + const answerMap = {}; + answer.forEach((note) => { + if (!(note in answerMap)) { + answerMap[note] = 0; + } + answerMap[note]++; + }); + + const result = Array(guess.length); + + // First pass to find correct notes + guess.forEach((note, i) => { + if (note === answer[i]) { + result[i] = "C"; + answerMap[note]--; + } + }); + + // Second pass to figure out the other results + guess.forEach((note, i) => { + if (result[i] === "C") { + return; + } + + if (answerMap[note]) { + result[i] = "W"; + answerMap[note]--; + } + else { + result[i] = "X"; + } + }); + + return result; + } + + static readonly EMOJI_MAP = { + "C": "🟩", + "W": "🟨", + "X": "⬛" + } + static readonly URL = "seanyeh.github.io/scaldle"; + + static resultsToShareable(resultsList, puzzleNumber): string { + const tries = resultsList.length; + const resultsStr = resultsList.map((results) => ( + `${results.map((x) => Util.EMOJI_MAP[x]).join("")}\n` + )).join(""); + + return `Scale-dle #${puzzleNumber}\n${resultsStr}\n${Util.URL}`; + } + + static normalizeScaleName(scaleName) { + // Remove octave from name + let newName = scaleName.replace(/[0-9]/, ""); + + // Rename diminished scales to octatonic + if (scaleName.includes("half-whole diminished")) { + newName = scaleName.replace("half-whole diminished", "octatonic (half-whole)"); + } + else if (scaleName.includes("diminished")) { + newName = scaleName.replace("diminished", "octatonic (whole-half)"); + } + + return newName; + } +} diff --git a/src/components/ControlButtons.ts b/src/components/ControlButtons.ts new file mode 100644 index 0000000..9b88ff2 --- /dev/null +++ b/src/components/ControlButtons.ts @@ -0,0 +1,17 @@ +import m from "mithril"; + +export default class ControlButtons { + constructor(onEnter: any, onBackspace: any) { + this.onEnter = onEnter; + this.onBackspace = onBackspace; + } + + view() { + return m("div#control", [ + m("div.button", { onclick: this.onEnter }, "enter"), + m("div.button", { onclick: this.onBackspace }, "⌫"), + ]); + } +} + + diff --git a/src/components/Keyboard.ts b/src/components/Keyboard.ts new file mode 100644 index 0000000..255dbf6 --- /dev/null +++ b/src/components/Keyboard.ts @@ -0,0 +1,95 @@ +import m from "mithril"; + +import Util from "../Util"; + +export default class Keyboard { + callback: any; + + constructor(callback) { + this.callback = callback; + } + + initShapes(ctx) { + const width = ctx.canvas.clientWidth; + const height = ctx.canvas.clientHeight; + const keyWidth = width / 7; + + const offsets = [(2/3), 1 + 2/3, 3 + 2/3, 4 + 2/3, 5 + 2/3]; + this.blackKeys = offsets.map((offset) => [ + offset*keyWidth, 0, + (2/3)*keyWidth, height/2 + ]); + + this.whiteKeys = Array(7).fill().map((_, i) => [ + i*keyWidth, 0, + keyWidth, height + ]); + } + + _inBounds(px, py, x, y, w, h) { + return px >= x && py >= y && px <= (x + w) && py <= (y + h); + } + + onclick(e) { + const px = e.offsetX; + const py = e.offsetY; + + let index = this.blackKeys.findIndex(([x, y, w, h]) => this._inBounds(px, py, x, y, w, h)); + if (index !== -1) { + return this.callback(Util.BLACK_NOTES[index]); + } + + index = this.whiteKeys.findIndex(([x, y, w, h]) => this._inBounds(px, py, x, y, w, h)); + if (index !== -1) { + return this.callback(Util.WHITE_NOTES[index]); + } + } + + drawKeyboard(ctx) { + this.initShapes(ctx); + + ctx.fillStyle = "white"; + ctx.textAlign = "center"; + ctx.fillRect(0, 0, ctx.canvas.clientWidth, ctx.canvas.clientHeight); + + // Draw white keys (only right edge) + ctx.fillStyle = "black"; + this.whiteKeys.forEach(([x, y, w, h], i) => { + // Draw right edge + ctx.beginPath(); + ctx.moveTo(x + w, 0); + ctx.lineTo(x + w, y + h); + ctx.stroke(); + + // Draw letter + ctx.font = "24px sans-serif"; + ctx.fillText(Util.WHITE_NOTES[i], x + w/2, h - 30); + }); + + // Draw black keys + this.blackKeys.forEach(([x, y, w, h], i) => { + ctx.fillStyle = "black"; + ctx.fillRect(x, y, w, h); + + // Draw letter + ctx.fillStyle = "white"; + ctx.font = "16px sans-serif"; + ctx.fillText(Util.BLACK_NOTES[i], x + w/2, 30); + ctx.fillText(Util.BLACK_NOTES_FLAT[i], x + w/2, 60); + }); + } + + view() { + const attrs = { + oncreate: (vnode) => { + const ctx = vnode.dom.getContext("2d"); + this.drawKeyboard(ctx); + vnode.dom.addEventListener('click', this.onclick.bind(this)); + } + } + + return m("div#canvas-wrapper", [ + m("canvas[width=400][height=180]", attrs) + ]); + } +} diff --git a/src/components/MainPage.ts b/src/components/MainPage.ts new file mode 100644 index 0000000..4d17f5c --- /dev/null +++ b/src/components/MainPage.ts @@ -0,0 +1,122 @@ +import m from "mithril"; + +import ControlButtons from "./ControlButtons"; +import Modal from "./Modal"; +import Keyboard from "./Keyboard"; +import Row from "./Row"; +import Util from "../Util"; + +import { Note, Scale } from "tonal"; + +export default class MainPage { + static readonly MAX_ROWS = 6; + + isFinished: boolean; + modal: any; + + constructor() { + // Init components + this.keyboard = new Keyboard(this.setValue.bind(this)); + this.controlButtons = new ControlButtons( + this.onEnter.bind(this), + this.onBackspace.bind(this) + ); + + const scaleNumber = Number(m.route.param("id")); + this.reset( Number.isInteger(scaleNumber) ? scaleNumber : null); + + // Debug + window.M = this; + } + + reset(scaleNumber: number = null) { + this.rows = new Array(MainPage.MAX_ROWS).fill().map(() => new Row()); + + this.isFinished = false; + this.currentRowIndex = 0; + this.scaleNumber = scaleNumber === null ? Util.getRandomScaleNumber() : scaleNumber; + this.answerScale = Util.getScaleByNumber(this.scaleNumber); + this.modal = null; + + m.redraw(); + } + + openModal() { + // Add delay to show animation + window.setTimeout(() => { + this.modal = new Modal( + this.closeModal.bind(this), + this.reset.bind(this), + this.answerScale.name, + this.getShareable() + ); + m.redraw(); + }, 2500); + } + + closeModal() { + this.modal = null; + } + + onEnter() { + if (this.isFinished) { return; } + + if (!this.currentRow().isFilled()) { + return; + } + + this.submitGuess(); + } + + onBackspace() { + if (this.isFinished) { return; } + + this.currentRow().clearLast(); + } + + currentRow() { + return this.rows[this.currentRowIndex]; + } + + getShareable() { + const rows = this.rows.slice(0, this.currentRowIndex); + const fullResults = rows.map((row) => row.compareWithAnswer(this.answerScale)); + + return Util.resultsToShareable(fullResults, this.scaleNumber); + } + + submitGuess() { + const results = this.currentRow().compareWithAnswer(this.answerScale); + + this.currentRow().displayResults(results); + this.currentRowIndex++; + + m.redraw(); + + if (results.every((x) => x === "C")) { + this.isFinished = true; + this.openModal(); + } + else if (this.currentRowIndex >= MainPage.MAX_ROWS) { + this.isFinished = true; + this.openModal(); + } + } + + setValue(value: string) { + if (this.isFinished) { return; } + + this.currentRow().setValue(value); + m.redraw(); + } + + view() { + return [ + m("h1", "Scale-dle"), + this.modal ? m(this.modal) : null, + m("div.grid", this.rows.map((row) => m(row))), + m(this.controlButtons), + m(this.keyboard) + ]; + } +} diff --git a/src/components/Modal.ts b/src/components/Modal.ts new file mode 100644 index 0000000..d3f5f77 --- /dev/null +++ b/src/components/Modal.ts @@ -0,0 +1,32 @@ +import m from "mithril"; + +import Util from "../Util"; + +export default class Modal { + constructor(onclose, onreset, answerScaleName, shareable) { + this.onclose = onclose; + this.onreset = onreset; + this.answerScaleName = answerScaleName; + this.shareable = shareable; + } + + static readonly SHARE_ICON = ` `; + + async onshare() { + await navigator.clipboard.writeText(this.shareable); + } + + view() { + return m("div.modal", [ + m("span.close", { onclick: this.onclose }, "X"), + m("div.content", [ + m("h1", Util.normalizeScaleName(this.answerScaleName)), + m("div.button", { onclick: this.onshare.bind(this) }, [ + m("span.share", "Share"), + m.trust(Modal.SHARE_ICON) + ]), + m("div.button", { onclick: () => { this.onreset() } }, "Next Scale-dle!"), + ]) + ]); + } +} diff --git a/src/components/Row.ts b/src/components/Row.ts new file mode 100644 index 0000000..aeaaddf --- /dev/null +++ b/src/components/Row.ts @@ -0,0 +1,132 @@ +import m from "mithril"; +import { Note } from "tonal"; + +import Util from "../Util"; + +export default class Row { + cells: Cell[]; + + constructor() { + this.cells = new Array(7).fill().map((_, i) => new Cell(i)); + } + + compareWithAnswer(answerScale) { + const answerMidis = Util.toMidiArray(answerScale.notes); + const guessMidis = Util.toMidiArray(this.getNotes()); + + return Util.calculateGuess(guessMidis, answerMidis); + } + + getNotes() { + const octave = 5; // Doesn't matter + const notes = this.cells.map((cell) => Note.get(`${cell.value}${octave}`)); + return notes; + } + + setValue(value: string) { + const cell = this.nextEmptyCell(); + if (cell) { + cell.setValue(value); + } + } + + displayResults(results) { + this.cells.forEach((cell, i) => cell.setStatus(results[i])); + } + + clearLast() { + const cell = this.lastCell(); + if (cell) { + cell.setValue(null); + } + } + + getNextCellIndex() { + for (let i = 0; i < this.cells.length; i++) { + if (this.cells[i].isEmpty()) { + return i; + } + } + + return -1; + } + + // Returns last filled cell + lastCell() { + const i = this.getNextCellIndex(); + + if (i === -1) { + // All cells filled, so return last + return this.cells[this.cells.length - 1]; + } + else if (i === 0) { + // All cells empty + return; + } + + return this.cells[i - 1]; + } + + nextEmptyCell() { + const i = this.getNextCellIndex(); + if (i === -1) { + return null; + } + + return this.cells[i]; + } + + isFilled(): boolean { + return !this.nextEmptyCell(); + } + + view() { + return m("div.row", this.cells.map((box) => m(box))); + } +} + +class Cell { + value: string; + status: string; + + // Corresponding CSS classes + static readonly STATUS_CLASSES = { + "C": "correct", + "W": "wrong-position", + "X": "wrong", + " ": "none" + } + + constructor(column) { + this.column = column; // Used for css delay + + this.value = null; + this.status = " "; + + this.flipped = false; + } + + setValue(value: string) { + this.value = value; + } + + setStatus(status: string) { + this.status = status; + + this.flipped = true; + } + + isEmpty() { + return this.value === null; + } + + view() { + return m("div.cell", [ + m(`div.delay${this.column}`, { class: this.flipped ? "inner-flipped" : "inner" }, [ + m("div.front", this.value), + m("div.back", { class: Cell.STATUS_CLASSES[this.status] }, this.value) + ]) + ]); + } +} + diff --git a/src/style.scss b/src/style.scss new file mode 100644 index 0000000..efc9b66 --- /dev/null +++ b/src/style.scss @@ -0,0 +1,185 @@ +$max-width: 400px; +$cell-width: 48px; + +$dark-gray: #2C2D2C; + +$green: #538D4E; + +html { + background-color: black; + color: white; +} + +body { + font-family: Sans-Serif; +} + +h1, h2 { + text-align: center; +} + +div.grid { + div.row { + display: flex; + margin-bottom: 10px; + justify-content: center; + + div.cell { + width: $cell-width; + height: $cell-width; + margin-left: 4px; + margin-right: 4px; + + font-size: 1.8em; + } + + .front, .back { + line-height: $cell-width; + + position: absolute; + width: 100%; + height: 100%; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + } + + .front { + border: 2px solid #3A3A3C; + background-color: black; + } + .back { + transform: rotateX(180deg); + } + + .correct { + background-color: $green; + } + .wrong-position { + background-color: #B59F3B; + } + .wrong { + background-color: #3A3A3C; + } + .none { + background-color: black; + } + + div.inner { + position: relative; + width: 100%; + height: 100%; + text-align: center; + transition: transform 1s; + transform-style: preserve-3d; + } + + div.inner-flipped { + position: relative; + width: 100%; + height: 100%; + text-align: center; + transition: transform 1s; + transform-style: preserve-3d; + + transform: rotateX(180deg); + + &.delay0 { transition-delay: 0s } + &.delay1 { transition-delay: 0.25s } + &.delay2 { transition-delay: 0.5s } + &.delay3 { transition-delay: 0.75s } + &.delay4 { transition-delay: 1s } + &.delay5 { transition-delay: 1.25s } + &.delay6 { transition-delay: 1.5s } + } + } +} + +#control { + display: flex; + flex-direction: row; + justify-content: space-between; + + margin: 0 auto; + max-width: $max-width; +} + +div.button { + display: inline-block; + background-color: gray; + border-radius: 5px; + + padding: 10px; + margin: 20px; + font-size: 1.5em; + + user-select: none; + + &:hover { + cursor: pointer; + } +} + +canvas { +} + +#canvas-wrapper { + display: flex; + justify-content: center; +} + +div.modal { + position: fixed; + z-index: 1; + background-color: $dark-gray; + color: white; + margin: 0 auto; + left: 0; + right: 0; + padding: 20px 20px 50px 20px; + max-width: $max-width; + animation-name: fadein; + animation-duration: 0.3s; + + border-radius: 5px; + + div.button { + background-color: $green; + display: flex; + align-items: center; + justify-content: center; + max-width: 50%; + margin: 0 auto; + margin-top: 20px; + } + + div.content { + display: flex; + flex-direction: column; + justify-content: center; + } +} +@keyframes fadein { + from {opacity: 0} + to {opacity: 1} +} + +.close { + color: #aaa; + float: right; + font-size: 14px; + font-weight: bold; + + &:hover, &:focus { + color: black; + text-decoration: none; + cursor: pointer; + } +} + +span.share { + margin-right: 10px; +} + +svg { + fill: white; +}