Apply haircut fallback based on HeadAccessoryType: - FullyCovering: hide hair entirely - HalfCovering: use GenericShort/Medium/Long fallback - Simple: keep original haircut
395 lines
9.9 KiB
Go
395 lines
9.9 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/hytale-tools/blockymodel-merger/pkg/blockymodel"
|
|
"github.com/hytale-tools/blockymodel-merger/pkg/character"
|
|
"github.com/hytale-tools/blockymodel-merger/pkg/export"
|
|
"github.com/hytale-tools/blockymodel-merger/pkg/merger"
|
|
"github.com/hytale-tools/blockymodel-merger/pkg/registry"
|
|
"github.com/hytale-tools/blockymodel-merger/pkg/texture"
|
|
)
|
|
|
|
const (
|
|
basePath = "assets/Characters/Player.blockymodel"
|
|
baseTexturePath = "assets/Characters/Player_Textures/Player_Greyscale.png"
|
|
)
|
|
|
|
// HeadAccessoryEntry extends registry entry with HeadAccessoryType
|
|
type HeadAccessoryEntry struct {
|
|
ID string `json:"Id"`
|
|
HeadAccessoryType string `json:"HeadAccessoryType"`
|
|
DisableCharacterPartCategory string `json:"DisableCharacterPartCategory"`
|
|
}
|
|
|
|
// HaircutEntry extends registry entry with HairType
|
|
type HaircutEntry struct {
|
|
ID string `json:"Id"`
|
|
HairType string `json:"HairType"`
|
|
}
|
|
|
|
// MergeService handles character merging operations
|
|
type MergeService struct {
|
|
registry *registry.Registry
|
|
gradientSets *texture.GradientSets
|
|
baseModel *blockymodel.BlockyModel
|
|
headAccessories map[string]HeadAccessoryEntry
|
|
haircuts map[string]HaircutEntry
|
|
haircutFallbacks map[string]string // HairType -> fallback haircut ID
|
|
}
|
|
|
|
// MergeResult contains the results of a merge operation
|
|
type MergeResult struct {
|
|
Model *blockymodel.BlockyModel
|
|
Atlas *texture.Atlas
|
|
GLBBytes []byte
|
|
}
|
|
|
|
// NewMergeService creates a new merge service with all required data loaded
|
|
func NewMergeService() (*MergeService, error) {
|
|
// Load gradient sets for tinting
|
|
gradientSets, err := texture.LoadGradientSets()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading gradient sets: %w", err)
|
|
}
|
|
|
|
// Load accessory registry
|
|
reg, err := registry.Load()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading registry: %w", err)
|
|
}
|
|
|
|
// Load base player model
|
|
baseModel, err := blockymodel.Load(basePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading base model: %w", err)
|
|
}
|
|
|
|
// Load head accessories for HeadAccessoryType
|
|
headAccessories, err := loadHeadAccessories("data/HeadAccessory.json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading head accessories: %w", err)
|
|
}
|
|
|
|
// Load haircuts for HairType
|
|
haircuts, err := loadHaircuts("data/Haircuts.json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading haircuts: %w", err)
|
|
}
|
|
|
|
// Load haircut fallbacks
|
|
haircutFallbacks, err := loadHaircutFallbacks("data/HaircutFallbacks.json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading haircut fallbacks: %w", err)
|
|
}
|
|
|
|
return &MergeService{
|
|
registry: reg,
|
|
gradientSets: gradientSets,
|
|
baseModel: baseModel,
|
|
headAccessories: headAccessories,
|
|
haircuts: haircuts,
|
|
haircutFallbacks: haircutFallbacks,
|
|
}, nil
|
|
}
|
|
|
|
// MergeFromJSON merges a character from JSON data and returns the result
|
|
func (s *MergeService) MergeFromJSON(charJSON []byte) (*MergeResult, error) {
|
|
// Parse character data
|
|
var charData character.CharacterData
|
|
if err := json.Unmarshal(charJSON, &charData); err != nil {
|
|
return nil, fmt.Errorf("parsing character JSON: %w", err)
|
|
}
|
|
|
|
// Apply haircut fallback if headAccessory requires it
|
|
s.applyHaircutFallback(&charData)
|
|
|
|
// Resolve accessories
|
|
result, err := charData.ResolveAccessories(s.registry)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving accessories: %w", err)
|
|
}
|
|
|
|
// Create merger from base model
|
|
m, err := merger.New(s.baseModel)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating merger: %w", err)
|
|
}
|
|
|
|
// Merge each accessory
|
|
for _, acc := range result.Accessories {
|
|
accessory, err := blockymodel.Load(acc.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading accessory %s: %w", acc.Path, err)
|
|
}
|
|
|
|
if err := m.Merge(accessory, acc.Spec.ID); err != nil {
|
|
return nil, fmt.Errorf("merging accessory %s: %w", acc.Path, err)
|
|
}
|
|
}
|
|
|
|
// Get merged model
|
|
mergedModel := m.Result()
|
|
|
|
// Process textures
|
|
var tintedTextures []*texture.TintedTexture
|
|
skinTone := charData.GetSkinTone()
|
|
|
|
// Load and tint base player texture
|
|
if skinTone != "" {
|
|
baseTinted, err := texture.ProcessAccessoryTexture(
|
|
"_base",
|
|
baseTexturePath,
|
|
"Skin",
|
|
skinTone,
|
|
s.gradientSets,
|
|
)
|
|
if err == nil {
|
|
tintedTextures = append(tintedTextures, baseTinted)
|
|
}
|
|
} else {
|
|
baseImg, err := texture.LoadImage(baseTexturePath)
|
|
if err == nil {
|
|
baseTex := &texture.TintedTexture{
|
|
Name: "_base",
|
|
Image: baseImg,
|
|
OriginalPath: baseTexturePath,
|
|
}
|
|
tintedTextures = append(tintedTextures, baseTex)
|
|
}
|
|
}
|
|
|
|
// Process accessory textures
|
|
for _, acc := range result.Accessories {
|
|
if acc.ResolvedTexture == nil {
|
|
continue
|
|
}
|
|
|
|
var tinted *texture.TintedTexture
|
|
|
|
if acc.ResolvedTexture.DirectTexture != "" {
|
|
img, err := texture.LoadImage(acc.ResolvedTexture.DirectTexture)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
tinted = &texture.TintedTexture{
|
|
Name: acc.Spec.ID,
|
|
Image: img,
|
|
OriginalPath: acc.ResolvedTexture.DirectTexture,
|
|
}
|
|
} else if acc.ResolvedTexture.GreyscaleTexture != "" {
|
|
var err error
|
|
tinted, err = texture.ProcessAccessoryTexture(
|
|
acc.Spec.ID,
|
|
acc.ResolvedTexture.GreyscaleTexture,
|
|
acc.ResolvedTexture.GradientSet,
|
|
acc.Spec.Color,
|
|
s.gradientSets,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
tintedTextures = append(tintedTextures, tinted)
|
|
}
|
|
|
|
// Pack textures into atlas
|
|
var atlas *texture.Atlas
|
|
if len(tintedTextures) > 0 {
|
|
var err error
|
|
atlas, err = texture.PackAtlasSimple(tintedTextures, 1)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("packing atlas: %w", err)
|
|
}
|
|
|
|
// Update texture offsets in the merged model
|
|
for _, tex := range tintedTextures {
|
|
if tex.Name == "_base" {
|
|
continue
|
|
}
|
|
|
|
x, y, _, _, ok := atlas.GetPixelCoords(tex.Name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Find all node IDs that came from this accessory
|
|
nodeIDs := make(map[string]bool)
|
|
for nodeID, accessoryID := range m.NodeSources {
|
|
if accessoryID == tex.Name {
|
|
nodeIDs[nodeID] = true
|
|
}
|
|
}
|
|
|
|
if len(nodeIDs) > 0 {
|
|
offset := blockymodel.AtlasOffset{X: float64(x), Y: float64(y)}
|
|
blockymodel.UpdateTextureOffsets(mergedModel.Nodes, nodeIDs, offset)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export to GLB
|
|
exporter := export.NewGLBExporter()
|
|
|
|
var materialIdx uint32
|
|
if atlas != nil {
|
|
w, h := atlas.Image.Bounds().Dx(), atlas.Image.Bounds().Dy()
|
|
exporter.SetAtlasSize(float64(w), float64(h))
|
|
|
|
atlasBytes, err := texture.EncodePNG(atlas.Image)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encoding atlas: %w", err)
|
|
}
|
|
|
|
texIdx := exporter.AddTexture(atlasBytes)
|
|
materialIdx = exporter.AddMaterial("textured", texIdx)
|
|
} else {
|
|
exporter.SetAtlasSize(64, 64)
|
|
materialIdx = 0
|
|
}
|
|
|
|
if err := exporter.ExportModel(mergedModel, materialIdx); err != nil {
|
|
return nil, fmt.Errorf("exporting model: %w", err)
|
|
}
|
|
|
|
glbBytes, err := exporter.Bytes()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting GLB bytes: %w", err)
|
|
}
|
|
|
|
return &MergeResult{
|
|
Model: mergedModel,
|
|
Atlas: atlas,
|
|
GLBBytes: glbBytes,
|
|
}, nil
|
|
}
|
|
|
|
// applyHaircutFallback modifies haircut based on headAccessory type
|
|
func (s *MergeService) applyHaircutFallback(charData *character.CharacterData) {
|
|
if charData.HeadAccessory == nil || *charData.HeadAccessory == "" {
|
|
return
|
|
}
|
|
|
|
// Parse head accessory ID
|
|
headAccID := strings.Split(*charData.HeadAccessory, ".")[0]
|
|
headAcc, ok := s.headAccessories[headAccID]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Check if headAccessory disables haircut entirely
|
|
if headAcc.DisableCharacterPartCategory == "Haircut" {
|
|
charData.Haircut = nil
|
|
return
|
|
}
|
|
|
|
// Check headAccessory type
|
|
switch headAcc.HeadAccessoryType {
|
|
case "FullyCovering":
|
|
// No hair visible
|
|
charData.Haircut = nil
|
|
case "HalfCovering":
|
|
// Use fallback hairstyle
|
|
if charData.Haircut != nil && *charData.Haircut != "" {
|
|
s.setFallbackHaircut(charData)
|
|
}
|
|
}
|
|
// "Simple" or empty: keep original haircut
|
|
}
|
|
|
|
// setFallbackHaircut replaces haircut with appropriate fallback based on HairType
|
|
func (s *MergeService) setFallbackHaircut(charData *character.CharacterData) {
|
|
if charData.Haircut == nil || *charData.Haircut == "" {
|
|
return
|
|
}
|
|
|
|
// Parse haircut spec (ID.Color.Variant)
|
|
parts := strings.Split(*charData.Haircut, ".")
|
|
haircutID := parts[0]
|
|
color := ""
|
|
if len(parts) > 1 {
|
|
color = parts[1]
|
|
}
|
|
|
|
// Get haircut entry to find HairType
|
|
haircut, ok := s.haircuts[haircutID]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Get fallback haircut ID for this HairType
|
|
fallbackID, ok := s.haircutFallbacks[haircut.HairType]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Build new haircut string with fallback ID but same color
|
|
newHaircut := fallbackID
|
|
if color != "" {
|
|
newHaircut = fallbackID + "." + color
|
|
}
|
|
charData.Haircut = &newHaircut
|
|
}
|
|
|
|
// loadHeadAccessories loads head accessory data from JSON file
|
|
func loadHeadAccessories(path string) (map[string]HeadAccessoryEntry, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var entries []HeadAccessoryEntry
|
|
if err := json.Unmarshal(data, &entries); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make(map[string]HeadAccessoryEntry)
|
|
for _, e := range entries {
|
|
if e.ID != "" {
|
|
result[e.ID] = e
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// loadHaircuts loads haircut data from JSON file
|
|
func loadHaircuts(path string) (map[string]HaircutEntry, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var entries []HaircutEntry
|
|
if err := json.Unmarshal(data, &entries); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make(map[string]HaircutEntry)
|
|
for _, e := range entries {
|
|
if e.ID != "" {
|
|
result[e.ID] = e
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// loadHaircutFallbacks loads haircut fallback mappings from JSON file
|
|
func loadHaircutFallbacks(path string) (map[string]string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var result map[string]string
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|