diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1c49635 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2025 Ilia Denisov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index be091f9..4cd7123 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,44 @@ # 15 Puzzle game with Go +🚀 [Play in Telegram](https://t.me/puzzle_15_bot?startapp). +Game has [simple rules](https://en.wikipedia.org/wiki/15_puzzle). + +## Description + +The intentions of the project is to create a simple proof-of-concept demonstrating that a [Telegram Mini App](https://core.telegram.org/bots/webapps) can be developed with Go and [WebAssembly](https://go.dev/wiki/WebAssembly). + +✨ Graphics are made with [Ebitengine](https://github.com/hajimehoshi/ebiten). + +Basic "features" include: a splash screen, game move sound, a switchable silent mode without moves count, +a players' rating table, a congratulations screen for achieving 1st place, +a pin-code protected game statistics screen, +and backend API requests secured through [data validation](https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app). + +## Running game + +Local gameplay can be initiated using the following command: + +```shell +go run ./cmd/ui +``` + +A fully functional Mini App requires a web server. +You can use [`Dockerfile`](Dockerfile) to build a Docker Image +containing all necessary artifacts: the server binary, the WebAssemply (wasm) binary, and html page. +Certain environment variables should be set for the server to start: + +| Env | Description | +| - | - | +| **`BOT_TOKEN`** | Telegram Bot [API Token](https://core.telegram.org/bots/tutorial). | +| **`DATA_FILE`** | Path to the file where games data will be stored. | +| **`ACCESS_CODE`** | Pin-code to access the Statistics Screen. | +| **`PROJECT_LINK`** | URL to the project's source code. | +| `SERVER_PORT` | Port for the server to listen for API requests, defaulting to `8080` if not set. | +| `CONTEXT_ROOT` | Server requests URI root path, defaulting to `/` if not set. | +| `STATIC_DIR` | Directory where static files are located, defaulting to the current directory if not set. | + ## Credits -- [Gopher by gheimifurt](https://www.reddit.com/r/golang/comments/xdxb9a/gopher_ascii_art_for_bashrc/). +- [Ebitengine](https://github.com/hajimehoshi/ebiten) game engine by Hajime Hoshi. +- Font by [VileR](http://int10h.org) and [fly_indiz](http://old-dos.ru/index.php?page=files&mode=files&do=show&id=102798). +- ASCII Gopher by [gheimifurt](https://www.reddit.com/r/golang/comments/xdxb9a/gopher_ascii_art_for_bashrc/). diff --git a/internal/puzzle/assets/fonts/README.md b/internal/puzzle/assets/fonts/README.md index 743c5ea..982cec3 100644 --- a/internal/puzzle/assets/fonts/README.md +++ b/internal/puzzle/assets/fonts/README.md @@ -2,5 +2,5 @@ Credits: -- [VileR](http://int10h.org/). +- [VileR](http://int10h.org). - [fly_indiz](http://old-dos.ru/index.php?page=files&mode=files&do=show&id=102798). diff --git a/internal/puzzle/controller.go b/internal/puzzle/controller.go index 75de0e4..baeddce 100644 --- a/internal/puzzle/controller.go +++ b/internal/puzzle/controller.go @@ -139,11 +139,11 @@ func Init(init ...func(*Controller)) error { init[i](c) } c.screens[screenGame] = newGame(c.OnGameStart, c.OnGameSolve, c.UserStatsRequest) - c.screens[screenForm] = newForm(c.MonitoringRequest) + c.screens[screenForm] = newStats(c.MonitoringRequest) c.screens[screenSplash] = newSplash(c.UrlOpener) c.screens[screenDebug], c.debugFn = newDebugOverlay() - c.SetLangCode(langCodeRu) + c.SetLangCode(langCodeEn) return ebiten.RunGame(c) } diff --git a/internal/puzzle/form.go b/internal/puzzle/stats.go similarity index 55% rename from internal/puzzle/form.go rename to internal/puzzle/stats.go index edf4dc5..161abce 100644 --- a/internal/puzzle/form.go +++ b/internal/puzzle/stats.go @@ -15,7 +15,7 @@ const ( codeInputTemplate = `[%s] [%s] [%s] [%s]` ) -type form struct { +type stats struct { dials [dials]*button request func(string) requestSent bool @@ -34,10 +34,10 @@ func codeBox(b byte) string { return "_" } -func newForm(request func(string)) *form { +func newStats(request func(string)) *stats { col := func(i int) int { return (i%3)*puzzleTileSymW + 5 } row := func(i int) int { return (i/3)*puzzleTileSymH + 3 } - f := &form{request: request} + f := &stats{request: request} for i := 1; i < dials; i++ { f.dials[i] = NewButton(stringFn(strconv.Itoa(i)), intFn(col(i-1)), intFn(row(i-1))) } @@ -45,20 +45,20 @@ func newForm(request func(string)) *form { return f } -func (f *form) ApiMonitoringHandler(m model.Monitoring) { - f.mon.Store(m) - f.authFail = false +func (st *stats) ApiMonitoringHandler(m model.Monitoring) { + st.mon.Store(m) + st.authFail = false } -func (f *form) Tick(t ticker) { - if t == ticker1Hz && f.requestSent { - f.authFail = f.mon.Load() == nil +func (st *stats) Tick(t ticker) { + if t == ticker1Hz && st.requestSent { + st.authFail = st.mon.Load() == nil } } -func (f *form) Draw(s Screen) { +func (st *stats) Draw(s Screen) { drawGameField(s) - if v := f.mon.Load(); v != nil { + if v := st.mon.Load(); v != nil { m := v.(model.Monitoring) printHeader(s, "Usage Statistics", 0) s.Print(fmt.Sprintf("Players: %d\nGames: %d\nSolved: %d", m.Users, m.GamesStarted, m.GamesSolved), @@ -66,30 +66,30 @@ func (f *form) Draw(s Screen) { color.RGBA{0, 0xFF, 0xFF, 0xFF}) } else { r := image.Rect(8, 1, len(codeInputTemplate)+4, 2) - if f.authFail { + if st.authFail { s.Fill(r, color.RGBA{0xFF, 0, 0, 0xFF}) - } else if f.idx == len(f.input) { + } else if st.idx == len(st.input) { s.Fill(r, color.RGBA{0x80, 0x80, 0x80, 0xFF}) } - printHeader(s, fmt.Sprintf(codeInputTemplate, codeBox(f.input[0]), codeBox(f.input[1]), codeBox(f.input[2]), codeBox(f.input[3])), 0) - for i := range f.dials { - f.dials[i].Draw(s) + printHeader(s, fmt.Sprintf(codeInputTemplate, codeBox(st.input[0]), codeBox(st.input[1]), codeBox(st.input[2]), codeBox(st.input[3])), 0) + for i := range st.dials { + st.dials[i].Draw(s) } } } -func (f *form) Interact(a Audio, col, row int, t time.Duration) actionResult { +func (st *stats) Interact(a Audio, col, row int, t time.Duration) actionResult { if (image.Point{col, row}).In(image.Rect(2, 1, puzzleSymX-2, 2)) { - f.input = [4]byte{} - f.authFail = false - f.requestSent = false - f.idx = 0 + st.input = [4]byte{} + st.authFail = false + st.requestSent = false + st.idx = 0 return resultSwitchGame } - if f.mon.Load() == nil { - for i := range f.dials { - if f.dials[i].Interact(col, row) { - f.pressNextButton('0' + byte(i)) + if st.mon.Load() == nil { + for i := range st.dials { + if st.dials[i].Interact(col, row) { + st.pressNextButton('0' + byte(i)) break } } @@ -97,17 +97,17 @@ func (f *form) Interact(a Audio, col, row int, t time.Duration) actionResult { return resultNone } -func (f *form) pressNextButton(digit byte) { - if f.idx >= len(f.input) { +func (st *stats) pressNextButton(digit byte) { + if st.idx >= len(st.input) { return } - f.input[f.idx] = digit - f.idx++ - if f.idx == len(f.input) { - f.request(string(f.input[:])) - f.requestSent = true + st.input[st.idx] = digit + st.idx++ + if st.idx == len(st.input) { + st.request(string(st.input[:])) + st.requestSent = true } } -func (f *form) Activate() {} -func (f *form) SetLang(langCode) {} +func (st *stats) Activate() {} +func (st *stats) SetLang(langCode) {} diff --git a/internal/tgbot/tgbot.go b/internal/tgbot/tgbot.go index fb5ff26..329c5b2 100644 --- a/internal/tgbot/tgbot.go +++ b/internal/tgbot/tgbot.go @@ -2,7 +2,6 @@ package tgbot import ( "context" - "fmt" "log/slog" "github.com/go-telegram/bot" @@ -38,8 +37,4 @@ func (b *tgBot) Start() { go b.b.Start(b.ctx) } -func (b *tgBot) updateHandler(ctx context.Context, _ *bot.Bot, update *models.Update) { - if update != nil { - b.log.Info(fmt.Sprintf("update:\n%#v\n", *update)) - } -} +func (b *tgBot) updateHandler(ctx context.Context, _ *bot.Bot, _ *models.Update) {}