├── 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 |
--------------------------------------------------------------------------------