-- Cosmetic-only factory. No global access, world storage, identity or physics. return function(hearth, api) local C = {VERSION = 1, MAX_JSON = 2048, FORM = "hearth:character"} local key = "hearth:appearance" local options = { body = {"compact", "lean", "balanced", "broad", "sturdy"}, skin = {"porcelain", "sand", "honey", "olive", "umber", "ebony"}, face = {"calm", "angular", "soft", "weathered", "lined"}, eyes = {"hazel", "brown", "green", "blue", "grey"}, hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none", "curly", "topknot", "undercut"}, hairColor = {"ash", "flax", "chestnut", "auburn", "black", "silver"}, beard = {"none", "stubble", "moustache", "short_beard", "full_beard", "goatee", "braided_beard"}, garment = {"tunic", "workshirt", "jerkin", "coat", "hooded_cloak", "travel_coat", "wool_mantle"}, cloth = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"}, trousers = {"earth", "slate", "moss", "charcoal"}, boots = {"walnut", "black", "tan"}, accent = {"none", "scarf", "apron", "sash", "vest", "satchel", "tool_belt", "pauldrons", "hood"}, accentColor = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"}, detail = {"none", "freckles", "scar", "freckles_scar", "birthmark"}, } local order = {"body", "skin", "face", "eyes", "hair", "hairColor", "beard", "garment", "cloth", "trousers", "boots", "accent", "accentColor", "detail"} local defaults = {version = 1, body = "balanced", skin = "sand", face = "calm", eyes = "hazel", hair = "sidepart", hairColor = "chestnut", beard = "none", garment = "tunic", cloth = "teal", trousers = "earth", boots = "walnut", accent = "scarf", accentColor = "sage", detail = "none"} local function copy(t) local out = {}; for k, v in pairs(t) do out[k] = v end; return out end function C.defaults() return copy(defaults) end function C.options() local out = {}; for k, list in pairs(options) do out[k] = copy(list) end; return out end local sets = {} for k, list in pairs(options) do sets[k] = {}; for _, id in ipairs(list) do sets[k][id] = true end end function C.validate(value) if type(value) ~= "table" or getmetatable(value) ~= nil or value.version ~= 1 then return nil end for k in pairs(value) do if k ~= "version" and not options[k] then return nil end end local out = {version = 1} for _, k in ipairs(order) do local id = value[k] if type(id) ~= "string" or #id > 24 or not sets[k][id] then return nil end out[k] = id end return out end -- Reject nesting/arrays before invoking the engine parser (bounded stack as -- well as bytes). The parser, not this scanner, validates JSON grammar. local function flat_json(raw) if type(raw) ~= "string" or #raw == 0 or #raw > C.MAX_JSON then return false end local quoted, escaped, depth = false, false, 0 for i = 1, #raw do local c = raw:sub(i,i) if quoted then if escaped then escaped = false elseif c == "\\" then escaped = true elseif c == '"' then quoted = false end elseif c == '"' then quoted = true elseif c == "[" or c == "]" then return false elseif c == "{" then depth = depth + 1; if depth > 1 then return false end elseif c == "}" then depth = depth - 1; if depth < 0 then return false end end end return not quoted and depth == 0 end function C.decode(raw) if not flat_json(raw) then return nil end local ok, value = pcall(api.parse_json, raw, nil, true) return ok and C.validate(value) or nil end function C.encode(value) local valid = C.validate(value) if not valid then return nil end local ok, raw = pcall(api.write_json, valid) if not ok or type(raw) ~= "string" or #raw > C.MAX_JSON then return nil end local roundtrip = C.decode(raw) if not roundtrip then return nil end for k, v in pairs(valid) do if roundtrip[k] ~= v then return nil end end return raw end function C.preview(value) local p = C.validate(value) if not p then return nil end local function name(stem) return "hearth_char_" .. stem .. ".png" end local texture = table.concat({name("skin_" .. p.skin), name("face_" .. p.face), name("eyes_" .. p.eyes), name("garment_" .. p.garment .. "_" .. p.cloth), name("trousers_" .. p.trousers), name("boots_" .. p.boots), name("hair_" .. p.hair .. "_" .. p.hairColor), name("beard_" .. p.beard .. "_" .. p.hairColor), name("accent_" .. p.accent .. "_" .. p.accentColor), name("detail_" .. p.detail)}, "^") return "hearth_person" .. (p.body == "balanced" and "" or "_" .. p.body) .. ".b3d", texture end local function publish(player, value) local mesh, texture = C.preview(value) player:set_properties({mesh = mesh, textures = {texture}}) end local function session(player) if not player then return nil end local name = player:get_player_name() if api.get_player_by_name(name) ~= player then return nil end return hearth.sessions[name] end local corrupt = "Saved appearance is unreadable. Showing defaults; original data is retained until you Apply." local function load(player) local ok, raw = pcall(function() return player:get_meta():get_string(key) end) if ok and raw == "" then return C.defaults() end local valid = ok and C.decode(raw) if valid then return valid end return C.defaults(), corrupt end function C.apply(player) local s = session(player); if not s then return false end local value, warning = load(player) s.character = {player = player, saved = value, warning = warning} publish(player, value) if warning and hearth.notify then hearth.notify(player, warning) end return true end C.on_join = C.apply local tabs = {{"Face", "body", "skin", "face", "eyes"}, {"Hair", "hair", "hairColor", "beard"}, {"Clothing", "garment", "cloth", "trousers", "boots"}, {"Finishing", "accent", "accentColor", "detail"}} local labels = {body = "Body shape (equal mechanics)", skin = "Skin tone", face = "Face lines", eyes = "Eyes", hair = "Hair style", hairColor = "Hair & facial hair color", beard = "Facial hair", garment = "Garment", cloth = "Cloth color", trousers = "Trousers", boots = "Boot leather", accent = "Accent", accentColor = "Accent color", detail = "Face details"} local names = {sidepart = "Side part", workshirt = "Work shirt", freckles_scar = "Freckles and scar", hooded_cloak = "Hooded cloak", travel_coat = "Travel coat", wool_mantle = "Wool mantle", tool_belt = "Tool belt", braided_beard = "Braided beard", body="Body shape", compact="Compact", lean="Lean", balanced="Balanced", broad="Broad", sturdy="Sturdy", curly="Curly", topknot="Topknot", undercut="Undercut", goatee="Goatee", birthmark="Birthmark", pauldrons="Shoulder guards"} local function title(id) return names[id] or (id:gsub("_", " "):gsub("^%l", string.upper)) end local function state(player) local s = session(player) local st = s and s.character return st and st.player == player and st or nil end function C.state(player) local st = state(player) return st and {saved = copy(st.saved), draft = st.draft and copy(st.draft), warning = st.warning, tab = st.tab, yaw = st.yaw} or nil end local function render(player, st) local esc = api.formspec_escape local mesh, texture = C.preview(st.draft) local f = {"formspec_version[6]size[14,11]bgcolor[#1e1e2e;true]", "style_type[label,textarea;textcolor=#cdd6f4]style_type[button;bgcolor=#292940;textcolor=#cdd6f4]", "label[0.6,0.5;THE LAST HEARTH / WOODLAND TRAVELER]", "label[0.6,1;Quiet cloth, worn leather, a face of your own.]", "box[0.5,1.5;5.4,7.6;#181825]", -- lua_api.md: camera x,y; continuous rotation; mouse control; frames; FPS. "model[0.65,1.65;5.1,6.4;traveler;" .. esc(mesh) .. ";" .. esc(texture) .. ";0," .. st.yaw .. ";true;true;0,19;12]", "button[0.7,8.25;1.55,0.6;front;Front]button[2.4,8.25;1.55,0.6;profile;Profile]button[4.1,8.25;1.55,0.6;back;Back]", "tooltip[traveler;" .. esc("Drag to turn the preview. Only this model rotates.") .. "]"} for i, tab in ipairs(tabs) do f[#f+1] = "button[" .. (6.2 + (i-1)*1.8) .. ",1.65;1.7,0.65;tab_" .. i .. ";" .. tab[1] .. "]" end for i = 2, #tabs[st.tab] do local k, y = tabs[st.tab][i], 2.8 + (i-2)*1.45 local choices, selected = {}, 1 for j, id in ipairs(options[k]) do choices[j] = esc(title(id)); if id == st.draft[k] then selected = j end end f[#f+1] = "label[6.3," .. y .. ";" .. esc(labels[k]) .. "]dropdown[6.3," .. (y+0.3) .. ";7,0.7;pick_" .. k .. ";" .. table.concat(choices,",") .. ";" .. selected .. ";true]" end f[#f+1] = "textarea[6.2,8.7;7.2,0.8;;;" .. esc(st.warning or "Preview only. Nothing changes until Save / Apply.") .. "]" f[#f+1] = "button[0.7,9.85;2.6,0.65;randomize;Randomize]button[3.5,9.85;2.6,0.65;reset;Reset draft]" .. "button[7.1,9.85;2.7,0.65;cancel;Cancel]button[10,9.85;3.3,0.65;apply;Save / Apply]" api.show_formspec(player:get_player_name(), C.FORM, table.concat(f)) end function C.show(player) if not session(player) then return false end local st = state(player) if not st then C.apply(player); st = state(player) end st.draft, st.tab, st.yaw = copy(st.saved), 1, 180 render(player, st); return true end local controls = {quit = true, cancel = true, apply = true, reset = true, randomize = true, front = true, profile = true, back = true, tab_1 = true, tab_2 = true, tab_3 = true, tab_4 = true} function C.handle(player, fields) local st = state(player) if not st or not st.draft or type(fields) ~= "table" then return false end -- No target name/ID is ever accepted. The engine-supplied PlayerRef owns -- this draft, tied to the current session and connected object identity. local visible, draft = {}, copy(st.draft) for i = 2, #tabs[st.tab] do visible["pick_" .. tabs[st.tab][i]] = tabs[st.tab][i] end local count = 0 for k, v in pairs(fields) do count = count + 1; if count > 20 then return false end if visible[k] then local n = type(v) == "string" and #v <= 2 and v:match("^[1-9][0-9]?$") and tonumber(v) if not n or not options[visible[k]][n] then return false end draft[visible[k]] = options[visible[k]][n] elseif not controls[k] or (type(v) ~= "string" and type(v) ~= "boolean") or (type(v) == "string" and #v > 32) then return false end end if fields.quit or fields.cancel then st.draft = nil; api.close_formspec(player:get_player_name(), C.FORM); return true end st.draft = draft if fields.apply then local raw = C.encode(draft) local ok, result = false, false if raw then ok, result = pcall(function() return player:get_meta():set_string(key, raw) end) end if not ok or result == false then st.warning = "Appearance could not be saved. Your previous appearance is unchanged; try again." render(player, st); return true end st.saved, st.draft, st.warning = copy(draft), nil, nil publish(player, draft) api.close_formspec(player:get_player_name(), C.FORM); return true elseif fields.reset then st.draft = C.defaults() elseif fields.randomize then for _, k in ipairs(order) do st.draft[k] = options[k][math.random(1, #options[k])] end end for i = 1, 4 do if fields["tab_" .. i] then st.tab = i; break end end if fields.front then st.yaw = 180 elseif fields.profile then st.yaw = 90 elseif fields.back then st.yaw = 0 end render(player, st); return true end api.register_on_player_receive_fields(function(player, form, fields) if form ~= C.FORM then return end C.handle(player, fields); return true end) api.register_chatcommand("character", {description = "Preview and save your woodland traveler appearance (cosmetic only)", func = function(name) local player = api.get_player_by_name(name) if not player or not C.show(player) then return false, "Join the world first." end return true end}) return C end