diff --git a/internal/assets/assets.go b/internal/assets/assets.go new file mode 100644 index 00000000..e700bc08 --- /dev/null +++ b/internal/assets/assets.go @@ -0,0 +1,100 @@ +// Package assets 内容包加载与资源管理。 +// +// 职责(设计.md §1.3 内容包统一格式): +// - 扫描 mods/ 与 resourcepacks/,识别「原生 pack.mcmeta 素材包」与「扩展 manifest.json 内容包」; +// - 按优先级链(功能 Mod 资源 > 素材包 > 光影包 > 默认嵌入)解析资源; +// - 提供纹理、模型、方块状态、图集、语言、字体的统一访问。 +// +// 引用约定:资源键使用原版命名(如 "block/stone"、"models/block/stone"), +// 不含命名空间前缀与扩展名。 +package assets + +import ( + "fmt" + "os" + "path/filepath" + "sort" +) + +// Manager 资源管理器:持有按优先级排序的内容包列表与嵌入资源根目录。 +type Manager struct { + packs []*Pack // 已排序内容包(高优先级在前) + embeddedRoot string // 嵌入/默认资源根(如 assets/,命名空间 minecraft) +} + +// New 创建空的资源管理器。 +func New() *Manager { return &Manager{} } + +// Load 扫描全部包目录并按优先级排序;目录不存在时视为无包(默认回退策略)。 +func (m *Manager) Load(embeddedRoot string, packDirs ...string) error { + m.embeddedRoot = embeddedRoot + var packs []*Pack + for _, dir := range packDirs { + ps, err := Discover(dir) + if err != nil { + return fmt.Errorf("assets.Manager.Load %s: %w", dir, err) + } + packs = append(packs, ps...) + } + // 排序(确定性):Mod(0) < 素材包(1) < 光影包(2);同类型 priority 大者优先,再按 ID 字典序。 + sort.SliceStable(packs, func(i, j int) bool { + a, b := packs[i], packs[j] + if a.Type != b.Type { + return a.Type < b.Type + } + if a.Priority() != b.Priority() { + return a.Priority() > b.Priority() + } + return a.ID < b.ID + }) + m.packs = packs + return nil +} + +// Packs 返回已加载内容包(只读视图,供日志/调试)。 +func (m *Manager) Packs() []*Pack { return m.packs } + +// namespaces 返回包的资源命名空间候选(原版包固定 minecraft)。 +func (p *Pack) namespaces() []string { + if p.Manifest != nil && p.Manifest.ResourceNamespace != "" { + return []string{p.Manifest.ResourceNamespace, "minecraft"} + } + return []string{"minecraft"} +} + +// find 按优先级链查找 kind 类别下的资源 key(扩展名 ext),返回首个存在的文件路径。 +// kind 取值:textures / models / blockstates / lang / font / atlases / shaders。 +func (m *Manager) find(kind, key, ext string) (string, bool) { + for _, p := range m.packs { + for _, ns := range p.namespaces() { + path := filepath.Join(p.Root, "assets", ns, kind, key+ext) + if fileExists(path) { + return path, true + } + } + } + // 默认嵌入资源(assets/ 根,命名空间 minecraft) + if m.embeddedRoot != "" { + path := filepath.Join(m.embeddedRoot, kind, key+ext) + if fileExists(path) { + return path, true + } + } + return "", false +} + +// ResolveFile 通用资源解析入口。 +func (m *Manager) ResolveFile(kind, key, ext string) (string, bool) { + return m.find(kind, key, ext) +} + +// ResolveTexture 解析纹理键(如 "block/stone")到 PNG 文件路径。 +func (m *Manager) ResolveTexture(key string) (string, bool) { + return m.find("textures", key, ".png") +} + +// fileExists 判断路径是否为已存在的普通文件。 +func fileExists(path string) bool { + st, err := os.Stat(path) + return err == nil && !st.IsDir() +} diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go new file mode 100644 index 00000000..c62f730b --- /dev/null +++ b/internal/assets/assets_test.go @@ -0,0 +1,137 @@ +package assets + +import ( + "path/filepath" + "testing" +) + +// 测试指向仓库根下的真实素材包(用户已放入 resourcepacks/)。 +// go test 的工作目录为 internal/assets,因此向上两级。 +var ( + testPackDir = filepath.Join("..", "..", "resourcepacks") + testEmbedded = filepath.Join("..", "..", "assets") +) + +// newTestManager 构造加载了真实素材包的资源管理器。 +func newTestManager(t *testing.T) *Manager { + t.Helper() + m := New() + if err := m.Load(testEmbedded, testPackDir); err != nil { + t.Fatalf("加载内容包失败: %v", err) + } + return m +} + +// TestDiscover 验证原生 pack.mcmeta 素材包被发现并解析元数据。 +func TestDiscover(t *testing.T) { + m := newTestManager(t) + if len(m.Packs()) == 0 { + t.Fatal("未发现任何内容包") + } + p := m.Packs()[0] + if p.Type != PackResource { + t.Fatalf("期望素材包类型,实际 %v", p.Type) + } + if p.Meta == nil || p.Meta.Pack.PackFormat != 34 { + t.Fatalf("pack.mcmeta 解析异常: %+v", p.Meta) + } +} + +// TestResolveTexture 验证纹理解析命中素材包(原版命名空间)。 +func TestResolveTexture(t *testing.T) { + m := newTestManager(t) + path, ok := m.ResolveTexture("block/stone") + if !ok { + t.Fatal("block/stone 未解析到纹理") + } + if !fileExists(path) { + t.Fatalf("纹理路径不存在: %s", path) + } +} + +// TestExpandAtlas 验证图集源展开:blocks 含 block/stone,gui 递归子目录含 widget/button。 +func TestExpandAtlas(t *testing.T) { + m := newTestManager(t) + blocks, err := m.ExpandAtlas("blocks") + if err != nil { + t.Fatalf("展开 blocks 图集失败: %v", err) + } + found := false + for _, k := range blocks { + if k == "block/stone" { + found = true + } + } + if !found { + t.Fatalf("blocks 图集缺少 block/stone(共 %d 键)", len(blocks)) + } + + gui, err := m.ExpandAtlas("gui") + if err != nil { + t.Fatalf("展开 gui 图集失败: %v", err) + } + for _, k := range gui { + if k == "widget/button" { + return // 命中即通过 + } + } + t.Fatalf("gui 图集缺少 widget/button(共 %d 键)", len(gui)) +} + +// TestLoadLang 验证语言表加载与缺键回退。 +func TestLoadLang(t *testing.T) { + m := newTestManager(t) + lang, err := m.LoadLang("en_us") + if err != nil { + t.Fatalf("加载语言失败: %v", err) + } + if v := lang.Get("block.minecraft.stone"); v == "" || v == "block.minecraft.stone" { + t.Fatalf("block.minecraft.stone 翻译缺失: %q", v) + } + // 缺键回退键名本身 + if v := lang.Get("this.key.does.not.exist"); v != "this.key.does.not.exist" { + t.Fatalf("缺键应回退键名,实际 %q", v) + } +} + +// TestLoadFont 验证字体 provider 展开与 ASCII 字形命中。 +func TestLoadFont(t *testing.T) { + m := newTestManager(t) + f, err := m.LoadFont("default") + if err != nil { + t.Fatalf("加载字体失败: %v", err) + } + if len(f.Providers) == 0 { + t.Fatal("字体 providers 为空") + } + if _, ok := f.Glyph('A'); !ok { + t.Fatal("字体缺少 'A' 字形") + } +} + +// TestLoadModel 验证模型 parent 继承链展开与纹理变量解析。 +func TestLoadModel(t *testing.T) { + m := newTestManager(t) + mod, err := m.LoadModel("block/stone") + if err != nil { + t.Fatalf("加载模型失败: %v", err) + } + if tex := mod.ResolvedTexture("all"); tex != "block/stone" { + t.Fatalf("stone 模型纹理 all 期望 block/stone,实际 %q", tex) + } + if len(mod.Elements) == 0 { + t.Fatal("stone 模型元素为空(parent 链未展开)") + } +} + +// TestLoadBlockState 验证方块状态解析与缺省变体。 +func TestLoadBlockState(t *testing.T) { + m := newTestManager(t) + bs, err := m.LoadBlockState("stone") + if err != nil { + t.Fatalf("加载方块状态失败: %v", err) + } + if len(bs.VariantsOf(nil)) == 0 { + t.Fatal("stone 默认变体为空") + } +} diff --git a/internal/assets/atlas.go b/internal/assets/atlas.go new file mode 100644 index 00000000..1c199e9f --- /dev/null +++ b/internal/assets/atlas.go @@ -0,0 +1,131 @@ +package assets + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// AtlasSource 图集源定义(atlases/*.json,原版 1.20.5+ 结构,设计.md §1.3)。 +type AtlasSource struct { + Type string `json:"type"` // minecraft:directory / minecraft:single + Prefix string `json:"prefix"` // directory 源的键前缀 + Source string `json:"source"` // directory 源相对 textures/ 的目录 + Resource string `json:"resource"` // single 源资源键(如 minecraft:entity/bell/bell_body) +} + +// Atlas 图集定义。 +type Atlas struct { + Sources []AtlasSource `json:"sources"` +} + +// LoadAtlas 解析 atlases/.json(如 "blocks"、"gui"、"items")。 +func (m *Manager) LoadAtlas(name string) (*Atlas, error) { + path, ok := m.find("atlases", name, ".json") + if !ok { + return nil, fmt.Errorf("assets.LoadAtlas: 图集 %q 不存在", name) + } + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("assets.LoadAtlas %s: %w", name, err) + } + var a Atlas + if err := json.Unmarshal(b, &a); err != nil { + return nil, fmt.Errorf("assets.LoadAtlas %s: %w", name, err) + } + return &a, nil +} + +// ExpandAtlas 展开图集源为有序去重的纹理键列表(无扩展名、无命名空间)。 +// 顺序:按包优先级从高到低贡献键,低优先级补缺(渲染.md §4 图集构建依据)。 +func (m *Manager) ExpandAtlas(name string) ([]string, error) { + a, err := m.LoadAtlas(name) + if err != nil { + return nil, err + } + var keys []string + seen := make(map[string]struct{}) + add := func(k string) { + if _, ok := seen[k]; !ok { + seen[k] = struct{}{} + keys = append(keys, k) + } + } + for _, src := range a.Sources { + switch { + case strings.HasSuffix(src.Type, ":directory"): + // 从高到低遍历包,收集 textures// 下全部 PNG(递归子目录) + files, err := m.listTextureDir(src.Source) + if err != nil { + return nil, err + } + for _, rel := range files { + add(filepath.ToSlash(filepath.Join(src.Prefix, rel))) + } + case strings.HasSuffix(src.Type, ":single"): + // 去掉命名空间前缀(minecraft:entity/x → entity/x) + add(strings.TrimPrefix(src.Resource, "minecraft:")) + } + } + return keys, nil +} + +// listTextureDir 汇总全部包 textures// 下的 PNG 相对路径(含子目录)。 +// 包优先级高者先列出;单个包内按路径排序,保证确定性。 +func (m *Manager) listTextureDir(dir string) ([]string, error) { + var rels []string + seen := make(map[string]struct{}) + for _, root := range m.textureRoots() { + base := filepath.Join(root, dir) + var names []string + err := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil // 该包没有此目录:跳过 + } + return err + } + if d.IsDir() { + return nil + } + if strings.HasSuffix(d.Name(), ".png") { + rel, err := filepath.Rel(base, path) + if err != nil { + return err + } + names = append(names, filepath.ToSlash(strings.TrimSuffix(rel, ".png"))) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("assets.listTextureDir %s: %w", dir, err) + } + sort.Strings(names) + for _, n := range names { + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + rels = append(rels, n) + } + } + return rels, nil +} + +// textureRoots 返回按优先级排序的 textures 根目录列表(含嵌入根)。 +func (m *Manager) textureRoots() []string { + var roots []string + for _, p := range m.packs { + for _, ns := range p.namespaces() { + roots = append(roots, filepath.Join(p.Root, "assets", ns, "textures")) + } + } + if m.embeddedRoot != "" { + roots = append(roots, filepath.Join(m.embeddedRoot, "textures")) + } + return roots +} diff --git a/internal/assets/font.go b/internal/assets/font.go new file mode 100644 index 00000000..e77bba4d --- /dev/null +++ b/internal/assets/font.go @@ -0,0 +1,109 @@ +package assets + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// FontProvider 字体 provider(font/*.json,原版结构,设计.md §1.3)。 +// 支持 type:bitmap(位图字形)与 reference(引用其他 provider 文件)。 +type FontProvider struct { + Type string `json:"type"` + File string `json:"file"` // bitmap:相对 textures/ 的位图路径(无扩展名) + Chars []string `json:"chars"` + Height int `json:"height"` + Ascent int `json:"ascent"` + ID string `json:"id"` // reference:目标 provider 文件(minecraft:include/default 等) +} + +// Font 字体定义(reference 已递归展开)。 +type Font struct { + Providers []FontProvider +} + +// Glyph 一个字符的字形定位信息。 +type Glyph struct { + File string // 位图资源键(相对 textures/,如 font/ascii) + Index int // 在 chars 拼接串中的索引 + Height int // 字形高度(px) + Ascent int // 基线偏移 +} + +// LoadFont 解析 font/.json 并展开 reference provider(防环)。 +func (m *Manager) LoadFont(name string) (*Font, error) { + providers, err := m.loadFontProviders(name, map[string]bool{}) + if err != nil { + return nil, err + } + return &Font{Providers: providers}, nil +} + +// loadFontProviders 递归加载 provider 文件;stack 用于循环引用检测。 +func (m *Manager) loadFontProviders(name string, stack map[string]bool) ([]FontProvider, error) { + path, ok := m.find("font", name, ".json") + if !ok { + return nil, fmt.Errorf("assets.loadFontProviders: 字体 %q 不存在", name) + } + if stack[name] { + return nil, fmt.Errorf("assets.loadFontProviders: 字体引用循环 %q", name) + } + stack[name] = true + defer delete(stack, name) + + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("assets.loadFontProviders %s: %w", name, err) + } + var def struct { + Providers []FontProvider `json:"providers"` + } + if err := json.Unmarshal(b, &def); err != nil { + return nil, fmt.Errorf("assets.loadFontProviders %s: %w", name, err) + } + + var out []FontProvider + for _, p := range def.Providers { + if p.Type == "reference" { + // 展开引用:minecraft:include/default → font/include/default.json + refName := strings.TrimPrefix(p.ID, "minecraft:") + sub, err := m.loadFontProviders(refName, stack) + if err != nil { + return nil, err + } + out = append(out, sub...) + continue + } + out = append(out, p) + } + return out, nil +} + +// Glyph 查找字符字形(按 providers 顺序,首个命中优先)。 +func (f *Font) Glyph(ch rune) (Glyph, bool) { + for _, p := range f.Providers { + if p.Type != "bitmap" { + continue + } + idx := charIndex(p.Chars, ch) + if idx >= 0 { + return Glyph{File: p.File, Index: idx, Height: p.Height, Ascent: p.Ascent}, true + } + } + return Glyph{}, false +} + +// charIndex 返回 rune 在 chars 拼接串中的索引;未找到返回 -1。 +func charIndex(chars []string, ch rune) int { + offset := 0 + for _, s := range chars { + for i, r := range s { + if r == ch { + return offset + i + } + } + offset += len([]rune(s)) + } + return -1 +} diff --git a/internal/assets/lang.go b/internal/assets/lang.go new file mode 100644 index 00000000..3ea842d2 --- /dev/null +++ b/internal/assets/lang.go @@ -0,0 +1,76 @@ +package assets + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Lang 语言表:指定语言覆盖层 + en_us 回退层(设计.md §2.4、UI与输入.md §5)。 +type Lang struct { + keys map[string]string // 指定语言 + fallback map[string]string // en_us 回退 +} + +// Get 取翻译;缺失回退 en_us,再缺失返回键名本身(便于发现缺失键)。 +func (l *Lang) Get(key string) string { + if v, ok := l.keys[key]; ok { + return v + } + if v, ok := l.fallback[key]; ok { + return v + } + return key +} + +// LoadLang 加载语言:先加载 en_us 作为回退层,再合并目标语言。 +// 合并顺序:低优先级包 → 高优先级包(后者覆盖),与资源优先级链一致。 +func (m *Manager) LoadLang(code string) (*Lang, error) { + fallback := make(map[string]string) + if code != "en_us" { + if err := m.loadLangInto("en_us", fallback); err != nil { + return nil, err + } + } + keys := make(map[string]string) + if err := m.loadLangInto(code, keys); err != nil { + return nil, err + } + return &Lang{keys: keys, fallback: fallback}, nil +} + +// loadLangInto 按优先级从低到高合并所有包的 lang/.json 到 dst。 +func (m *Manager) loadLangInto(code string, dst map[string]string) error { + var paths []string + // 从低优先级到高优先级收集路径(逆序遍历包列表) + for i := len(m.packs) - 1; i >= 0; i-- { + p := m.packs[i] + for _, ns := range p.namespaces() { + path := filepath.Join(p.Root, "assets", ns, "lang", code+".json") + if fileExists(path) { + paths = append(paths, path) + } + } + } + if m.embeddedRoot != "" { + path := filepath.Join(m.embeddedRoot, "lang", code+".json") + if fileExists(path) { + paths = append(paths, path) + } + } + for _, path := range paths { + b, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("assets.loadLangInto %s: %w", code, err) + } + var kv map[string]string + if err := json.Unmarshal(b, &kv); err != nil { + return fmt.Errorf("assets.loadLangInto %s: %w", code, err) + } + for k, v := range kv { + dst[k] = v + } + } + return nil +} diff --git a/internal/assets/model.go b/internal/assets/model.go new file mode 100644 index 00000000..0a0622d5 --- /dev/null +++ b/internal/assets/model.go @@ -0,0 +1,181 @@ +package assets + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" +) + +// BlockModel 原版方块模型(子集:parent / textures / elements,设计.md §2.2)。 +type BlockModel struct { + Parent string `json:"parent"` + Textures map[string]string `json:"textures"` + Elements []Element `json:"elements"` + AmbientOcclusion *bool `json:"ambientocclusion"` +} + +// Element 模型元素(立方体)。 +type Element struct { + From [3]float32 `json:"from"` + To [3]float32 `json:"to"` + Faces map[string]Face `json:"faces"` +} + +// Face 元素面。 +type Face struct { + UV [4]int `json:"uv"` + Texture string `json:"texture"` // "#变量" 或 "minecraft:block/x" + CullFace string `json:"cullface"` + TintIndex int `json:"tintindex"` +} + +// BlockState 原版方块状态(blockstates/.json)。 +type BlockState struct { + Variants map[string]any `json:"variants"` // string | []Variant + Multipart []any `json:"multipart"` // 后置支持(设计.md §2.2) +} + +// Variant 一个状态变体。 +type Variant struct { + Model string `json:"model"` + X int `json:"x"` + Y int `json:"y"` + UVLock bool `json:"uvlock"` + Weight int `json:"weight"` +} + +// LoadModel 解析模型并展开 parent 继承链(含循环检测)。 +// 返回的模型:纹理变量已合并子代覆盖、元素取自继承链末端(子代无元素时)。 +func (m *Manager) LoadModel(key string) (*BlockModel, error) { + return m.loadModel(key, map[string]bool{}) +} + +func (m *Manager) loadModel(key string, stack map[string]bool) (*BlockModel, error) { + key = strings.TrimPrefix(key, "minecraft:") + path, ok := m.find("models", key, ".json") + if !ok { + return nil, fmt.Errorf("assets.loadModel: 模型 %q 不存在", key) + } + if stack[key] { + return nil, fmt.Errorf("assets.loadModel: 模型继承循环 %q", key) + } + stack[key] = true + defer delete(stack, key) + + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("assets.loadModel %s: %w", key, err) + } + var mod BlockModel + if err := json.Unmarshal(b, &mod); err != nil { + return nil, fmt.Errorf("assets.loadModel %s: %w", key, err) + } + + // 展开 parent 链:纹理变量子代覆盖父代,元素/环境光遮蔽继承 + if mod.Parent != "" { + parent, err := m.loadModel(mod.Parent, stack) + if err != nil { + return nil, err + } + merged := make(map[string]string, len(parent.Textures)+len(mod.Textures)) + for k, v := range parent.Textures { + merged[k] = v + } + for k, v := range mod.Textures { + merged[k] = v + } + mod.Textures = merged + if len(mod.Elements) == 0 { + mod.Elements = parent.Elements + } + if mod.AmbientOcclusion == nil { + mod.AmbientOcclusion = parent.AmbientOcclusion + } + } + // 解析纹理变量引用("#all" → 变量表取值) + for k, v := range mod.Textures { + if strings.HasPrefix(v, "#") { + mod.Textures[k] = mod.Textures[strings.TrimPrefix(v, "#")] + } + } + return &mod, nil +} + +// ResolvedTexture 返回模型纹理变量的最终值(去命名空间前缀)。 +func (mod *BlockModel) ResolvedTexture(name string) string { + v, ok := mod.Textures[name] + if !ok { + v = name + } + return strings.TrimPrefix(v, "minecraft:") +} + +// LoadBlockState 解析方块状态文件。 +func (m *Manager) LoadBlockState(id string) (*BlockState, error) { + path, ok := m.find("blockstates", id, ".json") + if !ok { + return nil, fmt.Errorf("assets.LoadBlockState: 方块状态 %q 不存在", id) + } + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("assets.LoadBlockState %s: %w", id, err) + } + var bs BlockState + if err := json.Unmarshal(b, &bs); err != nil { + return nil, fmt.Errorf("assets.LoadBlockState %s: %w", id, err) + } + return &bs, nil +} + +// VariantsOf 返回匹配属性的变体列表:先精确匹配属性键,再回退空键(缺省变体)。 +func (bs *BlockState) VariantsOf(props map[string]string) []Variant { + raw, ok := bs.Variants[stateKey(props)] + if !ok { + raw, ok = bs.Variants[""] + } + if !ok { + return nil + } + return parseVariants(raw) +} + +// stateKey 把属性映射拼成原版变体键(字典序,如 "facing=north,half=lower")。 +func stateKey(props map[string]string) string { + if len(props) == 0 { + return "" + } + ks := make([]string, 0, len(props)) + for k := range props { + ks = append(ks, k) + } + sort.Strings(ks) + parts := make([]string, 0, len(ks)) + for _, k := range ks { + parts = append(parts, k+"="+props[k]) + } + return strings.Join(parts, ",") +} + +// parseVariants 解析变体值:string(单模型)或对象数组。 +func parseVariants(raw any) []Variant { + switch v := raw.(type) { + case string: + return []Variant{{Model: v}} + case []any: + out := make([]Variant, 0, len(v)) + for _, it := range v { + b, err := json.Marshal(it) + if err != nil { + continue + } + var vv Variant + if json.Unmarshal(b, &vv) == nil { + out = append(out, vv) + } + } + return out + } + return nil +} diff --git a/internal/assets/pack.go b/internal/assets/pack.go new file mode 100644 index 00000000..9628c050 --- /dev/null +++ b/internal/assets/pack.go @@ -0,0 +1,135 @@ +package assets + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// PackType 内容包类型(设计.md §1.3)。 +type PackType uint8 + +const ( + // PackMod 功能 Mod(脚本 + 数据 + 资源)。 + PackMod PackType = iota + // PackResource 素材包(覆盖默认纹理/模型/语言/音效)。 + PackResource + // PackShader 光影包(覆盖着色器管线)。 + PackShader +) + +// Pack 一个内容包(目录形式;zip 支持后置)。 +type Pack struct { + ID string // 目录名或 manifest.id + Type PackType // 包类型 + Root string // 包根目录(绝对路径) + Meta *PackMeta // pack.mcmeta(原生素材包) + Manifest *Manifest // manifest.json(扩展格式,可选) +} + +// Priority 返回包优先级:manifest.priority,缺省为 0。 +func (p *Pack) Priority() int { + if p.Manifest != nil { + return p.Manifest.Priority + } + return 0 +} + +// PackMeta 原生 Minecraft 资源包元数据(pack.mcmeta)。 +type PackMeta struct { + Pack struct { + PackFormat int `json:"pack_format"` // 1.20.5 = 34 + Description string `json:"description"` + SupportedFormats []int `json:"supported_formats"` + } `json:"pack"` +} + +// Manifest 扩展内容包清单(manifest.json,设计.md §1.3)。 +type Manifest struct { + Type string `json:"type"` // mod / resource / shader + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + APIVersion int `json:"api_version"` + Entry string `json:"entry"` + Priority int `json:"priority"` + ResourceNamespace string `json:"resource_namespace"` + Shaders map[string]string `json:"shaders"` + Depends []struct { + ID string `json:"id"` + MinVersion string `json:"min_version"` + } `json:"depends"` + Permissions []string `json:"permissions"` + Description string `json:"description"` +} + +// Discover 扫描目录发现内容包;无清单的子目录被忽略(不报错)。 +// 目录不存在视为没有包(默认回退);扫描顺序按目录名排序保证确定性。 +func Discover(dir string) ([]*Pack, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("assets.Discover %s: %w", dir, err) + } + var packs []*Pack + for _, e := range entries { + if !e.IsDir() { + continue // TODO: 支持 .zip 内容包(设计.md §1.3) + } + p, err := loadPack(e.Name(), filepath.Join(dir, e.Name())) + if err != nil { + return nil, fmt.Errorf("assets.Discover %s/%s: %w", dir, e.Name(), err) + } + if p != nil { + packs = append(packs, p) + } + } + return packs, nil +} + +// loadPack 识别单个目录的内容包类型并解析元数据。 +func loadPack(name, root string) (*Pack, error) { + // 原生格式:pack.mcmeta(用户实际投放的主要格式) + mcmeta := filepath.Join(root, "pack.mcmeta") + if fileExists(mcmeta) { + b, err := os.ReadFile(mcmeta) + if err != nil { + return nil, fmt.Errorf("读取 pack.mcmeta: %w", err) + } + var meta PackMeta + if err := json.Unmarshal(b, &meta); err != nil { + return nil, fmt.Errorf("解析 pack.mcmeta: %w", err) + } + return &Pack{ID: name, Type: PackResource, Root: root, Meta: &meta}, nil + } + // 扩展格式:manifest.json(功能 Mod / 光影包) + manifest := filepath.Join(root, "manifest.json") + if fileExists(manifest) { + b, err := os.ReadFile(manifest) + if err != nil { + return nil, fmt.Errorf("读取 manifest.json: %w", err) + } + var mf Manifest + if err := json.Unmarshal(b, &mf); err != nil { + return nil, fmt.Errorf("解析 manifest.json: %w", err) + } + t := PackResource + switch mf.Type { + case "mod": + t = PackMod + case "shader": + t = PackShader + case "", "resource": + t = PackResource + } + id := name + if mf.ID != "" { + id = mf.ID + } + return &Pack{ID: id, Type: t, Root: root, Manifest: &mf}, nil + } + return nil, nil // 不含任何清单的目录:忽略 +}