Building a Desktop Pet with Go and Ebiten
This article walks through creating a Go‑based desktop pet that follows the mouse, reacts to clicks with sounds and visual changes, and runs transparently using the Ebiten game engine, complete with code snippets, configuration options, and launch instructions.
Pet behavior
The desktop pet named KunKun tracks the mouse cursor, moves across the screen, and changes its appearance and sound when clicked.
Dependencies
Implemented with the Go game engine github.com/hajimehoshi/ebiten. Providing a type that implements the Game interface is sufficient to run the pet.
Source code walkthrough
Read configuration information.
Print the slogan "IKun爱坤程序,可以不爱,但别伤害~".
Load image and audio resources from the material directory.
Create a window and pass a struct that implements the Game interface ( ikun).
func main() {
// 读取配置信息
config.PrefixEnv = "IKUN"
config.File = "ikun.ini"
config.Parse(cfg)
// 打印Slogan
fmt.Println("IKun爱坤程序,可以不爱,但别伤害~")
mSprite = make(map[string]*ebiten.Image)
mSound = make(map[string][]byte)
// 读取 material中的所有的资源(图片/音频)
a, _ := fs.ReadDir(f, "material")
for _, v := range a {
data, _ := f.ReadFile("material/" + 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)
audio.CurrentContext().NewPlayerFromBytes([]byte{}).Play()
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("IKun")
iKun := &ikun{
x: monitorWidth / 2,
y: monitorHeight / 2,
count: 0,
min: 50,
picName: strconv.Itoa(r.Intn(8)), // 默认显示的图片
}
err := ebiten.RunGameWithOptions(iKun, &ebiten.RunGameOptions{
InitUnfocused: true,
ScreenTransparent: true,
SkipTaskbar: true,
X11ClassName: "IKun",
X11InstanceName: "IKun",
})
if err != nil { log.Fatal(err) }
}ikun struct
type ikun struct {
// 窗体的屏幕坐标
x int
y int
distance int // 鼠标和窗体的距离
isFlag bool // 状态切换标记
picName string // 当前显示的图片
lastPicName string // 上次显示的图片
waiting bool // 窗体是否固定(不移动)
count int // 图片切换计数
min int
max int
}Game interface implementation
func (i *ikun) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
return width, height
}
func (i *ikun) Draw(screen *ebiten.Image) {
if i.waiting {
if i.count < i.min {
i.picName = "wait0"
} else if i.count >= i.min && i.count < i.max {
i.picName = "wait1"
} else {
i.count = 0
}
} else if i.isFlag {
if i.count > i.min {
i.count = 0
i.picName = strconv.Itoa(r.Intn(8))
}
}
pic := i.picName
if i.lastPicName == pic { return }
i.lastPicName = pic
img := mSprite[pic]
screen.Clear()
screen.DrawImage(img, nil)
}
func (i *ikun) Update() error {
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
if i.waiting {
playSound(mSound["wa"])
i.picName = strconv.Itoa(r.Intn(8))
i.count = 0
i.min = 50
i.max = 100
} else {
playSound(mSound["ganma"])
i.count = 0
i.min = 10
i.max = 20
}
i.waiting = !i.waiting
}
i.count++
if i.waiting { return nil }
i.x = max(0, min(i.x, monitorWidth))
i.y = max(0, min(i.y, monitorHeight))
ebiten.SetWindowPosition(i.x, i.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 }
i.distance = dx + dy
if i.distance < width {
if i.isFlag { playSound(mSound["ji"]) }
i.isFlag = false
} else {
i.catchCursor(x, y)
}
return nil
}
func (i *ikun) catchCursor(x, y int) {
i.isFlag = true
r := math.Atan2(float64(y), float64(x))
tr := 0.0
if r <= 0 { tr = 360 }
a := (r / math.Pi * 180) + tr
switch {
case a <= 292.5 && a > 247.5:
i.y -= cfg.Speed // 上
case a <= 337.5 && a > 292.5:
i.x += cfg.Speed; i.y -= cfg.Speed // 右上
case a <= 22.5 || a > 337.5:
i.x += cfg.Speed // 右
case a <= 67.5 && a > 22.5:
i.x += cfg.Speed; i.y += cfg.Speed // 右下
case a <= 112.5 && a > 67.5:
i.y += cfg.Speed // 下
case a <= 157.5 && a > 112.5:
i.x -= cfg.Speed; i.y += cfg.Speed // 左下
case a <= 202.5 && a > 157.5:
i.x -= cfg.Speed // 左
case a <= 247.5 && a > 202.5:
i.x -= cfg.Speed; i.y -= cfg.Speed // 左上
}
}
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()
}Running the program
Clone the repository https://github.com/gofish2020/ikun and execute go run main.go. The program starts with a transparent, floating window that follows the cursor.
Command‑line flags
-mousepassthrough: enable mouse‑through window (default false). -quiet: mute audio (default false). -scale: scale the window size (default 2.0). -speed: pixel movement per update (default 2). -h: show help.
References
Reference implementation: https://github.com/crgimenes/neko
Additional Ebiten game examples: https://github.com/sedyh/awesome-ebitengine?tab=readme-ov-file#games
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Nullbody Notes
Go backend development, learning open-source project source code together, focusing on simplicity and practicality.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
