Files
rat/main.go
2025-09-02 16:59:26 -03:00

295 lines
5.8 KiB
Go

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)
}
}