├── .gitignore
├── Dockerfile
├── README.MD
├── go.mod
├── go.sum
├── main.go
└── whackboard.go
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | whack
3 | .ssh
4 | .idea
5 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.15-alpine3.12 AS builder
2 |
3 | # Copying over all the files:
4 | COPY . /usr/src/app
5 | WORKDIR /usr/src/app
6 |
7 | # Installing dependencies/
8 | RUN go get -v -t -d ./...
9 |
10 | # Build the app
11 | RUN go build -o app .
12 |
13 | # hadolint ignore=DL3006,DL3007
14 | FROM alpine:latest
15 | WORKDIR /
16 | COPY --from=builder /usr/src/app/app .
17 |
18 | EXPOSE 23234
19 |
20 | CMD ["./app"]
21 |
--------------------------------------------------------------------------------
/README.MD:
--------------------------------------------------------------------------------
1 | # Whack a Mole
2 |
3 | #### But over SSH and multiplayer
4 |
5 | ```shell
6 | ssh whack.pranav.land
7 | ```
8 |
9 |
10 |
11 |
12 | Made by [Ishan](https://github.com/quackduck) and [Pranav](https://github.com/pranavnt) for [CodeDay Seattle](https://event.codeday.org/seattle) 2022 with [Wish](https://github.com/charmbracelet/wish).
13 |
14 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module whack
2 |
3 | go 1.18
4 |
5 | require (
6 | github.com/charmbracelet/bubbletea v0.20.0
7 | github.com/charmbracelet/wish v0.4.0
8 | github.com/gliderlabs/ssh v0.3.4
9 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739
10 | )
11 |
12 | require (
13 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
14 | github.com/caarlos0/sshmarshal v0.1.0 // indirect
15 | github.com/charmbracelet/keygen v0.3.0 // indirect
16 | github.com/charmbracelet/lipgloss v0.4.0 // indirect
17 | github.com/containerd/console v1.0.3 // indirect
18 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
19 | github.com/mattn/go-isatty v0.0.14 // indirect
20 | github.com/mattn/go-runewidth v0.0.13 // 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/rivo/uniseg v0.2.0 // indirect
25 | golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70 // indirect
26 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
27 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed // indirect
28 | )
29 |
--------------------------------------------------------------------------------
/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/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I=
4 | github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=
5 | github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc=
6 | github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM=
7 | github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y=
8 | github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM=
9 | github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g=
10 | github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
11 | github.com/charmbracelet/wish v0.4.0 h1:MLo8JjyvSK1lYkhPCzpI+8pwpKO8cUDP4GZtEoOKu4c=
12 | github.com/charmbracelet/wish v0.4.0/go.mod h1:jRL2Shd80OlP77bR8x3v8PrLCtkYCc/1nUV1eGexNj0=
13 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
14 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
15 | github.com/gliderlabs/ssh v0.3.4 h1:+AXBtim7MTKaLVPgvE+3mhewYRawNLTd+jEEz/wExZw=
16 | github.com/gliderlabs/ssh v0.3.4/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914=
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.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
20 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
21 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
22 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
23 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
24 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
25 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
26 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
27 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
28 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
29 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
30 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
31 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
32 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
33 | github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
34 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
35 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
36 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
37 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
38 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
39 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
40 | golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70 h1:syTAU9FwmvzEoIYMqcPHOcVm4H3U5u90WsvuYgwpETU=
41 | golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
42 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
43 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
44 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
45 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
46 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
47 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
48 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
49 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
50 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
51 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
52 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
53 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "math/rand"
8 | "os"
9 | "os/signal"
10 | "strings"
11 | "sync"
12 | "syscall"
13 | "time"
14 |
15 | tea "github.com/charmbracelet/bubbletea"
16 | "github.com/charmbracelet/wish"
17 | bm "github.com/charmbracelet/wish/bubbletea"
18 | lm "github.com/charmbracelet/wish/logging"
19 | "github.com/gliderlabs/ssh"
20 | "github.com/muesli/termenv"
21 | )
22 |
23 | const (
24 | port = 23234
25 |
26 | rules = `Rules:
27 |
28 | • Clicking a target wins a point for your team
29 |
30 | • Clicking the other team's emoji loses a point and makes water
31 |
32 | • Clicking on water takes away a point`
33 | )
34 |
35 | var (
36 | b = NewBoard()
37 |
38 | fireScore = 0
39 | iceScore = 0
40 | )
41 |
42 | func main() {
43 | rand.Seed(time.Now().UnixNano())
44 | s, err := wish.NewServer(
45 | wish.WithAddress(fmt.Sprintf(":%d", port)),
46 | wish.WithHostKeyPath(".ssh/term_info_ed25519"),
47 | wish.WithMiddleware(
48 | myCustomBubbleteaMiddleware(),
49 | lm.Middleware(),
50 | ),
51 | )
52 | if err != nil {
53 | log.Fatalln(err)
54 | }
55 |
56 | done := make(chan os.Signal, 1)
57 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
58 | log.Printf("Starting SSH server on port %d", port)
59 | go func() {
60 | if err = s.ListenAndServe(); err != nil {
61 | log.Fatalln(err)
62 | }
63 | }()
64 |
65 | <-done
66 | log.Println("Stopping SSH server")
67 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
68 | defer func() { cancel() }()
69 | if err := s.Shutdown(ctx); err != nil {
70 | log.Fatalln(err)
71 | }
72 | }
73 |
74 | var programs = make([]*tea.Program, 0, 100)
75 |
76 | var currTeam = true
77 |
78 | func myCustomBubbleteaMiddleware() wish.Middleware {
79 | teaHandler := func(s ssh.Session) *tea.Program {
80 | m := &model{
81 | team: currTeam,
82 | }
83 | currTeam = !currTeam
84 |
85 | p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen(),
86 | tea.WithMouseCellMotion())
87 |
88 | m.thisProgram = p
89 | programs = append(programs, p)
90 | return p
91 | }
92 | return bm.MiddlewareWithProgramHandler(teaHandler, termenv.ANSI256)
93 | }
94 |
95 | type model struct {
96 | team bool // true means fire
97 | thisProgram *tea.Program
98 | x int
99 | y int
100 |
101 | comment string
102 | }
103 |
104 | func (m model) Init() tea.Cmd {
105 | return nil
106 | }
107 |
108 | // I tried not using this and it _looked_ like it still worked.
109 | var lock = new(sync.Mutex)
110 |
111 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112 | // fmt.Println(fmt.Sprintf("%p", m.thisProgram), msg)
113 | switch msg := msg.(type) {
114 | //case tea.WindowSizeMsg:
115 | // m.height = msg.Height
116 | // m.width = msg.Width
117 | case tea.KeyMsg:
118 | switch msg.String() {
119 | case "q", "ctrl+c":
120 | for i, p := range programs {
121 | if p == m.thisProgram {
122 | programs = append(programs[:i], programs[i+1:]...)
123 | break
124 | }
125 | }
126 | fmt.Println(m.thisProgram, "quitting")
127 | return m, tea.Quit
128 | }
129 | case tea.MouseMsg:
130 | if msg.Type != tea.MouseRelease { // trigger on release only - no dragging allowed
131 | return m, nil
132 | }
133 | m.x = (msg.X - 1) / 2 // divide by 2: each emoji is two cells wide
134 | m.y = msg.Y - 3 // subtract 2: the top two rows are not part of the board and the border isn't either
135 |
136 | m.comment = b.Click(m.x, m.y, m.team)
137 |
138 | lock.Lock()
139 |
140 | for _, p := range programs {
141 | if p == m.thisProgram {
142 | continue
143 | }
144 | p.Send(tea.Msg(true)) // trigger render
145 | }
146 |
147 | lock.Unlock()
148 | }
149 |
150 | return m, nil
151 | }
152 |
153 | var gameDoneMsg = ""
154 |
155 | func (m model) View() string {
156 | if len(gameDoneMsg) > 0 {
157 | l := len([]rune(gameDoneMsg))
158 | return strings.Repeat("\n", height/2) + strings.Repeat(" ", width) + "Game over!\n" + strings.Repeat(" ", width-l/2) + gameDoneMsg + strings.Repeat(" ", width-l/2)
159 | }
160 | t := ""
161 | if m.team {
162 | t = "🔥"
163 | } else {
164 | t = "🧊"
165 | }
166 | return "You're in the " + t + " team! Click on targets to win " + t + "s for your team!\n" +
167 | "\n" +
168 | b.RenderBoard(t, fireScore, iceScore, m.comment) + "\n" + rules
169 | }
170 |
--------------------------------------------------------------------------------
/whackboard.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "math/rand"
5 | "strconv"
6 | "strings"
7 | )
8 |
9 | const (
10 | Tree rune = '🌳'
11 | TreeHot rune = '🌴'
12 | TreeCold rune = '🌲'
13 | Fire rune = '🔥'
14 | Ice rune = '🧊'
15 | Whack rune = '🎯'
16 | Water rune = '💧'
17 | width int = 15
18 | height int = 15
19 | )
20 |
21 | type Board struct {
22 | board [][]rune
23 | whackX int
24 | whackY int
25 | }
26 |
27 | func NewBoard() *Board {
28 | board := make([][]rune, height)
29 | for i := range board {
30 | board[i] = make([]rune, width)
31 | for j := range board[i] {
32 | r := rand.Float64()
33 |
34 | if r < 0.02 {
35 | board[i][j] = Fire
36 | } else if r < 0.04 {
37 | board[i][j] = Ice
38 | } else {
39 | board[i][j] = Tree
40 | }
41 | }
42 | }
43 |
44 | b := &Board{
45 | board: board,
46 | }
47 |
48 | b.Generate()
49 |
50 | return b
51 | }
52 |
53 | func (b *Board) RenderBoard(t string, fireScore, iceScore int, comment string) string {
54 | s := ""
55 | border := "─"
56 | scoreStr := "🔥 " + strconv.Itoa(fireScore) + " 🧊 " + strconv.Itoa(iceScore)
57 |
58 | l := len([]rune(scoreStr))
59 | s += t + strings.Repeat(border, width-l/2-l%2-2) + scoreStr + strings.Repeat(border, width-l/2-2) + t + "\n"
60 |
61 | for _, row := range b.board {
62 | s += "│" + string(row) + "│\n"
63 | }
64 |
65 | l = len([]rune(comment))
66 | s += t + strings.Repeat(border, width-l/2-l%2-1) + comment + strings.Repeat(border, width-l/2-1) + t + "\n"
67 |
68 | return s
69 | }
70 |
71 | func (b *Board) Generate() {
72 | b.whackX = rand.Intn(width)
73 | b.whackY = rand.Intn(height)
74 | if b.board[b.whackY][b.whackX] == Tree || b.board[b.whackY][b.whackX] == TreeHot || b.board[b.whackY][b.whackX] == TreeCold {
75 | b.board[b.whackY][b.whackX] = Whack
76 | return
77 | }
78 | b.Generate()
79 | }
80 |
81 | func (b *Board) Click(x, y int, team bool) string {
82 | if x >= width || y >= height || x < 0 || y < 0 {
83 | return "Out of bounds!"
84 | }
85 |
86 | comment := ""
87 |
88 | if b.board[y][x] == Whack {
89 | if team {
90 | b.board[y][x] = Fire
91 | fireScore++
92 | } else {
93 | b.board[y][x] = Ice
94 | iceScore++
95 | }
96 | comment = "Nice!"
97 | b.Generate()
98 | } else if b.board[y][x] == Fire {
99 | if !team {
100 | b.board[y][x] = Water
101 | iceScore--
102 | comment = "Ouch! You made water!"
103 | }
104 | } else if b.board[y][x] == Ice {
105 | if team {
106 | b.board[y][x] = Water
107 | fireScore--
108 | comment = "Ouch! You made water!"
109 | }
110 | } else if b.board[y][x] == Tree {
111 | if team {
112 | b.board[y][x] = TreeHot
113 | comment = "Palm tree!"
114 | } else {
115 | b.board[y][x] = TreeCold
116 | comment = "Pine tree!"
117 | }
118 | } else if b.board[y][x] == Water {
119 | if team {
120 | fireScore--
121 | comment = "Ouch! Water puts out fire!"
122 | } else {
123 | iceScore--
124 | comment = "Ouch! Water melts ice!"
125 | }
126 | }
127 |
128 | out:
129 | for r, row := range b.board { // detect game end
130 | for c, cell := range row {
131 | if cell == TreeHot || cell == TreeCold || cell == Tree {
132 | break out
133 | }
134 |
135 | if r == len(b.board)-1 && c == len(row)-1 {
136 | s := ""
137 | if fireScore > iceScore {
138 | s += "🔥 wins!"
139 | } else if iceScore > fireScore {
140 | s += "🧊 wins!"
141 | } else {
142 | s += "It's a tie!"
143 | }
144 | gameDoneMsg = s
145 | return ""
146 | }
147 | }
148 | }
149 |
150 | return comment
151 | }
152 |
--------------------------------------------------------------------------------