├── .DS_Store ├── .gitignore ├── README.md ├── example.coffee ├── joystick.coffee ├── module.json ├── package.json ├── src ├── ActionHandler.coffee ├── App.coffee ├── Background.coffee ├── Broadcaster.coffee ├── FocusSystem.coffee ├── Focusable.coffee ├── Gamepad.coffee ├── Grid.coffee ├── Transitions.coffee └── View.coffee ├── thumb.png └── webpack.config.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emilwidlund/framer-joystick/639f9011a8245d1bcb2e42e738796e0c212f3187/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Framer Joystick 2 | A Framer module for Gamepad-driven UI prototypes. 3 | 4 | A detailed API documentation will be added soon. Until then, make sure to read through this article: https://blog.framer.com/introducing-framer-joystick-28359287bef0 5 | 6 | 7 | Install with Framer Modules 9 | 10 | -------------------------------------------------------------------------------- /example.coffee: -------------------------------------------------------------------------------- 1 | # A more thorough introduction of this module can be found here: 2 | # https://blog.framer.com/introducing-framer-joystick-28359287bef0 3 | 4 | class CardCarousel extends ScrollComponent 5 | constructor: (properties={}) -> 6 | super properties 7 | 8 | @content.clip = properties.clip; 9 | 10 | app.on 'change:focusedElement', (focusable) => 11 | if focusable && focusable.parent == @content 12 | @scrollToCard focusable 13 | 14 | scrollToCard: (focusable) -> 15 | if (focusable.screenFrame.x + focusable.screenFrame.width) > (@screenFrame.x + @screenFrame.width) 16 | @scrollToLayer(focusable, 1, 0, true) 17 | else if focusable.screenFrame.x < @screenFrame.x 18 | @scrollToLayer(focusable, 0, 0, true) 19 | 20 | homeView = new View 21 | background: 22 | backgroundColor: '#eaeaea' 23 | 24 | app = new App 25 | 26 | scroller = new CardCarousel 27 | parent: homeView.safezone 28 | width: homeView.safezone.width 29 | height: Grid.getHeight(35) 30 | y: Align.center 31 | clip: false 32 | 33 | for index in [0..5] 34 | new Focusable 35 | parent: scroller.content 36 | width: Grid.getWidth(6) 37 | height: Grid.getHeight(35) 38 | x: index * Grid.getWidth(7) 39 | backgroundColor: '#fff' 40 | focusProperties: 41 | backgroundColor: '#00ffdd' 42 | scale: 1.1 43 | animationOptions: 44 | time: .2 45 | animationOptions: 46 | time: .2 47 | 48 | app.transitionToView(homeView) -------------------------------------------------------------------------------- /joystick.coffee: -------------------------------------------------------------------------------- 1 | {App} = require './src/App.coffee' 2 | {FocusSystem} = require './src/FocusSystem.coffee' 3 | {Focusable} = require './src/Focusable.coffee' 4 | {Gamepad} = require './src/Gamepad.coffee' 5 | {Transitions} = require './src/Transitions.coffee' 6 | {View} = require './src/View.coffee' 7 | {Grid} = require './src/Grid.coffee' 8 | 9 | joystick = 10 | App: App 11 | FocusSystem: FocusSystem 12 | Focusable: Focusable 13 | Gamepad: Gamepad 14 | Transitions: Transitions 15 | View: View 16 | Grid: Grid 17 | 18 | module.exports = joystick -------------------------------------------------------------------------------- /module.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Framer Joystick", 3 | "description": "Framer Joystick makes it easy to create Gamepad-driven UI prototypes", 4 | "author": "Emil Widlund", 5 | "require": "{App, View, Gamepad, Focusable, Transitions, Grid} = require 'joystick'", 6 | "install": [ 7 | "joystick.coffee", 8 | "/src/ActionHandler.coffee", 9 | "/src/App.coffee", 10 | "/src/Background.coffee", 11 | "/src/Broadcaster.coffee", 12 | "/src/Focusable.coffee", 13 | "/src/FocusSystem.coffee", 14 | "/src/Gamepad.coffee", 15 | "/src/Grid.coffee", 16 | "/src/Transitions.coffee", 17 | "/src/View.coffee" 18 | ], 19 | "example": "example.coffee", 20 | "thumb": "thumb.png" 21 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "framer-joystick", 3 | "version": "0.0.1", 4 | "description": "Joystick is a Framer-module that makes it easy to create UI's tailored for Gamepad interactions.", 5 | "main": "index.coffee", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Emil Widlund", 10 | "license": "ISC" 11 | } -------------------------------------------------------------------------------- /src/ActionHandler.coffee: -------------------------------------------------------------------------------- 1 | {Gamepad} = require './Gamepad.coffee' 2 | {Broadcaster} = require './Broadcaster.coffee' 3 | _ = Framer._ 4 | 5 | class exports.ActionHandler 6 | constructor: -> 7 | 8 | @focusableActions = [] 9 | @viewActions = [] 10 | 11 | Gamepad.on 'gamepadevent', (event) => 12 | if event.keyCode < 4 13 | action = _.find @actions(), {keyCode: event.keyCode} 14 | if action 15 | action.function() 16 | 17 | window.addEventListener 'keydown', (event) => 18 | if event.keyCode == 13 19 | action = _.find @actions(), {keyCode: 0} 20 | if action 21 | action.function() 22 | if event.keyCode == 8 23 | action = _.find @actions(), {keyCode: 1} 24 | if action 25 | action.function() 26 | 27 | Broadcaster.on 'focusEvent', (focusable) => 28 | @focusableActions = focusable.actions 29 | 30 | actions: -> 31 | return @focusableActions.concat @viewActions 32 | 33 | clearActions: -> 34 | @focusableActions = [] 35 | @viewActions = [] -------------------------------------------------------------------------------- /src/App.coffee: -------------------------------------------------------------------------------- 1 | {FocusSystem} = require './FocusSystem.coffee' 2 | {Broadcaster} = require './Broadcaster.coffee' 3 | {Focusable} = require './Focusable.coffee' 4 | {ActionHandler} = require './ActionHandler.coffee' 5 | {Background} = require './Background.coffee' 6 | _ = Framer._ 7 | 8 | class exports.App extends FlowComponent 9 | 10 | constructor: (properties={}) -> 11 | 12 | new Background 13 | 14 | super _.defaults properties, 15 | backgroundColor: 'transparent' 16 | 17 | @focusSystem = new FocusSystem(@) 18 | @actionHandler = new ActionHandler 19 | 20 | # Manipulate FlowComponent's ScrollComponent content insets 21 | @on 'transitionstart', (previousView, nextView, direction) => 22 | sc = @_tempScroll 23 | sc.contentInset = 0 24 | 25 | # Fix freezed state when halting transition 26 | @on 'transitionhalt', (previousView, nextView, direction) -> 27 | if previousView 28 | previousView.visible = false 29 | 30 | transitionToView: (view, transition) -> 31 | if transition 32 | @transition(view, transition) 33 | else 34 | @showNext(view) 35 | 36 | @focusSystem.clearFocusables() 37 | @actionHandler.clearActions() 38 | 39 | @actionHandler.viewActions = view.actions 40 | 41 | for child, index in view.descendants 42 | if child instanceof Focusable 43 | @focusSystem.focusableElements.push child 44 | 45 | if @focusSystem.focusableElements.length 46 | @focusSystem.focus @focusSystem.focusableElements[0] 47 | 48 | @emit 'change:view', view 49 | Broadcaster.viewTransitionEvent(view) -------------------------------------------------------------------------------- /src/Background.coffee: -------------------------------------------------------------------------------- 1 | {Broadcaster} = require './Broadcaster.coffee' 2 | _ = Framer._ 3 | 4 | class exports.Background extends Layer 5 | constructor: (properties={}) -> 6 | super properties 7 | 8 | @bgLayer = new Layer 9 | parent: @ 10 | size: Screen.size 11 | backgroundColor: '#000' 12 | 13 | @gradientLayer = {} 14 | 15 | Broadcaster.on 'viewTransitionEvent', (view) => 16 | @populateBackground(view.background) 17 | 18 | populateBackground: (background) -> 19 | 20 | tempVar = new Layer 21 | parent: @ 22 | size: Screen.size 23 | image: background.image || null 24 | backgroundColor: background.backgroundColor || '#000' 25 | blur: background.blur || null 26 | opacity: 0 27 | 28 | tempVar.animate 29 | opacity: 1 30 | options: 31 | time: .4 32 | 33 | Utils.delay .4, => 34 | @bgLayer.destroy() 35 | @bgLayer = tempVar -------------------------------------------------------------------------------- /src/Broadcaster.coffee: -------------------------------------------------------------------------------- 1 | _ = Framer._ 2 | 3 | class BroadcasterSystem extends Framer.EventEmitter 4 | viewTransitionEvent: (view) -> 5 | @emit 'viewTransitionEvent', view 6 | 7 | focusEvent: (focusable) -> 8 | @emit 'focusEvent', focusable 9 | 10 | exports.Broadcaster = new BroadcasterSystem() -------------------------------------------------------------------------------- /src/FocusSystem.coffee: -------------------------------------------------------------------------------- 1 | {Gamepad} = require './Gamepad.coffee' 2 | {Broadcaster} = require './Broadcaster.coffee' 3 | _ = Framer._ 4 | 5 | class exports.FocusSystem 6 | constructor: (app) -> 7 | 8 | @focusableElements = [] 9 | @focusedElement = {} 10 | @previouslyFocusedElement = {} 11 | 12 | @app = app 13 | 14 | # Setup event listeners for navigation 15 | Gamepad.on 'gamepadevent', (event) => 16 | if event.keyCode > 36 && event.keyCode < 41 && @focusableElements.length 17 | @navigate event.keyCode 18 | else if event.keyCode > 11 && event.keyCode < 16 && @focusableElements.length 19 | @navigate event.keyCode 20 | 21 | window.addEventListener 'keydown', (event) => 22 | if event.keyCode > 36 && event.keyCode < 41 && @focusableElements.length 23 | @navigate(event.keyCode) 24 | 25 | navigate: (keyCode) -> 26 | 27 | focusedPosition = 28 | x: @focusedElement.screenFrame.x + (@focusedElement.screenFrame.width / 2), 29 | y: @focusedElement.screenFrame.y + (@focusedElement.screenFrame.height / 2) 30 | 31 | relevantFocusables = _.filter @focusableElements, (focusable) -> 32 | 33 | if focusable.parent.parent.visible == false || focusable.parent.parent.opacity == 0 34 | return false 35 | if focusable.visible == false || focusable.opacity == 0 36 | return false 37 | 38 | switch keyCode 39 | when 13 40 | focusablePosition = 41 | x: focusable.screenFrame.x + focusable.screenFrame.width 42 | y: focusable.screenFrame.y + (focusable.screenFrame.height / 2) 43 | 44 | if focusablePosition.x < focusedPosition.x 45 | return true 46 | else 47 | return false 48 | 49 | when 11 50 | focusablePosition = 51 | x: focusable.screenFrame.x + (focusable.screenFrame.width / 2) 52 | y: focusable.screenFrame.y + focusable.screenFrame.height 53 | 54 | if focusablePosition.y < focusedPosition.y 55 | return true 56 | else 57 | return false 58 | 59 | when 14 60 | focusablePosition = 61 | x: focusable.screenFrame.x 62 | y: focusable.screenFrame.y + (focusable.screenFrame.height / 2) 63 | 64 | if focusablePosition.x > focusedPosition.x 65 | return true 66 | else 67 | return false 68 | 69 | when 12 70 | focusablePosition = 71 | x: focusable.screenFrame.x + (focusable.screenFrame.width / 2) 72 | y: focusable.screenFrame.y 73 | 74 | if focusablePosition.y > focusedPosition.y 75 | return true 76 | else 77 | return false 78 | 79 | when 37 80 | focusablePosition = 81 | x: focusable.screenFrame.x + focusable.screenFrame.width 82 | y: focusable.screenFrame.y + (focusable.screenFrame.height / 2) 83 | 84 | if focusablePosition.x < focusedPosition.x 85 | return true 86 | else 87 | return false 88 | 89 | when 38 90 | focusablePosition = 91 | x: focusable.screenFrame.x + (focusable.screenFrame.width / 2) 92 | y: focusable.screenFrame.y + focusable.screenFrame.height 93 | 94 | if focusablePosition.y < focusedPosition.y 95 | return true 96 | else 97 | return false 98 | 99 | when 39 100 | focusablePosition = 101 | x: focusable.screenFrame.x, 102 | y: focusable.screenFrame.y + (focusable.screenFrame.height / 2) 103 | 104 | if focusablePosition.x > focusedPosition.x 105 | return true 106 | else 107 | return false 108 | 109 | when 40 110 | focusablePosition = 111 | x: focusable.screenFrame.x + (focusable.screenFrame.width / 2) 112 | y: focusable.screenFrame.y 113 | 114 | if focusablePosition.y > focusedPosition.y 115 | return true 116 | else 117 | return false 118 | 119 | 120 | sortedFocusables = _.sortBy relevantFocusables, (focusable) -> 121 | 122 | switch keyCode 123 | when 14 124 | angleOffset = 90 125 | break 126 | 127 | when 12 128 | angleOffset = 0 129 | break 130 | 131 | when 15 132 | angleOffset = 90 133 | break 134 | 135 | when 13 136 | angleOffset = 180 137 | break 138 | 139 | when 37 140 | angleOffset = 90 141 | break 142 | 143 | when 38 144 | angleOffset = 0 145 | break 146 | 147 | when 39 148 | angleOffset = 90 149 | break 150 | 151 | when 40 152 | angleOffset = 180 153 | break 154 | 155 | 156 | focusablePosition = 157 | x: focusable.screenFrame.x + (focusable.screenFrame.width / 2) 158 | y: focusable.screenFrame.y + (focusable.screenFrame.height / 2) 159 | 160 | Y = Math.abs(focusedPosition.y - focusablePosition.y) 161 | X = Math.abs(focusedPosition.x - focusablePosition.x) 162 | 163 | distance = Math.sqrt(Math.abs(X*X + Y*Y)) 164 | angle = Math.abs(Math.atan2(focusedPosition.x - focusablePosition.x, focusedPosition.y - focusablePosition.y) * 180 / Math.PI) 165 | score = distance + Math.abs(angleOffset - angle) 166 | return score 167 | 168 | if sortedFocusables.length 169 | @focus sortedFocusables[0] 170 | 171 | focus: (focusable) -> 172 | 173 | Broadcaster.focusEvent(focusable) 174 | @app.emit 'change:focusedElement', focusable 175 | 176 | # If an element is focused, set it as previouslyFocused and change state to default 177 | # Loop through descendant elements and update their states as well 178 | 179 | if Object.keys(@focusedElement).length 180 | @previouslyFocusedElement = @focusedElement 181 | @previouslyFocusedElement.animate 'default' 182 | 183 | @previouslyFocusedElement.descendants.map (desc, index) -> 184 | if desc.states.focused 185 | desc.animate 'default' 186 | 187 | # Focus focusable and change state to focused 188 | # Loop through descendant elements and update their states as well 189 | 190 | @focusedElement = focusable 191 | @focusedElement.animate 'focused' 192 | 193 | for desc, index in @focusedElement.descendants 194 | if desc.states.focused 195 | desc.animate 'focused' 196 | 197 | clearFocusables: -> 198 | @focusableElements = [] 199 | @previouslyFocusedElement = @focusedElement 200 | if Object.keys(@focusedElement).length 201 | @clearFocused() 202 | 203 | clearFocused: -> 204 | @previouslyFocusedElement = @focusedElement 205 | @previouslyFocusedElement.animate 'default' 206 | 207 | @previouslyFocusedElement.descendants.map (desc, index) -> 208 | if desc.states.focused 209 | desc.animate 'default' 210 | 211 | @focusedElement = {} -------------------------------------------------------------------------------- /src/Focusable.coffee: -------------------------------------------------------------------------------- 1 | _ = Framer._ 2 | 3 | class exports.Focusable extends Layer 4 | constructor: (properties={}) -> 5 | super properties 6 | 7 | @meta = properties.meta 8 | @actions = properties.actions || [] 9 | 10 | @states.default.animationOptions = properties.animationOptions 11 | @states.focused = properties.focusProperties -------------------------------------------------------------------------------- /src/Gamepad.coffee: -------------------------------------------------------------------------------- 1 | _ = Framer._ 2 | 3 | Function::define = (prop, desc) -> 4 | Object.defineProperty this.prototype, prop, desc 5 | 6 | class GamepadSystem extends Framer.EventEmitter 7 | constructor: -> 8 | 9 | @connectedGamepad = undefined 10 | @loopRequest = undefined 11 | @pollingGP = undefined 12 | @buttonsPressed = [] 13 | 14 | # Poll loop X times a second for new states 15 | @loopInterval = 500 16 | 17 | # Threshold for approved axis values - Values above X will be registered as an input 18 | @axisSensitivity = .7 19 | 20 | # Amount of button events occuring in a sequence 21 | @eventsInSequence = 0 22 | 23 | # Should events be throttled? 24 | @throttled = true 25 | 26 | if navigator.getGamepads()[0] 27 | @connectedGamepad = navigator.getGamepads()[0] 28 | @loopRequest = window.requestAnimationFrame @eventLoop.bind(@) 29 | 30 | window.addEventListener 'gamepadconnected', (e) => 31 | @connectedGamepad = navigator.getGamepads()[0] 32 | @loopRequest = window.requestAnimationFrame @eventLoop.bind(@) 33 | 34 | window.addEventListener 'gamepaddisconnected', (e) => 35 | @connectedGamepad = null 36 | window.cancelAnimationFrame @loopRequest 37 | 38 | @define 'throttle', 39 | set: (bool) -> 40 | @throttled = bool 41 | 42 | if bool == true 43 | @axisSensitivity = .7 44 | else 45 | @axisSensitivity = .2 46 | 47 | eventLoop: () => 48 | 49 | setTimeout () => 50 | 51 | @pollingGP = navigator.getGamepads()[0] 52 | 53 | for button, index in @pollingGP.buttons 54 | button.type = 'button' 55 | button.keyCode = index 56 | 57 | @buttonsPressed = _.filter @pollingGP.buttons, {pressed: true} 58 | 59 | for axis, index in @pollingGP.axes 60 | 61 | if (index <= 3) 62 | activeAxis = {} 63 | 64 | if axis > @axisSensitivity || axis < -@axisSensitivity 65 | activeAxis.type = 'axis' 66 | activeAxis.value = axis 67 | 68 | switch index 69 | when 0 70 | if axis > 0 71 | activeAxis.keyCode = 39 72 | @buttonsPressed.push activeAxis 73 | else 74 | activeAxis.keyCode = 37 75 | @buttonsPressed.push activeAxis 76 | when 1 77 | if axis > 0 78 | activeAxis.keyCode = 40 79 | @buttonsPressed.push activeAxis 80 | else 81 | activeAxis.keyCode = 38 82 | @buttonsPressed.push activeAxis 83 | when 2 84 | if axis > 0 85 | activeAxis.keyCode = 44 86 | @buttonsPressed.push activeAxis 87 | else 88 | activeAxis.keyCode = 42 89 | @buttonsPressed.push activeAxis 90 | when 3 91 | if axis > 0 92 | activeAxis.keyCode = 43 93 | @buttonsPressed.push activeAxis 94 | else 95 | activeAxis.keyCode = 41 96 | @buttonsPressed.push activeAxis 97 | 98 | if @buttonsPressed.length 99 | for buttonPressed, index in @buttonsPressed 100 | @emit 'gamepadevent', buttonPressed 101 | 102 | if @throttled 103 | switch @eventsInSequence 104 | when 0 105 | @loopInterval = 3 106 | when 1 107 | @loopInterval = 8 108 | else 109 | @loopInterval = 1000 110 | 111 | @eventsInSequence++ 112 | else 113 | if @throttled 114 | @eventsInSequence = 0 115 | @loopInterval = 500 116 | else 117 | @eventsInSequence = 0 118 | @loopInterval = 1000 119 | 120 | @loopRequest = window.requestAnimationFrame @eventLoop.bind(@) 121 | 122 | , 1000 / @loopInterval 123 | 124 | exports.Gamepad = new GamepadSystem() -------------------------------------------------------------------------------- /src/Grid.coffee: -------------------------------------------------------------------------------- 1 | _ = Framer._ 2 | 3 | class GridSystem 4 | constructor: -> 5 | @safezoneBounds = 6 | width: Screen.width * .9 7 | height: Screen.height * .9 8 | 9 | @dangerzoneBounds = 10 | width: (Screen.width * .1) / 2 11 | height: (Screen.height * .1) / 2 12 | 13 | @columnCount = 24 14 | @columnGutterCount = @columnCount - 1 15 | @columnGutter = 10 16 | @columnWidth = (@safezoneBounds.width - (@columnGutterCount * @columnGutter)) / @columnCount 17 | 18 | @rowHeight = 10 19 | 20 | getWidth: (cells) -> 21 | return (@columnWidth * cells) + (@columnGutter * (cells - 1)) 22 | 23 | getHeight: (rows) -> 24 | return @rowHeight * rows 25 | 26 | getSafezone: (parent) -> 27 | return new Layer 28 | parent: parent 29 | name: 'safezone' 30 | width: @safezoneBounds.width 31 | height: @safezoneBounds.height 32 | y: Align.center 33 | x: Align.center 34 | backgroundColor: 'transparent' 35 | 36 | exports.Grid = new GridSystem() -------------------------------------------------------------------------------- /src/Transitions.coffee: -------------------------------------------------------------------------------- 1 | exports.Transitions = 2 | goIn: -> 3 | options = 4 | curve: 'cubic-bezier(0.645, 0.045, 0.355, 1)' 5 | time: .4 6 | 7 | return ( 8 | layerA: 9 | show: 10 | opacity: 1 11 | scale: 1 12 | options: options 13 | hide: 14 | opacity: 0 15 | scale: 1.2 16 | options: options 17 | layerB: 18 | show: 19 | opacity: 1 20 | scale: 1 21 | options: options 22 | hide: 23 | opacity: 0 24 | scale: .8 25 | options: options 26 | options: options 27 | ) 28 | 29 | goOut: -> 30 | options = 31 | curve: 'cubic-bezier(0.645, 0.045, 0.355, 1)' 32 | time: .4 33 | 34 | return ( 35 | layerA: 36 | show: 37 | opacity: 1 38 | scale: 1 39 | options: options 40 | hide: 41 | opacity: 0 42 | scale: .8 43 | options: options 44 | layerB: 45 | show: 46 | opacity: 1 47 | scale: 1 48 | options: options 49 | hide: 50 | opacity: 0 51 | scale: 1.2 52 | options: options 53 | options: options 54 | ) -------------------------------------------------------------------------------- /src/View.coffee: -------------------------------------------------------------------------------- 1 | {Grid} = require './Grid.coffee' 2 | _ = Framer._ 3 | 4 | class exports.View extends Layer 5 | constructor: (properties={}) -> 6 | 7 | properties.backgroundColor = 'transparent' 8 | 9 | super _.defaults properties, 10 | width: Screen.width 11 | height: Screen.height 12 | 13 | @safezone = Grid.getSafezone(@) 14 | 15 | @actions = properties.actions || [] 16 | @background = properties.background || {} 17 | @title = properties.title || '' -------------------------------------------------------------------------------- /thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emilwidlund/framer-joystick/639f9011a8245d1bcb2e42e738796e0c212f3187/thumb.png -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: './index.coffee', 3 | output: { 4 | path: __dirname + '/build', 5 | filename: 'joystick.coffee' 6 | }, 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.coffee$/, 11 | use: [ 'coffee-loader' ] 12 | } 13 | ] 14 | } 15 | } --------------------------------------------------------------------------------