diff --git a/go.mod b/go.mod index 976e268c..864c3a18 100644 --- a/go.mod +++ b/go.mod @@ -2,14 +2,24 @@ module mc go 1.26.4 -require gopkg.in/yaml.v3 v3.0.1 +require ( + github.com/ebitengine/oto/v3 v3.4.0 + github.com/faiface/beep v1.1.0 + github.com/fsnotify/fsnotify v1.10.1 + github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260802143932-8fa725040a18 + github.com/klauspost/compress v1.19.2 + github.com/ojrac/opensimplex-go v1.0.2 + github.com/yuin/gopher-lua v1.1.2 + gopkg.in/yaml.v3 v3.0.1 +) require ( - github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect - github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260802143932-8fa725040a18 // indirect - github.com/klauspost/compress v1.19.2 // indirect - github.com/ojrac/opensimplex-go v1.0.2 // indirect - github.com/yuin/gopher-lua v1.1.2 // indirect - golang.org/x/sys v0.13.0 // indirect + github.com/ebitengine/purego v0.9.0 // indirect + github.com/hajimehoshi/oto v0.7.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect + golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 // indirect + golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect + golang.org/x/sys v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 847756a5..39d08d1e 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,57 @@ +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= +github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ= +github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI= +github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= +github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= +github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= +github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI= github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260802143932-8fa725040a18 h1:c/72gzBO3eZPUVuHPnfyvt79XoIEi7KYGBY/8J/BpeI= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260802143932-8fa725040a18/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc= +github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= +github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= +github.com/hajimehoshi/oto v0.7.1 h1:I7maFPz5MBCwiutOrz++DLdbr4rTzBsbBuV2VpgU9kk= +github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= +github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= +github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= +github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= +github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= github.com/ojrac/opensimplex-go v1.0.2 h1:l4vs0D+JCakcu5OV0kJ99oEaWJfggSc9jiLpxaWvSzs= github.com/ojrac/opensimplex-go v1.0.2/go.mod h1:NwbXFFbXcdGgIFdiA7/REME+7n/lOf1TuEbLiZYOWnM= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSinK/ChZFBE43/WtIy8zg= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 h1:vyLBGJPIl9ZYbcQFM2USFmJBK6KI+t+z6jL0lbwjrnc= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/audio/audio.go b/internal/audio/audio.go new file mode 100644 index 00000000..5bd82033 --- /dev/null +++ b/internal/audio/audio.go @@ -0,0 +1,152 @@ +// Package audio 音频管理器:3D 衰减、声源上限、分类音量(场景/音频.md §2–§4)。 +// +// 无音频文件阶段用程序化合成音(正弦/噪声包络);播放后端为 beep + oto(Windows 纯 Go, +// 基于 purego,无需 CGO)。 +package audio + +import ( + "math" + "sync" + "time" + + "github.com/ebitengine/oto/v3" + "github.com/faiface/beep" + "github.com/faiface/beep/effects" + "github.com/faiface/beep/speaker" +) + +// 声源上限(音频.md §3:同时播放 ≤64,超出按最远淘汰)。 +const maxSources = 64 + +// 音量分类(音频.md §4)。 +type category uint8 + +const ( + catSFX category = iota // 音效 + catMusic // 音乐 + catAmbient // 环境 +) + +// Manager 音频管理器(单例)。 +type Manager struct { + mu sync.Mutex + enabled bool + ctx *oto.Context + master float64 + vols [3]float64 // sfx / music / ambient + sources []*source + listener [3]float64 // 玩家位置 +} + +// source 一个正在播放的声源。 +type source struct { + key string + pos [3]float64 // 世界坐标 + dist float64 // 距玩家距离 + ctrl *beep.Ctrl + cat category + start time.Time +} + +// New 创建音频管理器(找不到音频设备时降级为静默——服务器/CI 安全)。 +func New() *Manager { + m := &Manager{master: 1.0, vols: [3]float64{1, 1, 1}} + const ( + sampleRate = 44100 + bufferSize = 100 * time.Millisecond + ) + ctx, _, err := oto.NewContext(&oto.NewContextOptions{ + SampleRate: sampleRate, + ChannelCount: 2, + BufferSize: bufferSize, + }) + if err != nil { + return m // 无设备:静默模式 + } + m.ctx = ctx + m.enabled = true + speaker.Init(sampleRate, int(sampleRate*0.1)) + return m +} + +// SetListener 更新玩家位置(每帧/tick)。 +func (m *Manager) SetListener(x, y, z float64) { + m.mu.Lock() + m.listener = [3]float64{x, y, z} + m.mu.Unlock() +} + +// SetVolumes 设置分类音量(master × 分类)。 +func (m *Manager) SetVolumes(master, sfx, music, ambient float64) { + m.mu.Lock() + m.master, m.vols[catSFX], m.vols[catMusic], m.vols[catAmbient] = master, sfx, music, ambient + m.mu.Unlock() +} + +// Play 播放一个音效(3D 衰减,音频.md §3):key 为音效键,pos 为声源世界坐标。 +func (m *Manager) Play(key string, x, y, z float64) { + m.mu.Lock() + defer m.mu.Unlock() + if !m.enabled { + return + } + dist := math.Sqrt((x-m.listener[0])*(x-m.listener[0]) + (y-m.listener[1])*(y-m.listener[1]) + (z-m.listener[2])*(z-m.listener[2])) + gain := attenuation(dist) + if gain <= 0.01 { + return // 超出衰减范围:不发声 + } + // 声源池:满员时淘汰最远(音频.md §3) + if len(m.sources) >= maxSources { + worst := -1 + worstDist := -1.0 + for i, s := range m.sources { + if s.dist > worstDist { + worstDist = s.dist + worst = i + } + } + if worst >= 0 { + m.sources[worst].ctrl.Streamer = nil + m.sources[worst].ctrl.Paused = true + m.sources = append(m.sources[:worst], m.sources[worst+1:]...) + } + } + // 程序化合成:按音效键选择波形(无音频文件的占位方案,音频.md §6) + streamer := synthFor(key) + ctrl := &beep.Ctrl{Streamer: streamer, Paused: false} + vol := &effects.Volume{Streamer: ctrl, Base: 2, Volume: math.Log2(0.1+gain)*m.master*m.vols[catSFX] + 1} + speaker.Play(vol) + m.sources = append(m.sources, &source{key: key, pos: [3]float64{x, y, z}, dist: dist, ctrl: ctrl, start: time.Now()}) +} + +// attenuation 距离衰减:线性,maxDist 20(音频.md §3)。 +func attenuation(dist float64) float64 { + const maxDist = 20.0 + if dist >= maxDist { + return 0 + } + return 1 - dist/maxDist +} + +// Close 释放设备。 +func (m *Manager) Close() { + m.mu.Lock() + defer m.mu.Unlock() + if m.ctx != nil { + _ = m.ctx.Suspend() + m.ctx = nil + } + m.enabled = false +} + +// synthFor 按音效键生成占位波形(后续替换为真实音效文件)。 +func synthFor(key string) beep.Streamer { + switch key { + case "block.stone.break", "block.grass.break": + return noiseBurst(0.12) + case "block.place", "step": + return sineBurst(220, 0.06) + default: + return sineBurst(440, 0.1) + } +} diff --git a/internal/audio/audio_test.go b/internal/audio/audio_test.go new file mode 100644 index 00000000..7ccf60e5 --- /dev/null +++ b/internal/audio/audio_test.go @@ -0,0 +1,50 @@ +package audio + +import "testing" + +// TestAttenuation 距离衰减公式(音频.md §3)。 +func TestAttenuation(t *testing.T) { + if a := attenuation(0); a != 1 { + t.Fatalf("零距离衰减期望 1,实际 %v", a) + } + if a := attenuation(10); a != 0.5 { + t.Fatalf("10 格衰减期望 0.5,实际 %v", a) + } + if a := attenuation(20); a != 0 { + t.Fatalf("最大距离衰减期望 0,实际 %v", a) + } + if a := attenuation(30); a != 0 { + t.Fatalf("超距衰减期望 0,实际 %v", a) + } +} + +// TestBurstStreamer 合成流:有限长度与数据填充。 +func TestBurstStreamer(t *testing.T) { + b := sineBurst(440, 0.05) + samples := make([][2]float64, 1024) + total := 0 + for { + n, ok := b.Stream(samples) + total += n + if ok { + break + } + } + if total != b.Len() { + t.Fatalf("流长度期望 %d,实际 %d", b.Len(), total) + } + if total < 1000 { + t.Fatalf("采样数异常: %d", total) + } +} + +// TestManagerSilent 无音频设备环境(CI/服务器)下降级为静默、不崩溃。 +func TestManagerSilent(t *testing.T) { + m := New() // 可能无设备:必须不 panic + defer m.Close() + m.SetVolumes(1, 1, 1, 1) + m.SetListener(0, 0, 0) + for i := 0; i < 100; i++ { + m.Play("block.stone.break", float64(i), 0, 0) // 池满淘汰也不崩 + } +} diff --git a/internal/audio/synth.go b/internal/audio/synth.go new file mode 100644 index 00000000..aee89c8a --- /dev/null +++ b/internal/audio/synth.go @@ -0,0 +1,69 @@ +// 程序化音效合成:正弦脉冲与噪声脉冲(无音频文件的占位实现,音频.md §6)。 +package audio + +import ( + "math" + "math/rand" + "time" + + "github.com/faiface/beep" +) + +// sineBurst 正弦脉冲(频率 Hz、时长秒)。 +func sineBurst(freq float64, dur float64) *burst { + n := int(44100 * dur) + return &burst{n: n, gen: func(t int) float64 { + env := 1 - float64(t)/float64(n) // 线性衰减包络 + return env * 0.5 * math.Sin(2*math.Pi*freq*float64(t)/44100) + }} +} + +// noiseBurst 噪声脉冲(破坏音效感)。 +func noiseBurst(dur float64) *burst { + n := int(44100 * dur) + rng := rand.New(rand.NewSource(42)) + return &burst{n: n, gen: func(t int) float64 { + env := 1 - float64(t)/float64(n) + return env * 0.4 * (rng.Float64()*2 - 1) + }} +} + +// burst 有限长度合成流。 +type burst struct { + n int + pos int + gen func(t int) float64 +} + +// Stream beep.Streamer:填充采样。 +func (b *burst) Stream(samples [][2]float64) (int, bool) { + i := 0 + for ; i < len(samples) && b.pos < b.n; i++ { + v := b.gen(b.pos) + samples[i][0] = v + samples[i][1] = v + b.pos++ + } + return i, b.pos >= b.n +} + +// Err 无错误。 +func (b *burst) Err() error { return nil } + +// Len 时长采样数。 +func (b *burst) Len() int { return b.n } + +// Position 当前位置。 +func (b *burst) Position() int { return b.pos } + +// Seek 跳转。 +func (b *burst) Seek(p int) error { + if p < 0 || p > b.n { + p = 0 + } + b.pos = p + return nil +} + +var _ beep.StreamSeeker = (*burst)(nil) +var _ = time.Second diff --git a/tools/zig.zip b/tools/zig.zip index 46497855..c93e8339 100644 Binary files a/tools/zig.zip and b/tools/zig.zip differ