├── .eslintrc ├── .gitignore ├── .travis.yml ├── Gruntfile.js ├── LICENSE ├── README.md ├── app ├── ActionTypes.js ├── __tests__ │ └── index.js ├── actions │ ├── AudioActions.js │ ├── ChartActions.js │ ├── PlaybackActions.js │ └── SongActions.js ├── audioContext.js ├── config │ ├── constants.js │ ├── flags.js │ ├── history.js │ ├── routes.js │ └── songs.js ├── main.js ├── polyfill.js ├── records.js ├── reducers │ ├── __tests__ │ │ └── playback.spec.js │ ├── audio.js │ ├── chart.js │ ├── fps.js │ ├── index.js │ ├── playback.js │ └── songs.js ├── runLoop.js ├── store.js ├── util │ ├── immutableReducer.js │ └── ordinal.js └── views │ ├── App.js │ ├── Attract │ └── Handler.js │ ├── Editor │ ├── Handler.js │ ├── __tests__ │ │ └── Editor.spec.js │ └── components │ │ ├── EditorControls.js │ │ └── SaveModal.js │ ├── Player │ ├── Handler.js │ ├── LifeBar.js │ ├── YouTube.js │ └── states │ │ ├── Done.js │ │ ├── Loaded.js │ │ ├── Loading.js │ │ └── Playing.js │ ├── SongList │ └── Handler.js │ ├── SongSelect │ ├── Arrow.js │ └── Handler.js │ └── lib │ ├── AudioPlayback.js │ ├── Chart │ ├── __tests__ │ │ └── Chart.spec.js │ ├── constants.js │ └── index.js │ ├── GameWrapper.js │ ├── GlobalHotKeys.js │ ├── PlaybackWrapper.js │ └── RenderedCanvas.js ├── assets └── DeterminationMonoWeb.woff ├── index.html ├── karma.conf.js ├── notes.txt ├── package.json ├── scripts └── convert_to_total_offset.js ├── songs └── demo │ └── click │ ├── click.json │ ├── click.mp3 │ ├── index.js │ └── short.json ├── styles ├── attract.less ├── editor.less ├── in-game.less ├── main.less ├── player.less └── song-list.less ├── vendor └── BlurInput.js └── webpack ├── base.js ├── dev.js ├── production.js ├── test.js └── util └── glob-chunk.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "mocha": true 8 | }, 9 | "rules": { 10 | "semi": 1, 11 | "strict": 0, 12 | "quotes": [2, "single"], 13 | "no-underscore-dangle": [0], 14 | "new-cap": 0, 15 | "no-shadow": 0, 16 | "no-console": 0, 17 | "comma-dangle": 0, 18 | 19 | // react stuff 20 | "react/jsx-boolean-value": 1, 21 | "react/jsx-no-undef": 2, 22 | "react/jsx-uses-react": 2, 23 | "react/jsx-uses-vars": 1, 24 | "react/no-did-mount-set-state": 1, 25 | "react/no-did-update-set-state": 1, 26 | "react/no-unknown-property": 1, 27 | "react/react-in-jsx-scope": 1, 28 | "react/self-closing-comp": 1, 29 | "react/wrap-multilines": 1 30 | }, 31 | "ecmaFeatures": { 32 | "jsx": true 33 | }, 34 | "plugins": [ 35 | 'react' 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | secret.json 4 | songs/* 5 | !songs/demo/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | 5 | notifications: 6 | email: false 7 | 8 | # ensure we're using the container-based infrastructure 9 | # see https://docs.travis-ci.com/user/workers/container-based-infrastructure/#Routing-your-build-to-container-based-infrastructure 10 | sudo: false 11 | 12 | # enable firefox 13 | before_script: 14 | - export DISPLAY=:99.0 15 | - sh -e /etc/init.d/xvfb start 16 | - npm install npm@^3 17 | 18 | script: 19 | # --silent surpresses that big ol' NPM script error 20 | - npm run-script lint --silent 21 | - npm run-script ci --silent 22 | 23 | cache: 24 | directories: 25 | - node_modules 26 | 27 | # enable native dependencies for node 4.x 28 | # see https://docs.travis-ci.com/user/languages/javascript-with-nodejs#Node.js-v4-(or-io.js-v3)-compiler-requirements 29 | env: 30 | - CXX=g++-4.8 31 | addons: 32 | apt: 33 | sources: 34 | - ubuntu-toolchain-r-test 35 | packages: 36 | - g++-4.8 37 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | require('load-grunt-tasks')(grunt); 3 | 4 | grunt.initConfig({ 5 | secret: grunt.file.readJSON('secret.json'), 6 | 7 | clean: { 8 | build: ['build/'] 9 | }, 10 | 11 | webpack: { 12 | production: require('./webpack/production') 13 | }, 14 | 15 | sftp: { 16 | options: { 17 | path: '<%= secret.path %>', 18 | host: '<%= secret.host %>', 19 | username: '<%= secret.username %>', 20 | agent: process.env.SSH_AUTH_SOCK, 21 | showProgress: true, 22 | srcBasePath: 'build/', 23 | createDirectories: true 24 | }, 25 | 26 | code: { 27 | files: { 28 | './': ['build/**', '!build/assets/**'] 29 | } 30 | }, 31 | 32 | assets: { 33 | files: { 34 | './': ['build/assets/**'] 35 | } 36 | } 37 | }, 38 | 39 | copy: { 40 | index: { 41 | src: 'index.html', 42 | dest: 'build/index.html' 43 | } 44 | }, 45 | 46 | zip: { 47 | itch: { 48 | cwd: 'build/', 49 | src: ['build/**/*'], 50 | dest: 'build/itch' + Date.now() + '.zip' 51 | } 52 | } 53 | }); 54 | 55 | grunt.registerTask('dist', ['clean:build', 'webpack:production', 'copy:index']); 56 | 57 | grunt.registerTask('deploy:code', ['dist', 'sftp:code']); 58 | grunt.registerTask('deploy', ['dist', 'sftp']); 59 | 60 | grunt.registerTask('itch', ['dist', 'zip']); 61 | }; 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Thomas Boyt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bipp [![Build Status](https://travis-ci.org/thomasboyt/bipp.svg?branch=master)](https://travis-ci.org/thomasboyt/bipp) [![Stories in Ready](https://badge.waffle.io/thomasboyt/bipp.svg?label=ready&title=Ready)](http://waffle.io/thomasboyt/bipp) 2 | 3 | This is a music game + engine I'm working on. 4 | 5 | ### Install 6 | 7 | You're probably going to want `node@4.x` and `npm@3.x`. 8 | 9 | Then: 10 | 11 | ``` 12 | npm install 13 | ``` 14 | 15 | ### Run 16 | 17 | ``` 18 | npm run-script dev 19 | ``` 20 | 21 | Navigate to `localhost:8080/songs-list` and you should be in business. I recommend loading the demo song and chart in the `demo/` folder. 22 | 23 | ### Test 24 | 25 | ``` 26 | npm run-script karma 27 | ``` 28 | 29 | Will run tests in Chrome. 30 | 31 | ### Editor Manual 32 | 33 | ``` 34 | sdfjkl - set the 7 columns 35 | p - toggle playback 36 | up/down keys - move up and down 37 | left/right keys - select movement resolution 38 | pgup/pgdown - move up and down by measure 39 | plus/minus - increase/decrease space between beats (think speed multipliers) 40 | ``` 41 | 42 | ### Credits 43 | 44 | [Determination Mono font by Harry Wakamatsu](https://www.behance.net/gallery/31268855/Determination-Better-Undertale-Font) 45 | -------------------------------------------------------------------------------- /app/ActionTypes.js: -------------------------------------------------------------------------------- 1 | // export const NAME = 'NAME'; 2 | 3 | export const LOAD_SONG = 'LOAD_SONG'; 4 | export const TOGGLE_NOTE = 'TOGGLE_NOTE'; 5 | export const CHANGE_BPM = 'CHANGE_BPM'; 6 | 7 | export const LOAD_AUDIO = 'LOAD_AUDIO'; 8 | 9 | export const RESET_PLAYBACK = 'RESET_PLAYBACK'; 10 | export const ENTER_PLAYBACK = 'ENTER_PLAYBACK'; 11 | export const EXIT_PLAYBACK = 'EXIT_PLAYBACK'; 12 | export const PLAY_NOTE = 'PLAY_NOTE'; 13 | export const SET_RATE = 'SET_RATE'; 14 | 15 | export const TICK = 'TICK'; 16 | 17 | export const LOAD_SONGS = 'LOAD_SONGS'; 18 | -------------------------------------------------------------------------------- /app/__tests__/index.js: -------------------------------------------------------------------------------- 1 | import '../polyfill'; 2 | 3 | const context = require.context('../', true, /__tests__\/.*\.spec\.js$/); 4 | context.keys().forEach(context); 5 | -------------------------------------------------------------------------------- /app/actions/AudioActions.js: -------------------------------------------------------------------------------- 1 | import audioCtx from '../audioContext'; 2 | 3 | import { 4 | LOAD_AUDIO, 5 | } from '../ActionTypes'; 6 | 7 | export function loadAudio(song) { 8 | // TODO: this should really have some kind of error handling 9 | return async function(dispatch) { 10 | const url = song.musicUrl; 11 | const resp = await window.fetch(url); 12 | const data = await resp.arrayBuffer(); 13 | 14 | audioCtx.decodeAudioData(data, (audioData) => { 15 | dispatch({ 16 | type: LOAD_AUDIO, 17 | song, 18 | audioData, 19 | }); 20 | }); 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /app/actions/ChartActions.js: -------------------------------------------------------------------------------- 1 | import { 2 | LOAD_SONG, 3 | TOGGLE_NOTE, 4 | CHANGE_BPM, 5 | } from '../ActionTypes'; 6 | 7 | export function loadSong(song, difficulty) { 8 | return { 9 | type: LOAD_SONG, 10 | song: song, 11 | difficulty, 12 | }; 13 | } 14 | 15 | export function toggleNote(offset, column) { 16 | return { 17 | type: TOGGLE_NOTE, 18 | offset, 19 | column, 20 | }; 21 | } 22 | 23 | export function changeBPM(bpm) { 24 | return { 25 | type: CHANGE_BPM, 26 | bpm, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /app/actions/PlaybackActions.js: -------------------------------------------------------------------------------- 1 | import { 2 | RESET_PLAYBACK, 3 | ENTER_PLAYBACK, 4 | EXIT_PLAYBACK, 5 | PLAY_NOTE, 6 | SET_RATE, 7 | } from '../ActionTypes'; 8 | 9 | export function enterPlayback(offset, bpm, notes, beatSpacing) { 10 | return { 11 | type: ENTER_PLAYBACK, 12 | offset, 13 | bpm, 14 | notes, 15 | beatSpacing, 16 | }; 17 | } 18 | 19 | export function exitPlayback() { 20 | return { 21 | type: EXIT_PLAYBACK, 22 | }; 23 | } 24 | 25 | export function resetPlayback() { 26 | return { 27 | type: RESET_PLAYBACK, 28 | }; 29 | } 30 | 31 | export function playNote(time, column) { 32 | return { 33 | type: PLAY_NOTE, 34 | time, 35 | column 36 | }; 37 | } 38 | 39 | export function updateRate(rate) { 40 | return { 41 | type: SET_RATE, 42 | rate 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /app/actions/SongActions.js: -------------------------------------------------------------------------------- 1 | import {LOAD_SONGS} from '../ActionTypes'; 2 | 3 | export function loadSongs(songs) { 4 | return { 5 | type: LOAD_SONGS, 6 | songs 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /app/audioContext.js: -------------------------------------------------------------------------------- 1 | /* global AudioContext */ 2 | 3 | const ctx = new AudioContext(); 4 | 5 | export default ctx; 6 | -------------------------------------------------------------------------------- /app/config/constants.js: -------------------------------------------------------------------------------- 1 | export const judgements = [ 2 | // window (ms), label, continue combo (true) or break (false) 3 | { threshold: 50, label: 'Perfect', className: 'perfect', keepCombo: true, hp: 4 }, 4 | { threshold: 100, label: 'Great', className: 'great', keepCombo: true, hp: 2 }, 5 | { threshold: 135, label: 'Decent', className: 'decent', keepCombo: true, hp: 1 }, 6 | { threshold: 180, label: 'Way Off', className: 'way-off', keepCombo: false, hp: -2 }, 7 | ]; 8 | 9 | export const missedJudgement = { 10 | label: 'Miss', 11 | className: 'miss', 12 | keepCombo: false, 13 | hp: -6, 14 | }; 15 | 16 | // Map keyCodes to columns 17 | export const keyCodeColMap = { 18 | '83': 0, 19 | '68': 1, 20 | '70': 2, 21 | '32': 3, 22 | '74': 4, 23 | '75': 5, 24 | '76': 6 25 | }; 26 | 27 | // Map keys (as seen by Mousetrap) to columns 28 | export const keyColMap = { 29 | 's': 0, 30 | 'd': 1, 31 | 'f': 2, 32 | 'space': 3, 33 | 'j': 4, 34 | 'k': 5, 35 | 'l': 6 36 | }; 37 | 38 | // [fill, stroke] 39 | const editorColors = { 40 | color1: ['white', 'black'], 41 | color2: ['rgb(255,0,255)', 'black'], 42 | centerColor: ['red', 'black'], 43 | separatorStyle: 'rgba(0,0,0,0)', 44 | beatLineStyle: 'black', 45 | offsetBarFillStyle: '#4A90E2', 46 | }; 47 | 48 | const playerColors = { 49 | color1: ['white', 'rgba(0,0,0,0)'], 50 | color2: ['rgb(255,0,255)', 'rgba(0,0,0,0)'], 51 | centerColor: ['red', 'rgba(0,0,0,0)'], 52 | separatorStyle: 'white', 53 | beatLineStyle: 'rgba(0,0,0,0)', 54 | offsetBarFillStyle: '#4A90E2', 55 | }; 56 | 57 | [editorColors, playerColors].forEach((colors) => { 58 | const {color1, color2, centerColor} = colors; 59 | 60 | colors.noteColors = [ 61 | color1, color2, color1, centerColor, color1, color2, color1 62 | ]; 63 | }); 64 | 65 | export {playerColors, editorColors}; 66 | -------------------------------------------------------------------------------- /app/config/flags.js: -------------------------------------------------------------------------------- 1 | import qs from 'qs'; 2 | 3 | // TODO: if we're in a test mode it'd be nice not to have to set document.location.search... pull 4 | // from global or something instead? 5 | const qsObj = qs.parse(document.location.search.slice(1)); // lop off leading question mark 6 | 7 | const types = { 8 | bool(value, name) { 9 | // this is a bool 10 | if (value === '') { 11 | return true; 12 | } else if (value === '0' || value === 'false') { 13 | return false; 14 | } else if (value === '1' || value === 'true') { 15 | return true; 16 | } else { 17 | throw new Error(`could not parse boolean flag ${name}`); 18 | } 19 | } 20 | }; 21 | 22 | function getFlag(name, type, defaultValue) { 23 | if (qsObj[name] !== undefined) { 24 | return type(qsObj[name], name); 25 | } else { 26 | return defaultValue; 27 | } 28 | } 29 | 30 | export const ENABLE_YT_PLAYBACK = getFlag('enableyt', types.bool, false); 31 | 32 | export const MUTE = getFlag('mute', types.bool, false); 33 | 34 | export const SHOW_FPS = getFlag('fps', types.bool, false); 35 | -------------------------------------------------------------------------------- /app/config/history.js: -------------------------------------------------------------------------------- 1 | import createBrowserHistory from 'history/lib/createBrowserHistory'; 2 | import createMemoryHistory from 'history/lib/createMemoryHistory'; 3 | 4 | let history; 5 | 6 | if (process.env.NODE_ENV === 'production') { 7 | history = createMemoryHistory(); 8 | } else { 9 | history = createBrowserHistory(); 10 | } 11 | 12 | export default history; 13 | -------------------------------------------------------------------------------- /app/config/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Route} from 'react-router'; 3 | 4 | import App from '../views/App'; 5 | 6 | export default ( 7 | 8 | 9 | 12 | 13 | 16 | 17 | 20 | 21 | 24 | 25 | 28 | 29 | 30 | ); 31 | -------------------------------------------------------------------------------- /app/config/songs.js: -------------------------------------------------------------------------------- 1 | // This loads every file matching `../../songs/*/index.js` 2 | 3 | const req = require.context('../../songs', true, /\/index.js$/); 4 | 5 | const songs = req.keys().map((key) => { 6 | return req(key).default; 7 | }); 8 | 9 | export default songs; 10 | -------------------------------------------------------------------------------- /app/main.js: -------------------------------------------------------------------------------- 1 | require('../styles/main.less'); 2 | 3 | import './polyfill'; 4 | 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | 8 | // Set up store 9 | import createStore from './store'; 10 | const store = createStore(); 11 | 12 | // Load songs 13 | // TODO: maybe do this elsewhere... 14 | import songs from './config/songs'; 15 | import {loadSongs} from './actions/SongActions'; 16 | store.dispatch(loadSongs(songs)); 17 | 18 | // Set up runLoop 19 | import runLoop from './runLoop'; 20 | runLoop.setStore(store); 21 | runLoop.start(); 22 | 23 | // Set up router 24 | import {Router} from 'react-router'; 25 | 26 | import routes from './config/routes'; 27 | import history from './config/history'; 28 | 29 | // Render root 30 | import {Provider} from 'react-redux'; 31 | 32 | ReactDOM.render(( 33 | 34 | 35 | {routes} 36 | 37 | 38 | ), document.getElementById('container')); 39 | -------------------------------------------------------------------------------- /app/polyfill.js: -------------------------------------------------------------------------------- 1 | // XXX: We can't use babel-polyfill for dev because the 2 | // polyfilled promise library breaks stack traces: 3 | // import 'babel-polyfill'; 4 | 5 | // So instead we manually import polyfills... 6 | 7 | import 'core-js/es6/array'; 8 | import 'core-js/es6/object'; 9 | import 'core-js/fn/object/values'; 10 | 11 | import 'babel-regenerator-runtime'; 12 | -------------------------------------------------------------------------------- /app/records.js: -------------------------------------------------------------------------------- 1 | import I from 'immutable'; 2 | 3 | /* 4 | * This file contains common Records shared between reducers and used in component PropTypes. 5 | * 6 | * It doesn't contain reducer state Records, since those are never referenced outside of reducer 7 | * files and their tests. 8 | */ 9 | 10 | export const Note = I.Record({ 11 | // Offset, in "24ths", from the start of the song 12 | // e.g., if it's the first beat: 13 | // 0 is a 4th 14 | // 12 is an 8th 15 | // 8 and 16 are triplets ("12ths") 16 | // 6 and 18 are 16ths 17 | // 3 is a 32nd... 18 | totalOffset: 0, 19 | 20 | // Column, between 0 and 6, for the note to be placed in 21 | col: 0, 22 | 23 | // Calculated at playback time, not saved 24 | time: 0, 25 | }); 26 | 27 | export const Song = I.Record({ 28 | title: null, 29 | artist: null, 30 | data: null, 31 | bpm: null, 32 | musicUrl: null, 33 | img: null, 34 | slug: null, 35 | hidden: false, 36 | }); 37 | -------------------------------------------------------------------------------- /app/reducers/__tests__/playback.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | 3 | import chartReducer from '../chart'; 4 | import { 5 | default as reducer, 6 | maxJudgementThreshold 7 | } from '../playback'; 8 | 9 | import { 10 | LOAD_SONG, 11 | 12 | PLAY_NOTE, 13 | ENTER_PLAYBACK, 14 | TICK, 15 | } from '../../ActionTypes'; 16 | 17 | // TODO: maybe don't rely on this? I dunno 18 | import song from '../../../songs/demo/click'; 19 | 20 | const chart = chartReducer(undefined, { 21 | type: LOAD_SONG, 22 | song, 23 | difficulty: 'easy', 24 | }); 25 | 26 | describe('playback reducer', () => { 27 | const initState = reducer(undefined, { 28 | type: ENTER_PLAYBACK, 29 | offset: 0, 30 | bpm: chart.bpm, 31 | notes: chart.notes, 32 | beatSpacing: 0, 33 | }); 34 | 35 | // time of first note, which is at offset = 0 36 | // TODO: this could be some kind of util method, getTimeFor(offset) 37 | const initTime = initState.startTime - initState.initialOffsetMs; 38 | 39 | it('playing a note increments combo', () => { 40 | const playedState = reducer(initState, { 41 | type: PLAY_NOTE, 42 | time: initTime, 43 | column: 3, 44 | }); 45 | 46 | expect(playedState.combo).toEqual(1); 47 | expect(playedState.maxCombo).toEqual(1); 48 | }); 49 | 50 | it('missing a note resets combo', () => { 51 | const playedState = reducer(initState, { 52 | type: PLAY_NOTE, 53 | time: initTime, 54 | column: 3, 55 | }); 56 | 57 | expect(playedState.combo).toEqual(1); 58 | 59 | // tick past the second note's threshold window to allow sweep 60 | const missedState = reducer(playedState, { 61 | type: TICK, 62 | dt: -playedState.initialOffsetMs + (playedState.msPerOffset * 24) + maxJudgementThreshold + 1, 63 | }); 64 | 65 | expect(missedState.combo).toEqual(0); 66 | expect(playedState.maxCombo).toEqual(1); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /app/reducers/audio.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This reducer holds a map of song slugs to their loaded audio data. 3 | */ 4 | 5 | import createImmutableReducer from '../util/immutableReducer'; 6 | import I from 'immutable'; 7 | 8 | import { 9 | LOAD_AUDIO, 10 | } from '../ActionTypes'; 11 | 12 | export const AudioState = I.Record({ 13 | audioData: I.Map(), 14 | }); 15 | 16 | const initialState = new AudioState(); 17 | 18 | const audioReducer = createImmutableReducer(initialState, { 19 | [LOAD_AUDIO]: function({audioData, song}, state) { 20 | return state.setIn(['audioData', song.slug], audioData); 21 | }, 22 | }); 23 | 24 | export default audioReducer; 25 | -------------------------------------------------------------------------------- /app/reducers/chart.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This reducer holds the state of the currently-loaded chart & song. 3 | */ 4 | 5 | import createImmutableReducer from '../util/immutableReducer'; 6 | import I from 'immutable'; 7 | 8 | import { 9 | LOAD_SONG, 10 | TOGGLE_NOTE, 11 | CHANGE_BPM, 12 | } from '../ActionTypes'; 13 | 14 | import { 15 | Note, 16 | } from '../records'; 17 | 18 | export const ChartState = I.Record({ 19 | loaded: false, 20 | notes: null, 21 | bpm: null, 22 | song: null 23 | }); 24 | 25 | const initialState = new ChartState(); 26 | 27 | const chartReducer = createImmutableReducer(initialState, { 28 | [LOAD_SONG]: function({song, difficulty}) { 29 | const chart = song.data[difficulty]; 30 | const noteRecords = chart.notes.map((noteProps) => new Note(noteProps)); 31 | const notes = new I.List(noteRecords); 32 | 33 | return new ChartState({ 34 | loaded: true, 35 | notes, 36 | bpm: song.bpm, 37 | 38 | song: song, 39 | }); 40 | }, 41 | 42 | [TOGGLE_NOTE]: function({offset, column}, state) { 43 | const entry = state.notes.findEntry((note) => { 44 | return note.totalOffset === offset && note.col === column; 45 | }); 46 | 47 | if (!entry) { 48 | const note = new Note({ 49 | col: column, 50 | totalOffset: offset, 51 | }); 52 | 53 | return state.updateIn(['notes'], (notes) => notes.push(note)); 54 | 55 | } else { 56 | const idx = entry[0]; 57 | 58 | return state.updateIn(['notes'], (notes) => notes.remove(idx)); 59 | } 60 | }, 61 | 62 | [CHANGE_BPM]: function({bpm}, state) { 63 | return state.set('bpm', bpm); 64 | }, 65 | }); 66 | 67 | export default chartReducer; 68 | -------------------------------------------------------------------------------- /app/reducers/fps.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This reducer is used in dev to display an FPS meter based on runLoop tick times. This is just 3 | * accurate for internal computation time, not the actual browser render FPS (you can use browser 4 | * dev tools for that). 5 | */ 6 | 7 | import _ from 'lodash'; 8 | import createImmutableReducer from '../util/immutableReducer'; 9 | import {TICK} from '../ActionTypes'; 10 | 11 | const initialState = 0; 12 | 13 | // via http://stackoverflow.com/a/87732 14 | const maxSamples = 100; 15 | 16 | let tickIdx = 0; 17 | let tickSum = 0; 18 | const tickList = _.range(0, maxSamples).map(() => 0); 19 | 20 | const fpsReducer = createImmutableReducer(initialState, { 21 | [TICK]: function({dt}) { 22 | tickSum -= tickList[tickIdx]; 23 | tickSum += dt; 24 | tickList[tickIdx] = dt; 25 | 26 | tickIdx += 1; 27 | if (tickIdx === maxSamples) { 28 | tickIdx = 0; 29 | } 30 | 31 | return 1000 / (tickSum / maxSamples); 32 | } 33 | }); 34 | 35 | export default fpsReducer; 36 | -------------------------------------------------------------------------------- /app/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import audio from './audio'; 4 | import chart from './chart'; 5 | import playback from './playback'; 6 | import fps from './fps'; 7 | import songs from './songs'; 8 | 9 | const rootReducer = combineReducers({ 10 | audio, 11 | chart, 12 | playback, 13 | fps, 14 | songs, 15 | }); 16 | 17 | export default rootReducer; 18 | -------------------------------------------------------------------------------- /app/reducers/playback.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This reducer contains playback state, including: 3 | * 4 | * - The (unplayed) notes of the chart 5 | * - Current playback offset & elapsed ms 6 | * - Scoring info (combo, judgments, life bar...) 7 | * 8 | * It gets completely reset every time playback is entered. 9 | */ 10 | 11 | import createImmutableReducer from '../util/immutableReducer'; 12 | import I from 'immutable'; 13 | 14 | import { 15 | RESET_PLAYBACK, 16 | ENTER_PLAYBACK, 17 | EXIT_PLAYBACK, 18 | PLAY_NOTE, 19 | SET_RATE, 20 | TICK, 21 | } from '../ActionTypes'; 22 | 23 | /* 24 | * 25 | * Constants 26 | * 27 | */ 28 | 29 | import {judgements, missedJudgement} from '../config/constants'; 30 | 31 | export const maxJudgementThreshold = judgements[judgements.length - 1].threshold; 32 | 33 | const OFFSET_PADDING = 24 * 4; // One measure 34 | 35 | /* 36 | * 37 | * Initial state 38 | * 39 | */ 40 | 41 | export const PlaybackState = I.Record({ 42 | notes: null, 43 | bpm: null, 44 | 45 | inPlayback: false, 46 | playbackOffset: 0, 47 | 48 | startTime: null, 49 | initialOffsetMs: null, 50 | msPerOffset: null, 51 | maxOffset: null, 52 | beatSpacing: null, 53 | 54 | judgement: null, 55 | judgements: getJudgementsMap(), 56 | combo: 0, 57 | maxCombo: 0, 58 | elapsedMs: 0, 59 | hp: 50, 60 | 61 | playbackRate: 1 62 | }); 63 | 64 | const initialState = new PlaybackState(); 65 | 66 | /* 67 | * 68 | * Utility methods 69 | * 70 | */ 71 | 72 | function judgementFor(diff) { 73 | const absDiff = Math.abs(diff); 74 | return judgements.filter((j) => absDiff < j.threshold)[0]; 75 | } 76 | 77 | /* 78 | * Get a map of {judgementLabel: 0} for use for counting judgements 79 | */ 80 | function getJudgementsMap() { 81 | const types = judgements.concat(missedJudgement).map((judgement) => judgement.label); 82 | return new I.Map(types.map((type) => [type, 0])); 83 | } 84 | 85 | function getMsPerOffset(bpm) { 86 | const secPerBeat = 60 / bpm; 87 | const secPerThirtySecond = secPerBeat / 24; 88 | return secPerThirtySecond * 1000; 89 | } 90 | 91 | /* 92 | * Find a hit note for a given column and time. 93 | */ 94 | function findNoteFor(notes, time, column) { 95 | // 1. Calculate offset for time 96 | // 2. Calculate offset for time-50ms and time+50ms 97 | // 3. Return *earliest* note filtered for in that range with matching column 98 | return notes.filter((note) => { 99 | if (note.col !== column) { 100 | return false; 101 | } 102 | 103 | return (time + maxJudgementThreshold > note.time && time - maxJudgementThreshold < note.time); 104 | 105 | }).minBy((note) => note.totalOffset); 106 | } 107 | 108 | /* 109 | * Update the playback offset and elapsed milliseconds counters. 110 | */ 111 | function updatePlaybackOffset(state, dt) { 112 | const deltaOffset = dt / state.msPerOffset; 113 | 114 | return state 115 | .update('elapsedMs', (elapsedMs) => elapsedMs + dt) 116 | .update('playbackOffset', (offset) => offset + deltaOffset); 117 | } 118 | 119 | /* 120 | * Remove notes that have fallen out of the judgement window. 121 | */ 122 | function sweepMissedNotes(state) { 123 | const elapsed = state.elapsedMs + state.initialOffsetMs; 124 | 125 | const missedNotes = state.notes.filter((note) => elapsed > note.time + maxJudgementThreshold); 126 | 127 | if (missedNotes.count() > 0) { 128 | return missedNotes.reduce((state, note) => playNote(state, note, missedJudgement), state); 129 | } else { 130 | return state; 131 | } 132 | } 133 | 134 | function updateHp(state, judgement) { 135 | let hp = state.hp + judgement.hp; 136 | 137 | // hp can't go < 0 or > 100 138 | hp = hp < 0 ? 0 : hp; 139 | hp = hp > 100 ? 100 : hp; 140 | 141 | // TODO: if dead, do something 142 | return state 143 | .set('hp', hp); 144 | } 145 | 146 | /* 147 | * "Play" a note (also could indicate a miss). Removes the note, updates combo, sets 148 | * current judgement, and updates the judgement count and score. 149 | */ 150 | function playNote(state, note, judgement) { 151 | const label = judgement.label; 152 | 153 | return state 154 | .update('notes', (notes) => notes.remove(note)) 155 | .update((state) => { 156 | if (judgement.keepCombo) { 157 | return incCombo(state); 158 | } else { 159 | return state.set('combo', 0); 160 | } 161 | }) 162 | .set('judgement', judgement) 163 | .update((state) => updateHp(state, judgement)) 164 | .updateIn(['judgements', label], (count) => count + 1); 165 | } 166 | 167 | /* 168 | * Increment combo. Called if a note was played. Update maxCombo if necessary. 169 | */ 170 | function incCombo(state) { 171 | const combo = state.get('combo') + 1; 172 | 173 | return state 174 | .set('combo', combo) 175 | .update('maxCombo', (maxCombo) => combo > maxCombo ? combo : maxCombo); 176 | } 177 | 178 | /* 179 | * 180 | * Reducer 181 | * 182 | */ 183 | 184 | const playbackReducer = createImmutableReducer(initialState, { 185 | [RESET_PLAYBACK]: function() { 186 | return initialState; 187 | }, 188 | 189 | [ENTER_PLAYBACK]: function({offset, bpm, notes, beatSpacing}, state) { 190 | const playbackRate = state.playbackRate; 191 | 192 | bpm = bpm * playbackRate; 193 | 194 | const msPerOffset = getMsPerOffset(bpm); 195 | 196 | const offsetWithStartPadding = offset - OFFSET_PADDING; 197 | 198 | notes = notes.map((note) => { 199 | const time = note.totalOffset * msPerOffset; 200 | 201 | return note.set('time', time); 202 | }); 203 | 204 | notes = notes.toSet(); 205 | 206 | const maxOffset = notes.maxBy((note) => note.totalOffset).totalOffset + OFFSET_PADDING; 207 | 208 | return new PlaybackState({ 209 | notes, 210 | bpm, 211 | playbackRate, 212 | 213 | inPlayback: true, 214 | playbackOffset: offsetWithStartPadding, 215 | 216 | startTime: Date.now(), // TODO: probably should supply this from the action for testing 217 | initialOffsetMs: offsetWithStartPadding * msPerOffset, 218 | msPerOffset, 219 | maxOffset, 220 | beatSpacing, 221 | }); 222 | }, 223 | 224 | [EXIT_PLAYBACK]: function(action, state) { 225 | return state.set('inPlayback', false); 226 | }, 227 | 228 | [PLAY_NOTE]: function({time, column}, state) { 229 | // This calculates time from the action, and not from the `elapsedMs` state, to be more 230 | // accurate to the original hit time. 231 | // The action supplies the time so that this reducer is actually testable 232 | const elapsed = time - state.startTime + state.initialOffsetMs; 233 | 234 | const note = findNoteFor(state.notes, elapsed, column); 235 | 236 | if (!note) { 237 | return state; 238 | } 239 | 240 | const offset = elapsed - note.time; 241 | const judgement = judgementFor(offset); 242 | 243 | return playNote(state, note, judgement); 244 | }, 245 | 246 | [SET_RATE]: function({rate}, state) { 247 | return state.set('playbackRate', parseFloat(rate)); 248 | }, 249 | 250 | [TICK]: function({dt}, state) { 251 | if (!state.inPlayback) { 252 | return state; 253 | } 254 | 255 | const nextState = sweepMissedNotes(updatePlaybackOffset(state, dt)); 256 | 257 | if (nextState.playbackOffset > nextState.maxOffset) { 258 | return state.set('inPlayback', false); 259 | } 260 | 261 | return nextState; 262 | }, 263 | }); 264 | 265 | export default playbackReducer; 266 | -------------------------------------------------------------------------------- /app/reducers/songs.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This reducer holds the loaded list of songs. 3 | */ 4 | 5 | import createImmutableReducer from '../util/immutableReducer'; 6 | import I from 'immutable'; 7 | import slugify from 'slug'; 8 | 9 | import {LOAD_SONGS} from '../ActionTypes'; 10 | 11 | import { 12 | Song, 13 | } from '../records'; 14 | 15 | export const SongsState = I.Record({ 16 | songs: null, 17 | }); 18 | 19 | const initialState = new SongsState(); 20 | 21 | 22 | const songsReducer = createImmutableReducer(initialState, { 23 | [LOAD_SONGS]: function({songs}, state) { 24 | const songMap = songs.reduce((map, song) => { 25 | const slug = slugify(song.title); 26 | 27 | return map.set(slug, new Song({ 28 | slug, 29 | ...song, 30 | })); 31 | }, I.Map()); 32 | 33 | return state.set('songs', songMap); 34 | } 35 | }); 36 | 37 | export default songsReducer; 38 | -------------------------------------------------------------------------------- /app/runLoop.js: -------------------------------------------------------------------------------- 1 | import { 2 | TICK, 3 | } from './ActionTypes'; 4 | 5 | class RunLoop { 6 | constructor() { 7 | this.store = null; 8 | this._listeners = []; 9 | } 10 | 11 | start() { 12 | this._lastTickMs = Date.now(); 13 | 14 | this._nextTick = this._runLoop.bind(this); 15 | window.requestAnimationFrame(this._nextTick); 16 | } 17 | 18 | stop() { 19 | this._nextTick = () => {}; 20 | } 21 | 22 | _runLoop() { 23 | const now = Date.now(); 24 | const dt = now - this._lastTickMs; 25 | this._lastTickMs = now; 26 | 27 | this.store.dispatch({ 28 | type: TICK, 29 | dt, 30 | }); 31 | 32 | this._listeners.forEach((listener) => listener()); 33 | 34 | window.requestAnimationFrame(this._nextTick); 35 | } 36 | 37 | setStore(store) { 38 | this.store = store; 39 | } 40 | 41 | subscribe(listener) { 42 | this._listeners.push(listener); 43 | } 44 | 45 | unsubscribe(listener) { 46 | const idx = this._listeners.indexOf(listener); 47 | 48 | if (idx === -1) { 49 | throw new Error('tried to unsubscribe listener that wasn\'t subscribed'); 50 | } 51 | 52 | this._listeners.splice(idx, 1); 53 | } 54 | } 55 | 56 | const runLoop = new RunLoop(); 57 | 58 | if (process.env.NODE_ENV === 'development') { 59 | window.stop = runLoop.stop.bind(runLoop); 60 | window.start = runLoop.start.bind(runLoop); 61 | } 62 | 63 | export default runLoop; 64 | -------------------------------------------------------------------------------- /app/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | 3 | import thunkMiddleware from 'redux-thunk'; 4 | 5 | const createStoreWithMiddleware = applyMiddleware( 6 | thunkMiddleware 7 | )(createStore); 8 | 9 | import appReducer from './reducers'; 10 | 11 | export default function createAppStore(data) { 12 | return createStoreWithMiddleware(appReducer, data); 13 | } 14 | -------------------------------------------------------------------------------- /app/util/immutableReducer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * lets you define a reducer as a series of action handlers that return new state 3 | * 4 | * example: 5 | * 6 | * const reducer = createImmutableReducer(initialState, { 7 | * [ACTION_TYPE]: function(action, state) { 8 | * return state.set('field', action.field); 9 | * } 10 | * }); 11 | */ 12 | export default function createImmutableReducer(initialState, handlers) { 13 | return (state = initialState, action) => { 14 | if (handlers[action.type]) { 15 | const newState = handlers[action.type](action, state); 16 | return newState; 17 | } else { 18 | return state; 19 | } 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /app/util/ordinal.js: -------------------------------------------------------------------------------- 1 | // http://stackoverflow.com/questions/13627308/add-st-nd-rd-and-th-ordinal-suffix-to-a-number 2 | export default function ordinal (i) { 3 | const j = i % 10; 4 | const k = i % 100; 5 | if (j === 1 && k !== 11) { 6 | return i + 'st'; 7 | } 8 | if (j === 2 && k !== 12) { 9 | return i + 'nd'; 10 | } 11 | if (j === 3 && k !== 13) { 12 | return i + 'rd'; 13 | } 14 | return i + 'th'; 15 | } 16 | -------------------------------------------------------------------------------- /app/views/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const App = React.createClass({ 4 | render() { 5 | return ( 6 |
7 | {this.props.children} 8 |
9 | ); 10 | } 11 | }); 12 | 13 | export default App; 14 | -------------------------------------------------------------------------------- /app/views/Attract/Handler.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HotKeys from '../lib/GlobalHotKeys'; 3 | import {History} from 'react-router'; 4 | 5 | import GameWrapper from '../lib/GameWrapper'; 6 | 7 | const Attract = React.createClass({ 8 | mixins: [ 9 | History, 10 | ], 11 | 12 | getHandlers() { 13 | return { 14 | advance: (e) => { 15 | e.preventDefault(); 16 | 17 | this.history.pushState(null, '/song-select'); 18 | } 19 | }; 20 | }, 21 | 22 | getKeyMap() { 23 | return { 24 | 'advance': ['space', 'enter'] 25 | }; 26 | }, 27 | 28 | render() { 29 | return ( 30 | 31 | 32 |
33 |
34 |

Undertune

35 |

press space

36 |
37 |
38 |
39 |
40 | ); 41 | } 42 | }); 43 | 44 | export default Attract; 45 | -------------------------------------------------------------------------------- /app/views/Editor/Handler.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HotKeys from '../lib/GlobalHotKeys'; 3 | import { connect } from 'react-redux'; 4 | 5 | import AudioPlayback from '../lib/AudioPlayback'; 6 | import Chart from '../lib/Chart'; 7 | import PlaybackWrapper from '../lib/PlaybackWrapper'; 8 | 9 | import EditorControls from './components/EditorControls'; 10 | 11 | import audioCtx from '../../audioContext'; 12 | 13 | import { 14 | resetPlayback, 15 | enterPlayback, 16 | exitPlayback, 17 | } from '../../actions/PlaybackActions'; 18 | 19 | import { 20 | toggleNote, 21 | loadSong, 22 | } from '../../actions/ChartActions'; 23 | 24 | import { 25 | loadAudio, 26 | } from '../../actions/AudioActions'; 27 | 28 | import { 29 | editorColors, 30 | keyColMap as colMap, 31 | } from '../../config/constants'; 32 | 33 | const resolutions = [24, 12, 8, 6, 4, 3]; 34 | 35 | const findSmallestResIdx = function(offset) { 36 | for (let i = 0; i < resolutions.length; i++) { 37 | if (offset % resolutions[i] === 0) { 38 | return i; 39 | } 40 | } 41 | }; 42 | 43 | const Editor = React.createClass({ 44 | getInitialState() { 45 | return { 46 | offset: 0, 47 | scrollResolutionIdx: 0, 48 | beatSpacing: 80 49 | }; 50 | }, 51 | 52 | componentWillMount() { 53 | const difficulty = this.props.params.difficulty; 54 | this.props.dispatch(loadSong(this.props.song, difficulty)); 55 | 56 | if (!this.props.audioData) { 57 | this.props.dispatch(loadAudio(this.props.song)); 58 | } 59 | }, 60 | 61 | componentWillUnmount() { 62 | this.props.dispatch(resetPlayback()); 63 | }, 64 | 65 | getKeyMap() { 66 | if (this.props.inPlayback) { 67 | return { 68 | 'exitPlayback': ['p', 'esc'], 69 | }; 70 | 71 | } else { 72 | return { 73 | 'scrollUp': ['up', 'shift+k'], 74 | 'scrollDown': ['down', 'shift+j'], 75 | 'measureUp': 'pageup', 76 | 'measureDown': 'pagedown', 77 | 'jumpToStart': 'home', 78 | 'jumpToEnd': 'end', 79 | 'zoomOut': '-', 80 | 'zoomIn': '=', 81 | 'scrollResDown': 'left', 82 | 'scrollResUp': 'right', 83 | 'enterPlayback': 'p', 84 | 'toggleNote': ['s', 'd', 'f', 'space', 'j', 'k', 'l'] 85 | }; 86 | } 87 | }, 88 | 89 | setOffset(nextOffset) { 90 | if (nextOffset <= this.getMaxOffset() && nextOffset >= 0) { 91 | this.setState({ 92 | offset: nextOffset 93 | }); 94 | } 95 | }, 96 | 97 | getHandlers() { 98 | return { 99 | scrollUp: (e) => { 100 | e.preventDefault(); 101 | 102 | this.setOffset(this.state.offset + this.getScrollResolution()); 103 | }, 104 | 105 | scrollDown: (e) => { 106 | e.preventDefault(); 107 | 108 | this.setOffset(this.state.offset - this.getScrollResolution()); 109 | }, 110 | 111 | measureUp: (e) => { 112 | e.preventDefault(); 113 | 114 | this.setOffset(this.state.offset + (24 * 4)); 115 | }, 116 | 117 | measureDown: (e) => { 118 | e.preventDefault(); 119 | 120 | this.setOffset(this.state.offset - (24 * 4)); 121 | }, 122 | 123 | jumpToStart: (e) => { 124 | e.preventDefault(); 125 | 126 | this.setOffset(0); 127 | }, 128 | 129 | jumpToEnd: (e) => { 130 | e.preventDefault(); 131 | 132 | const lastOffset = this.getLastNoteOffset(); 133 | 134 | if (lastOffset % this.getScrollResolution !== 0) { 135 | this.setState({ 136 | scrollResolutionIdx: findSmallestResIdx(lastOffset) 137 | }); 138 | } 139 | 140 | this.setState({ 141 | offset: lastOffset 142 | }); 143 | }, 144 | 145 | zoomOut: () => { 146 | this.setState({ 147 | beatSpacing: this.state.beatSpacing - 40 148 | }); 149 | }, 150 | 151 | zoomIn: () => { 152 | this.setState({ 153 | beatSpacing: this.state.beatSpacing + 40 154 | }); 155 | }, 156 | 157 | scrollResUp: () => { 158 | this.handleUpdateScrollResolution(true); 159 | }, 160 | 161 | scrollResDown: () => { 162 | this.handleUpdateScrollResolution(false); 163 | }, 164 | 165 | enterPlayback: () => { 166 | this.props.dispatch(enterPlayback(this.state.offset, this.props.bpm, this.getNotes())); 167 | }, 168 | 169 | exitPlayback: () => { 170 | this.props.dispatch(exitPlayback()); 171 | }, 172 | 173 | toggleNote: (e, key) => { 174 | const col = colMap[key]; 175 | 176 | this.props.dispatch(toggleNote(this.state.offset, col)); 177 | }, 178 | }; 179 | }, 180 | 181 | getOffset() { 182 | if (this.props.inPlayback) { 183 | return this.props.playbackOffset; 184 | } else { 185 | return this.state.offset; 186 | } 187 | }, 188 | 189 | getNotes() { 190 | if (this.props.inPlayback) { 191 | return this.props.playbackNotes; 192 | } else { 193 | return this.props.songNotes; 194 | } 195 | }, 196 | 197 | getScrollResolution() { 198 | return resolutions[this.state.scrollResolutionIdx]; 199 | }, 200 | 201 | getNumMeasures() { 202 | const numBeats = Math.ceil(this.props.bpm / (60 / this.props.audioData.duration)); 203 | return Math.ceil(numBeats / 4); 204 | }, 205 | 206 | getMaxOffset() { 207 | return this.getNumMeasures() * 4 * 24 - 24; 208 | }, 209 | 210 | getLastNoteOffset() { 211 | return this.props.songNotes.maxBy((note) => note.totalOffset).totalOffset; 212 | }, 213 | 214 | handleUpdateScrollResolution(increase) { 215 | const inc = increase ? 1 : -1; 216 | 217 | const nextResIdx = this.state.scrollResolutionIdx + inc; 218 | 219 | if (nextResIdx < 0 || nextResIdx >= resolutions.length) { 220 | return; 221 | } 222 | 223 | const nextRes = resolutions[nextResIdx]; 224 | 225 | // Snap to nearest note in resolution if resolution changes 226 | // i.e. if you're on an 8th and drop 8ths to 4ths, snap to previous 4th 227 | // if you're on an 8th and drop 16ths to 8ths, stay on the same 228 | 229 | let nextOffset = this.state.offset - (this.state.offset % nextRes); 230 | 231 | this.setState({ 232 | scrollResolutionIdx: nextResIdx, 233 | offset: nextOffset 234 | }); 235 | }, 236 | 237 | renderChart() { 238 | const chart = ( 239 | 251 | ); 252 | 253 | if (this.props.inPlayback) { 254 | return ( 255 | 256 | {chart} 257 | 258 | ); 259 | } 260 | 261 | return chart; 262 | }, 263 | 264 | renderLoaded() { 265 | return ( 266 | 267 |
268 |
269 | {this.renderChart()} 270 |
271 | 272 | 273 | 274 | 277 |
278 |
279 | ); 280 | }, 281 | 282 | render() { 283 | return ( 284 |
285 | {this.props.audioData ? this.renderLoaded() : null} 286 |
287 | ); 288 | }, 289 | }); 290 | 291 | function select(state, props) { 292 | const slug = props.params.slug; 293 | 294 | return { 295 | song: state.songs.songs.get(slug), 296 | 297 | songNotes: state.chart.notes, 298 | bpm: state.chart.bpm, 299 | 300 | inPlayback: state.playback.inPlayback, 301 | playbackOffset: state.playback.playbackOffset, 302 | playbackNotes: state.playback.notes, 303 | playbackRate: state.playback.playbackRate, 304 | 305 | audioData: state.audio.audioData.get(slug), 306 | 307 | fps: state.fps, 308 | }; 309 | } 310 | 311 | export default connect(select)(Editor); 312 | -------------------------------------------------------------------------------- /app/views/Editor/__tests__/Editor.spec.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | 3 | import React from 'react'; 4 | import {renderIntoDocument} from 'react-addons-test-utils'; 5 | import {Provider} from 'react-redux'; 6 | import Mousetrap from 'mousetrap'; 7 | import createStore from '../../../store'; 8 | import {loadSongs} from '../../../actions/SongActions'; 9 | import {loadSong, toggleNote} from '../../../actions/ChartActions'; 10 | 11 | // TODO: use some kinda fixture song here instead 12 | import songs from '../../../config/songs'; 13 | import {LOAD_AUDIO} from '../../../ActionTypes'; 14 | 15 | import Editor from '../Handler'; 16 | 17 | import { 18 | keyColMap as colMap, 19 | } from '../../../config/constants'; 20 | 21 | const SONG_SLUG = 'Click'; 22 | const DIFFICULTY = 'easy'; 23 | 24 | describe('Editor component', () => { 25 | let store; 26 | 27 | beforeEach(() => { 28 | // Create a store for the editor with the 'click' song and fake audio data loaded 29 | // so that it enters its "loaded" state immediately 30 | store = createStore(); 31 | store.dispatch(loadSongs(songs)); 32 | 33 | // Load chart 34 | const song = store.getState().songs.songs.get(SONG_SLUG); 35 | store.dispatch(loadSong(song, DIFFICULTY)); 36 | 37 | // load some fake audio data immediately 38 | store.dispatch({ 39 | type: LOAD_AUDIO, 40 | song, 41 | audioData: new ArrayBuffer(0) 42 | }); 43 | }); 44 | 45 | afterEach(() => { 46 | expect.restoreSpies(); 47 | }); 48 | 49 | it('toggles note on key press', () => { 50 | const spy = expect.spyOn(store, 'dispatch').andCallThrough(); 51 | 52 | renderIntoDocument( 53 | 54 | 55 | 56 | ); 57 | 58 | // get an arbitrary key from the key->column mapping 59 | const key = Object.keys(colMap)[0]; 60 | const col = colMap[key]; 61 | 62 | Mousetrap.trigger(key); 63 | 64 | // Assume the last dispatch is from the key press 65 | expect(spy).toHaveBeenCalled(); 66 | const dispatchCall = spy.calls[spy.calls.length - 1]; 67 | 68 | expect(dispatchCall.arguments[0]).toEqual(toggleNote(0, col)); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /app/views/Editor/components/EditorControls.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import I from 'immutable'; 3 | import { connect } from 'react-redux'; 4 | 5 | import {Button, Well} from 'react-bootstrap'; 6 | import SaveModal from './SaveModal'; 7 | import BlurInput from '../../../../vendor/BlurInput'; 8 | 9 | import { 10 | updateRate 11 | } from '../../../actions/PlaybackActions'; 12 | 13 | 14 | function serializeData(chartData) { 15 | const {notes} = chartData; 16 | 17 | // Convert Notes from Record to Map so we can remove props from them 18 | const serializedNotes = notes.map((note) => I.Map(note).remove('time')); 19 | 20 | const serialized = JSON.stringify({notes: serializedNotes}); 21 | 22 | return serialized; 23 | } 24 | 25 | const EditorControls = React.createClass({ 26 | getInitialState() { 27 | return { 28 | openModal: null 29 | }; 30 | }, 31 | 32 | requestCloseModal() { 33 | this.setState({openModal: null}); 34 | }, 35 | 36 | handleSave() { 37 | const serialized = serializeData(this.props.chartData); 38 | 39 | const modal = ( 40 | this.requestCloseModal()} /> 41 | ); 42 | 43 | this.setState({ 44 | openModal: modal 45 | }); 46 | }, 47 | 48 | handlePlaybackRateChange(val) { 49 | this.props.dispatch(updateRate(val)); 50 | }, 51 | 52 | render() { 53 | return ( 54 | 55 | 58 | 59 | 64 | 65 | {this.state.openModal} 66 | 67 | ); 68 | }, 69 | }); 70 | 71 | function select(state) { 72 | return { 73 | chartData: state.chart 74 | }; 75 | } 76 | 77 | export default connect(select)(EditorControls); 78 | -------------------------------------------------------------------------------- /app/views/Editor/components/SaveModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Button, Modal} from 'react-bootstrap'; 3 | 4 | const SaveModal = React.createClass({ 5 | propTypes: { 6 | data: React.PropTypes.string.isRequired, 7 | onClose: React.PropTypes.func.isRequired, 8 | }, 9 | 10 | // Select all data text on click 11 | handleClickData() { 12 | const el = this.refs.data; 13 | 14 | const selection = window.getSelection(); 15 | const range = document.createRange(); 16 | range.selectNodeContents(el); 17 | selection.removeAllRanges(); 18 | selection.addRange(range); 19 | }, 20 | 21 | render() { 22 | return ( 23 | 24 | 25 | 26 | Save 27 | 28 | 29 | 30 |

