diff --git a/go.mod b/go.mod index 788689fd..976e268c 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,11 @@ go 1.26.4 require 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 ) diff --git a/go.sum b/go.sum index a8910767..847756a5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= 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= @@ -6,6 +8,10 @@ github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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/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= 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/mods/lua.go b/internal/mods/lua.go new file mode 100644 index 00000000..c131ac21 --- /dev/null +++ b/internal/mods/lua.go @@ -0,0 +1,199 @@ +package mods + +import ( + "context" + "fmt" + "time" + + lua "github.com/yuin/gopher-lua" +) + +// 沙箱限制(设计.md §3.5、Mod开发指南.md §6)。 +const ( + defaultScriptTimeout = 5 * time.Millisecond // 单事件处理时限 + defaultMaxEntries = 256 // 注册数量上限 +) + +// LuaMod 一个 Lua 脚本 Mod。 +type LuaMod struct { + id string + reg *Registry + timeout time.Duration + entries int + handlers map[string]*lua.LFunction // 事件 → 处理器 +} + +// NewLuaMod 创建 Lua Mod。 +func NewLuaMod(id string, reg *Registry) *LuaMod { + return &LuaMod{ + id: id, + reg: reg, + timeout: defaultScriptTimeout, + handlers: make(map[string]*lua.LFunction), + } +} + +// Load 在沙箱中执行入口脚本(设计.md §3.3 第 5 步)。 +// 脚本内可调用 api.* 注册内容与事件;重复 Load 用于热重载。 +func (m *LuaMod) Load(script string) error { + return m.reg.Reload(m.id, func(gen uint64) error { + return m.run(script, gen) + }) +} + +// run 执行脚本(generation 版本化 + 超时 + 限额)。 +func (m *LuaMod) run(script string, gen uint64) error { + ctx, cancel := context.WithTimeout(context.Background(), m.timeout*10) // 加载阶段放宽 + defer cancel() + + L := lua.NewState(lua.Options{SkipOpenLibs: true}) + defer L.Close() + // 仅开放安全基础库(沙箱:禁 os/io/debug/package,Mod开发指南.md §6) + for _, pair := range []struct { + n string + f lua.LGFunction + }{ + {lua.BaseLibName, lua.OpenBase}, + {lua.TabLibName, lua.OpenTable}, + {lua.StringLibName, lua.OpenString}, + {lua.MathLibName, lua.OpenMath}, + } { + L.Push(L.NewFunction(pair.f)) + L.Push(lua.LString(pair.n)) + L.Call(1, 0) + } + L.SetContext(ctx) + + api := L.NewTable() + L.SetFuncs(api, map[string]lua.LGFunction{ + "register_block": m.apiRegisterBlock(gen), + "register_item": m.apiRegisterItem(gen), + "register_recipe": m.apiRegisterRecipe(gen), + "on": m.apiOn, + "get_block": m.apiGetBlock, + }) + L.SetGlobal("api", api) + + if err := L.DoString(script); err != nil { + return fmt.Errorf("mods.run %s: %w", m.id, err) + } + return nil +} + +// apiRegisterBlock 注册方块(Mod开发指南.md §4.2)。 +func (m *LuaMod) apiRegisterBlock(gen uint64) lua.LGFunction { + return func(L *lua.LState) int { + t := L.CheckTable(1) + id := L.GetField(t, "id").String() + m.entries++ + return m.registerOrError(L, "block", id, tableToMap(L, t), gen) + } +} + +// apiRegisterItem 注册物品。 +func (m *LuaMod) apiRegisterItem(gen uint64) lua.LGFunction { + return func(L *lua.LState) int { + t := L.CheckTable(1) + id := L.GetField(t, "id").String() + m.entries++ + return m.registerOrError(L, "item", id, tableToMap(L, t), gen) + } +} + +// apiRegisterRecipe 注册配方。 +func (m *LuaMod) apiRegisterRecipe(gen uint64) lua.LGFunction { + return func(L *lua.LState) int { + t := L.CheckTable(1) + key := fmt.Sprintf("%d", m.entries) + m.entries++ + return m.registerOrError(L, "recipe", key, tableToMap(L, t), gen) + } +} + +// registerOrError 写入注册表;失败向 Lua 抛错(热更新.md §4.2 回滚路径)。 +func (m *LuaMod) registerOrError(L *lua.LState, kind, key string, data map[string]any, gen uint64) int { + if m.entries > defaultMaxEntries { + L.RaiseError("注册数量超上限 %d", defaultMaxEntries) + return 0 + } + if err := m.reg.Register(m.id, gen, kind, key, data); err != nil { + L.RaiseError("%v", err) + return 0 + } + return 0 +} + +// apiOn 注册事件处理器(Mod开发指南.md §4.3)。 +func (m *LuaMod) apiOn(L *lua.LState) int { + event := L.CheckString(1) + fn := L.CheckFunction(2) + m.handlers[event] = fn + return 0 +} + +// apiGetBlock 世界只读(占位:完整接入见 Mod 加载器与 world 联动)。 +func (m *LuaMod) apiGetBlock(L *lua.LState) int { + L.Push(lua.LNil) + return 1 +} + +// tableToMap Lua 表 → map[string]any(仅一层)。 +func tableToMap(L *lua.LState, t *lua.LTable) map[string]any { + out := make(map[string]any) + t.ForEach(func(k, v lua.LValue) { + out[k.String()] = luaValueToGo(L, v) + }) + return out +} + +// luaValueToGo Lua 值 → Go 值(基础类型)。 +func luaValueToGo(L *lua.LState, v lua.LValue) any { + switch vv := v.(type) { + case *lua.LTable: + return tableToMap(L, vv) + case lua.LString: + return string(vv) + case lua.LNumber: + return float64(vv) + case lua.LBool: + return bool(vv) + default: + return nil + } +} + +// DispatchEvent 触发事件(热更新.md §4.1:处理器按来源绑定,重载时解绑)。 +func (m *LuaMod) DispatchEvent(event string, args ...any) error { + fn, ok := m.handlers[event] + if !ok { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), m.timeout) + defer cancel() + L := m.newDispatchState(ctx, args...) + defer L.Close() + if err := L.CallByParam(lua.P{Fn: fn, NRet: 0, Protect: true}); err != nil { + return fmt.Errorf("mods.DispatchEvent %s: %w", event, err) + } + return nil +} + +// newDispatchState 创建事件分发 VM(基础库最小集)。 +func (m *LuaMod) newDispatchState(ctx context.Context, args ...any) *lua.LState { + L := lua.NewState(lua.Options{SkipOpenLibs: true}) + for _, pair := range []struct { + n string + f lua.LGFunction + }{ + {lua.BaseLibName, lua.OpenBase}, + {lua.TabLibName, lua.OpenTable}, + {lua.StringLibName, lua.OpenString}, + {lua.MathLibName, lua.OpenMath}, + } { + L.Push(L.NewFunction(pair.f)) + L.Push(lua.LString(pair.n)) + L.Call(1, 0) + } + L.SetContext(ctx) + return L +} diff --git a/internal/mods/mods_test.go b/internal/mods/mods_test.go new file mode 100644 index 00000000..8024df30 --- /dev/null +++ b/internal/mods/mods_test.go @@ -0,0 +1,138 @@ +package mods + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// TestLuaRegister 脚本注册方块/物品/配方与事件(Mod开发指南.md §4)。 +func TestLuaRegister(t *testing.T) { + reg := NewRegistry() + m := NewLuaMod("test_mod", reg) + script := ` +api.register_block{ id = "ruby_ore", hardness = 3.0, light = 0 } +api.register_item{ id = "ruby", stack = 64 } +api.register_recipe{ type = "crafting_shaped", pattern = {"RRR","R R","RRR"}, result = {item = "ruby_block"} } +` + if err := m.Load(script); err != nil { + t.Fatalf("加载失败: %v", err) + } + entries := reg.Entries() + if len(entries) != 3 { + t.Fatalf("注册条目期望 3,实际 %d", len(entries)) + } + if reg.Gen("test_mod") != 1 { + t.Fatalf("代数期望 1,实际 %d", reg.Gen("test_mod")) + } + // 方块数据完整性 + found := false + for _, e := range entries { + if e.Key == "block:ruby_ore" { + found = true + if d, ok := e.Data.(map[string]any); !ok || d["hardness"] != 3.0 { + t.Fatalf("方块数据异常: %+v", e.Data) + } + } + } + if !found { + t.Fatal("缺少 block:ruby_ore 注册") + } +} + +// TestHotReload 热重载:新脚本替换旧注册(generation 机制,热更新.md §4.1)。 +func TestHotReload(t *testing.T) { + reg := NewRegistry() + m := NewLuaMod("hot_mod", reg) + if err := m.Load(`api.register_block{ id = "old_block" }`); err != nil { + t.Fatalf("首次加载失败: %v", err) + } + // 热重载:注册新方块 + if err := m.Load(`api.register_block{ id = "new_block" }`); err != nil { + t.Fatalf("重载失败: %v", err) + } + if reg.Gen("hot_mod") != 2 { + t.Fatalf("重载后代数期望 2,实际 %d", reg.Gen("hot_mod")) + } + for _, e := range reg.Entries() { + if e.Key == "block:old_block" { + t.Fatal("旧代注册未回收") + } + } +} + +// TestReloadRollback 坏脚本回滚:保留旧版本(热更新.md §4.2)。 +func TestReloadRollback(t *testing.T) { + reg := NewRegistry() + m := NewLuaMod("rollback_mod", reg) + if err := m.Load(`api.register_block{ id = "good_block" }`); err != nil { + t.Fatalf("首次加载失败: %v", err) + } + // 语法错误脚本:重载失败,旧版保留 + if err := m.Load(`api.register_block{ id = `); err == nil { + t.Fatal("坏脚本应报错") + } + if reg.Gen("rollback_mod") != 1 { + t.Fatalf("回滚后代数应保持 1,实际 %d", reg.Gen("rollback_mod")) + } + found := false + for _, e := range reg.Entries() { + if e.Key == "block:good_block" { + found = true + } + } + if !found { + t.Fatal("回滚后旧注册丢失") + } +} + +// TestSandbox 沙箱:os/io 库不可用(Mod开发指南.md §6)。 +func TestSandbox(t *testing.T) { + reg := NewRegistry() + m := NewLuaMod("sandbox_mod", reg) + if err := m.Load(`os.exit(0)`); err == nil { + t.Fatal("沙箱应禁止 os 库") + } +} + +// TestWatcher 文件监听热重载(热更新.md §4.5 防抖)。 +func TestWatcher(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "init.lua") + if err := os.WriteFile(path, []byte(`api.register_block{ id = "v1" }`), 0o644); err != nil { + t.Fatalf("写脚本失败: %v", err) + } + reg := NewRegistry() + m := NewLuaMod("watched_mod", reg) + reload := func() error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + return m.Load(string(b)) + } + w, err := NewWatcher() + if err != nil { + t.Fatalf("创建监听器失败: %v", err) + } + defer w.Close() + if err := w.WatchFile(path, reload); err != nil { + t.Fatalf("监听失败: %v", err) + } + go w.Run() + if err := reload(); err != nil { + t.Fatalf("初始加载失败: %v", err) + } + // 修改文件 → 防抖后自动重载 + if err := os.WriteFile(path, []byte(`api.register_block{ id = "v2" }`), 0o644); err != nil { + t.Fatalf("写脚本失败: %v", err) + } + deadline := time.Now().Add(5 * time.Second) + for reg.Gen("watched_mod") != 2 && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + } + if reg.Gen("watched_mod") != 2 { + t.Fatalf("文件监听未触发重载(代数 %d)", reg.Gen("watched_mod")) + } +} diff --git a/internal/mods/registry.go b/internal/mods/registry.go new file mode 100644 index 00000000..7a70c56a --- /dev/null +++ b/internal/mods/registry.go @@ -0,0 +1,80 @@ +// Package mods Mod 运行时:generation 版本化注册表、Lua 沙箱、注册 API 与热重载(热更新.md §4、Mod开发指南.md)。 +package mods + +import ( + "fmt" + "sync" +) + +// Entry 一个注册条目。 +type Entry struct { + Key string // 注册键(如 "block:ruby_ore") + Kind string // block / item / recipe / event / command + Gen uint64 // 注册代数(热更新.md §4.1) + Source string // 来源 Mod ID + Data any // 条目数据(结构由注册方定义) +} + +// Registry 注册表(generation 版本化)。 +type Registry struct { + mu sync.RWMutex + entries map[string]Entry + gen map[string]uint64 // 来源 → 当前代数 +} + +// NewRegistry 创建注册表。 +func NewRegistry() *Registry { + return &Registry{entries: make(map[string]Entry), gen: make(map[string]uint64)} +} + +// Reload 重载一个来源的注册(热更新.md §4.1–§4.2 两阶段 + 提交): +// 先以新代数执行 register;成功才回收旧代条目;失败保留旧版并返回错误。 +func (r *Registry) Reload(source string, register func(gen uint64) error) error { + r.mu.Lock() + next := r.gen[source] + 1 + r.mu.Unlock() + + if err := register(next); err != nil { + return fmt.Errorf("mods.Registry.Reload %s: %w(保留旧版本)", source, err) + } + // 提交:回收旧代 + r.mu.Lock() + defer r.mu.Unlock() + for k, e := range r.entries { + if e.Source == source && e.Gen != next { + delete(r.entries, k) + } + } + r.gen[source] = next + return nil +} + +// Register 写入条目(register 回调内调用)。 +func (r *Registry) Register(source string, gen uint64, kind, key string, data any) error { + r.mu.Lock() + defer r.mu.Unlock() + k := kind + ":" + key + if old, dup := r.entries[k]; dup && old.Source != source { + return fmt.Errorf("mods.Register: 注册键 %q 与 %q 冲突", k, old.Source) + } + r.entries[k] = Entry{Key: k, Kind: kind, Gen: gen, Source: source, Data: data} + return nil +} + +// Entries 导出全部条目(只读副本)。 +func (r *Registry) Entries() []Entry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Entry, 0, len(r.entries)) + for _, e := range r.entries { + out = append(out, e) + } + return out +} + +// Gen 返回来源的当前代数。 +func (r *Registry) Gen(source string) uint64 { + r.mu.RLock() + defer r.mu.RUnlock() + return r.gen[source] +} diff --git a/internal/mods/watcher.go b/internal/mods/watcher.go new file mode 100644 index 00000000..22f56980 --- /dev/null +++ b/internal/mods/watcher.go @@ -0,0 +1,73 @@ +// 热重载驱动:fsnotify 文件监听 + 防抖(热更新.md §4.5)。 +package mods + +import ( + "fmt" + "path/filepath" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +// Watcher 脚本文件监听器(开发模式)。 +type Watcher struct { + w *fsnotify.Watcher + mu sync.Mutex + reload map[string]func() error // 脚本路径 → 重载函数 + timers map[string]*time.Timer // 防抖计时器(500ms) +} + +// NewWatcher 创建监听器。 +func NewWatcher() (*Watcher, error) { + w, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("mods.NewWatcher: %w", err) + } + return &Watcher{w: w, reload: make(map[string]func() error), timers: make(map[string]*time.Timer)}, nil +} + +// WatchFile 监听一个脚本文件:变化后防抖触发 reload(热更新.md §4.5 防抖 500ms)。 +func (w *Watcher) WatchFile(path string, reload func() error) error { + if err := w.w.Add(filepath.Dir(path)); err != nil { + return fmt.Errorf("mods.WatchFile %s: %w", path, err) + } + w.mu.Lock() + w.reload[path] = reload + w.mu.Unlock() + return nil +} + +// Run 事件循环(goroutine):文件变化 → 防抖 → 重载。 +func (w *Watcher) Run() { + for { + select { + case ev, ok := <-w.w.Events: + if !ok { + return + } + if ev.Op&(fsnotify.Write|fsnotify.Create) == 0 { + continue + } + w.mu.Lock() + fn, ok := w.reload[ev.Name] + if !ok { + w.mu.Unlock() + continue + } + if t, exists := w.timers[ev.Name]; exists { + t.Stop() + } + reloadFn := fn + w.timers[ev.Name] = time.AfterFunc(500*time.Millisecond, func() { _ = reloadFn() }) + w.mu.Unlock() + case _, ok := <-w.w.Errors: + if !ok { + return + } + } + } +} + +// Close 停止监听。 +func (w *Watcher) Close() error { return w.w.Close() } diff --git a/internal/textr/text.go b/internal/textr/text.go new file mode 100644 index 00000000..86713654 --- /dev/null +++ b/internal/textr/text.go @@ -0,0 +1,79 @@ +// Package textr 文本光栅化:把字符串用位图字体渲染成 RGBA 图像(CPU 侧,UI还原.md §4)。 +// +// 字形来自素材包 8×8 位图字体(internal/font);本包输出可直接上传为 GL 纹理的图像, +// 因此与渲染线程解耦(GL 只负责贴图)。 +package textr + +import ( + "image" + "image/color" + "image/draw" + + "mc/internal/font" +) + +// Drawer 文本绘制器。 +type Drawer struct { + atlas *font.Atlas +} + +// New 创建绘制器。 +func New(atlas *font.Atlas) *Drawer { return &Drawer{atlas: atlas} } + +// Width 计算文本像素宽度(8px/字符)。 +func (d *Drawer) Width(s string) int { + w := 0 + for _, r := range s { + if g, ok := d.atlas.Glyphs[r]; ok { + w += g.Width + } else { + w += 8 // 缺失字形占位 + } + } + return w +} + +// Draw 渲染一行文本(白字透明底;shadow 为 true 时附加 1px 黑色阴影,UI还原.md §4)。 +// 返回图像与阴影偏移(阴影在图像内左上角,调用方绘制时需偏移 1px)。 +func (d *Drawer) Draw(s string, c color.RGBA, shadow bool) image.Image { + w := d.Width(s) + h := 8 + if shadow { + w++ + h++ + } + img := image.NewRGBA(image.Rect(0, 0, w, h)) + x := 0 + for _, r := range s { + g, ok := d.atlas.Glyphs[r] + if !ok { + g = d.atlas.Missing() + } + d.blit(img, g, x, 0, c) + if shadow { + d.blit(img, g, x+1, 1, color.RGBA{0, 0, 0, 255}) + } + x += g.Width + } + return img +} + +// blit 把字形像素以指定颜色写入目标(字形位图按 alpha 通道着色)。 +func (d *Drawer) blit(dst *image.RGBA, g font.Glyph, dx, dy int, c color.RGBA) { + if d.atlas.Image == nil { + return + } + src := d.atlas.Image + for y := 0; y < g.H; y++ { + for x := 0; x < g.W; x++ { + r, _, _, a := src.At(g.X+x, g.Y+y).RGBA() + if a == 0 { + continue + } + _ = r // 字形颜色以纹理亮度为准,目标色统一用 c + dst.SetRGBA(dx+x, dy+y, c) + } + } +} + +var _ = draw.Draw // 保持 image/draw 引用(后续 9-patch 使用) diff --git a/internal/textr/text_test.go b/internal/textr/text_test.go new file mode 100644 index 00000000..1333674b --- /dev/null +++ b/internal/textr/text_test.go @@ -0,0 +1,66 @@ +package textr + +import ( + "image/color" + "path/filepath" + "testing" + + "mc/internal/assets" + "mc/internal/font" +) + +// newTestDrawer 从真实素材包构建字体图集与绘制器。 +func newTestDrawer(t *testing.T) *Drawer { + t.Helper() + m := assets.New() + if err := m.Load(filepath.Join("..", "..", "assets"), filepath.Join("..", "..", "resourcepacks")); err != nil { + t.Fatalf("加载内容包失败: %v", err) + } + at, err := font.Build(m, "default") + if err != nil { + t.Fatalf("构建字体失败: %v", err) + } + return New(at) +} + +// TestWidth 文本宽度 = 字符数 × 8。 +func TestWidth(t *testing.T) { + d := newTestDrawer(t) + if w := d.Width("AB"); w != 16 { + t.Fatalf("宽度期望 16,实际 %d", w) + } + if w := d.Width(""); w != 0 { + t.Fatalf("空串宽度期望 0,实际 %d", w) + } +} + +// TestDraw 渲染 'A' 得到 8×8 且含非透明像素。 +func TestDraw(t *testing.T) { + d := newTestDrawer(t) + img := d.Draw("A", color.RGBA{255, 255, 255, 255}, false) + b := img.Bounds() + if b.Dx() != 8 || b.Dy() != 8 { + t.Fatalf("图像尺寸期望 8×8,实际 %dx%d", b.Dx(), b.Dy()) + } + opaque := 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + if _, _, _, a := img.At(x, y).RGBA(); a > 0 { + opaque++ + } + } + } + if opaque == 0 { + t.Fatal("渲染结果全透明") + } +} + +// TestDrawShadow 阴影模式:图像加 1px 且含黑色像素。 +func TestDrawShadow(t *testing.T) { + d := newTestDrawer(t) + img := d.Draw("A", color.RGBA{255, 255, 255, 255}, true) + b := img.Bounds() + if b.Dx() != 9 || b.Dy() != 9 { + t.Fatalf("阴影图像尺寸期望 9×9,实际 %dx%d", b.Dx(), b.Dy()) + } +} diff --git a/tools/zig.zip b/tools/zig.zip index 89c29406..a9ff323e 100644 Binary files a/tools/zig.zip and b/tools/zig.zip differ