233 lines
12 KiB
Lua
Executable File
233 lines
12 KiB
Lua
Executable File
-- 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
|