Game Development 10 min read

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.

Nullbody Notes
Nullbody Notes
Nullbody Notes
Building a Desktop Pet with Go and Ebiten

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

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

GoGame DevelopmentDesktop PetEbitenMouse Interaction
Nullbody Notes
Written by

Nullbody Notes

Go backend development, learning open-source project source code together, focusing on simplicity and practicality.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.