├── img ├── banner.png └── video_link.png ├── obs ├── config │ ├── assets │ │ ├── footer.png │ │ └── controller.png │ ├── basic │ │ ├── profiles │ │ │ └── main │ │ │ │ ├── service.json │ │ │ │ └── basic.ini │ │ └── scenes │ │ │ └── Main.json │ └── global.ini ├── obs.go ├── display.go └── setup.go ├── cmd ├── nostalgic-rewind │ └── main.go ├── root.go ├── authenticate.go └── stream.go ├── util ├── time.go ├── file.go ├── strings.go └── facebook.go ├── README.md ├── facebook ├── util.go ├── comments.go ├── stream.go ├── reactions.go └── auth.go ├── emulator └── emulator.go └── game └── game.go /img/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zachlatta/final-fantasy-live/HEAD/img/banner.png -------------------------------------------------------------------------------- /img/video_link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zachlatta/final-fantasy-live/HEAD/img/video_link.png -------------------------------------------------------------------------------- /obs/config/assets/footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zachlatta/final-fantasy-live/HEAD/obs/config/assets/footer.png -------------------------------------------------------------------------------- /obs/config/assets/controller.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zachlatta/final-fantasy-live/HEAD/obs/config/assets/controller.png -------------------------------------------------------------------------------- /cmd/nostalgic-rewind/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/zachlatta/nostalgic-rewind/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /util/time.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // Layout for parsing ISO8601 times using the built-in time package. 4 | const ISO8601 = "2006-01-02T15:04:05-0700" 5 | -------------------------------------------------------------------------------- /obs/config/basic/profiles/main/service.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "key": "{{.StreamKey}}", 4 | "server": "{{.StreamUrl}}" 5 | }, 6 | "type": "rtmp_custom" 7 | } 8 | -------------------------------------------------------------------------------- /util/file.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func FileExists(path string) (bool, error) { 8 | _, err := os.Stat(path) 9 | if err == nil { 10 | return true, nil 11 | } 12 | if os.IsNotExist(err) { 13 | return false, nil 14 | } 15 | 16 | return false, err 17 | } 18 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var RootCmd = &cobra.Command{ 11 | Use: "nostalgic-rewind", 12 | Short: "Nostalgic Rewind streams classic arcade games to Facebook Live", 13 | } 14 | 15 | func Execute() { 16 | if err := RootCmd.Execute(); err != nil { 17 | fmt.Fprintln(os.Stderr, err) 18 | os.Exit(-1) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /obs/config/basic/profiles/main/basic.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | Name=main 3 | 4 | [Output] 5 | Mode=Simple 6 | 7 | [AdvOut] 8 | RescaleRes=1440x900 9 | TrackIndex=1 10 | RecType=Standard 11 | RecRescaleRes=1440x900 12 | RecTracks=1 13 | FFOutputToFile=true 14 | FFFormat= 15 | FFFormatMimeType= 16 | FFRescaleRes=1440x900 17 | FFVEncoderId=0 18 | FFVEncoder= 19 | FFAEncoderId=0 20 | FFAEncoder= 21 | FFAudioTrack=1 22 | 23 | [Video] 24 | BaseCX=1360 25 | BaseCY=720 26 | OutputCX=1360 27 | OutputCY=720 28 | -------------------------------------------------------------------------------- /util/strings.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "io" 7 | 8 | pad "github.com/willf/pad/utf8" 9 | ) 10 | 11 | func LeftPad(str, padChar string, length int) string { 12 | return pad.Left(str, length, padChar) 13 | } 14 | 15 | func RightPad(str, padChar string, length int) string { 16 | return pad.Right(str, length, padChar) 17 | } 18 | 19 | func MD5HashString(romPath string) string { 20 | h := md5.New() 21 | io.WriteString(h, romPath) 22 | 23 | return fmt.Sprintf("%x", h.Sum(nil)) 24 | } 25 | -------------------------------------------------------------------------------- /obs/config/global.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | LicenseAccepted=true 3 | LastVersion=301989889 4 | 5 | [PropertiesWindow] 6 | cx=720 7 | cy=580 8 | 9 | [BasicWindow] 10 | geometry=AdnQywACAAAAAALQAAAAEgAABZ8AAANwAAAC0gAAABIAAAWdAAADbgAAAAAAAAAABaA= 11 | splitterTop=467 12 | splitterBottom=328 13 | PreviewEnabled=true 14 | AlwaysOnTop=false 15 | SceneDuplicationMode=true 16 | SwapScenesMode=true 17 | EditPropertiesMode=false 18 | PreviewProgramMode=false 19 | 20 | [Basic] 21 | SceneCollection=Main 22 | SceneCollectionFile=Main 23 | Profile=main 24 | ProfileDir=main 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | Play Final Fantasy through Facebook Live with strangers on the internet. Click the image below to give it a whirl: 4 | 5 | [](https://www.facebook.com/finalfantasylive/videos/1345449772214122) 6 | 7 | You can control which buttons are pressed with your reactions. Every 10 seconds the reactions are totaled and it's button is pressed. 8 | 9 | Built with [@paked](https://github.com/paked) and [@MaxWofford](https://github.com/MaxWofford) for [Ludum Dare](https://ldjam.com/). 10 | -------------------------------------------------------------------------------- /util/facebook.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // Given a URL to a Facebook stream, split it into a stream URL and a stream key 8 | // for use in OBS. 9 | // 10 | // rtmp://rtmp-api.facebook.com:80/rtmp/1234567890?ds=1&s_l=1&a=abh2mv1sdjf3sf 11 | // 12 | // Becomes: 13 | // 14 | // Stream URL: rtmp://rtmp-api.facebook.com:80/rtmp/ 15 | // Stream key: 1234567890?ds=1&s_l=1&a=abh2mv1sdjf3sf 16 | func SplitStreamUrl(url string) (streamUrl, streamkey string) { 17 | parts := strings.SplitAfter(url, "/rtmp/") 18 | 19 | return parts[0], parts[1] 20 | } 21 | -------------------------------------------------------------------------------- /facebook/util.go: -------------------------------------------------------------------------------- 1 | package facebook 2 | 3 | import ( 4 | fb "github.com/huandu/facebook" 5 | ) 6 | 7 | func authedSession(accessToken string) *fb.Session { 8 | session := fb.Session{} 9 | session.SetAccessToken(accessToken) 10 | 11 | return &session 12 | } 13 | 14 | func getAllPaginated(session *fb.Session, path string, params fb.Params) ([]fb.Result, error) { 15 | res, err := session.Get(path, params) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | paging, err := res.Paging(session) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | noMore := !paging.HasNext() 26 | 27 | for !noMore { 28 | noMore, err = paging.Next() 29 | if err != nil { 30 | return nil, err 31 | } 32 | } 33 | 34 | return paging.Data(), nil 35 | } 36 | -------------------------------------------------------------------------------- /obs/obs.go: -------------------------------------------------------------------------------- 1 | package obs 2 | 3 | import ( 4 | "os/exec" 5 | ) 6 | 7 | type Obs struct { 8 | StreamUrl string 9 | StreamKey string 10 | 11 | NextButtonPressPath string 12 | VoteBreakdownPath string 13 | MostRecentPressesPath string 14 | ActivePlayersPath string 15 | TotalPressesPath string 16 | TotalUptimePath string 17 | 18 | buttonPressCount int 19 | mostRecentPresses []string 20 | } 21 | 22 | func New(streamUrl, streamKey string) Obs { 23 | return Obs{ 24 | StreamUrl: streamUrl, 25 | StreamKey: streamKey, 26 | } 27 | } 28 | 29 | func (o *Obs) Start() error { 30 | if err := o.setup(); err != nil { 31 | return err 32 | } 33 | 34 | cmd := exec.Command("obs", "--profile", "main", "--startstreaming") 35 | 36 | if err := cmd.Run(); err != nil { 37 | return err 38 | } 39 | 40 | if err := o.cleanup(); err != nil { 41 | return err 42 | } 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /cmd/authenticate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/zachlatta/nostalgic-rewind/facebook" 9 | ) 10 | 11 | var appId, appSecret string 12 | 13 | var authenticateCmd = &cobra.Command{ 14 | Use: "authenticate", 15 | Short: "Get a long-lived access token", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | if appId == "" || appSecret == "" { 18 | fmt.Fprintln(os.Stderr, "App ID and app secret required. See help. ") 19 | os.Exit(1) 20 | } 21 | 22 | token, err := facebook.Login(appId, appSecret) 23 | if err != nil { 24 | fmt.Fprintln(os.Stderr, "Error authenticating:", err) 25 | os.Exit(1) 26 | } 27 | 28 | fmt.Println() 29 | fmt.Println("Access token:", token) 30 | }, 31 | } 32 | 33 | func init() { 34 | RootCmd.AddCommand(authenticateCmd) 35 | authenticateCmd.Flags().StringVarP(&appId, "app-id", "i", "", "Facebook app ID") 36 | authenticateCmd.Flags().StringVarP(&appSecret, "app-secret", "s", "", "Facebook app secret") 37 | } 38 | -------------------------------------------------------------------------------- /facebook/comments.go: -------------------------------------------------------------------------------- 1 | package facebook 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/zachlatta/nostalgic-rewind/util" 8 | ) 9 | 10 | type Comment struct { 11 | Id string 12 | Created time.Time 13 | AuthorName string 14 | AuthorId string 15 | Message string 16 | } 17 | 18 | func Comments(id, accessToken string) ([]Comment, error) { 19 | session := authedSession(accessToken) 20 | 21 | rawComments, err := getAllPaginated(session, fmt.Sprintf("/%s/comments", id), nil) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | comments := make([]Comment, len(rawComments)) 27 | 28 | for i, rawComment := range rawComments { 29 | created, err := time.Parse(util.ISO8601, rawComment["created_time"].(string)) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | authorInfo := rawComment["from"].(map[string]interface{}) 35 | 36 | comments[i] = Comment{ 37 | Id: rawComment["id"].(string), 38 | Created: created, 39 | AuthorId: authorInfo["id"].(string), 40 | AuthorName: authorInfo["name"].(string), 41 | Message: rawComment["message"].(string), 42 | } 43 | } 44 | 45 | return comments, nil 46 | } 47 | -------------------------------------------------------------------------------- /facebook/stream.go: -------------------------------------------------------------------------------- 1 | package facebook 2 | 3 | import ( 4 | "fmt" 5 | 6 | fb "github.com/huandu/facebook" 7 | ) 8 | 9 | func currentId(accessToken string) (string, error) { 10 | session := authedSession(accessToken) 11 | res, err := session.Get("/me", fb.Params{"fields": "id"}) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | return res["id"].(string), nil 17 | } 18 | 19 | type LiveVideo struct { 20 | Id string 21 | StreamUrl string 22 | SecureStreamUrl string 23 | } 24 | 25 | func CreateLiveVideo(accessToken string) (vid LiveVideo, err error) { 26 | id, err := currentId(accessToken) 27 | if err != nil { 28 | return vid, err 29 | } 30 | 31 | res, err := fb.Post(fmt.Sprintf("/%s/live_videos", id), fb.Params{ 32 | "access_token": accessToken, 33 | 34 | // Makes the live video support unlimited streaming. Usually live streaming 35 | // cuts off after 24 hours. 36 | "stream_type": "AMBIENT", 37 | }) 38 | if err != nil { 39 | return vid, err 40 | } 41 | 42 | vid.Id = res["id"].(string) 43 | vid.StreamUrl = res["stream_url"].(string) 44 | vid.SecureStreamUrl = res["secure_stream_url"].(string) 45 | 46 | return vid, nil 47 | } 48 | -------------------------------------------------------------------------------- /facebook/reactions.go: -------------------------------------------------------------------------------- 1 | package facebook 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type ReactionType int 8 | 9 | const ( 10 | ReactionLike ReactionType = iota 11 | ReactionLove 12 | ReactionHaha 13 | ReactionWow 14 | ReactionSad 15 | ReactionAngry 16 | ReactionThankful 17 | ) 18 | 19 | type Reaction struct { 20 | AuthorId string 21 | AuthorName string 22 | Type ReactionType 23 | } 24 | 25 | func Reactions(id, accessToken string) ([]Reaction, error) { 26 | session := authedSession(accessToken) 27 | rawReacts, err := getAllPaginated(session, fmt.Sprintf("/%s/reactions", id), nil) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | reacts := make([]Reaction, len(rawReacts)) 33 | 34 | for i, rawReact := range rawReacts { 35 | reacts[i] = Reaction{ 36 | AuthorId: rawReact["id"].(string), 37 | AuthorName: rawReact["name"].(string), 38 | Type: reactionTypeForName(rawReact["type"].(string)), 39 | } 40 | } 41 | 42 | return reacts, nil 43 | } 44 | 45 | func reactionTypeForName(reactionName string) ReactionType { 46 | switch reactionName { 47 | case "LIKE": 48 | return ReactionLike 49 | case "LOVE": 50 | return ReactionLove 51 | case "HAHA": 52 | return ReactionHaha 53 | case "WOW": 54 | return ReactionWow 55 | case "SAD": 56 | return ReactionSad 57 | case "ANGRY": 58 | return ReactionAngry 59 | case "THANKFUL": 60 | return ReactionThankful 61 | default: 62 | return -1 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /emulator/emulator.go: -------------------------------------------------------------------------------- 1 | package emulator 2 | 3 | import ( 4 | "runtime" 5 | "time" 6 | 7 | "github.com/go-gl/gl/v2.1/gl" 8 | "github.com/go-gl/glfw/v3.1/glfw" 9 | "github.com/gordonklaus/portaudio" 10 | "github.com/paked/nes/nes" 11 | "github.com/paked/nes/ui" 12 | ) 13 | 14 | var DefaultSettings = Settings{ 15 | Width: 246, 16 | Height: 240, 17 | Scale: 3, 18 | Title: "NES", 19 | } 20 | 21 | func init() { 22 | runtime.GOMAXPROCS(2) 23 | runtime.LockOSThread() 24 | } 25 | 26 | type Emulator struct { 27 | PlayerOneController ui.ControllerAdapter 28 | PlayerTwoController ui.ControllerAdapter 29 | 30 | Director *ui.Director 31 | 32 | Settings Settings 33 | savePath string 34 | } 35 | 36 | func NewEmulator(settings Settings, controllerOne ui.ControllerAdapter, controllerTwo ui.ControllerAdapter, savePath string) (*Emulator, error) { 37 | e := &Emulator{ 38 | PlayerOneController: controllerOne, 39 | PlayerTwoController: controllerTwo, 40 | Settings: settings, 41 | 42 | savePath: savePath, 43 | } 44 | 45 | return e, nil 46 | } 47 | 48 | func (e *Emulator) Play(romPath string) error { 49 | // initialize audio 50 | portaudio.Initialize() 51 | defer portaudio.Terminate() 52 | 53 | audio := ui.NewAudio() 54 | if err := audio.Start(); err != nil { 55 | return err 56 | } 57 | defer audio.Stop() 58 | 59 | // initialize glfw 60 | if err := glfw.Init(); err != nil { 61 | return err 62 | } 63 | defer glfw.Terminate() 64 | 65 | // create window 66 | glfw.WindowHint(glfw.ContextVersionMajor, 2) 67 | glfw.WindowHint(glfw.ContextVersionMinor, 1) 68 | 69 | window, err := glfw.CreateWindow(e.Settings.Width*e.Settings.Scale, e.Settings.Height*e.Settings.Scale, e.Settings.Title, nil, nil) 70 | if err != nil { 71 | return err 72 | } 73 | window.MakeContextCurrent() 74 | 75 | // initialize gl 76 | if err := gl.Init(); err != nil { 77 | return err 78 | } 79 | gl.Enable(gl.TEXTURE_2D) 80 | 81 | e.PlayerOneController.SetWindow(window) 82 | e.PlayerTwoController.SetWindow(window) 83 | 84 | e.Director = ui.NewDirector(window, audio, e.PlayerOneController, e.PlayerTwoController) 85 | 86 | go func() { 87 | time.Sleep(time.Second) 88 | 89 | e.LoadState(e.savePath) 90 | }() 91 | 92 | e.Director.Start([]string{romPath}) 93 | 94 | return nil 95 | } 96 | 97 | func (e *Emulator) SaveState(path string) error { 98 | c := e.console() 99 | 100 | return c.SaveState(path) 101 | } 102 | 103 | func (e *Emulator) LoadState(path string) error { 104 | c := e.console() 105 | 106 | return c.LoadState(path) 107 | } 108 | 109 | func (e *Emulator) console() *nes.Console { 110 | return e.Director.Console() 111 | } 112 | 113 | type Settings struct { 114 | Width int 115 | Height int 116 | Scale int 117 | Title string 118 | } 119 | -------------------------------------------------------------------------------- /facebook/auth.go: -------------------------------------------------------------------------------- 1 | package facebook 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | fb "github.com/huandu/facebook" 9 | "net/http" 10 | ) 11 | 12 | const baseOauthUrl = "https://www.facebook.com/v2.9/dialog/oauth" 13 | 14 | var requiredScopes = []string{"publish_actions", "manage_pages", "publish_pages", "user_posts", "user_videos"} 15 | var responseTypes = []string{"code", "granted_scopes"} 16 | 17 | // Handles every step of the login process and returns a long-lived access 18 | // token. 19 | func Login(appId, appSecret string) (accessToken string, err error) { 20 | redirectUrl := "http://localhost:6262/" 21 | 22 | mux := http.NewServeMux() 23 | srv := http.Server{Addr: ":6262", Handler: mux} 24 | 25 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 26 | params := r.URL.Query() 27 | code := params.Get("code") 28 | 29 | token, err := exchangeCode(appId, appSecret, code, redirectUrl) 30 | if err != nil { 31 | fmt.Fprintln(w, "Error exchanging code:", err) 32 | return 33 | } 34 | 35 | accessToken = token 36 | 37 | fmt.Fprintln(w, "Success!") 38 | 39 | srv.Close() 40 | }) 41 | 42 | fmt.Println("Open", loginUrl(appId, redirectUrl), "in your web browser to authenticate...") 43 | srv.ListenAndServe() 44 | fmt.Println("Authenticated!") 45 | 46 | fmt.Println("Getting long lived token...") 47 | 48 | longLived, err := GetLongLivedAccessToken(appId, appSecret, accessToken) 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | fmt.Println("Done!") 54 | 55 | return longLived, nil 56 | } 57 | 58 | func loginUrl(appId, redirectUrl string) string { 59 | url, _ := url.Parse(baseOauthUrl) 60 | query := url.Query() 61 | 62 | query.Add("client_id", appId) 63 | query.Add("redirect_uri", redirectUrl) 64 | query.Add("scope", strings.Join(requiredScopes, ",")) 65 | query.Add("response_type", strings.Join(responseTypes, ",")) 66 | 67 | url.RawQuery = query.Encode() 68 | 69 | return url.String() 70 | } 71 | 72 | func exchangeCode(appId, appSecret, code, redirectUri string) (string, error) { 73 | res, err := fb.Get("/oauth/access_token", fb.Params{ 74 | "client_id": appId, 75 | "client_secret": appSecret, 76 | "code": code, 77 | "redirect_uri": redirectUri, 78 | }) 79 | if err != nil { 80 | return "", err 81 | } 82 | 83 | token := res["access_token"].(string) 84 | 85 | return token, nil 86 | } 87 | 88 | func GetLongLivedAccessToken(appId, appSecret, accessToken string) (string, error) { 89 | res, err := fb.Get("/oauth/access_token", fb.Params{ 90 | "grant_type": "fb_exchange_token", 91 | "client_id": appId, 92 | "client_secret": appSecret, 93 | "fb_exchange_token": accessToken, 94 | }) 95 | if err != nil { 96 | return "", err 97 | } 98 | 99 | return res["access_token"].(string), nil 100 | } 101 | -------------------------------------------------------------------------------- /obs/display.go: -------------------------------------------------------------------------------- 1 | package obs 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "strings" 8 | 9 | "github.com/paked/nes/nes" 10 | "github.com/zachlatta/nostalgic-rewind/util" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | func (o *Obs) drawDefaults() error { 16 | if err := o.UpdateNextButtonPress(0); err != nil { 17 | return err 18 | } 19 | if err := o.UpdateVoteBreakdown(map[int]int{}); err != nil { 20 | return err 21 | } 22 | if err := o.UpdateActivePlayers(0); err != nil { 23 | return err 24 | } 25 | if err := o.updateMostRecentPresses(); err != nil { 26 | return err 27 | } 28 | if err := o.updateTotalPresses(); err != nil { 29 | return err 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func (o *Obs) UpdateNextButtonPress(secondsRemaining int) error { 36 | return ioutil.WriteFile( 37 | o.NextButtonPressPath, 38 | []byte(fmt.Sprintf("Next button press in: %d seconds", secondsRemaining)), 39 | os.ModePerm, 40 | ) 41 | } 42 | 43 | func (o *Obs) UpdateVoteBreakdown(breakdown map[int]int) error { 44 | displayOrder := []int{ 45 | nes.ButtonUp, nes.ButtonLeft, nes.ButtonA, 46 | nes.ButtonDown, nes.ButtonRight, nes.ButtonB, 47 | } 48 | 49 | values := make([]interface{}, len(displayOrder)) 50 | for i, btn := range displayOrder { 51 | values[i] = padCount(breakdown[btn]) 52 | } 53 | 54 | str := fmt.Sprintf(`Current votes: 55 | 56 | UP: %s LEFT: %s A: %s 57 | DOWN: %s RIGHT: %s B: %s 58 | `, values...) 59 | 60 | return ioutil.WriteFile(o.VoteBreakdownPath, []byte(str), os.ModePerm) 61 | } 62 | 63 | func padCount(num int) string { 64 | const length = 5 65 | const padder = " " // Character to pad with 66 | 67 | return util.RightPad(strconv.Itoa(num), padder, length) 68 | } 69 | 70 | func (o *Obs) AddMostRecentPress(newPress string) error { 71 | o.mostRecentPresses = append([]string{strings.ToUpper(newPress)}, o.mostRecentPresses...) 72 | 73 | if len(o.mostRecentPresses) > 3 { 74 | o.mostRecentPresses = o.mostRecentPresses[0:3] 75 | } 76 | 77 | return o.updateMostRecentPresses() 78 | } 79 | 80 | func (o *Obs) updateMostRecentPresses() error { 81 | return ioutil.WriteFile( 82 | o.MostRecentPressesPath, 83 | []byte(fmt.Sprintf("Most recent presses:\n%s", strings.Join(o.mostRecentPresses, ", "))), 84 | os.ModePerm, 85 | ) 86 | } 87 | 88 | func (o *Obs) UpdateActivePlayers(count int) error { 89 | str := fmt.Sprintf("Active players: %d", count) 90 | 91 | return ioutil.WriteFile(o.ActivePlayersPath, []byte(str), os.ModePerm) 92 | } 93 | 94 | func (o *Obs) IncrementButtonPresses() error { 95 | o.buttonPressCount += 1 96 | 97 | return o.updateTotalPresses() 98 | } 99 | 100 | func (o *Obs) updateTotalPresses() error { 101 | return ioutil.WriteFile( 102 | o.TotalPressesPath, 103 | []byte(fmt.Sprintf("Total presses: %d", o.buttonPressCount)), 104 | os.ModePerm, 105 | ) 106 | } 107 | 108 | func (o *Obs) UpdateTotalUptime(startTime, currentTime time.Time) error { 109 | delta := currentTime.Sub(startTime) 110 | days := int(delta.Hours()) / int(time.Hour*24) 111 | hours := int(delta.Hours()) - int(days*24) 112 | minutes := int(delta.Minutes()) - int(hours*60) 113 | seconds := int(delta.Seconds()) - int(minutes*60) 114 | 115 | str := fmt.Sprintf( 116 | "Total uptime: %sD, %sH, %sM, %sS", 117 | padTime(days), 118 | padTime(hours), 119 | padTime(minutes), 120 | padTime(seconds), 121 | ) 122 | 123 | return ioutil.WriteFile(o.TotalUptimePath, []byte(str), os.ModePerm) 124 | } 125 | 126 | func padTime(num int) string { 127 | length := 2 128 | padChar := "0" 129 | 130 | return util.LeftPad(strconv.Itoa(num), padChar, length) 131 | } 132 | -------------------------------------------------------------------------------- /cmd/stream.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/zachlatta/nostalgic-rewind/facebook" 11 | "github.com/zachlatta/nostalgic-rewind/game" 12 | "github.com/zachlatta/nostalgic-rewind/util" 13 | ) 14 | 15 | var accessToken string 16 | var romPath string 17 | var vidId string 18 | var vidStreamUrl string 19 | var savePath string 20 | 21 | var streamCmd = &cobra.Command{ 22 | Use: "stream", 23 | Short: "Manage a Facebook Live stream", 24 | } 25 | 26 | var createStreamCmd = &cobra.Command{ 27 | Use: "create", 28 | Short: "Create a Facebook Live stream", 29 | Run: func(cmd *cobra.Command, args []string) { 30 | if accessToken == "" { 31 | fmt.Fprintln(os.Stderr, "Access token is required.") 32 | os.Exit(1) 33 | } 34 | 35 | vid, err := facebook.CreateLiveVideo(accessToken) 36 | if err != nil { 37 | fmt.Fprintln(os.Stderr, "Error creating stream:", err) 38 | os.Exit(1) 39 | } 40 | 41 | fmt.Println("Stream created!") 42 | fmt.Println() 43 | fmt.Println("ID:", vid.Id) 44 | fmt.Println("Stream URL:", vid.StreamUrl) 45 | fmt.Println() 46 | fmt.Println("Run `stream play` to cast to the stream.") 47 | }, 48 | } 49 | 50 | var playStreamCmd = &cobra.Command{ 51 | Use: "play [path to rom to play]", 52 | Short: "Start casting the given ROM", 53 | Run: func(cmd *cobra.Command, args []string) { 54 | if len(args) != 1 { 55 | fmt.Fprintln(os.Stderr, "Please provide a path to the ROM to play.") 56 | os.Exit(1) 57 | } 58 | 59 | if accessToken == "" || vidId == "" || vidStreamUrl == "" { 60 | fmt.Fprintln(os.Stderr, "Access token, stream ID, and stream URL are all required.") 61 | os.Exit(1) 62 | } 63 | 64 | romPath := args[0] 65 | vid := facebook.LiveVideo{ 66 | Id: vidId, 67 | StreamUrl: vidStreamUrl, 68 | } 69 | 70 | fmt.Printf("Starting %s...\n", romPath) 71 | 72 | spath := filepath.Join(savePath, util.MD5HashString(romPath), game.GameSavePath) 73 | 74 | var g game.Game 75 | 76 | ok, err := util.FileExists(spath) 77 | if err != nil { 78 | fmt.Fprintln(os.Stderr, "Wasn't able to check if a file existed", err) 79 | os.Exit(1) 80 | } 81 | 82 | if ok { 83 | fmt.Println("Loading game from save...") 84 | 85 | var save game.Save 86 | f, err := os.Open(spath) 87 | if err != nil { 88 | fmt.Fprintln(os.Stderr, "Error loading save", err) 89 | os.Exit(1) 90 | } 91 | 92 | if err := json.NewDecoder(f).Decode(&save); err != nil { 93 | fmt.Fprintln(os.Stderr, "Error decoding game save", err) 94 | os.Exit(1) 95 | } 96 | 97 | g, err = game.NewFromSave(save, vid, accessToken) 98 | if err != nil { 99 | fmt.Fprintln(os.Stderr, "Error creating game:", err) 100 | os.Exit(1) 101 | } 102 | } else { 103 | fmt.Println("Loading game without existing save") 104 | g, err = game.New(vid, romPath, accessToken, savePath) 105 | if err != nil { 106 | fmt.Fprintln(os.Stderr, "Error creating game:", err) 107 | os.Exit(1) 108 | } 109 | } 110 | 111 | g.Start() 112 | }, 113 | } 114 | 115 | func init() { 116 | RootCmd.AddCommand(streamCmd) 117 | 118 | streamCmd.AddCommand(createStreamCmd) 119 | createStreamCmd.Flags().StringVarP(&accessToken, "token", "t", "", "Facebook access token for page to stream from") 120 | 121 | streamCmd.AddCommand(playStreamCmd) 122 | playStreamCmd.Flags().StringVarP(&accessToken, "token", "t", "", "Facebook access token for page to stream from") 123 | playStreamCmd.Flags().StringVarP(&vidId, "stream-id", "i", "", "ID of Facebook Live stream to cast to") 124 | playStreamCmd.Flags().StringVarP(&vidStreamUrl, "stream-url", "u", "", "URL of Facebook Live stream to cast to") 125 | playStreamCmd.Flags().StringVarP(&savePath, "save", "s", "./.saves", "The directory to save the state of the emulator") 126 | } 127 | -------------------------------------------------------------------------------- /obs/setup.go: -------------------------------------------------------------------------------- 1 | package obs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | 10 | "github.com/mitchellh/go-homedir" 11 | "github.com/zachlatta/nostalgic-rewind/util" 12 | "io/ioutil" 13 | "text/template" 14 | ) 15 | 16 | // Run `go generate` to regenerate the configData.go file (must be done any time 17 | // a file changes in ./config/). 18 | 19 | //go:generate go-bindata -o configData.go -pkg obs config/... 20 | 21 | const ( 22 | customConfigPath = "config/" 23 | backupConfigExtension = ".bak" 24 | ) 25 | 26 | func (o *Obs) setup() error { 27 | if err := o.setupTmpFiles(); err != nil { 28 | return err 29 | } 30 | 31 | if err := o.setupConfig(); err != nil { 32 | return err 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func (o *Obs) setupTmpFiles() (err error) { 39 | o.NextButtonPressPath, err = createTmp("next-btn-countdown") 40 | if err != nil { 41 | return err 42 | } 43 | 44 | o.VoteBreakdownPath, err = createTmp("vote-breakdown") 45 | if err != nil { 46 | return err 47 | } 48 | 49 | o.MostRecentPressesPath, err = createTmp("most-recent-presses") 50 | if err != nil { 51 | return err 52 | } 53 | 54 | o.ActivePlayersPath, err = createTmp("active-players") 55 | if err != nil { 56 | return err 57 | } 58 | 59 | o.TotalPressesPath, err = createTmp("total-presses") 60 | if err != nil { 61 | return err 62 | } 63 | 64 | o.TotalUptimePath, err = createTmp("total-uptime") 65 | if err != nil { 66 | return err 67 | } 68 | 69 | // Set default values 70 | if err := o.drawDefaults(); err != nil { 71 | return err 72 | } 73 | 74 | return nil 75 | } 76 | 77 | func createTmp(prefix string) (filename string, err error) { 78 | file, err := ioutil.TempFile("", prefix) 79 | if err != nil { 80 | return "", err 81 | } 82 | defer file.Close() 83 | 84 | return file.Name(), nil 85 | } 86 | 87 | func (o Obs) setupConfig() error { 88 | config, err := o.ConfigPath() 89 | if err != nil { 90 | return err 91 | } 92 | 93 | configExists, err := util.FileExists(config) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | if configExists { 99 | if err := os.Rename(config, config+backupConfigExtension); err != nil { 100 | return err 101 | } 102 | } 103 | 104 | files := map[string]string{ 105 | "global.ini": "process", 106 | "basic/profiles/main/basic.ini": "process", 107 | "basic/profiles/main/service.json": "process", 108 | "basic/scenes/Main.json": "process", 109 | "assets/controller.png": "copy", 110 | "assets/footer.png": "copy", 111 | } 112 | 113 | for path, action := range files { 114 | data, err := Asset(filepath.Join(customConfigPath, path)) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | fullPath := filepath.Join(config, path) 120 | dir := filepath.Join(config, filepath.Dir(path)) 121 | 122 | exists, err := util.FileExists(dir) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | if !exists { 128 | os.MkdirAll(dir, os.ModePerm) 129 | } 130 | 131 | file, err := os.Create(fullPath) 132 | if err != nil { 133 | return err 134 | } 135 | defer file.Close() 136 | 137 | switch action { 138 | case "process": 139 | tmpl, err := template.New(path).Parse(string(data)) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | if err := tmpl.Execute(file, o); err != nil { 145 | return err 146 | } 147 | case "copy": 148 | if _, err := file.Write(data); err != nil { 149 | return err 150 | } 151 | default: 152 | return errors.New(fmt.Sprintf("encountered unknown action while processing %s: %s", path, action)) 153 | } 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (o *Obs) cleanup() error { 160 | if err := o.cleanupTmpFiles(); err != nil { 161 | return err 162 | } 163 | 164 | if err := o.cleanupConfig(); err != nil { 165 | return err 166 | } 167 | 168 | return nil 169 | } 170 | 171 | func (o *Obs) cleanupTmpFiles() error { 172 | if err := os.Remove(o.NextButtonPressPath); err != nil { 173 | return err 174 | } 175 | o.NextButtonPressPath = "" 176 | 177 | if err := os.Remove(o.VoteBreakdownPath); err != nil { 178 | return err 179 | } 180 | o.VoteBreakdownPath = "" 181 | 182 | if err := os.Remove(o.MostRecentPressesPath); err != nil { 183 | return err 184 | } 185 | o.MostRecentPressesPath = "" 186 | 187 | if err := os.Remove(o.ActivePlayersPath); err != nil { 188 | return err 189 | } 190 | o.ActivePlayersPath = "" 191 | 192 | if err := os.Remove(o.TotalPressesPath); err != nil { 193 | return err 194 | } 195 | o.TotalPressesPath = "" 196 | 197 | if err := os.Remove(o.TotalUptimePath); err != nil { 198 | return err 199 | } 200 | o.TotalUptimePath = "" 201 | 202 | return nil 203 | } 204 | 205 | func (o Obs) cleanupConfig() error { 206 | config, err := o.ConfigPath() 207 | if err != nil { 208 | return err 209 | } 210 | 211 | if err := os.RemoveAll(config); err != nil { 212 | return err 213 | } 214 | 215 | backupConfigExists, err := util.FileExists(config + backupConfigExtension) 216 | if err != nil { 217 | return err 218 | } 219 | 220 | if backupConfigExists { 221 | if err := os.Rename(config+backupConfigExtension, config); err != nil { 222 | return err 223 | } 224 | } 225 | 226 | return nil 227 | } 228 | 229 | func (o Obs) ConfigPath() (string, error) { 230 | switch runtime.GOOS { 231 | case "darwin": 232 | return homedir.Expand("~/Library/Application Support/obs-studio") 233 | case "linux": 234 | return homedir.Expand("~/.config/obs-studio") 235 | default: 236 | return "", errors.New("unsupported OS") 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /game/game.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/paked/nes/nes" 11 | "github.com/paked/nes/ui" 12 | "github.com/zachlatta/nostalgic-rewind/emulator" 13 | "github.com/zachlatta/nostalgic-rewind/facebook" 14 | "github.com/zachlatta/nostalgic-rewind/obs" 15 | "github.com/zachlatta/nostalgic-rewind/util" 16 | ) 17 | 18 | const ( 19 | GameSavePath = "game.json" 20 | 21 | actionInterval = 10 22 | pollInterval = 2 * time.Second 23 | inactivityCutoff = 1 * time.Minute 24 | 25 | buttonPressTime = 1 * time.Second 26 | ) 27 | 28 | var buttonToString = map[int]string{ 29 | nes.ButtonUp: "up", 30 | nes.ButtonDown: "down", 31 | nes.ButtonRight: "right", 32 | nes.ButtonLeft: "left", 33 | nes.ButtonA: "A", 34 | nes.ButtonB: "B", 35 | nes.ButtonStart: "start", 36 | nes.ButtonSelect: "select", 37 | } 38 | 39 | type Game struct { 40 | Video facebook.LiveVideo `json:"-"` 41 | RomPath string 42 | SavePath string 43 | AccessToken string 44 | Emulator *emulator.Emulator `json:"-"` 45 | Obs obs.Obs 46 | 47 | startTime time.Time `json: "startTime"` 48 | 49 | // Key is user ID 50 | reactions map[string]facebook.Reaction 51 | lastUserReactions map[string]time.Time 52 | 53 | buttonsToPress chan int 54 | 55 | comments chan facebook.Comment 56 | lastCommentTime time.Time 57 | } 58 | 59 | func NewFromSave(save Save, vid facebook.LiveVideo, accessToken string) (Game, error) { 60 | romPath := save.RomPath 61 | savePath := save.SavePath 62 | 63 | playerOne := ui.NewKeyboardControllerAdapter() 64 | playerTwo := &ui.DummyControllerAdapter{} 65 | 66 | e, err := emulator.NewEmulator( 67 | emulator.DefaultSettings, 68 | playerOne, 69 | playerTwo, 70 | nesSaveFilePath(savePath, romPath), 71 | ) 72 | 73 | if err != nil { 74 | return Game{}, err 75 | } 76 | 77 | streamUrl, streamKey := util.SplitStreamUrl(vid.StreamUrl) 78 | 79 | return Game{ 80 | Video: vid, 81 | RomPath: romPath, 82 | SavePath: savePath, 83 | AccessToken: accessToken, 84 | Emulator: e, 85 | Obs: obs.New(streamUrl, streamKey), 86 | 87 | reactions: save.PastReactions, 88 | lastUserReactions: save.LastUserReactions, 89 | 90 | buttonsToPress: make(chan int), 91 | 92 | comments: make(chan facebook.Comment), 93 | lastCommentTime: time.Now(), 94 | }, nil 95 | } 96 | 97 | func New(vid facebook.LiveVideo, romPath string, accessToken string, savePath string) (Game, error) { 98 | playerOne := ui.NewKeyboardControllerAdapter() 99 | playerTwo := &ui.DummyControllerAdapter{} 100 | 101 | e, err := emulator.NewEmulator( 102 | emulator.DefaultSettings, 103 | playerOne, 104 | playerTwo, 105 | nesSaveFilePath(savePath, romPath), 106 | ) 107 | 108 | if err != nil { 109 | return Game{}, err 110 | } 111 | 112 | streamUrl, streamKey := util.SplitStreamUrl(vid.StreamUrl) 113 | 114 | return Game{ 115 | Video: vid, 116 | RomPath: romPath, 117 | SavePath: savePath, 118 | AccessToken: accessToken, 119 | Emulator: e, 120 | Obs: obs.New(streamUrl, streamKey), 121 | 122 | reactions: map[string]facebook.Reaction{}, 123 | lastUserReactions: map[string]time.Time{}, 124 | 125 | buttonsToPress: make(chan int), 126 | 127 | comments: make(chan facebook.Comment), 128 | lastCommentTime: time.Now(), 129 | }, nil 130 | } 131 | 132 | func (g *Game) Start() { 133 | fmt.Println("Stream created!") 134 | fmt.Println("ID:", g.Video.Id) 135 | fmt.Println("Direct your stream to:", g.Video.StreamUrl) 136 | 137 | g.startTime = time.Now() 138 | 139 | go g.startObs() 140 | go g.pollForReactions() 141 | go g.buttonCountdown() 142 | go g.handleButtonPresses() 143 | go g.continuoslySave() 144 | 145 | // Emulator must be on main thread 146 | g.startEmulator() 147 | } 148 | 149 | func (g *Game) Save() error { 150 | epath := nesSaveFilePath(g.SavePath, g.RomPath) 151 | err := g.Emulator.SaveState(epath) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | save := Save{ 157 | PastReactions: g.reactions, 158 | LastUserReactions: g.lastUserReactions, 159 | RomPath: g.RomPath, 160 | SavePath: g.SavePath, 161 | } 162 | 163 | fpath := filepath.Join(g.SavePath, util.MD5HashString(g.RomPath), GameSavePath) 164 | 165 | var f *os.File 166 | 167 | // Check if file doesn't exist 168 | if ok, err := util.FileExists(fpath); !ok || err != nil { 169 | f, err = os.Create(fpath) 170 | } else { 171 | f, err = os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0777) 172 | } 173 | 174 | if err != nil { 175 | return err 176 | } 177 | 178 | err = json.NewEncoder(f).Encode(save) 179 | 180 | return err 181 | } 182 | 183 | func (g *Game) Load() error { 184 | err := g.Emulator.LoadState(g.SavePath) 185 | if err != nil { 186 | return err 187 | } 188 | 189 | return nil 190 | } 191 | 192 | func (g *Game) startObs() { 193 | if err := g.Obs.Start(); err != nil { 194 | fmt.Fprintln(os.Stderr, "Error running OBS:", err) 195 | os.Exit(1) 196 | } 197 | } 198 | 199 | func (g *Game) pollForReactions() { 200 | ticker := time.NewTicker(pollInterval) 201 | 202 | for range ticker.C { 203 | reactions, err := facebook.Reactions(g.Video.Id, g.AccessToken) 204 | if err != nil { 205 | fmt.Fprintln(os.Stderr, "Error polling for reactions:", err) 206 | os.Exit(1) 207 | } 208 | 209 | // Update g.lastUserReactions 210 | for _, reaction := range reactions { 211 | lastReaction := g.reactions[reaction.AuthorId] 212 | 213 | if lastReaction != reaction { 214 | g.lastUserReactions[reaction.AuthorId] = time.Now() 215 | } 216 | } 217 | 218 | // Update g.reactions 219 | reactionMap := map[string]facebook.Reaction{} 220 | for _, reaction := range reactions { 221 | reactionMap[reaction.AuthorId] = reaction 222 | } 223 | g.reactions = reactionMap 224 | 225 | // Update vote breakdown 226 | buttonVoteMap := map[int]int{} 227 | for reactionType, count := range g.reactionCounts(true) { 228 | buttonVoteMap[reactionToButton(reactionType)] = count 229 | } 230 | if err := g.Obs.UpdateVoteBreakdown(buttonVoteMap); err != nil { 231 | fmt.Fprintln(os.Stderr, "Error updating vote breakdown:", err) 232 | os.Exit(1) 233 | } 234 | } 235 | } 236 | 237 | func (g *Game) buttonCountdown() { 238 | ticker := time.NewTicker(1 * time.Second) 239 | timer := actionInterval 240 | 241 | for range ticker.C { 242 | g.Obs.UpdateNextButtonPress(timer) 243 | g.Obs.UpdateTotalUptime(g.startTime, time.Now()) 244 | g.Obs.UpdateActivePlayers(len(g.activePlayers())) 245 | 246 | timer -= 1 247 | 248 | if timer == 0 { 249 | timer = actionInterval 250 | 251 | mostCommonReact := mostCommonReact(g.reactionCounts(true)) 252 | if mostCommonReact == -1 { 253 | fmt.Println("No reactions. Skipping button press.") 254 | continue 255 | } 256 | 257 | g.buttonsToPress <- reactionToButton(mostCommonReact) 258 | } 259 | } 260 | } 261 | 262 | func (g Game) activePlayers() map[string]struct{} { 263 | activeIds := map[string]struct{}{} 264 | 265 | for userId, lastReactionTime := range g.lastUserReactions { 266 | // cutoff = current time - inactivity cutoff 267 | cutoff := time.Now().Add(-inactivityCutoff) 268 | 269 | if lastReactionTime.After(cutoff) { 270 | activeIds[userId] = struct{}{} 271 | } 272 | } 273 | 274 | return activeIds 275 | } 276 | 277 | func (g Game) reactionCounts(onlyIncludeActive bool) map[facebook.ReactionType]int { 278 | countMap := map[facebook.ReactionType]int{} 279 | activeUserIds := g.activePlayers() 280 | 281 | for userId, reaction := range g.reactions { 282 | if onlyIncludeActive { 283 | if _, present := activeUserIds[userId]; present == true { 284 | countMap[reaction.Type] += 1 285 | } 286 | } else { 287 | countMap[reaction.Type] += 1 288 | } 289 | } 290 | 291 | return countMap 292 | } 293 | 294 | func reactionToButton(reaction facebook.ReactionType) int { 295 | switch reaction { 296 | case facebook.ReactionLike: 297 | return nes.ButtonLeft 298 | case facebook.ReactionLove: 299 | return nes.ButtonUp 300 | case facebook.ReactionHaha: 301 | return nes.ButtonDown 302 | case facebook.ReactionWow: 303 | return nes.ButtonRight 304 | case facebook.ReactionSad: 305 | return nes.ButtonB 306 | case facebook.ReactionAngry: 307 | return nes.ButtonA 308 | default: 309 | return -1 310 | } 311 | } 312 | 313 | // Returns the most common reaction given an array of them 314 | func mostCommonReact(countMap map[facebook.ReactionType]int) facebook.ReactionType { 315 | var foundMostCommon bool 316 | var mostCommon facebook.ReactionType 317 | var maxCount int 318 | 319 | for reactionType, occurances := range countMap { 320 | if occurances > maxCount { 321 | foundMostCommon = true 322 | mostCommon = reactionType 323 | maxCount = occurances 324 | } 325 | } 326 | 327 | if !foundMostCommon { 328 | return -1 329 | } 330 | 331 | return mostCommon 332 | } 333 | 334 | func (g *Game) handleButtonPresses() { 335 | for action := range g.buttonsToPress { 336 | g.Obs.IncrementButtonPresses() 337 | g.Obs.AddMostRecentPress(buttonToString[action]) 338 | 339 | g.Emulator.PlayerOneController.Trigger(action, true) 340 | time.Sleep(time.Second / 5) 341 | g.Emulator.PlayerOneController.Trigger(action, false) 342 | } 343 | } 344 | 345 | func (g *Game) startEmulator() { 346 | g.Emulator.Play(g.RomPath) 347 | } 348 | 349 | func (g *Game) continuoslySave() { 350 | c := time.Tick(1 * time.Minute) 351 | for range c { 352 | path := nesSaveFilePath(g.SavePath, g.RomPath) 353 | 354 | fmt.Println("Saving NES's game state to:", path) 355 | g.Save() 356 | fmt.Println("Finished saving...") 357 | } 358 | } 359 | 360 | func nesSaveFilePath(savePath, romPath string) string { 361 | return filepath.Join(savePath, util.MD5HashString(romPath), "save.dat") 362 | } 363 | 364 | type Save struct { 365 | PastReactions map[string]facebook.Reaction `json:"past_reactions"` 366 | LastUserReactions map[string]time.Time `json:"last_user_reactions"` 367 | RomPath string `json:"rom_path"` 368 | SavePath string `json:"save_path"` 369 | } 370 | -------------------------------------------------------------------------------- /obs/config/basic/scenes/Main.json: -------------------------------------------------------------------------------- 1 | { 2 | "AuxAudioDevice1": { 3 | "deinterlace_field_order": 0, 4 | "deinterlace_mode": 0, 5 | "enabled": true, 6 | "flags": 0, 7 | "hotkeys": { 8 | "libobs.mute": [], 9 | "libobs.push-to-mute": [], 10 | "libobs.push-to-talk": [], 11 | "libobs.unmute": [] 12 | }, 13 | "id": "pulse_input_capture", 14 | "mixers": 255, 15 | "monitoring_type": 0, 16 | "muted": false, 17 | "name": "Mic/Aux", 18 | "push-to-mute": false, 19 | "push-to-mute-delay": 0, 20 | "push-to-talk": false, 21 | "push-to-talk-delay": 0, 22 | "settings": { 23 | "device_id": "default" 24 | }, 25 | "sync": 0, 26 | "volume": 1.0 27 | }, 28 | "DesktopAudioDevice1": { 29 | "deinterlace_field_order": 0, 30 | "deinterlace_mode": 0, 31 | "enabled": true, 32 | "flags": 0, 33 | "hotkeys": { 34 | "libobs.mute": [], 35 | "libobs.push-to-mute": [], 36 | "libobs.push-to-talk": [], 37 | "libobs.unmute": [] 38 | }, 39 | "id": "pulse_output_capture", 40 | "mixers": 255, 41 | "monitoring_type": 0, 42 | "muted": false, 43 | "name": "Desktop Audio", 44 | "push-to-mute": false, 45 | "push-to-mute-delay": 0, 46 | "push-to-talk": false, 47 | "push-to-talk-delay": 0, 48 | "settings": { 49 | "device_id": "default" 50 | }, 51 | "sync": 0, 52 | "volume": 1.0 53 | }, 54 | "current_program_scene": "Scene", 55 | "current_scene": "Scene", 56 | "current_transition": "Fade", 57 | "modules": { 58 | "output-timer": { 59 | "autoStartRecordTimer": false, 60 | "autoStartStreamTimer": false, 61 | "recordTimerHours": 0, 62 | "recordTimerMinutes": 0, 63 | "recordTimerSeconds": 30, 64 | "streamTimerHours": 0, 65 | "streamTimerMinutes": 0, 66 | "streamTimerSeconds": 30 67 | } 68 | }, 69 | "name": "Main", 70 | "preview_locked": false, 71 | "quick_transitions": [ 72 | { 73 | "duration": 300, 74 | "hotkeys": [], 75 | "id": 1, 76 | "name": "Cut" 77 | }, 78 | { 79 | "duration": 300, 80 | "hotkeys": [], 81 | "id": 2, 82 | "name": "Fade" 83 | } 84 | ], 85 | "saved_preview_projectors": [ 86 | { 87 | "saved_preview_projectors": 0 88 | }, 89 | { 90 | "saved_preview_projectors": 0 91 | }, 92 | { 93 | "saved_preview_projectors": 0 94 | }, 95 | { 96 | "saved_preview_projectors": 0 97 | }, 98 | { 99 | "saved_preview_projectors": 0 100 | }, 101 | { 102 | "saved_preview_projectors": 0 103 | }, 104 | { 105 | "saved_preview_projectors": 0 106 | }, 107 | { 108 | "saved_preview_projectors": 0 109 | }, 110 | { 111 | "saved_preview_projectors": 0 112 | }, 113 | { 114 | "saved_preview_projectors": 0 115 | } 116 | ], 117 | "saved_projectors": [ 118 | { 119 | "saved_projectors": "" 120 | }, 121 | { 122 | "saved_projectors": "" 123 | }, 124 | { 125 | "saved_projectors": "" 126 | }, 127 | { 128 | "saved_projectors": "" 129 | }, 130 | { 131 | "saved_projectors": "" 132 | }, 133 | { 134 | "saved_projectors": "" 135 | }, 136 | { 137 | "saved_projectors": "" 138 | }, 139 | { 140 | "saved_projectors": "" 141 | }, 142 | { 143 | "saved_projectors": "" 144 | }, 145 | { 146 | "saved_projectors": "" 147 | } 148 | ], 149 | "scaling_mode": 0, 150 | "scene_order": [ 151 | { 152 | "name": "Scene" 153 | } 154 | ], 155 | "sources": [ 156 | { 157 | "deinterlace_field_order": 0, 158 | "deinterlace_mode": 0, 159 | "enabled": true, 160 | "flags": 0, 161 | "hotkeys": {}, 162 | "id": "text_ft2_source", 163 | "mixers": 0, 164 | "monitoring_type": 0, 165 | "muted": false, 166 | "name": "Active players...", 167 | "push-to-mute": false, 168 | "push-to-mute-delay": 0, 169 | "push-to-talk": false, 170 | "push-to-talk-delay": 0, 171 | "settings": { 172 | "font": { 173 | "face": "VT323", 174 | "flags": 0, 175 | "size": 34, 176 | "style": "Regular" 177 | }, 178 | "from_file": true, 179 | "log_mode": false, 180 | "text_file": "{{.ActivePlayersPath}}" 181 | }, 182 | "sync": 0, 183 | "volume": 1.0 184 | }, 185 | { 186 | "deinterlace_field_order": 0, 187 | "deinterlace_mode": 0, 188 | "enabled": true, 189 | "flags": 0, 190 | "hotkeys": {}, 191 | "id": "text_ft2_source", 192 | "mixers": 0, 193 | "monitoring_type": 0, 194 | "muted": false, 195 | "name": "Total presses...", 196 | "push-to-mute": false, 197 | "push-to-mute-delay": 0, 198 | "push-to-talk": false, 199 | "push-to-talk-delay": 0, 200 | "settings": { 201 | "font": { 202 | "face": "VT323", 203 | "flags": 0, 204 | "size": 34, 205 | "style": "Regular" 206 | }, 207 | "from_file": true, 208 | "text_file": "{{.TotalPressesPath}}" 209 | }, 210 | "sync": 0, 211 | "volume": 1.0 212 | }, 213 | { 214 | "deinterlace_field_order": 0, 215 | "deinterlace_mode": 0, 216 | "enabled": true, 217 | "flags": 0, 218 | "hotkeys": {}, 219 | "id": "text_ft2_source", 220 | "mixers": 0, 221 | "monitoring_type": 0, 222 | "muted": false, 223 | "name": "Most recent presses...", 224 | "push-to-mute": false, 225 | "push-to-mute-delay": 0, 226 | "push-to-talk": false, 227 | "push-to-talk-delay": 0, 228 | "settings": { 229 | "font": { 230 | "face": "VT323", 231 | "flags": 0, 232 | "size": 34, 233 | "style": "Regular" 234 | }, 235 | "from_file": true, 236 | "text_file": "{{.MostRecentPressesPath}}" 237 | }, 238 | "sync": 0, 239 | "volume": 1.0 240 | }, 241 | { 242 | "deinterlace_field_order": 0, 243 | "deinterlace_mode": 0, 244 | "enabled": true, 245 | "flags": 0, 246 | "hotkeys": {}, 247 | "id": "image_source", 248 | "mixers": 0, 249 | "monitoring_type": 0, 250 | "muted": false, 251 | "name": "Footer", 252 | "push-to-mute": false, 253 | "push-to-mute-delay": 0, 254 | "push-to-talk": false, 255 | "push-to-talk-delay": 0, 256 | "settings": { 257 | "file": "{{.ConfigPath}}/assets/footer.png" 258 | }, 259 | "sync": 0, 260 | "volume": 1.0 261 | }, 262 | { 263 | "deinterlace_field_order": 0, 264 | "deinterlace_mode": 0, 265 | "enabled": true, 266 | "flags": 0, 267 | "hotkeys": {}, 268 | "id": "image_source", 269 | "mixers": 0, 270 | "monitoring_type": 0, 271 | "muted": false, 272 | "name": "Controller", 273 | "push-to-mute": false, 274 | "push-to-mute-delay": 0, 275 | "push-to-talk": false, 276 | "push-to-talk-delay": 0, 277 | "settings": { 278 | "file": "{{.ConfigPath}}/assets/controller.png" 279 | }, 280 | "sync": 0, 281 | "volume": 1.0 282 | }, 283 | { 284 | "deinterlace_field_order": 0, 285 | "deinterlace_mode": 0, 286 | "enabled": true, 287 | "flags": 0, 288 | "hotkeys": {}, 289 | "id": "color_source", 290 | "mixers": 0, 291 | "monitoring_type": 0, 292 | "muted": false, 293 | "name": "Background", 294 | "push-to-mute": false, 295 | "push-to-mute-delay": 0, 296 | "push-to-talk": false, 297 | "push-to-talk-delay": 0, 298 | "settings": { 299 | "color": 4279900698 300 | }, 301 | "sync": 0, 302 | "volume": 1.0 303 | }, 304 | { 305 | "deinterlace_field_order": 0, 306 | "deinterlace_mode": 0, 307 | "enabled": true, 308 | "flags": 0, 309 | "hotkeys": { 310 | "OBSBasic.SelectScene": [], 311 | "libobs.hide_scene_item.Active players...": [], 312 | "libobs.hide_scene_item.Background": [], 313 | "libobs.hide_scene_item.Controller": [], 314 | "libobs.hide_scene_item.Footer": [], 315 | "libobs.hide_scene_item.Most recent presses...": [], 316 | "libobs.hide_scene_item.NES Emulator": [], 317 | "libobs.hide_scene_item.Next button press in...": [], 318 | "libobs.hide_scene_item.Total presses...": [], 319 | "libobs.hide_scene_item.Total uptime...": [], 320 | "libobs.hide_scene_item.Vote breakdown...": [], 321 | "libobs.show_scene_item.Active players...": [], 322 | "libobs.show_scene_item.Background": [], 323 | "libobs.show_scene_item.Controller": [], 324 | "libobs.show_scene_item.Footer": [], 325 | "libobs.show_scene_item.Most recent presses...": [], 326 | "libobs.show_scene_item.NES Emulator": [], 327 | "libobs.show_scene_item.Next button press in...": [], 328 | "libobs.show_scene_item.Total presses...": [], 329 | "libobs.show_scene_item.Total uptime...": [], 330 | "libobs.show_scene_item.Vote breakdown...": [] 331 | }, 332 | "id": "scene", 333 | "mixers": 0, 334 | "monitoring_type": 0, 335 | "muted": false, 336 | "name": "Scene", 337 | "push-to-mute": false, 338 | "push-to-mute-delay": 0, 339 | "push-to-talk": false, 340 | "push-to-talk-delay": 0, 341 | "settings": { 342 | "items": [ 343 | { 344 | "align": 5, 345 | "bounds": { 346 | "x": 0.0, 347 | "y": 0.0 348 | }, 349 | "bounds_align": 0, 350 | "bounds_type": 0, 351 | "crop_bottom": 0, 352 | "crop_left": 0, 353 | "crop_right": 0, 354 | "crop_top": 0, 355 | "name": "Background", 356 | "pos": { 357 | "x": 0.0, 358 | "y": 0.0 359 | }, 360 | "rot": 0.0, 361 | "scale": { 362 | "x": 3.5999999046325684, 363 | "y": 3.5999999046325684 364 | }, 365 | "scale_filter": "disable", 366 | "visible": true 367 | }, 368 | { 369 | "align": 5, 370 | "bounds": { 371 | "x": 0.0, 372 | "y": 0.0 373 | }, 374 | "bounds_align": 0, 375 | "bounds_type": 0, 376 | "crop_bottom": 0, 377 | "crop_left": 0, 378 | "crop_right": 0, 379 | "crop_top": 0, 380 | "name": "Total uptime...", 381 | "pos": { 382 | "x": 764.0, 383 | "y": 665.0 384 | }, 385 | "rot": 0.0, 386 | "scale": { 387 | "x": 1.0, 388 | "y": 1.0 389 | }, 390 | "scale_filter": "disable", 391 | "visible": true 392 | }, 393 | { 394 | "align": 5, 395 | "bounds": { 396 | "x": 0.0, 397 | "y": 0.0 398 | }, 399 | "bounds_align": 0, 400 | "bounds_type": 0, 401 | "crop_bottom": 0, 402 | "crop_left": 0, 403 | "crop_right": 0, 404 | "crop_top": 0, 405 | "name": "Total presses...", 406 | "pos": { 407 | "x": 764.0, 408 | "y": 615.0 409 | }, 410 | "rot": 0.0, 411 | "scale": { 412 | "x": 1.0, 413 | "y": 1.0 414 | }, 415 | "scale_filter": "disable", 416 | "visible": true 417 | }, 418 | { 419 | "align": 5, 420 | "bounds": { 421 | "x": 0.0, 422 | "y": 0.0 423 | }, 424 | "bounds_align": 0, 425 | "bounds_type": 0, 426 | "crop_bottom": 0, 427 | "crop_left": 0, 428 | "crop_right": 0, 429 | "crop_top": 0, 430 | "name": "Active players...", 431 | "pos": { 432 | "x": 766.0, 433 | "y": 564.0 434 | }, 435 | "rot": 0.0, 436 | "scale": { 437 | "x": 1.0, 438 | "y": 1.0 439 | }, 440 | "scale_filter": "disable", 441 | "visible": true 442 | }, 443 | { 444 | "align": 5, 445 | "bounds": { 446 | "x": 0.0, 447 | "y": 0.0 448 | }, 449 | "bounds_align": 0, 450 | "bounds_type": 0, 451 | "crop_bottom": 0, 452 | "crop_left": 0, 453 | "crop_right": 0, 454 | "crop_top": 0, 455 | "name": "Most recent presses...", 456 | "pos": { 457 | "x": 767.0, 458 | "y": 483.0 459 | }, 460 | "rot": 0.0, 461 | "scale": { 462 | "x": 1.0, 463 | "y": 1.0 464 | }, 465 | "scale_filter": "disable", 466 | "visible": true 467 | }, 468 | { 469 | "align": 5, 470 | "bounds": { 471 | "x": 0.0, 472 | "y": 0.0 473 | }, 474 | "bounds_align": 0, 475 | "bounds_type": 0, 476 | "crop_bottom": 0, 477 | "crop_left": 0, 478 | "crop_right": 0, 479 | "crop_top": 0, 480 | "name": "Vote breakdown...", 481 | "pos": { 482 | "x": 767.0, 483 | "y": 341.10000610351562 484 | }, 485 | "rot": 0.0, 486 | "scale": { 487 | "x": 1.0, 488 | "y": 1.0 489 | }, 490 | "scale_filter": "disable", 491 | "visible": true 492 | }, 493 | { 494 | "align": 5, 495 | "bounds": { 496 | "x": 0.0, 497 | "y": 0.0 498 | }, 499 | "bounds_align": 0, 500 | "bounds_type": 0, 501 | "crop_bottom": 0, 502 | "crop_left": 0, 503 | "crop_right": 0, 504 | "crop_top": 0, 505 | "name": "Next button press in...", 506 | "pos": { 507 | "x": 767.0, 508 | "y": 294.0 509 | }, 510 | "rot": 0.0, 511 | "scale": { 512 | "x": 1.0322580337524414, 513 | "y": 1.0333333015441895 514 | }, 515 | "scale_filter": "disable", 516 | "visible": true 517 | }, 518 | { 519 | "align": 5, 520 | "bounds": { 521 | "x": 0.0, 522 | "y": 0.0 523 | }, 524 | "bounds_align": 0, 525 | "bounds_type": 0, 526 | "crop_bottom": 0, 527 | "crop_left": 0, 528 | "crop_right": 0, 529 | "crop_top": 0, 530 | "name": "Controller", 531 | "pos": { 532 | "x": 765.0, 533 | "y": 20.0 534 | }, 535 | "rot": 0.0, 536 | "scale": { 537 | "x": 0.10035087913274765, 538 | "y": 0.10040485858917236 539 | }, 540 | "scale_filter": "disable", 541 | "visible": true 542 | }, 543 | { 544 | "align": 5, 545 | "bounds": { 546 | "x": 0.0, 547 | "y": 0.0 548 | }, 549 | "bounds_align": 0, 550 | "bounds_type": 0, 551 | "crop_bottom": 0, 552 | "crop_left": 0, 553 | "crop_right": 0, 554 | "crop_top": 0, 555 | "name": "NES Emulator", 556 | "pos": { 557 | "x": 0.0, 558 | "y": 0.0 559 | }, 560 | "rot": 0.0, 561 | "scale": { 562 | "x": 1.0, 563 | "y": 1.0 564 | }, 565 | "scale_filter": "disable", 566 | "visible": true 567 | }, 568 | { 569 | "align": 5, 570 | "bounds": { 571 | "x": 0.0, 572 | "y": 0.0 573 | }, 574 | "bounds_align": 0, 575 | "bounds_type": 0, 576 | "crop_bottom": 0, 577 | "crop_left": 0, 578 | "crop_right": 0, 579 | "crop_top": 0, 580 | "name": "Footer", 581 | "pos": { 582 | "x": 287.0, 583 | "y": 696.0 584 | }, 585 | "rot": 0.0, 586 | "scale": { 587 | "x": 0.11203007400035858, 588 | "y": 0.11081080883741379 589 | }, 590 | "scale_filter": "disable", 591 | "visible": true 592 | } 593 | ] 594 | }, 595 | "sync": 0, 596 | "volume": 1.0 597 | }, 598 | { 599 | "deinterlace_field_order": 0, 600 | "deinterlace_mode": 0, 601 | "enabled": true, 602 | "flags": 0, 603 | "hotkeys": {}, 604 | "id": "xcomposite_input", 605 | "mixers": 0, 606 | "monitoring_type": 0, 607 | "muted": false, 608 | "name": "NES Emulator", 609 | "push-to-mute": false, 610 | "push-to-mute-delay": 0, 611 | "push-to-talk": false, 612 | "push-to-talk-delay": 0, 613 | "settings": { 614 | "capture_window": "52428807\r\nfinal_fantasy.nes\r\nNES", 615 | "swap_redblue": true 616 | }, 617 | "sync": 0, 618 | "volume": 1.0 619 | }, 620 | { 621 | "deinterlace_field_order": 0, 622 | "deinterlace_mode": 0, 623 | "enabled": true, 624 | "flags": 0, 625 | "hotkeys": {}, 626 | "id": "text_ft2_source", 627 | "mixers": 0, 628 | "monitoring_type": 0, 629 | "muted": false, 630 | "name": "Next button press in...", 631 | "push-to-mute": false, 632 | "push-to-mute-delay": 0, 633 | "push-to-talk": false, 634 | "push-to-talk-delay": 0, 635 | "settings": { 636 | "custom_width": 0, 637 | "font": { 638 | "face": "VT323", 639 | "flags": 0, 640 | "size": 34, 641 | "style": "Regular" 642 | }, 643 | "from_file": true, 644 | "text": "Next button press in:", 645 | "text_file": "{{.NextButtonPressPath}}" 646 | }, 647 | "sync": 0, 648 | "volume": 1.0 649 | }, 650 | { 651 | "deinterlace_field_order": 0, 652 | "deinterlace_mode": 0, 653 | "enabled": true, 654 | "flags": 0, 655 | "hotkeys": {}, 656 | "id": "text_ft2_source", 657 | "mixers": 0, 658 | "monitoring_type": 0, 659 | "muted": false, 660 | "name": "Vote breakdown...", 661 | "push-to-mute": false, 662 | "push-to-mute-delay": 0, 663 | "push-to-talk": false, 664 | "push-to-talk-delay": 0, 665 | "settings": { 666 | "font": { 667 | "face": "VT323", 668 | "flags": 0, 669 | "size": 34, 670 | "style": "Regular" 671 | }, 672 | "from_file": true, 673 | "text_file": "{{.VoteBreakdownPath}}" 674 | }, 675 | "sync": 0, 676 | "volume": 1.0 677 | }, 678 | { 679 | "deinterlace_field_order": 0, 680 | "deinterlace_mode": 0, 681 | "enabled": true, 682 | "flags": 0, 683 | "hotkeys": {}, 684 | "id": "text_ft2_source", 685 | "mixers": 0, 686 | "monitoring_type": 0, 687 | "muted": false, 688 | "name": "Total uptime...", 689 | "push-to-mute": false, 690 | "push-to-mute-delay": 0, 691 | "push-to-talk": false, 692 | "push-to-talk-delay": 0, 693 | "settings": { 694 | "font": { 695 | "face": "VT323", 696 | "flags": 0, 697 | "size": 34, 698 | "style": "Regular" 699 | }, 700 | "from_file": true, 701 | "text_file": "{{.TotalUptimePath}}" 702 | }, 703 | "sync": 0, 704 | "volume": 1.0 705 | } 706 | ], 707 | "transition_duration": 300, 708 | "transitions": [] 709 | } 710 | --------------------------------------------------------------------------------