feat(assets): 内容包加载器——原生 pack.mcmeta 素材包、图集/语言/字体/模型解析
This commit is contained in:
100
internal/assets/assets.go
Normal file
100
internal/assets/assets.go
Normal file
@@ -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()
|
||||
}
|
||||
137
internal/assets/assets_test.go
Normal file
137
internal/assets/assets_test.go
Normal file
@@ -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 默认变体为空")
|
||||
}
|
||||
}
|
||||
131
internal/assets/atlas.go
Normal file
131
internal/assets/atlas.go
Normal file
@@ -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/<name>.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/<source>/ 下全部 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/<dir>/ 下的 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
|
||||
}
|
||||
109
internal/assets/font.go
Normal file
109
internal/assets/font.go
Normal file
@@ -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/<name>.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
|
||||
}
|
||||
76
internal/assets/lang.go
Normal file
76
internal/assets/lang.go
Normal file
@@ -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/<code>.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
|
||||
}
|
||||
181
internal/assets/model.go
Normal file
181
internal/assets/model.go
Normal file
@@ -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/<id>.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
|
||||
}
|
||||
135
internal/assets/pack.go
Normal file
135
internal/assets/pack.go
Normal file
@@ -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 // 不含任何清单的目录:忽略
|
||||
}
|
||||
Reference in New Issue
Block a user