feat(mods): Lua 沙箱 Mod 运行时——generation 注册表、注册 API、事件、热重载与回滚、文件监听防抖(含测试)

This commit is contained in:
NianGao Dev
2026-08-15 23:04:51 +08:00
parent 07c086e127
commit 38f015ba22
9 changed files with 644 additions and 0 deletions

3
go.mod
View File

@@ -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
View File

@@ -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
View 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/packageMod开发指南.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
View 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
View 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
View 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
View 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 使用)

View 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())
}
}

Binary file not shown.