package main import ( "bytes" "embed" "image" _ "image/png" "io" "io/fs" "log" "math" "path/filepath" "strings" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/audio" "github.com/hajimehoshi/ebiten/v2/audio/wav" "github.com/hajimehoshi/ebiten/v2/inpututil" "crg.eti.br/go/config" _ "crg.eti.br/go/config/ini" ) type neko struct { waiting bool x float64 y float64 distance int count int min int max int state int sprite string lastSprite string img *ebiten.Image } type Config struct { Speed float64 `cfg:"speed" cfgDefault:"2.0" cfgHelper:"The speed of the cat."` Scale float64 `cfg:"scale" cfgDefault:"2.0" cfgHelper:"The scale of the cat."` Quiet bool `cfg:"quiet" cfgDefault:"false" cfgHelper:"Disable sound."` MousePassthrough bool `cfg:"mousepassthrough" cfgDefault:"false" cfgHelper:"Enable mouse passthrough."` } const ( width = 32 height = 32 ) var ( loaded = false mSprite map[string]*ebiten.Image mSound map[string][]byte //go:embed assets/* f embed.FS monitorWidth, monitorHeight = ebiten.Monitor().Size() cfg = &Config{} currentplayer *audio.Player = nil ) func (m *neko) Layout(outsideWidth, outsideHeight int) (int, int) { return width, height } func playSound(sound []byte) { if cfg.Quiet { return } if currentplayer != nil && currentplayer.IsPlaying() { currentplayer.Close() } currentplayer = audio.CurrentContext().NewPlayerFromBytes(sound) currentplayer.SetVolume(.3) currentplayer.Play() } func (m *neko) Update() error { m.count++ if m.state == 10 && m.count == m.min { playSound(mSound["idle3"]) } // Prevents neko from being stuck on the side of the screen // or randomly travelling to another monitor m.x = max(0, min(m.x, float64(monitorWidth))) m.y = max(0, min(m.y, float64(monitorHeight))) ebiten.SetWindowPosition(int(math.Round(m.x)), int(math.Round(m.y))) mx, my := ebiten.CursorPosition() x := mx - (height / 2) y := my - (width / 2) dy, dx := y, x if dy < 0 { dy = -dy } if dx < 0 { dx = -dx } m.distance = dx + dy if m.distance < width || m.waiting { m.stayIdle() if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { m.waiting = !m.waiting } return nil } if m.state >= 13 { playSound(mSound["awake"]) } m.catchCursor(x, y) return nil } func (m *neko) stayIdle() { // idle state switch m.state { case 0: m.state = 1 fallthrough case 1, 2, 3: m.sprite = "awake" case 4, 5, 6: m.sprite = "scratch" case 7, 8, 9: m.sprite = "wash" case 10, 11, 12: m.min = 32 m.max = 64 m.sprite = "yawn" default: m.sprite = "sleep" } } func (m *neko) catchCursor(x, y int) { m.state = 0 m.min = 8 m.max = 16 // get mouse direction r := math.Atan2(float64(y), float64(x)) a := math.Mod((r/math.Pi*180)+360, 360) // Normazing angle to [0, 360) switch { case a <= 292.5 && a > 247.5: // up m.y -= cfg.Speed m.sprite = "up" case a <= 337.5 && a > 292.5: // up right m.x += cfg.Speed / math.Sqrt2 m.y -= cfg.Speed / math.Sqrt2 m.sprite = "upright" case a <= 22.5 || a > 337.5: // right m.x += cfg.Speed m.sprite = "right" case a <= 67.5 && a > 22.5: // down right m.x += cfg.Speed / math.Sqrt2 m.y += cfg.Speed / math.Sqrt2 m.sprite = "downright" case a <= 112.5 && a > 67.5: // down m.y += cfg.Speed m.sprite = "down" case a <= 157.5 && a > 112.5: // down left m.x -= cfg.Speed / math.Sqrt2 m.y += cfg.Speed / math.Sqrt2 m.sprite = "downleft" case a <= 202.5 && a > 157.5: // left m.x -= cfg.Speed m.sprite = "left" case a <= 247.5 && a > 202.5: // up left m.x -= cfg.Speed m.y -= cfg.Speed m.sprite = "upleft" } } func (m *neko) Draw(screen *ebiten.Image) { var sprite string switch { case m.sprite == "awake": sprite = m.sprite case m.count < m.min: sprite = m.sprite + "1" default: sprite = m.sprite + "2" } m.img = mSprite[sprite] if m.count > m.max { m.count = 0 if m.state > 0 { m.state++ switch m.state { case 13: playSound(mSound["sleep"]) } } } if m.lastSprite == sprite { return } m.lastSprite = sprite screen.Clear() screen.DrawImage(m.img, nil) } func main() { config.PrefixEnv = "NEKO" config.File = "neko.ini" config.Parse(cfg) mSprite = make(map[string]*ebiten.Image) mSound = make(map[string][]byte) a, _ := fs.ReadDir(f, "assets") for _, v := range a { data, _ := f.ReadFile("assets/" + v.Name()) name := strings.TrimSuffix(v.Name(), filepath.Ext(v.Name())) ext := filepath.Ext(v.Name()) switch ext { case ".png": img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { log.Fatal(err) } mSprite[name] = ebiten.NewImageFromImage(img) case ".wav": stream, err := wav.DecodeWithSampleRate(44100, bytes.NewReader(data)) if err != nil { log.Fatal(err) } data, err := io.ReadAll(stream) if err != nil { log.Fatal(err) } mSound[name] = data } } audio.NewContext(44100) // Workaround: for some reason playing the first sound can incur significant delay. // So let's do this at the start. audio.CurrentContext().NewPlayerFromBytes([]byte{}).Play() n := &neko{ x: float64(monitorWidth / 2), y: float64(monitorHeight / 2), min: 8, max: 16, } ebiten.SetRunnableOnUnfocused(true) ebiten.SetScreenClearedEveryFrame(false) ebiten.SetTPS(50) ebiten.SetVsyncEnabled(true) ebiten.SetWindowDecorated(false) ebiten.SetWindowFloating(true) ebiten.SetWindowMousePassthrough(cfg.MousePassthrough) ebiten.SetWindowSize(int(float64(width)*cfg.Scale), int(float64(height)*cfg.Scale)) ebiten.SetWindowTitle("Neko") err := ebiten.RunGameWithOptions(n, &ebiten.RunGameOptions{ InitUnfocused: true, ScreenTransparent: true, SkipTaskbar: true, X11ClassName: "Neko", X11InstanceName: "Neko", }) if err != nil { log.Fatal(err) } }