├── .gitignore ├── README.md ├── doc ├── musshi-good-health.png └── musshi-heart-attack.png ├── go.mod ├── go.sum ├── main.go ├── musshi ├── activity.go ├── condition.go ├── heart.go ├── heart_test.go └── musshi.go └── scenes ├── activity.go ├── ascii_components.go ├── end.go ├── game.go ├── scene.go ├── start.go └── styles.go /.gitignore: -------------------------------------------------------------------------------- 1 | .ssh -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Musshi's Heart 2 | You drive the heart oh a little creature called: Musshi. 3 | 4 | Developed for the [50th Ludum Dare Game Jam: Delay The Inevitable](https://ldjam.com/events/ludum-dare/50/musshis-heart) 5 | 6 | ![screen](doc/musshi-good-health.png) 7 | 8 | ## Goal 9 | 10 | Every Musshi wants to find love and die peacefully, but they are very fragile. The goal is to maintain heartbeats per minute close to the Musshi's needs. Every heart failure decreases Musshi's life expectancy. Are you gonna make it survive long enough to find love ? 11 | 12 | ## Compile & Install 13 | 14 | ``` 15 | git clone git@github.com:yanc0/musshis-heart.git 16 | cd musshis-heart/ 17 | go build 18 | ``` 19 | 20 | ## Play locally 21 | 22 | ``` 23 | ssh -p 2222 localhost 24 | ``` 25 | 26 | ## License 27 | 28 | This component is released to the public domain by the author. 29 | -------------------------------------------------------------------------------- /doc/musshi-good-health.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yanc0/musshis-heart/2ade6d9b4fa510ced9af811e360d795c804f28a9/doc/musshi-good-health.png -------------------------------------------------------------------------------- /doc/musshi-heart-attack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yanc0/musshis-heart/2ade6d9b4fa510ced9af811e360d795c804f28a9/doc/musshi-heart-attack.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yanc0/musshis-heart 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v0.20.0 7 | github.com/charmbracelet/lipgloss v0.5.0 8 | github.com/charmbracelet/wish v0.3.0 9 | github.com/gliderlabs/ssh v0.3.3 10 | github.com/guptarohit/asciigraph v0.5.3 11 | ) 12 | 13 | require ( 14 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 15 | github.com/charmbracelet/keygen v0.2.1 // indirect 16 | github.com/containerd/console v1.0.3 // indirect 17 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 18 | github.com/mattn/go-isatty v0.0.14 // indirect 19 | github.com/mattn/go-runewidth v0.0.13 // indirect 20 | github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect 21 | github.com/mitchellh/go-homedir v1.1.0 // indirect 22 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 23 | github.com/muesli/reflow v0.3.0 // indirect 24 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect 25 | github.com/rivo/uniseg v0.2.0 // indirect 26 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect 27 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect 28 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 2 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 3 | github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= 4 | github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= 5 | github.com/charmbracelet/keygen v0.2.1 h1:H1yYTVe6qIDz+UILYXo6q+qLQNkvyXXA5KEhzyuEfzg= 6 | github.com/charmbracelet/keygen v0.2.1/go.mod h1:kFQ3Cvop12fXWX1K29vxDxV9x8ujG4wBSXq//GySSSk= 7 | github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= 8 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= 9 | github.com/charmbracelet/wish v0.3.0 h1:DsQ7wC5BfiQz07iOGiWB4GWJM53O4BPt3koO1Gqxw5w= 10 | github.com/charmbracelet/wish v0.3.0/go.mod h1:KB8u7Bp4a/akXmGVqy+R/2OhMxgNoeJ+3lDgSgBrog4= 11 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 12 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 13 | github.com/gliderlabs/ssh v0.3.3 h1:mBQ8NiOgDkINJrZtoizkC3nDNYgSaWtxyem6S2XHBtA= 14 | github.com/gliderlabs/ssh v0.3.3/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914= 15 | github.com/guptarohit/asciigraph v0.5.3 h1:jtVQFC7KX6cQowPntN6rtXmI9O+FuXNeVa3fzf8t120= 16 | github.com/guptarohit/asciigraph v0.5.3/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= 17 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 18 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 19 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 20 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 21 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 22 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 23 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 24 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 25 | github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE= 26 | github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ= 27 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 28 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 29 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 30 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 31 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 32 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 33 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 34 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 35 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= 36 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 37 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 38 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 39 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 40 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 41 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= 42 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 43 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 44 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 45 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 46 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= 49 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 51 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 52 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 53 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 54 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 55 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 56 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // An example Bubble Tea server. This will put an ssh session into alt screen 4 | // and continually print up to date terminal information. 5 | 6 | import ( 7 | "context" 8 | "flag" 9 | "fmt" 10 | "log" 11 | "os" 12 | "os/signal" 13 | "syscall" 14 | "time" 15 | 16 | tea "github.com/charmbracelet/bubbletea" 17 | "github.com/charmbracelet/wish" 18 | bm "github.com/charmbracelet/wish/bubbletea" 19 | lm "github.com/charmbracelet/wish/logging" 20 | "github.com/gliderlabs/ssh" 21 | "github.com/yanc0/musshis-heart/musshi" 22 | "github.com/yanc0/musshis-heart/scenes" 23 | ) 24 | 25 | var host = flag.String("host", "0.0.0.0", "host to listen on") 26 | var port = flag.Int("port", 2222, "port to listen on") 27 | 28 | func main() { 29 | flag.Parse() 30 | s, err := wish.NewServer( 31 | wish.WithAddress(fmt.Sprintf("%s:%d", *host, *port)), 32 | wish.WithHostKeyPath(".ssh/term_info_ed25519"), 33 | wish.WithMiddleware( 34 | bm.Middleware(teaHandler), 35 | lm.Middleware(), 36 | ), 37 | ) 38 | if err != nil { 39 | log.Fatalln(err) 40 | } 41 | 42 | done := make(chan os.Signal, 1) 43 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 44 | log.Printf("Starting SSH server on %s:%d", *host, *port) 45 | go func() { 46 | if err = s.ListenAndServe(); err != nil { 47 | log.Fatalln(err) 48 | } 49 | }() 50 | 51 | go func() { 52 | ticker := time.NewTicker(100 * time.Millisecond) 53 | for range ticker.C { 54 | } 55 | }() 56 | 57 | <-done 58 | log.Println("Stopping SSH server") 59 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 60 | defer func() { cancel() }() 61 | if err := s.Shutdown(ctx); err != nil { 62 | log.Fatalln(err) 63 | } 64 | } 65 | 66 | func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { 67 | pty, _, active := s.Pty() 68 | if !active { 69 | fmt.Println("no active terminal, skipping") 70 | return nil, nil 71 | } 72 | m := &gameState{ 73 | term: pty.Term, 74 | width: pty.Window.Width, 75 | height: pty.Window.Height, 76 | started: false, 77 | } 78 | return m, []tea.ProgramOption{tea.WithAltScreen()} 79 | } 80 | 81 | type gameState struct { 82 | term string 83 | width int 84 | height int 85 | 86 | started bool 87 | ended bool 88 | 89 | musshi *musshi.Musshi 90 | } 91 | 92 | func (m gameState) Init() tea.Cmd { 93 | return tickCmd() 94 | } 95 | 96 | type tickMsg time.Time 97 | 98 | func tickCmd() tea.Cmd { 99 | return tea.Every(time.Millisecond*100, func(t time.Time) tea.Msg { 100 | return tickMsg(t) 101 | }) 102 | } 103 | 104 | func (m gameState) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 105 | switch msg := msg.(type) { 106 | case tea.WindowSizeMsg: 107 | m.height = msg.Height 108 | m.width = msg.Width 109 | case tea.KeyMsg: 110 | switch msg.String() { 111 | case "q", "ctrl+c": 112 | return m, tea.Quit 113 | case "r": 114 | startedAt := time.Now() 115 | m.musshi = musshi.NewMusshi() 116 | // create musshi with two beats 117 | m.musshi.Heart.Beat(startedAt.Add(time.Second * -1)) 118 | m.musshi.Heart.Beat(startedAt) 119 | m.started = true 120 | m.ended = false 121 | return m, nil 122 | default: 123 | if m.ended { 124 | return m, nil 125 | } 126 | if !m.started { 127 | startedAt := time.Now() 128 | m.musshi = musshi.NewMusshi() 129 | // create musshi with two beats 130 | m.musshi.Heart.Beat(startedAt.Add(time.Second * -1)) 131 | m.musshi.Heart.Beat(startedAt) 132 | m.started = true 133 | m.ended = false 134 | return m, nil 135 | } 136 | m.musshi.Heart.Beat(time.Now()) 137 | if !m.musshi.Alive() { 138 | m.musshi.SetDeathTime(time.Now()) 139 | m.ended = true 140 | m.started = false 141 | } 142 | } 143 | case tickMsg: 144 | if m.started { 145 | m.musshi.AlterLifeTimeExpectancy() 146 | if !m.musshi.Alive() { 147 | m.musshi.SetDeathTime(time.Now()) 148 | m.ended = true 149 | m.started = false 150 | } 151 | } 152 | 153 | return m, tickCmd() 154 | } 155 | 156 | return m, nil 157 | } 158 | 159 | func (m gameState) View() string { 160 | if m.ended { 161 | return scenes.NewEnd(scenes.EndParams{ 162 | Width: m.width, 163 | Height: m.height, 164 | Musshi: m.musshi, 165 | }).Render() 166 | } 167 | if !m.started { 168 | return scenes.NewStart(scenes.StartParams{ 169 | Width: m.width, 170 | Height: m.height, 171 | }).Render() 172 | } 173 | 174 | return scenes.NewGame(scenes.GameParams{ 175 | Width: m.width, 176 | Height: m.height, 177 | Musshi: m.musshi, 178 | }).Render() 179 | 180 | } 181 | -------------------------------------------------------------------------------- /musshi/activity.go: -------------------------------------------------------------------------------- 1 | package musshi 2 | 3 | type Activity string 4 | 5 | const ( 6 | Sleeping Activity = "sleeping" 7 | Playing Activity = "playing" 8 | Loving Activity = "reproducing" 9 | Dying Activity = "dying" 10 | Dead Activity = "dead" 11 | ) 12 | 13 | func (a Activity) idealBPM() int { 14 | switch a { 15 | case Sleeping: 16 | return 60 17 | case Playing: 18 | return 135 19 | case Loving: 20 | return 400 21 | case Dying: 22 | return 20 23 | } 24 | return 0 25 | } 26 | -------------------------------------------------------------------------------- /musshi/condition.go: -------------------------------------------------------------------------------- 1 | package musshi 2 | 3 | type Condition string 4 | 5 | const ( 6 | VeryHighBPM Condition = "VeryHighBPM" 7 | TooHighBPM Condition = "TooHighBPM" 8 | TooLowBPM Condition = "TooLowBPM" 9 | VeryLowBPM Condition = "VeryLowBPM" 10 | 11 | idealBPM Condition = "idealBPM" 12 | Unknown Condition = "Unknown" 13 | ) 14 | 15 | func (c Condition) IsGood() bool { 16 | if c == idealBPM { 17 | return true 18 | } 19 | return false 20 | } 21 | -------------------------------------------------------------------------------- /musshi/heart.go: -------------------------------------------------------------------------------- 1 | package musshi 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | type Heart struct { 9 | mu *sync.Mutex 10 | beats []time.Time 11 | } 12 | 13 | func NewHeart() *Heart { 14 | return &Heart{ 15 | mu: &sync.Mutex{}, 16 | beats: make([]time.Time, 0), 17 | } 18 | } 19 | 20 | func (h *Heart) Beat(t time.Time) { 21 | h.mu.Lock() 22 | defer h.mu.Unlock() 23 | h.beats = append(h.beats, t) 24 | } 25 | 26 | func (h *Heart) JustBeaten() bool { 27 | return time.Since(h.beats[len(h.beats)]) < 100*time.Millisecond 28 | } 29 | 30 | func (h *Heart) Electrocardiogram() []float64 { 31 | var ecg = make([]float64, 0) 32 | end := time.Now() 33 | start := end.Add(-6 * time.Second) 34 | 35 | for i := 0; i < 30; i++ { 36 | end = start.Add(200 * time.Millisecond) 37 | toAdd := []float64{1, 1} 38 | if h.hasBeatenBetween(start, end) { 39 | toAdd = []float64{5, -1} 40 | } 41 | ecg = append(ecg, toAdd...) 42 | start = end 43 | } 44 | return ecg 45 | } 46 | 47 | func (h *Heart) hasBeatenBetween(start time.Time, end time.Time) bool { 48 | for _, beat := range h.beats { 49 | if start.Before(beat) && end.After(beat) { 50 | return true 51 | } 52 | } 53 | return false 54 | } 55 | 56 | func (h *Heart) BeatsPerMinute() int { 57 | h.mu.Lock() 58 | defer h.mu.Unlock() 59 | 60 | // keep only the last 6 seconds beats 61 | lastBeats := make([]time.Time, 0) 62 | for _, beat := range h.beats { 63 | if time.Since(beat) < time.Second*6 { 64 | lastBeats = append(lastBeats, beat) 65 | } 66 | } 67 | 68 | h.beats = lastBeats 69 | 70 | numBeats := len(h.beats) 71 | 72 | if numBeats <= 1 { 73 | return 0 74 | } 75 | 76 | if numBeats <= 2 { 77 | return int(time.Minute / h.beats[numBeats-1].Sub(h.beats[numBeats-2])) 78 | } 79 | 80 | if numBeats <= 3 { 81 | return (int(time.Minute/h.beats[numBeats-1].Sub(h.beats[numBeats-2])) + 82 | int(time.Minute/h.beats[numBeats-2].Sub(h.beats[numBeats-3]))) / 2 83 | } 84 | 85 | return (int(time.Minute/h.beats[numBeats-1].Sub(h.beats[numBeats-2])) + 86 | int(time.Minute/h.beats[numBeats-2].Sub(h.beats[numBeats-3])) + 87 | int(time.Minute/h.beats[numBeats-3].Sub(h.beats[numBeats-4]))) / 3 88 | 89 | } 90 | -------------------------------------------------------------------------------- /musshi/heart_test.go: -------------------------------------------------------------------------------- 1 | package musshi_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/yanc0/musshis-heart/musshi" 8 | ) 9 | 10 | func TestHeartbeatsPerMinute(t *testing.T) { 11 | heart := musshi.NewHeart() 12 | 13 | start := time.Time{} 14 | 15 | if bpm := heart.BeatsPerMinute(); bpm != 0 { 16 | t.Fatalf("unexpected beats per minute, wants %d, got %d\n", 0, bpm) 17 | } 18 | 19 | for i := 0; i < 10; i++ { 20 | heart.Beat(start.Add(time.Duration(i) * time.Second)) 21 | } 22 | 23 | if bpm := heart.BeatsPerMinute(); bpm != 60 { 24 | t.Fatalf("unexpected beats per minute, wants %d, got %d\n", 60, bpm) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /musshi/musshi.go: -------------------------------------------------------------------------------- 1 | package musshi 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | type Musshi struct { 9 | Heart *Heart 10 | IdealBeatsPerMinute int 11 | LifeTimeExpectancy time.Duration 12 | lastAltered time.Time 13 | BornAt time.Time 14 | DeadAt time.Time 15 | random int 16 | } 17 | 18 | func NewMusshi() *Musshi { 19 | rand.Seed(time.Now().UnixNano()) 20 | random := rand.Int() 21 | return &Musshi{ 22 | Heart: NewHeart(), 23 | IdealBeatsPerMinute: Sleeping.idealBPM(), 24 | LifeTimeExpectancy: time.Second * 130, 25 | lastAltered: time.Now(), 26 | BornAt: time.Now(), 27 | DeadAt: time.Time{}, 28 | random: random % 10, 29 | } 30 | } 31 | 32 | func (m *Musshi) GetIdealBPM() int { 33 | return m.Activity().idealBPM() + m.random 34 | } 35 | 36 | func (m *Musshi) Age() time.Duration { 37 | return time.Since(m.BornAt) 38 | } 39 | 40 | func (m *Musshi) GetCondition() Condition { 41 | difference := float64(m.Heart.BeatsPerMinute()) / float64(m.Activity().idealBPM()) 42 | 43 | if difference > 2 { 44 | return VeryHighBPM 45 | } 46 | if difference > 1.2 { 47 | return TooHighBPM 48 | } 49 | if difference < 0.1 { 50 | return VeryLowBPM 51 | } 52 | if difference < 0.8 { 53 | return TooLowBPM 54 | } 55 | 56 | return idealBPM 57 | } 58 | 59 | func (m *Musshi) AlterLifeTimeExpectancy() { 60 | if time.Since(m.lastAltered) < time.Second { 61 | return 62 | } 63 | if !m.GetCondition().IsGood() { 64 | m.lastAltered = time.Now() 65 | 66 | if m.GetCondition() == VeryLowBPM || m.GetCondition() == VeryHighBPM { 67 | m.LifeTimeExpectancy -= time.Second * 3 68 | return 69 | } 70 | m.LifeTimeExpectancy -= time.Second * 1 71 | 72 | } 73 | } 74 | 75 | func (m *Musshi) Activity() Activity { 76 | switch { 77 | case m.Age() < time.Second*20: 78 | return Sleeping 79 | case m.Age() < time.Second*45: 80 | return Playing 81 | case m.Age() < time.Second*60: 82 | return Sleeping 83 | case m.Age() < time.Second*70: 84 | return Playing 85 | case m.Age() < time.Second*80: 86 | return Sleeping 87 | case m.Age() < time.Second*100: 88 | return Loving 89 | case m.Age() < time.Second*110: 90 | return Sleeping 91 | } 92 | return Dying 93 | } 94 | 95 | func (m *Musshi) Alive() bool { 96 | return m.Age() < m.LifeTimeExpectancy 97 | } 98 | 99 | func (m *Musshi) SetDeathTime(t time.Time) { 100 | m.DeadAt = t 101 | } 102 | -------------------------------------------------------------------------------- /scenes/activity.go: -------------------------------------------------------------------------------- 1 | package scenes 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/yanc0/musshis-heart/musshi" 7 | ) 8 | 9 | func describeActivity(m *musshi.Musshi) string { 10 | switch m.Activity() { 11 | case musshi.Sleeping: 12 | return fmt.Sprintf("Your Musshi sleeps quietly.\nYou can rest at around %d BPM.", m.GetIdealBPM()) 13 | case musshi.Playing: 14 | return fmt.Sprintf("Your Musshi plays with his friends and burns energy.\nYou gotta keep the beat around %d BPM.", m.GetIdealBPM()) 15 | case musshi.Loving: 16 | return fmt.Sprintf("Your Musshi has found love.\nYou must beat wildly at around %d BPM.", m.GetIdealBPM()) 17 | case musshi.Dying: 18 | return fmt.Sprintf("Your Musshi had a great life.\nYou let him go slowly at around %d BPM.", m.GetIdealBPM()) 19 | default: 20 | return string(m.Activity()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /scenes/ascii_components.go: -------------------------------------------------------------------------------- 1 | package scenes 2 | 3 | var heartComponent = `░███░░░███░ 4 | █████░█████ 5 | ░█████████░ 6 | ░░███████░░ 7 | ░░░░███░░░░` 8 | 9 | var sleepingMusshi = `░░░░░░░░░░░░░░░░░░░░░░░░████████ 10 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ 11 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░██░░ 12 | ░░░░░░░░░░░░░░░░░░░░░░░░░░███░░░ 13 | ░░░░░░░░░░░░░░░░░░░░░░░░████████ 14 | ░░░░░░░░░░░░██████░░░░░░░░░░░░░░ 15 | ░░░░░░░░█████░░░░████░░░░░░░░░░░ 16 | ░░░░░████░░░░░░░░░░░█████░░░░░░░ 17 | ░░░░██░░░░░░░░░░░░░░░░░░███░░░░░ 18 | ░░░██░░░░░█████░░░████░░░░██░░░░ 19 | ░░██░░░░░██░░░██░██░░██░░░░██░░░ 20 | ░░█░░░░░░█░░░░░█░█░░░░█░░░░░█░░░ 21 | ░██░░░░░░░░░░░░░░░░░░░░░░░░░░█░░ 22 | ░█░░░░░░░░░░░░░░░░░░░░░░░░░░░██░ 23 | ░█░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░ 24 | ░█░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░ 25 | ░██████████████████████████████░ 26 | ` 27 | 28 | var playingMusshi = `░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 29 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 30 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 31 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 32 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 33 | ░░░░░░░░░░░░██████░░░░░░░░░░░░░░ 34 | ░░░░░░░░█████░░░░████░░░░░░░░░░░ 35 | ░░░░░████░░░░░░░░░░░░████░░░░░░░ 36 | ░░░░██░░░░█████░░█████░░███░░░░░ 37 | ░░░██░░░░░█░░░█░░█░░░█░░░░██░░░░ 38 | ░░██░░░░░░█░░░█░░█░░░█░░░░░██░░░ 39 | ░░█░░░░░░░█████░░█████░░░░░░█░░░ 40 | ░██░░░░░░░░░░░░░░░░░░░░░░░░░░█░░ 41 | ░█░░░░░░░░░█░░░░░░░█░░░░░░░░░██░ 42 | ░█░░░░░░░░░░███████░░░░░░░░░░░█░ 43 | ░█░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░ 44 | ░██████████████████████████████░ 45 | ` 46 | var lovingMusshi = `░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 47 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 48 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 49 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 50 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 51 | ░░░░░░░░░░░░██████░░░░░░░░░░░░░░ 52 | ░░░░░░░░█████░░░░████░░░░░░░░░░░ 53 | ░░░░░████░░░░░░░░░░░█████░░░░░░░ 54 | ░░░░██░░░░░░░░░░░░░░░░░░███░░░░░ 55 | ░░░██░░░██░██░░░░██░██░░░░██░░░░ 56 | ░░██░░░██░█░██░░██░█░██░░░░██░░░ 57 | ░░█░░░░░█░░░█░░░░█░░░█░░░░░░█░░░ 58 | ░██░░░░░░█░█░███░░█░█░░░░░░░░█░░ 59 | ░█░░░░░░░░█░░█░█░░░█░░░░░░░░░██░ 60 | ░█░░░░░░░░░░░███░░░░░░░░░░░░░░█░ 61 | ░█░░░░░░░░░░░░░░░░░░░░░░░░░░░░█░ 62 | ░██████████████████████████████░ 63 | ` -------------------------------------------------------------------------------- /scenes/end.go: -------------------------------------------------------------------------------- 1 | package scenes 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/yanc0/musshis-heart/musshi" 9 | ) 10 | 11 | type End struct { 12 | width int 13 | height int 14 | musshi *musshi.Musshi 15 | } 16 | 17 | type EndParams struct { 18 | Width int 19 | Height int 20 | Musshi *musshi.Musshi 21 | } 22 | 23 | func NewEnd(params EndParams) *End { 24 | return &End{ 25 | width: params.Width, 26 | height: params.Height, 27 | musshi: params.Musshi, 28 | } 29 | } 30 | 31 | func (scene End) Render() string { 32 | doc := strings.Builder{} 33 | 34 | okButton := activeButtonStyle.Render("Restart (r)") 35 | quitButton := buttonStyle.Render("Quit (q)") 36 | 37 | var personalizedMessage string 38 | switch { 39 | case scene.musshi.LifeTimeExpectancy.Seconds() < 35: 40 | personalizedMessage = "The heart was a complete mess." 41 | break 42 | case scene.musshi.LifeTimeExpectancy.Seconds() < 60: 43 | personalizedMessage = "How sad to die so young." 44 | break 45 | case scene.musshi.LifeTimeExpectancy.Seconds() < 80: 46 | personalizedMessage = "So close to love." 47 | break 48 | case scene.musshi.LifeTimeExpectancy.Seconds() < 105: 49 | personalizedMessage = "He found love but died brutally." 50 | break 51 | default: 52 | personalizedMessage = "He found love and had a great life" 53 | break 54 | } 55 | 56 | ageAfterDeath := scene.musshi.DeadAt.Sub(scene.musshi.BornAt) 57 | endMessage := fmt.Sprintf("Your Musshi lived %d seconds.\n%s", int(ageAfterDeath.Seconds()), personalizedMessage) 58 | 59 | message := lipgloss.NewStyle().Width(50).Align(lipgloss.Center).Render(endMessage) 60 | button := lipgloss.JoinHorizontal(lipgloss.Top, okButton, quitButton) 61 | ui := lipgloss.JoinVertical(lipgloss.Center, message, button) 62 | 63 | bxStyle := boxStyle.Copy().BorderForeground(lipgloss.Color("#c2a908")) 64 | dialog := lipgloss.Place(scene.width, scene.height, 65 | lipgloss.Center, lipgloss.Center, 66 | bxStyle.Render(ui), 67 | lipgloss.WithWhitespaceChars("°"), 68 | lipgloss.WithWhitespaceForeground(subtleColor), 69 | ) 70 | 71 | doc.WriteString(dialog) 72 | return doc.String() 73 | } 74 | -------------------------------------------------------------------------------- /scenes/game.go: -------------------------------------------------------------------------------- 1 | package scenes 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/guptarohit/asciigraph" 9 | "github.com/yanc0/musshis-heart/musshi" 10 | ) 11 | 12 | type Game struct { 13 | width int 14 | height int 15 | musshi *musshi.Musshi 16 | } 17 | 18 | type GameParams struct { 19 | Width int 20 | Height int 21 | Musshi *musshi.Musshi 22 | } 23 | 24 | func NewGame(params GameParams) *Game { 25 | return &Game{ 26 | width: params.Width, 27 | height: params.Height, 28 | musshi: params.Musshi, 29 | } 30 | } 31 | 32 | func (scene Game) Render() string { 33 | doc := strings.Builder{} 34 | 35 | heartColor := okColor 36 | var warningMessage string 37 | 38 | switch scene.musshi.GetCondition() { 39 | case musshi.TooHighBPM: 40 | heartColor = warningColor 41 | warningMessage = "Your Musshi is having heart palpitations. Slow down." 42 | case musshi.TooLowBPM: 43 | heartColor = warningColor 44 | warningMessage = "Your Musshi needs a little more blood. Keep pumping." 45 | case musshi.VeryHighBPM: 46 | heartColor = criticalColor 47 | warningMessage = "Your Musshi is having a heart attack !" 48 | case musshi.VeryLowBPM: 49 | heartColor = criticalColor 50 | warningMessage = "Your Musshi is having a stroke !" 51 | 52 | default: 53 | heartColor = okColor 54 | } 55 | 56 | var musshiDraw string 57 | switch scene.musshi.Activity() { 58 | case musshi.Sleeping: 59 | musshiDraw = sleepingMusshi 60 | case musshi.Playing: 61 | musshiDraw = playingMusshi 62 | case musshi.Loving: 63 | musshiDraw = lovingMusshi 64 | case musshi.Dying: 65 | musshiDraw = sleepingMusshi 66 | default: 67 | musshiDraw = sleepingMusshi 68 | } 69 | 70 | lifetimeBox := lipgloss.NewStyle().Align(lipgloss.Right).Padding(1).Foreground(heartColor).Render(fmt.Sprintf("age: %ds / %ds life expectancy", int(scene.musshi.Age().Seconds()), int(scene.musshi.LifeTimeExpectancy.Seconds()))) 71 | warningMessageBox := lipgloss.NewStyle().Align(lipgloss.Right).Padding(1).Foreground(heartColor).Render(warningMessage) 72 | 73 | heartDraw := lipgloss.NewStyle().Align(lipgloss.Center).Foreground(heartColor).Render(heartComponent) 74 | currentBPM := lipgloss.NewStyle().Align(lipgloss.Left).PaddingTop(1).Foreground(heartColor).Render(fmt.Sprintf("BPM: %d", scene.musshi.Heart.BeatsPerMinute())) 75 | heartBox := lipgloss.JoinVertical(lipgloss.Center, heartDraw, currentBPM) 76 | 77 | musshiBox := lipgloss.JoinVertical(lipgloss.Center, musshiDraw, string(describeActivity(scene.musshi))) 78 | 79 | data := append(scene.musshi.Heart.Electrocardiogram(), -1, 5) 80 | ecgPlot := asciigraph.Plot(data, asciigraph.Precision(0)) 81 | 82 | ecgPlot = strings.ReplaceAll(ecgPlot, "┤", "") 83 | ecgPlot = strings.ReplaceAll(ecgPlot, "┼", "") 84 | ecgPlot = strings.ReplaceAll(ecgPlot, "-", " ") 85 | ecgPlot = strings.ReplaceAll(ecgPlot, "1", "") 86 | ecgPlot = strings.ReplaceAll(ecgPlot, "2", "") 87 | ecgPlot = strings.ReplaceAll(ecgPlot, "3", "") 88 | ecgPlot = strings.ReplaceAll(ecgPlot, "4", "") 89 | ecgPlot = strings.ReplaceAll(ecgPlot, "5", "") 90 | ecgPlot = strings.ReplaceAll(ecgPlot, "0", "") 91 | 92 | ecgBox := lipgloss.NewStyle().Align(lipgloss.Left).Render(ecgPlot) 93 | heartBox = lipgloss.JoinHorizontal(lipgloss.Center, ecgBox, heartBox) 94 | 95 | tui := lipgloss.JoinVertical(lipgloss.Center, lifetimeBox, warningMessageBox, musshiBox, heartBox) 96 | 97 | bxStyle := boxStyle.Copy().BorderForeground(lipgloss.Color(heartColor)) 98 | doc.WriteString(lipgloss.Place(scene.width, 0, 99 | lipgloss.Center, lipgloss.Center, 100 | bxStyle.Render(tui), 101 | lipgloss.WithWhitespaceChars(" "), 102 | )) 103 | 104 | return doc.String() 105 | } 106 | -------------------------------------------------------------------------------- /scenes/scene.go: -------------------------------------------------------------------------------- 1 | package scenes 2 | 3 | 4 | type Scene interface{ 5 | Render() string 6 | } -------------------------------------------------------------------------------- /scenes/start.go: -------------------------------------------------------------------------------- 1 | package scenes 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | type Start struct { 10 | width int 11 | height int 12 | } 13 | 14 | type StartParams struct { 15 | Width int 16 | Height int 17 | } 18 | 19 | func NewStart(params StartParams) *Start { 20 | return &Start{ 21 | width: params.Width, 22 | height: params.Height, 23 | } 24 | } 25 | 26 | func (scene Start) Render() string { 27 | doc := strings.Builder{} 28 | 29 | okButton := activeButtonStyle.Render("Begin (Enter)") 30 | quitButton := buttonStyle.Render("Quit (q)") 31 | 32 | welcomeMessage := `You drive the heart of a Musshi. 33 | Beat with any key ❤` 34 | 35 | message := lipgloss.NewStyle().Width(50).Align(lipgloss.Center).Render(welcomeMessage) 36 | button := lipgloss.JoinHorizontal(lipgloss.Top, okButton, quitButton) 37 | ui := lipgloss.JoinVertical(lipgloss.Center, message, button) 38 | 39 | bxStyle := boxStyle.Copy().BorderForeground(lipgloss.Color("#c2a908")) 40 | dialog := lipgloss.Place(scene.width, scene.height, 41 | lipgloss.Center, lipgloss.Center, 42 | bxStyle.Render(ui), 43 | lipgloss.WithWhitespaceChars("°"), 44 | lipgloss.WithWhitespaceForeground(subtleColor), 45 | ) 46 | 47 | doc.WriteString(dialog) 48 | return doc.String() 49 | } 50 | -------------------------------------------------------------------------------- /scenes/styles.go: -------------------------------------------------------------------------------- 1 | package scenes 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var buttonStyle = lipgloss.NewStyle(). 6 | Foreground(lipgloss.Color("#FFF7DB")). 7 | Background(lipgloss.Color("#888B7E")). 8 | Padding(0, 3). 9 | MarginTop(1) 10 | 11 | var activeButtonStyle = buttonStyle.Copy(). 12 | Foreground(lipgloss.Color("#FFF7DB")). 13 | Background(lipgloss.Color("#2dad5e")). 14 | MarginRight(2). 15 | Underline(true) 16 | 17 | var boxStyle = lipgloss.NewStyle(). 18 | Border(lipgloss.RoundedBorder()). 19 | BorderTop(true). 20 | BorderLeft(true). 21 | BorderRight(true). 22 | BorderBottom(true) 23 | 24 | var subtleColor = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} 25 | 26 | var criticalColor = lipgloss.Color("#eb4034") 27 | var warningColor = lipgloss.Color("#ebb734") 28 | var okColor = lipgloss.Color("#13ad05") 29 | --------------------------------------------------------------------------------