31 | (click to select) 32 |

33 |
 this.handleClickData()} ref="data">
34 |             {this.props.data}
35 |           
36 |
37 | 38 | 41 | 42 |
43 | ); 44 | }, 45 | }); 46 | 47 | export default SaveModal; 48 | -------------------------------------------------------------------------------- /app/views/Player/Handler.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import Loading from './states/Loading'; 5 | import Loaded from './states/Loaded'; 6 | import Playing from './states/Playing'; 7 | import Done from './states/Done'; 8 | import GameWrapper from '../lib/GameWrapper'; 9 | 10 | import { 11 | resetPlayback, 12 | } from '../../actions/PlaybackActions'; 13 | 14 | import { 15 | loadSong, 16 | } from '../../actions/ChartActions'; 17 | 18 | import { 19 | loadAudio, 20 | } from '../../actions/AudioActions'; 21 | 22 | 23 | const STATE_LOADING = 'loading'; 24 | const STATE_LOADED = 'loaded'; 25 | const STATE_PLAYING = 'playing'; 26 | const STATE_DONE = 'done'; 27 | 28 | const Player = React.createClass({ 29 | getInitialState() { 30 | return { 31 | didPlayback: false, 32 | }; 33 | }, 34 | 35 | componentWillMount() { 36 | const difficulty = this.props.params.difficulty; 37 | this.props.dispatch(loadSong(this.props.song, difficulty)); 38 | 39 | if (!this.props.audioData) { 40 | this.props.dispatch(loadAudio(this.props.song)); 41 | } 42 | }, 43 | 44 | componentWillUnmount() { 45 | this.props.dispatch(resetPlayback()); 46 | }, 47 | 48 | componentWillReceiveProps(nextProps) { 49 | if (nextProps.inPlayback && !this.props.inPlayback) { 50 | this.setState({ 51 | didPlayback: true 52 | }); 53 | } 54 | }, 55 | 56 | getState() { 57 | // TODO: Replace this with a proper state machine so we can easily go from playing -> done -> playing... 58 | if (this.props.inPlayback) { 59 | return STATE_PLAYING; 60 | } else if (this.state.didPlayback) { 61 | return STATE_DONE; 62 | } else if (this.props.audioData && this.props.songLoaded) { 63 | return STATE_LOADED; 64 | } else { 65 | return STATE_LOADING; 66 | } 67 | }, 68 | 69 | render() { 70 | let outlet; 71 | 72 | if (this.getState() === STATE_LOADING) { 73 | outlet = ; 74 | } else if (this.getState() === STATE_LOADED) { 75 | outlet = ; 76 | } else if (this.getState() === STATE_PLAYING) { 77 | outlet = ; 78 | } else if (this.getState() === STATE_DONE) { 79 | outlet = ; 80 | } 81 | 82 | return ( 83 | 84 | {outlet} 85 | 86 | ); 87 | } 88 | }); 89 | 90 | function select(state, props) { 91 | const slug = props.params.slug; 92 | 93 | return { 94 | song: state.songs.songs.get(slug), 95 | 96 | songLoaded: state.chart.loaded, 97 | 98 | inPlayback: state.playback.inPlayback, 99 | 100 | audioData: state.audio.audioData.get(slug), 101 | }; 102 | } 103 | 104 | export default connect(select)(Player); 105 | -------------------------------------------------------------------------------- /app/views/Player/LifeBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import RenderedCanvas from '../lib/RenderedCanvas'; 4 | 5 | const LifeBar = React.createClass({ 6 | propTypes: { 7 | width: React.PropTypes.number.isRequired, 8 | height: React.PropTypes.number.isRequired, 9 | 10 | hp: React.PropTypes.number.isRequired, 11 | }, 12 | 13 | renderCanvas(ctx) { 14 | const percentFilled = this.props.hp / 100; 15 | 16 | const borderWidth = 3; 17 | 18 | ctx.fillStyle = 'red'; 19 | ctx.fillRect(borderWidth, borderWidth, 20 | this.props.width - borderWidth * 2, this.props.height - borderWidth * 2); 21 | 22 | ctx.fillStyle = 'yellow'; 23 | ctx.fillRect(borderWidth, borderWidth, 24 | (this.props.width - borderWidth * 2) * percentFilled, 25 | this.props.height - borderWidth * 2); 26 | }, 27 | 28 | render() { 29 | return ( 30 | 32 | ); 33 | } 34 | }); 35 | 36 | export default LifeBar; 37 | -------------------------------------------------------------------------------- /app/views/Player/YouTube.js: -------------------------------------------------------------------------------- 1 | /* global YT */ 2 | 3 | import React from 'react'; 4 | 5 | const YouTube = React.createClass({ 6 | componentDidMount() { 7 | const tub = this.refs.tub; 8 | 9 | this._player = new YT.Player(tub, { 10 | events: { 11 | onReady: (e) => this.onPlayerReady(e), 12 | onStateChange: (e) => this.onStateChange(e) 13 | } 14 | }); 15 | }, 16 | 17 | onPlayerReady(evt) { 18 | this._player = evt.target; 19 | 20 | this._player.setVolume(50); 21 | this._player.playVideo(); 22 | 23 | window._player = this._player; 24 | }, 25 | 26 | onStateChange(evt) { 27 | if (evt.data === YT.PlayerState.PLAYING) { 28 | this.props.onPlaying(); 29 | } 30 | }, 31 | 32 | render() { 33 | let url = `https://www.youtube.com/embed/${this.props.youtubeId}`; 34 | url += '?enablejsapi=1'; 35 | url += '&rel=0'; 36 | url += '&autoplay=0'; 37 | url += '&controls=0'; 38 | url += '&playsinline=1'; 39 | url += '&showinfo=0'; 40 | url += '&modestbranding=1'; 41 | 42 | return ( 43 |