Files
last-hearth/mods/hearth/character.lua
T
2026-11-03 12:41:09 -05:00

228 lines
11 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", "balanced", "broad"},
skin = {"porcelain", "sand", "honey", "olive", "umber", "ebony"},
face = {"calm", "angular", "soft", "weathered"},
eyes = {"hazel", "brown", "green", "blue", "grey"},
hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none"},
hairColor = {"ash", "flax", "chestnut", "auburn", "black", "silver"},
beard = {"none", "stubble", "moustache", "short_beard", "full_beard"},
garment = {"tunic", "workshirt", "jerkin", "coat"},
cloth = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
trousers = {"earth", "slate", "moss", "charcoal"}, boots = {"walnut", "black", "tan"},
accent = {"none", "scarf", "apron", "sash", "vest"},
accentColor = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
detail = {"none", "freckles", "scar", "freckles_scar"},
}
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("accent_" .. p.accent .. "_" .. p.accentColor), name("hair_" .. p.hair .. "_" .. p.hairColor),
name("beard_" .. p.beard .. "_" .. p.hairColor), 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"}
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;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 <= 1 and v:match("^[1-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