diff --git a/assets/image/cursor.png b/assets/image/cursor.png new file mode 100644 index 0000000..e9164cb Binary files /dev/null and b/assets/image/cursor.png differ diff --git a/main.go b/main.go index b125c49..5f2ab28 100644 --- a/main.go +++ b/main.go @@ -36,17 +36,23 @@ var wantedGopherData []byte //go:embed assets/audio/bgm.wav var bgmData []byte +//go:embed assets/image/cursor.png +var cursorData []byte + type Game struct { gameService *service.Game renderer *view.Renderer + cursor *service.Cursor } func NewGame() *Game { gameService := service.NewGame() renderer := view.NewRenderer(gameService) + cursor := service.NewCursor() return &Game{ gameService: gameService, renderer: renderer, + cursor: cursor, } } @@ -55,6 +61,13 @@ func (g *Game) Update() error { dt := now.Sub(g.gameService.LastUpdate).Seconds() g.gameService.LastUpdate = now + // カーソル位置を取得 + mx, my := ebiten.CursorPosition() + isClicking := ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) + + // カーソルを更新 + g.cursor.Update(mx, my, isClicking) + switch g.gameService.State { case service.StateTitle: if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { @@ -92,6 +105,7 @@ func (g *Game) Update() error { func (g *Game) Draw(screen *ebiten.Image) { g.renderer.Draw(screen) + g.renderer.DrawCursor(screen, g.cursor) } func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { @@ -111,9 +125,16 @@ func main() { bgmData, ) + // Load cursor image + if err := service.LoadCursorImage(cursorData); err != nil { + log.Printf("Failed to load cursor image: %v", err) + } + // Set window properties ebiten.SetWindowSize(service.ScreenWidth, service.ScreenHeight) ebiten.SetWindowTitle("Find Gopher") + // デフォルトカーソルを非表示 + ebiten.SetCursorMode(ebiten.CursorModeHidden) // Create and run game game := NewGame() diff --git a/service/cursor.go b/service/cursor.go new file mode 100644 index 0000000..b38a53f --- /dev/null +++ b/service/cursor.go @@ -0,0 +1,106 @@ +package service + +import ( + "bytes" + "image" + "time" + + "github.com/hajimehoshi/ebiten/v2" +) + +// Trail はカーソルのトレイル効果の一つの要素を表します +type Trail struct { + X float64 + Y float64 + Alpha float64 + Scale float64 + Created time.Time +} + +// Cursor はカスタムカーソルを管理する構造体です +type Cursor struct { + Image *ebiten.Image + X float64 + Y float64 + LastX float64 + LastY float64 + Trails []Trail + TrailMaxAge time.Duration + IsClicking bool + ClickScale float64 + BaseScale float64 + CurrentScale float64 +} + +var cursorImage *ebiten.Image + +// LoadCursorImage はカーソル画像を読み込みます +func LoadCursorImage(data []byte) error { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return err + } + cursorImage = ebiten.NewImageFromImage(img) + return nil +} + +// NewCursor は新しいカーソルインスタンスを作成します +func NewCursor() *Cursor { + return &Cursor{ + Image: cursorImage, + Trails: make([]Trail, 0), + TrailMaxAge: 500 * time.Millisecond, + BaseScale: 1.0, + ClickScale: 0.8, + CurrentScale: 1.0, + } +} + +// Update はカーソルの状態を更新します +func (c *Cursor) Update(x, y int, isClicking bool) { + c.X = float64(x) + c.Y = float64(y) + c.IsClicking = isClicking + + // カーソルが移動した距離を計算 + dx := c.X - c.LastX + dy := c.Y - c.LastY + distance := dx*dx + dy*dy + + now := time.Now() + + // 移動距離が閾値(2ピクセル)以上の場合のみトレイルを追加 + if distance >= 4.0 { // 2*2 = 4 + c.Trails = append(c.Trails, Trail{ + X: c.X, + Y: c.Y, + Alpha: 1.0, + Scale: 1.0, + Created: now, + }) + // 前回の位置を更新 + c.LastX = c.X + c.LastY = c.Y + } + + // 古いトレイルを削除 + newTrails := make([]Trail, 0) + for i := range c.Trails { + age := now.Sub(c.Trails[i].Created) + if age < c.TrailMaxAge { + // アルファ値とスケールを時間経過で減少 + progress := age.Seconds() / c.TrailMaxAge.Seconds() + c.Trails[i].Alpha = 1.0 - progress + c.Trails[i].Scale = 1.0 - progress*0.5 + newTrails = append(newTrails, c.Trails[i]) + } + } + c.Trails = newTrails + + // スケールをアニメーション(拡大効果を削除) + targetScale := c.BaseScale + if isClicking { + targetScale = c.ClickScale + } + c.CurrentScale += (targetScale - c.CurrentScale) * 0.2 +} diff --git a/view/components/cursor.go b/view/components/cursor.go new file mode 100644 index 0000000..a693862 --- /dev/null +++ b/view/components/cursor.go @@ -0,0 +1,72 @@ +package components + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + "github.com/snkrdunk/find-gopher/service" +) + +// DrawCursor はカスタムカーソルとその効果を描画します +func DrawCursor(screen *ebiten.Image, cursor *service.Cursor) { + if cursor == nil || cursor.Image == nil { + return + } + + // トレイル効果を描画 + drawCursorTrails(screen, cursor) + + // カーソル本体を描画 + drawCursorImage(screen, cursor) +} + +// drawCursorTrails はカーソルのトレイル効果を描画します +func drawCursorTrails(screen *ebiten.Image, cursor *service.Cursor) { + for _, trail := range cursor.Trails { + if trail.Alpha <= 0 { + continue + } + + // トレイルの円を描画 + radius := float32(6 * trail.Scale) + alpha := uint8(trail.Alpha * 100) + trailColor := color.RGBA{150, 200, 250, alpha} + + vector.DrawFilledCircle( + screen, + float32(trail.X), + float32(trail.Y), + radius, + trailColor, + true, + ) + } +} + +// drawCursorImage はカーソル画像を描画します +func drawCursorImage(screen *ebiten.Image, cursor *service.Cursor) { + if cursor.Image == nil { + return + } + + opts := &ebiten.DrawImageOptions{} + + // カーソル画像のサイズを取得 + bounds := cursor.Image.Bounds() + w := float64(bounds.Dx()) + h := float64(bounds.Dy()) + + // 中心を基準に変換 + opts.GeoM.Translate(-w/2, -h/2) + + // 64x64の画像を32x32に縮小(0.5倍) + baseScale := 0.5 + // スケール(baseScale * CurrentScale) + opts.GeoM.Scale(baseScale*cursor.CurrentScale, baseScale*cursor.CurrentScale) + + // カーソル位置に移動 + opts.GeoM.Translate(cursor.X, cursor.Y) + + screen.DrawImage(cursor.Image, opts) +} diff --git a/view/renderer.go b/view/renderer.go index f5026d2..68bca61 100644 --- a/view/renderer.go +++ b/view/renderer.go @@ -41,3 +41,8 @@ func (r *Renderer) Draw(screen *ebiten.Image) { func (r *Renderer) Layout(outsideWidth, outsideHeight int) (int, int) { return service.ScreenWidth, service.ScreenHeight } + +// DrawCursor はカスタムカーソルを描画します +func (r *Renderer) DrawCursor(screen *ebiten.Image, cursor *service.Cursor) { + components.DrawCursor(screen, cursor) +}