feat(mods): Lua 沙箱 Mod 运行时——generation 注册表、注册 API、事件、热重载与回滚、文件监听防抖(含测试)
This commit is contained in:
3
go.mod
3
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
|
||||
)
|
||||
|
||||
6
go.sum
6
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=
|
||||
|
||||
199
internal/mods/lua.go
Normal file
199
internal/mods/lua.go
Normal file
@@ -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
|
||||
}
|
||||
138
internal/mods/mods_test.go
Normal file
138
internal/mods/mods_test.go
Normal file
@@ -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"))
|
||||
}
|
||||
}
|
||||
80
internal/mods/registry.go
Normal file
80
internal/mods/registry.go
Normal file
@@ -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]
|
||||
}
|
||||
73
internal/mods/watcher.go
Normal file
73
internal/mods/watcher.go
Normal file
@@ -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() }
|
||||
79
internal/textr/text.go
Normal file
79
internal/textr/text.go
Normal file
@@ -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 使用)
|
||||
66
internal/textr/text_test.go
Normal file
66
internal/textr/text_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
BIN
tools/zig.zip
BIN
tools/zig.zip
Binary file not shown.
Reference in New Issue
Block a user