From ddac1d02cd7803afa6dcb6d9d9749a5e127974ac Mon Sep 17 00:00:00 2001 From: igor Date: Fri, 22 May 2026 13:00:28 +0200 Subject: [PATCH] added implementation in GO lang --- .gitignore | 3 + README.md | 80 +++++++++++ assets/animals/.gitkeep | 1 + assets/fonts/.gitkeep | 1 + assets/sounds/.gitkeep | 1 + go.mod | 18 +++ go.sum | 24 ++++ internal/app/app.go | 119 ++++++++++++++++ internal/app/config.go | 43 ++++++ internal/assets/manager.go | 49 +++++++ internal/audio/manager.go | 130 +++++++++++++++++ internal/modes/animal.go | 85 +++++++++++ internal/modes/calculator.go | 235 +++++++++++++++++++++++++++++++ internal/modes/findkey.go | 100 +++++++++++++ internal/modes/geometry.go | 180 +++++++++++++++++++++++ internal/modes/input.go | 83 +++++++++++ internal/modes/keyboard.go | 96 +++++++++++++ internal/modes/mode.go | 25 ++++ internal/modes/notimplemented.go | 34 +++++ internal/ui/draw.go | 117 +++++++++++++++ main.go | 23 +++ 21 files changed, 1447 insertions(+) create mode 100644 README.md create mode 100644 assets/animals/.gitkeep create mode 100644 assets/fonts/.gitkeep create mode 100644 assets/sounds/.gitkeep create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/app.go create mode 100644 internal/app/config.go create mode 100644 internal/assets/manager.go create mode 100644 internal/audio/manager.go create mode 100644 internal/modes/animal.go create mode 100644 internal/modes/calculator.go create mode 100644 internal/modes/findkey.go create mode 100644 internal/modes/geometry.go create mode 100644 internal/modes/input.go create mode 100644 internal/modes/keyboard.go create mode 100644 internal/modes/mode.go create mode 100644 internal/modes/notimplemented.go create mode 100644 internal/ui/draw.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore index 8ae252a..2e71bb1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ /.vs /out +/kidskeyboard +/kidskeyboard.exe +/KidsKeyboard.exe diff --git a/README.md b/README.md new file mode 100644 index 0000000..8134044 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# KidsKeyboard + +Jednoducha detska aplikacia v Go a Ebitengine. Po stlaceni klaves kresli efekty, zobrazuje obrazky a prehrava zvuky. Projekt je pripraveny tak, aby bezal aj bez externych assetov. + +## Spustenie na Windows + +```powershell +go run . +``` + +Build: + +```powershell +go build -o KidsKeyboard.exe +``` + +## Spustenie na Linuxe / Raspberry Pi + +```sh +go run . +``` + +Build: + +```sh +go build -o kidskeyboard +``` + +Na Raspberry Pi mozu byt potrebne systemove kniznice pre desktop, OpenGL/EGL, ALSA a X11/Wayland. Na Debian/Raspberry Pi OS typicky zacnite balickami ako: + +```sh +sudo apt install libasound2-dev libgl1-mesa-dev xorg-dev +``` + +## Fullscreen + +Fullscreen zapnete prepinasom: + +```sh +go run . --fullscreen +``` + +Alebo cez prostredie: + +```sh +KIDSKEYBOARD_FULLSCREEN=1 go run . +``` + +Okno je predvolene 1280x720. Rozmery mozete zmenit: + +```sh +go run . --width 1024 --height 768 +``` + +## Ovladenie + +- `CTRL+F1` az `CTRL+F12` prepina rezimy. +- Predvoleny rezim po starte je `CTRL+F1`. +- `CTRL+SHIFT+ESC` ukonci aplikaciu. +- Samotny `ESC` aplikaciu neukoncuje. + +## Rezimy + +- `CTRL+F1` Keyboard mode: kreslena klavesnica, svietiace klavesy a generovane tony. +- `CTRL+F2` Geometry mode: tvary pre klavesy, `DELETE` vymaze vsetko, `+`/`-` meni velkost posledneho tvaru, sipky ho posuvaju. +- `CTRL+F3` Animal mode: nahodne zvieratko a zvuk. Podporovane assety su napriklad `assets/animals/dog.png` a `assets/animals/dog.wav`. +- `CTRL+F4` Calculator mode: jednoducha kalkulacka s pip/error zvukmi. +- `CTRL+F5` Find key mode: hlada sa zobrazene pismeno alebo cislo. +- `CTRL+F6` az `CTRL+F12`: zatial neimplementovane obrazovky. + +## Assety + +Volitelne subory: + +- `assets/animals/dog.png`, `assets/animals/dog.wav` +- `assets/animals/cat.png`, `assets/animals/cat.wav` +- `assets/animals/cow.png`, `assets/animals/cow.wav` +- `assets/sounds/jingle1.wav` az `assets/sounds/jingle4.wav` + +Ak obrazok alebo zvuk chyba, aplikacia pouzije textovy alebo programovo generovany fallback. diff --git a/assets/animals/.gitkeep b/assets/animals/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/assets/animals/.gitkeep @@ -0,0 +1 @@ + diff --git a/assets/fonts/.gitkeep b/assets/fonts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/assets/fonts/.gitkeep @@ -0,0 +1 @@ + diff --git a/assets/sounds/.gitkeep b/assets/sounds/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/assets/sounds/.gitkeep @@ -0,0 +1 @@ + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ebef285 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module kidskeyboard + +go 1.24.0 + +require ( + github.com/hajimehoshi/ebiten/v2 v2.9.9 + golang.org/x/image v0.31.0 +) + +require ( + github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/oto/v3 v3.4.0 // indirect + github.com/ebitengine/purego v0.9.0 // indirect + github.com/jezek/xgb v1.1.1 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c2b7ea3 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0= +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI= +github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= +github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= +github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ= +github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI= +github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= +github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/hajimehoshi/bitmapfont/v4 v4.1.0 h1:eE3qa5Do4qhowZVIHjsrX5pYyyPN6sAFWMsO7QREm3U= +github.com/hajimehoshi/bitmapfont/v4 v4.1.0/go.mod h1:/PD+aLjAJ0F2UoQx6hkOfXqWN7BkroDUMr5W+IT1dpE= +github.com/hajimehoshi/ebiten/v2 v2.9.9 h1:JdDag6Ndj12iD4lxQGG8kbsrh7ssj4Sbzth6r929H/M= +github.com/hajimehoshi/ebiten/v2 v2.9.9/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= +golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..d5bf7df --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,119 @@ +package app + +import ( + "math/rand" + "time" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" + + "kidskeyboard/internal/assets" + kbaudio "kidskeyboard/internal/audio" + "kidskeyboard/internal/modes" +) + +type Game struct { + cfg Config + audio *kbaudio.Manager + assets *assets.Manager + rng *rand.Rand + modes []modes.Mode + current int +} + +func New(cfg Config) *Game { + audio := kbaudio.NewManager() + assets := assets.NewManager() + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + + ctx := modes.Context{ + Audio: audio, + Assets: assets, + RNG: rng, + } + + game := &Game{ + cfg: cfg, + audio: audio, + assets: assets, + rng: rng, + modes: []modes.Mode{ + modes.NewKeyboardMode(ctx), + modes.NewGeometryMode(ctx), + modes.NewAnimalMode(ctx), + modes.NewCalculatorMode(ctx), + modes.NewFindKeyMode(ctx), + modes.NewNotImplementedMode("CTRL+F6"), + modes.NewNotImplementedMode("CTRL+F7"), + modes.NewNotImplementedMode("CTRL+F8"), + modes.NewNotImplementedMode("CTRL+F9"), + modes.NewNotImplementedMode("CTRL+F10"), + modes.NewNotImplementedMode("CTRL+F11"), + modes.NewNotImplementedMode("CTRL+F12"), + }, + current: 0, + } + game.modes[game.current].OnEnter() + return game +} + +func (g *Game) Update() error { + g.audio.Update() + + if /*ctrlPressed() && */ shiftPressed() && inpututil.IsKeyJustPressed(ebiten.KeyEscape) { + return ebiten.Termination + } + + if ctrlPressed() { + for i, key := range modeKeys() { + if inpututil.IsKeyJustPressed(key) { + g.switchMode(i) + return nil + } + } + } + + g.modes[g.current].HandleInput() + g.modes[g.current].Update() + return nil +} + +func (g *Game) Draw(screen *ebiten.Image) { + g.modes[g.current].Draw(screen) +} + +func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { + if outsideWidth <= 0 || outsideHeight <= 0 { + return g.cfg.WindowWidth, g.cfg.WindowHeight + } + return outsideWidth, outsideHeight +} + +func (g *Game) switchMode(next int) { + if next < 0 || next >= len(g.modes) || next == g.current { + return + } + g.modes[g.current].OnLeave() + g.current = next + g.modes[g.current].OnEnter() +} + +func ctrlPressed() bool { + return ebiten.IsKeyPressed(ebiten.KeyControlLeft) || + ebiten.IsKeyPressed(ebiten.KeyControlRight) || + ebiten.IsKeyPressed(ebiten.KeyControl) +} + +func shiftPressed() bool { + return ebiten.IsKeyPressed(ebiten.KeyShiftLeft) || + ebiten.IsKeyPressed(ebiten.KeyShiftRight) || + ebiten.IsKeyPressed(ebiten.KeyShift) +} + +func modeKeys() []ebiten.Key { + return []ebiten.Key{ + ebiten.KeyF1, ebiten.KeyF2, ebiten.KeyF3, ebiten.KeyF4, + ebiten.KeyF5, ebiten.KeyF6, ebiten.KeyF7, ebiten.KeyF8, + ebiten.KeyF9, ebiten.KeyF10, ebiten.KeyF11, ebiten.KeyF12, + } +} diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..594fc0b --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,43 @@ +package app + +import ( + "flag" + "os" +) + +type Config struct { + WindowWidth int + WindowHeight int + Fullscreen bool +} + +func ParseConfig() Config { + cfg := Config{ + WindowWidth: 1280, + WindowHeight: 720, + Fullscreen: envBool("KIDSKEYBOARD_FULLSCREEN"), + } + + flag.IntVar(&cfg.WindowWidth, "width", cfg.WindowWidth, "window width") + flag.IntVar(&cfg.WindowHeight, "height", cfg.WindowHeight, "window height") + flag.BoolVar(&cfg.Fullscreen, "fullscreen", cfg.Fullscreen, "start in fullscreen mode") + flag.Bool("windowed", false, "force windowed mode") + flag.Parse() + + if flag.Lookup("windowed") != nil { + if v := flag.Lookup("windowed").Value.String(); v == "true" { + cfg.Fullscreen = false + } + } + + return cfg +} + +func envBool(name string) bool { + switch os.Getenv(name) { + case "1", "true", "TRUE", "yes", "YES", "on", "ON": + return true + default: + return false + } +} diff --git a/internal/assets/manager.go b/internal/assets/manager.go new file mode 100644 index 0000000..149c9d3 --- /dev/null +++ b/internal/assets/manager.go @@ -0,0 +1,49 @@ +package assets + +import ( + "image" + _ "image/jpeg" + _ "image/png" + "os" + "path/filepath" + + "github.com/hajimehoshi/ebiten/v2" +) + +type Manager struct { + images map[string]*ebiten.Image + misses map[string]bool +} + +func NewManager() *Manager { + return &Manager{ + images: map[string]*ebiten.Image{}, + misses: map[string]bool{}, + } +} + +func (m *Manager) Image(path string) (*ebiten.Image, bool) { + path = filepath.Clean(path) + if img, ok := m.images[path]; ok { + return img, true + } + if m.misses[path] { + return nil, false + } + + f, err := os.Open(path) + if err != nil { + m.misses[path] = true + return nil, false + } + defer f.Close() + + src, _, err := image.Decode(f) + if err != nil { + m.misses[path] = true + return nil, false + } + img := ebiten.NewImageFromImage(src) + m.images[path] = img + return img, true +} diff --git a/internal/audio/manager.go b/internal/audio/manager.go new file mode 100644 index 0000000..396c0da --- /dev/null +++ b/internal/audio/manager.go @@ -0,0 +1,130 @@ +package audio + +import ( + "bytes" + "encoding/binary" + "io" + "math" + "os" + "time" + + ebiaudio "github.com/hajimehoshi/ebiten/v2/audio" + "github.com/hajimehoshi/ebiten/v2/audio/wav" +) + +const sampleRate = 44100 + +type Manager struct { + context *ebiaudio.Context + players []*ebiaudio.Player + current *ebiaudio.Player +} + +func NewManager() *Manager { + return &Manager{context: ebiaudio.NewContext(sampleRate)} +} + +func (m *Manager) Update() { + alive := m.players[:0] + for _, p := range m.players { + if p.IsPlaying() { + alive = append(alive, p) + continue + } + _ = p.Close() + } + m.players = alive + if m.current != nil && !m.current.IsPlaying() { + _ = m.current.Close() + m.current = nil + } +} + +func (m *Manager) PlayTone(freq float64, d time.Duration, volume float64) { + m.playPCM(generateTone(freq, d, volume), false) +} + +func (m *Manager) PlayToneInterrupt(freq float64, d time.Duration, volume float64) { + m.playPCM(generateTone(freq, d, volume), true) +} + +func (m *Manager) PlayBeep() { + m.PlayTone(880, 80*time.Millisecond, 0.22) +} + +func (m *Manager) PlayError() { + data := append(generateTone(160, 110*time.Millisecond, 0.28), generateTone(110, 150*time.Millisecond, 0.28)...) + m.playPCM(data, false) +} + +func (m *Manager) PlayWAV(path string, interrupt bool) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + stream, err := wav.DecodeWithSampleRate(sampleRate, f) + if err != nil { + return false + } + data, err := io.ReadAll(stream) + if err != nil { + return false + } + m.playPCM(data, interrupt) + return true +} + +func (m *Manager) StopCurrent() { + if m.current == nil { + return + } + m.current.Pause() + _ = m.current.Close() + m.current = nil +} + +func (m *Manager) playPCM(data []byte, interrupt bool) { + if len(data) == 0 { + return + } + if interrupt { + m.StopCurrent() + } + player := m.context.NewPlayerFromBytes(data) + player.Play() + if interrupt { + m.current = player + return + } + m.players = append(m.players, player) +} + +func generateTone(freq float64, d time.Duration, volume float64) []byte { + samples := int(float64(sampleRate) * d.Seconds()) + buf := bytes.NewBuffer(make([]byte, 0, samples*4)) + for i := 0; i < samples; i++ { + t := float64(i) / sampleRate + env := envelope(i, samples) + v := int16(math.Sin(2*math.Pi*freq*t) * volume * env * math.MaxInt16) + _ = binary.Write(buf, binary.LittleEndian, v) + _ = binary.Write(buf, binary.LittleEndian, v) + } + return buf.Bytes() +} + +func envelope(i, samples int) float64 { + if samples <= 0 { + return 0 + } + attack := sampleRate / 200 + release := sampleRate / 100 + if i < attack { + return float64(i) / float64(attack) + } + if samples-i < release { + return float64(samples-i) / float64(release) + } + return 1 +} diff --git a/internal/modes/animal.go b/internal/modes/animal.go new file mode 100644 index 0000000..8be43e1 --- /dev/null +++ b/internal/modes/animal.go @@ -0,0 +1,85 @@ +package modes + +import ( + "image/color" + "path/filepath" + "strings" + "time" + + "github.com/hajimehoshi/ebiten/v2" + + "kidskeyboard/internal/ui" +) + +type animal struct { + name string + image string + sound string +} + +type AnimalMode struct { + ctx Context + animals []animal + current *animal +} + +func NewAnimalMode(ctx Context) *AnimalMode { + return &AnimalMode{ + ctx: ctx, + animals: []animal{ + {"dog", "assets/animals/dog.png", "assets/animals/dog.wav"}, + {"cat", "assets/animals/cat.png", "assets/animals/cat.wav"}, + {"cow", "assets/animals/cow.png", "assets/animals/cow.wav"}, + }, + } +} + +func (m *AnimalMode) Name() string { return "CTRL+F3 Animal" } +func (m *AnimalMode) OnEnter() {} +func (m *AnimalMode) OnLeave() { m.ctx.Audio.StopCurrent() } + +func (m *AnimalMode) HandleInput() { + for _, key := range justPressedKeys() { + if !childKey(key) { + continue + } + a := m.animals[m.ctx.RNG.Intn(len(m.animals))] + m.current = &a + if !m.ctx.Audio.PlayWAV(filepath.Clean(a.sound), true) { + m.ctx.Audio.PlayToneInterrupt(330+float64(m.ctx.RNG.Intn(280)), 260*time.Millisecond, 0.26) + } + return + } +} + +func (m *AnimalMode) Update() {} + +func (m *AnimalMode) Draw(screen *ebiten.Image) { + screen.Fill(color.Black) + w, h := screen.Bounds().Dx(), screen.Bounds().Dy() + ui.Text(screen, "CTRL+F3", 20, 20, color.White, 2) + if m.current == nil { + ui.CenteredText(screen, "ANIMAL", w/2, h/2, color.White, 7) + return + } + if img, ok := m.ctx.Assets.Image(m.current.image); ok { + drawImageCentered(screen, img, w/2, h/2, float64(w)*0.65, float64(h)*0.72) + return + } + ui.CenteredText(screen, strings.ToUpper(m.current.name), w/2, h/2, color.White, 9) +} + +func drawImageCentered(screen *ebiten.Image, img *ebiten.Image, cx, cy int, maxW, maxH float64) { + iw, ih := img.Bounds().Dx(), img.Bounds().Dy() + if iw <= 0 || ih <= 0 { + return + } + scale := min(maxW/float64(iw), maxH/float64(ih)) + if scale <= 0 { + scale = 1 + } + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(scale, scale) + op.GeoM.Translate(float64(cx)-float64(iw)*scale/2, float64(cy)-float64(ih)*scale/2) + screen.DrawImage(img, op) +} diff --git a/internal/modes/calculator.go b/internal/modes/calculator.go new file mode 100644 index 0000000..c5d16ba --- /dev/null +++ b/internal/modes/calculator.go @@ -0,0 +1,235 @@ +package modes + +import ( + "fmt" + "image/color" + "math" + "strconv" + "strings" + + "github.com/hajimehoshi/ebiten/v2" + + "kidskeyboard/internal/ui" +) + +type CalculatorMode struct { + ctx Context + display string + acc float64 + op string + newEntry bool + err bool +} + +func NewCalculatorMode(ctx Context) *CalculatorMode { + return &CalculatorMode{ctx: ctx, display: "0", newEntry: true} +} + +func (m *CalculatorMode) Name() string { return "CTRL+F4 Calculator" } +func (m *CalculatorMode) OnEnter() {} +func (m *CalculatorMode) OnLeave() {} +func (m *CalculatorMode) Update() {} + +func (m *CalculatorMode) HandleInput() { + handled := false + valid := false + chars := ebiten.AppendInputChars(nil) + + for _, r := range chars { + handled = true + if m.handleChar(r) { + valid = true + } + } + + for _, key := range justPressedKeys() { + switch { + case key == ebiten.KeyDelete: + handled = true + m.clear() + valid = true + case key == ebiten.KeyBackspace: + handled = true + m.backspace() + valid = true + case key == ebiten.KeyEnter || key == ebiten.KeyNumpadEnter: + handled = true + m.equals() + valid = true + case isNumpadOperatorKey(key) && len(chars) == 0: + handled = true + m.operator(operatorForKey(key)) + valid = true + default: + if childKey(key) && len(chars) == 0 { + handled = true + } + } + } + + if valid { + m.ctx.Audio.PlayBeep() + } else if handled { + m.ctx.Audio.PlayError() + } +} + +func (m *CalculatorMode) handleChar(r rune) bool { + switch { + case r >= '0' && r <= '9': + m.digit(string(r)) + return true + case r == '+' || r == '-' || r == '*' || r == '/': + m.operator(string(r)) + return true + case r == 'c' || r == 'C': + m.clear() + return true + default: + return false + } +} + +func (m *CalculatorMode) Draw(screen *ebiten.Image) { + screen.Fill(color.Black) + w, h := screen.Bounds().Dx(), screen.Bounds().Dy() + ui.Text(screen, "CTRL+F4", 20, 20, color.White, 2) + + panelW := float32(min(float64(w)*0.78, 620)) + keySize := panelW / 4 + startX := (float32(w) - panelW) / 2 + startY := float32(h)/2 - keySize*1.9 + displayH := keySize * 0.72 + + ui.Rect(screen, startX, startY-displayH-16, panelW, displayH, color.NRGBA{R: 20, G: 20, B: 20, A: 255}) + ui.RectOutline(screen, startX, startY-displayH-16, panelW, displayH, 2, color.White) + scale := 4.0 + if len(m.display) > 12 { + scale = 3 + } + ui.CenteredText(screen, m.display, int(startX+panelW/2), int(startY-displayH/2-16), color.White, scale) + + keys := [][]string{ + {"7", "8", "9", "/"}, + {"4", "5", "6", "*"}, + {"1", "2", "3", "-"}, + {"C", "0", "=", "+"}, + } + for r, row := range keys { + for c, label := range row { + x := startX + float32(c)*keySize + y := startY + float32(r)*keySize + ui.Rect(screen, x+4, y+4, keySize-8, keySize-8, color.NRGBA{R: 28, G: 28, B: 28, A: 255}) + ui.RectOutline(screen, x+4, y+4, keySize-8, keySize-8, 2, color.White) + ui.CenteredText(screen, label, int(x+keySize/2), int(y+keySize/2), color.White, 4) + } + } +} + +func (m *CalculatorMode) digit(d string) { + if m.err || m.newEntry || m.display == "0" { + m.display = d + m.newEntry = false + m.err = false + return + } + if len(m.display) < 16 { + m.display += d + } +} + +func (m *CalculatorMode) operator(op string) { + if m.err { + return + } + if m.op != "" && !m.newEntry { + m.equals() + } + m.acc = m.value() + m.op = op + m.newEntry = true +} + +func (m *CalculatorMode) equals() { + if m.err || m.op == "" { + m.newEntry = true + return + } + right := m.value() + var result float64 + switch m.op { + case "+": + result = m.acc + right + case "-": + result = m.acc - right + case "*": + result = m.acc * right + case "/": + if right == 0 { + m.display = "DIV 0" + m.err = true + m.op = "" + m.newEntry = true + return + } + result = m.acc / right + } + m.display = formatNumber(result) + m.acc = result + m.op = "" + m.newEntry = true +} + +func (m *CalculatorMode) backspace() { + if m.err || m.newEntry || len(m.display) <= 1 { + m.display = "0" + m.err = false + m.newEntry = true + return + } + m.display = m.display[:len(m.display)-1] +} + +func (m *CalculatorMode) clear() { + m.display = "0" + m.acc = 0 + m.op = "" + m.err = false + m.newEntry = true +} + +func (m *CalculatorMode) value() float64 { + v, _ := strconv.ParseFloat(m.display, 64) + return v +} + +func formatNumber(v float64) string { + if math.Abs(v-math.Round(v)) < 0.0000001 { + return fmt.Sprintf("%.0f", v) + } + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6f", v), "0"), ".") +} + +func isNumpadOperatorKey(key ebiten.Key) bool { + switch key { + case ebiten.KeyNumpadAdd, ebiten.KeyNumpadSubtract, ebiten.KeyNumpadMultiply, ebiten.KeyNumpadDivide: + return true + default: + return false + } +} + +func operatorForKey(key ebiten.Key) string { + switch key { + case ebiten.KeyNumpadAdd: + return "+" + case ebiten.KeyNumpadSubtract: + return "-" + case ebiten.KeyNumpadMultiply: + return "*" + case ebiten.KeyNumpadDivide: + return "/" + default: + return "" + } +} diff --git a/internal/modes/findkey.go b/internal/modes/findkey.go new file mode 100644 index 0000000..a5d2dea --- /dev/null +++ b/internal/modes/findkey.go @@ -0,0 +1,100 @@ +package modes + +import ( + "image/color" + "path/filepath" + "time" + + "github.com/hajimehoshi/ebiten/v2" + + "kidskeyboard/internal/ui" +) + +type FindKeyMode struct { + ctx Context + target rune + successTicks int + errorTicks int + lastJingle int +} + +const findChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func NewFindKeyMode(ctx Context) *FindKeyMode { + m := &FindKeyMode{ctx: ctx, lastJingle: -1} + m.pickNext() + return m +} + +func (m *FindKeyMode) Name() string { return "CTRL+F5 Find Key" } +func (m *FindKeyMode) OnEnter() {} +func (m *FindKeyMode) OnLeave() {} + +func (m *FindKeyMode) HandleInput() { + if m.successTicks > 0 { + return + } + for _, key := range justPressedKeys() { + if !childKey(key) { + continue + } + r, ok := keyRune(key) + if ok && r == m.target { + m.successTicks = 180 + m.errorTicks = 0 + m.playJingle() + return + } + m.errorTicks = 18 + m.ctx.Audio.PlayError() + return + } +} + +func (m *FindKeyMode) Update() { + if m.errorTicks > 0 { + m.errorTicks-- + } + if m.successTicks > 0 { + m.successTicks-- + if m.successTicks == 0 { + m.pickNext() + } + } +} + +func (m *FindKeyMode) Draw(screen *ebiten.Image) { + screen.Fill(color.Black) + w, h := screen.Bounds().Dx(), screen.Bounds().Dy() + ui.Text(screen, "CTRL+F5", 20, 20, color.White, 2) + + var clr color.Color = color.White + if m.errorTicks > 0 { + clr = color.NRGBA{R: 255, A: 255} + } + if m.successTicks > 0 { + clr = color.NRGBA{G: 255, A: 255} + } + ui.CenteredText(screen, string(m.target), w/2, h/2, clr, 18) +} + +func (m *FindKeyMode) pickNext() { + m.target = rune(findChars[m.ctx.RNG.Intn(len(findChars))]) +} + +func (m *FindKeyMode) playJingle() { + const count = 4 + next := m.ctx.RNG.Intn(count) + if count > 1 { + for next == m.lastJingle { + next = m.ctx.RNG.Intn(count) + } + } + m.lastJingle = next + path := filepath.Join("assets", "sounds", "jingle"+string(rune('1'+next))+".wav") + if m.ctx.Audio.PlayWAV(path, true) { + return + } + freq := 520 + float64(next)*120 + m.ctx.Audio.PlayToneInterrupt(freq, 180*time.Millisecond, 0.28) +} diff --git a/internal/modes/geometry.go b/internal/modes/geometry.go new file mode 100644 index 0000000..a5ebd17 --- /dev/null +++ b/internal/modes/geometry.go @@ -0,0 +1,180 @@ +package modes + +import ( + "image/color" + "math" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" + + "kidskeyboard/internal/ui" +) + +type shapeKind int + +const ( + shapeCircle shapeKind = iota + shapeRect + shapeTriangle + shapeLine + shapeStar +) + +type shape struct { + kind shapeKind + x float32 + y float32 + w float32 + h float32 + size float32 + color color.NRGBA +} + +type GeometryMode struct { + ctx Context + shapes map[ebiten.Key]*shape + order []ebiten.Key +} + +func NewGeometryMode(ctx Context) *GeometryMode { + return &GeometryMode{ctx: ctx, shapes: map[ebiten.Key]*shape{}} +} + +func (m *GeometryMode) Name() string { return "CTRL+F2 Geometry" } +func (m *GeometryMode) OnEnter() {} +func (m *GeometryMode) OnLeave() {} + +func (m *GeometryMode) HandleInput() { + if inpututil.IsKeyJustPressed(ebiten.KeyDelete) { + m.shapes = map[ebiten.Key]*shape{} + m.order = nil + return + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft) { + m.moveLast(-20, 0) + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowRight) { + m.moveLast(20, 0) + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) { + m.moveLast(0, -20) + } + if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) { + m.moveLast(0, 20) + } + if inpututil.IsKeyJustPressed(ebiten.KeyEqual) || inpututil.IsKeyJustPressed(ebiten.KeyNumpadAdd) { + m.scaleLast(1.12) + } + if inpututil.IsKeyJustPressed(ebiten.KeyMinus) || inpututil.IsKeyJustPressed(ebiten.KeyNumpadSubtract) { + m.scaleLast(0.88) + } + + for _, key := range justPressedKeys() { + if !childKey(key) || geometryControlKey(key) { + continue + } + if _, ok := m.shapes[key]; ok { + delete(m.shapes, key) + m.removeOrder(key) + continue + } + m.shapes[key] = m.randomShape() + m.order = append(m.order, key) + } +} + +func (m *GeometryMode) Update() {} + +func (m *GeometryMode) Draw(screen *ebiten.Image) { + screen.Fill(color.Black) + for _, key := range m.order { + if s := m.shapes[key]; s != nil { + drawShape(screen, s) + } + } + ui.Text(screen, "CTRL+F2", 20, 20, color.White, 2) +} + +func (m *GeometryMode) randomShape() *shape { + w, h := 1280, 720 + x := float32(80 + m.ctx.RNG.Intn(max(1, w-160))) + y := float32(80 + m.ctx.RNG.Intn(max(1, h-160))) + size := float32(35 + m.ctx.RNG.Intn(100)) + return &shape{ + kind: shapeKind(m.ctx.RNG.Intn(5)), + x: x, + y: y, + w: size + float32(m.ctx.RNG.Intn(90)), + h: size + float32(m.ctx.RNG.Intn(90)), + size: size, + color: color.NRGBA{ + R: uint8(80 + m.ctx.RNG.Intn(176)), + G: uint8(80 + m.ctx.RNG.Intn(176)), + B: uint8(80 + m.ctx.RNG.Intn(176)), + A: 255, + }, + } +} + +func drawShape(screen *ebiten.Image, s *shape) { + switch s.kind { + case shapeCircle: + ui.Circle(screen, s.x, s.y, s.size, s.color) + case shapeRect: + ui.Rect(screen, s.x-s.w/2, s.y-s.h/2, s.w, s.h, s.color) + case shapeTriangle: + ui.Triangle(screen, s.x, s.y-s.h/2, s.x-s.w/2, s.y+s.h/2, s.x+s.w/2, s.y+s.h/2, s.color) + case shapeLine: + ui.Line(screen, s.x-s.w/2, s.y-s.h/2, s.x+s.w/2, s.y+s.h/2, float32(math.Max(4, float64(s.size/8))), s.color) + case shapeStar: + ui.Star(screen, s.x, s.y, s.size, s.size*0.45, 5, s.color) + } +} + +func (m *GeometryMode) moveLast(dx, dy float32) { + last := m.last() + if last == nil { + return + } + last.x += dx + last.y += dy +} + +func (m *GeometryMode) scaleLast(factor float32) { + last := m.last() + if last == nil { + return + } + last.w *= factor + last.h *= factor + last.size *= factor +} + +func (m *GeometryMode) last() *shape { + for i := len(m.order) - 1; i >= 0; i-- { + if s := m.shapes[m.order[i]]; s != nil { + return s + } + } + return nil +} + +func (m *GeometryMode) removeOrder(key ebiten.Key) { + next := m.order[:0] + for _, k := range m.order { + if k != key { + next = append(next, k) + } + } + m.order = next +} + +func geometryControlKey(key ebiten.Key) bool { + switch key { + case ebiten.KeyDelete, ebiten.KeyArrowLeft, ebiten.KeyArrowRight, ebiten.KeyArrowUp, ebiten.KeyArrowDown, + ebiten.KeyEqual, ebiten.KeyMinus, ebiten.KeyNumpadAdd, ebiten.KeyNumpadSubtract: + return true + default: + return false + } +} diff --git a/internal/modes/input.go b/internal/modes/input.go new file mode 100644 index 0000000..a469f21 --- /dev/null +++ b/internal/modes/input.go @@ -0,0 +1,83 @@ +package modes + +import ( + "strconv" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" +) + +func justPressedKeys() []ebiten.Key { + var keys []ebiten.Key + keys = inpututil.AppendJustPressedKeys(keys) + return keys +} + +func childKey(key ebiten.Key) bool { + switch key { + case ebiten.KeyControl, ebiten.KeyControlLeft, ebiten.KeyControlRight, + ebiten.KeyShift, ebiten.KeyShiftLeft, ebiten.KeyShiftRight, + ebiten.KeyAlt, ebiten.KeyAltLeft, ebiten.KeyAltRight, + ebiten.KeyMeta, ebiten.KeyEscape, + ebiten.KeyF1, ebiten.KeyF2, ebiten.KeyF3, ebiten.KeyF4, + ebiten.KeyF5, ebiten.KeyF6, ebiten.KeyF7, ebiten.KeyF8, + ebiten.KeyF9, ebiten.KeyF10, ebiten.KeyF11, ebiten.KeyF12: + return false + default: + return true + } +} + +func keyLabel(key ebiten.Key) string { + if r, ok := keyRune(key); ok { + return string(r) + } + switch key { + case ebiten.KeySpace: + return "SPACE" + case ebiten.KeyEnter, ebiten.KeyNumpadEnter: + return "ENTER" + case ebiten.KeyBackspace: + return "BKSP" + case ebiten.KeyDelete: + return "DEL" + case ebiten.KeyTab: + return "TAB" + case ebiten.KeyArrowLeft: + return "LEFT" + case ebiten.KeyArrowRight: + return "RIGHT" + case ebiten.KeyArrowUp: + return "UP" + case ebiten.KeyArrowDown: + return "DOWN" + default: + return key.String() + } +} + +func keyRune(key ebiten.Key) (rune, bool) { + if key >= ebiten.KeyA && key <= ebiten.KeyZ { + return rune('A' + int(key-ebiten.KeyA)), true + } + if key >= ebiten.KeyDigit0 && key <= ebiten.KeyDigit9 { + return rune('0' + int(key-ebiten.KeyDigit0)), true + } + if key >= ebiten.KeyNumpad0 && key <= ebiten.KeyNumpad9 { + return rune('0' + int(key-ebiten.KeyNumpad0)), true + } + return 0, false +} + +func digitValue(key ebiten.Key) (string, bool) { + r, ok := keyRune(key) + if !ok || r < '0' || r > '9' { + return "", false + } + return string(r), true +} + +func parseDigit(s string) int { + v, _ := strconv.Atoi(s) + return v +} diff --git a/internal/modes/keyboard.go b/internal/modes/keyboard.go new file mode 100644 index 0000000..cd43d47 --- /dev/null +++ b/internal/modes/keyboard.go @@ -0,0 +1,96 @@ +package modes + +import ( + "image/color" + "time" + + "github.com/hajimehoshi/ebiten/v2" + + "kidskeyboard/internal/ui" +) + +type KeyboardMode struct { + ctx Context + highlight map[ebiten.Key]float64 + layout [][]ebiten.Key +} + +func NewKeyboardMode(ctx Context) *KeyboardMode { + return &KeyboardMode{ + ctx: ctx, + highlight: map[ebiten.Key]float64{}, + layout: [][]ebiten.Key{ + {ebiten.KeyDigit1, ebiten.KeyDigit2, ebiten.KeyDigit3, ebiten.KeyDigit4, ebiten.KeyDigit5, ebiten.KeyDigit6, ebiten.KeyDigit7, ebiten.KeyDigit8, ebiten.KeyDigit9, ebiten.KeyDigit0}, + {ebiten.KeyQ, ebiten.KeyW, ebiten.KeyE, ebiten.KeyR, ebiten.KeyT, ebiten.KeyY, ebiten.KeyU, ebiten.KeyI, ebiten.KeyO, ebiten.KeyP}, + {ebiten.KeyA, ebiten.KeyS, ebiten.KeyD, ebiten.KeyF, ebiten.KeyG, ebiten.KeyH, ebiten.KeyJ, ebiten.KeyK, ebiten.KeyL}, + {ebiten.KeyZ, ebiten.KeyX, ebiten.KeyC, ebiten.KeyV, ebiten.KeyB, ebiten.KeyN, ebiten.KeyM}, + {ebiten.KeySpace, ebiten.KeyEnter, ebiten.KeyBackspace}, + }, + } +} + +func (m *KeyboardMode) Name() string { return "CTRL+F1 Keyboard" } +func (m *KeyboardMode) OnEnter() {} +func (m *KeyboardMode) OnLeave() {} + +func (m *KeyboardMode) HandleInput() { + for _, key := range justPressedKeys() { + if !childKey(key) { + continue + } + m.highlight[key] = 1 + freq := 220 + float64(int(key)%48)*18 + m.ctx.Audio.PlayTone(freq, 120*time.Millisecond, 0.22) + } +} + +func (m *KeyboardMode) Update() { + for key, v := range m.highlight { + v -= 0.035 + if v <= 0 { + delete(m.highlight, key) + continue + } + m.highlight[key] = v + } +} + +func (m *KeyboardMode) Draw(screen *ebiten.Image) { + screen.Fill(color.Black) + w, h := screen.Bounds().Dx(), screen.Bounds().Dy() + keyW := float32(w) / 13 + keyH := float32(h) / 10 + gap := float32(8) + startY := float32(h)/2 - keyH*2.7 + + for rowIndex, row := range m.layout { + rowWidth := float32(len(row))*(keyW+gap) - gap + if rowIndex == 4 { + rowWidth = keyW*7 + gap*2 + } + x := (float32(w) - rowWidth) / 2 + y := startY + float32(rowIndex)*(keyH+gap) + for _, key := range row { + drawW := keyW + if key == ebiten.KeySpace { + drawW = keyW * 3 + } + if key == ebiten.KeyEnter || key == ebiten.KeyBackspace { + drawW = keyW * 2 + } + m.drawKey(screen, key, x, y, drawW, keyH) + x += drawW + gap + } + } + ui.CenteredText(screen, "CTRL+F1", w/2, 48, color.White, 2) +} + +func (m *KeyboardMode) drawKey(screen *ebiten.Image, key ebiten.Key, x, y, w, h float32) { + base := color.NRGBA{R: 18, G: 18, B: 18, A: 255} + if v := m.highlight[key]; v > 0 { + base = color.NRGBA{R: uint8(60 + 180*v), G: uint8(80 + 160*v), B: 255, A: 255} + } + ui.Rect(screen, x, y, w, h, base) + ui.RectOutline(screen, x, y, w, h, 2, color.White) + ui.CenteredText(screen, keyLabel(key), int(x+w/2), int(y+h/2), color.White, 2) +} diff --git a/internal/modes/mode.go b/internal/modes/mode.go new file mode 100644 index 0000000..eac61a4 --- /dev/null +++ b/internal/modes/mode.go @@ -0,0 +1,25 @@ +package modes + +import ( + "math/rand" + + "github.com/hajimehoshi/ebiten/v2" + + "kidskeyboard/internal/assets" + kbaudio "kidskeyboard/internal/audio" +) + +type Context struct { + Audio *kbaudio.Manager + Assets *assets.Manager + RNG *rand.Rand +} + +type Mode interface { + Name() string + OnEnter() + OnLeave() + HandleInput() + Update() + Draw(screen *ebiten.Image) +} diff --git a/internal/modes/notimplemented.go b/internal/modes/notimplemented.go new file mode 100644 index 0000000..bb972d9 --- /dev/null +++ b/internal/modes/notimplemented.go @@ -0,0 +1,34 @@ +package modes + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + + "kidskeyboard/internal/ui" +) + +type NotImplementedMode struct { + label string +} + +func NewNotImplementedMode(label string) *NotImplementedMode { + return &NotImplementedMode{label: label} +} + +func (m *NotImplementedMode) Name() string { return m.label } +func (m *NotImplementedMode) OnEnter() {} +func (m *NotImplementedMode) OnLeave() {} +func (m *NotImplementedMode) HandleInput() {} +func (m *NotImplementedMode) Update() {} +func (m *NotImplementedMode) Draw(screen *ebiten.Image) { + screen.Fill(color.Black) + w, h := screen.Bounds().Dx(), screen.Bounds().Dy() + boxW := float32(w) * 0.62 + boxH := float32(h) * 0.28 + x := (float32(w) - boxW) / 2 + y := (float32(h) - boxH) / 2 + ui.RectOutline(screen, x, y, boxW, boxH, 5, color.NRGBA{R: 255, G: 220, A: 255}) + ui.CenteredText(screen, "Este nebolo implementovane", w/2, h/2-24, color.NRGBA{R: 255, A: 255}, 3) + ui.CenteredText(screen, m.label, w/2, h/2+42, color.White, 3) +} diff --git a/internal/ui/draw.go b/internal/ui/draw.go new file mode 100644 index 0000000..3676bf8 --- /dev/null +++ b/internal/ui/draw.go @@ -0,0 +1,117 @@ +package ui + +import ( + "image/color" + "math" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/text" + "github.com/hajimehoshi/ebiten/v2/vector" + "golang.org/x/image/font/basicfont" +) + +var whitePixel = func() *ebiten.Image { + img := ebiten.NewImage(1, 1) + img.Fill(color.White) + return img +}() + +func Rect(screen *ebiten.Image, x, y, w, h float32, clr color.Color) { + vector.FillRect(screen, x, y, w, h, clr, true) +} + +func RectOutline(screen *ebiten.Image, x, y, w, h, stroke float32, clr color.Color) { + vector.StrokeRect(screen, x, y, w, h, stroke, clr, true) +} + +func Line(screen *ebiten.Image, x1, y1, x2, y2, width float32, clr color.Color) { + vector.StrokeLine(screen, x1, y1, x2, y2, width, clr, true) +} + +func Circle(screen *ebiten.Image, x, y, r float32, clr color.Color) { + vector.FillCircle(screen, x, y, r, clr, true) +} + +func Triangle(screen *ebiten.Image, x1, y1, x2, y2, x3, y3 float32, clr color.Color) { + Polygon(screen, []Point{{x1, y1}, {x2, y2}, {x3, y3}}, clr) +} + +func Star(screen *ebiten.Image, x, y, outer, inner float32, points int, clr color.Color) { + if points < 3 { + points = 5 + } + poly := make([]Point, 0, points*2) + for i := 0; i < points*2; i++ { + r := outer + if i%2 == 1 { + r = inner + } + a := -math.Pi/2 + float64(i)*math.Pi/float64(points) + poly = append(poly, Point{ + X: x + float32(math.Cos(a))*r, + Y: y + float32(math.Sin(a))*r, + }) + } + for i := 0; i < len(poly); i++ { + next := (i + 1) % len(poly) + Triangle(screen, x, y, poly[i].X, poly[i].Y, poly[next].X, poly[next].Y, clr) + } +} + +type Point struct { + X float32 + Y float32 +} + +func Polygon(screen *ebiten.Image, points []Point, clr color.Color) { + if len(points) < 3 { + return + } + r, g, b, a := clr.RGBA() + vertices := make([]ebiten.Vertex, len(points)) + for i, p := range points { + vertices[i] = ebiten.Vertex{ + DstX: p.X, + DstY: p.Y, + SrcX: 0, + SrcY: 0, + ColorR: float32(r) / 0xffff, + ColorG: float32(g) / 0xffff, + ColorB: float32(b) / 0xffff, + ColorA: float32(a) / 0xffff, + } + } + indices := make([]uint16, 0, (len(points)-2)*3) + for i := 1; i < len(points)-1; i++ { + indices = append(indices, 0, uint16(i), uint16(i+1)) + } + screen.DrawTriangles(vertices, indices, whitePixel, nil) +} + +func Text(screen *ebiten.Image, s string, x, y int, clr color.Color, scale float64) { + drawText(screen, s, x, y, clr, scale, false) +} + +func CenteredText(screen *ebiten.Image, s string, cx, cy int, clr color.Color, scale float64) { + drawText(screen, s, cx, cy, clr, scale, true) +} + +func drawText(screen *ebiten.Image, s string, x, y int, clr color.Color, scale float64, centered bool) { + if scale <= 0 { + scale = 1 + } + w := max(1, len(s)*7+4) + h := 17 + img := ebiten.NewImage(w, h) + text.Draw(img, s, basicfont.Face7x13, 2, 13, clr) + + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(scale, scale) + tx, ty := float64(x), float64(y) + if centered { + tx -= float64(w) * scale / 2 + ty -= float64(h) * scale / 2 + } + op.GeoM.Translate(tx, ty) + screen.DrawImage(img, op) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e0dc84c --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "log" + + "github.com/hajimehoshi/ebiten/v2" + + "kidskeyboard/internal/app" +) + +func main() { + cfg := app.ParseConfig() + game := app.New(cfg) + + ebiten.SetWindowTitle("KidsKeyboard") + ebiten.SetWindowSize(cfg.WindowWidth, cfg.WindowHeight) + ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) + ebiten.SetFullscreen(cfg.Fullscreen) + + if err := ebiten.RunGame(game); err != nil && err != ebiten.Termination { + log.Fatal(err) + } +}