outfitLevel moved, character ID safeguard

This commit is contained in:
2026-05-21 15:36:27 +03:00
parent 9968cb8c75
commit b19033b557
8 changed files with 191 additions and 70 deletions
@@ -26,9 +26,6 @@ struct SlotSelection {
struct CharacterSlotsComponent {
Ogre::String sex = "male";
/* Global outfit level: 0=nude, 1=lingerie, 2=clothed */
int outfitLevel = 2;
/* Backward-compat: old mesh-name map. Deserialized into slotSelections on load. */
std::unordered_map<Ogre::String, Ogre::String> slots;
+1 -1
View File
@@ -29,7 +29,7 @@ ecs.subscribe_event("game_start", function(event, params)
-- ecs.debug_crash("new_game triggered")
local tsub = ecs.subscribe_event("scene_ready", function(event, params)
-- ecs.unsubscribe_event(tsub)
ecs.debug_crash("scene_ready triggered")
-- ecs.debug_crash("scene_ready triggered")
end)
end)
end)
+39 -7
View File
@@ -522,9 +522,19 @@ static void registerAllComponents()
lua_setfield(L, -2, "explicitMesh");
lua_setfield(L, -2, kv.first.c_str());
} lua_setfield(L, -2, "slotSelections");
lua_pushinteger(L, c.outfitLevel);
lua_setfield(L, -2, "outfitLevel"); pushVector3(L, c.frontAxis);
lua_setfield(L, -2, "frontAxis");
{ // Push: outfitLevel from registry
int outfitLevel = 2;
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<CharacterIdentityComponent>();
const CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton()
.findCharacter(id.registryId);
if (rec)
outfitLevel = rec->inlineOutfitLevel;
}
lua_pushinteger(L, outfitLevel);
} lua_setfield(L, -2, "outfitLevel");
pushVector3(L, c.frontAxis); lua_setfield(L, -2, "frontAxis");
,
{ // Read: age into registry
if (lua_getfield(L, idx, "age"), lua_isstring(L, -1)) {
@@ -540,16 +550,38 @@ static void registerAllComponents()
rec->age = age;
CharacterRegistry::getSingleton()
.autoSave();
CharacterRegistry::getSingleton()
.markCharacterDirty(
id.registryId);
}
}
}
} lua_pop(L, 1);
if (lua_getfield(L, idx, "sex"), lua_isstring(L, -1))
c.sex = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "outfitLevel"), lua_isnumber(L, -1))
c.outfitLevel = lua_tointeger(L, -1);
lua_pop(L, 1);
lua_pop(L, 1); { // Read: outfitLevel into registry
if (lua_getfield(L, idx, "outfitLevel"),
lua_isnumber(L, -1)) {
int outfitLevel = (int)lua_tointeger(L, -1);
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<
CharacterIdentityComponent>();
CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton()
.findCharacter(
id.registryId);
if (rec) {
rec->inlineOutfitLevel =
outfitLevel;
CharacterRegistry::getSingleton()
.autoSave();
CharacterRegistry::getSingleton()
.markCharacterDirty(
id.registryId);
}
}
}
} lua_pop(L, 1);
if (lua_getfield(L, idx, "slots"), lua_istable(L, -1)) {
c.slots.clear();
lua_pushnil(L);
@@ -297,7 +297,6 @@ readPrefabAppearance(const std::string &path, CharacterSlotsComponent &cs,
if (j.contains("characterSlots")) {
auto &s = j["characterSlots"];
cs.sex = s.value("sex", "male");
cs.outfitLevel = s.value("outfitLevel", 2);
if (s.contains("slotSelections")) {
for (auto &[slot, selJson] :
s["slotSelections"].items()) {
@@ -429,7 +428,7 @@ uint64_t CharacterRegistry::createChild(uint64_t parentA, uint64_t parentB)
child->inlineShapeKeyWeights, prefabAge);
child->age = prefabAge;
child->inlineSex = tmpCs.sex;
child->inlineOutfitLevel = tmpCs.outfitLevel;
child->inlineOutfitLevel = sameSexParent->inlineOutfitLevel;
child->inlineSlotSelections = tmpCs.slotSelections;
} else {
child->age = sameSexParent->age;
@@ -540,7 +539,6 @@ flecs::entity CharacterRegistry::spawnInlineCharacter(const CharacterRecord &c,
/* CharacterSlots */
CharacterSlotsComponent cs;
cs.sex = c.inlineSex;
cs.outfitLevel = c.inlineOutfitLevel;
cs.slotSelections = c.inlineSlotSelections;
cs.dirty = true;
inst.set<CharacterSlotsComponent>(cs);
@@ -695,6 +693,15 @@ flecs::entity CharacterRegistry::findSpawnedEntity(uint64_t id) const
return result;
}
void CharacterRegistry::markCharacterDirty(uint64_t id)
{
flecs::entity e = findSpawnedEntity(id);
if (!e.is_alive())
return;
if (e.has<CharacterSlotsComponent>())
e.get_mut<CharacterSlotsComponent>().dirty = true;
}
bool CharacterRegistry::despawnCharacter(uint64_t id)
{
if (!m_world)
@@ -829,6 +836,14 @@ uint64_t CharacterRegistry::createCharacter(const std::string &firstName,
bool persistent)
{
uint64_t id = persistent ? m_nextId++ : m_nextRuntimeId++;
/* Safety: id must never be 0 */
if (id == 0) {
id = persistent ? m_nextId++ : m_nextRuntimeId++;
Ogre::LogManager::getSingleton().logMessage(
"CharacterRegistry: id wrapped to 0, "
"incremented to " +
std::to_string(id));
}
CharacterRecord rec;
rec.id = id;
rec.firstName = firstName;
@@ -1169,7 +1184,11 @@ nlohmann::json CharacterRegistry::serialize() const
const CharacterRecord &c = pair.second;
if (!c.persistent)
continue;
/* Belt-and-suspenders: never serialize id == 0 */
if (c.id == 0)
continue;
nlohmann::json rec;
rec["id"] = c.id;
rec["persistent"] = c.persistent;
rec["firstName"] = c.firstName;
@@ -1423,6 +1442,17 @@ void CharacterRegistry::deserialize(const nlohmann::json &j)
c.stringColumns[k] =
v.get<std::string>();
}
/* ---- Safeguard: reject id == 0 ---- */
if (c.id == 0) {
Ogre::LogManager::getSingleton().logMessage(
"CharacterRegistry: skipping corrupt "
"record with id=0 (firstName=" +
c.firstName +
" lastName=" + c.lastName + ")");
continue;
}
m_characters[c.id] = c;
}
}
@@ -1777,15 +1807,24 @@ void CharacterRegistry::drawEditor(bool *p_open)
ImGui::SameLine();
if (ImGui::SmallButton(
"Promote to Roster")) {
c->persistent = true;
c->prefabPath =
/* Assign a proper persistent
* ID */
uint64_t newId = m_nextId++;
CharacterRecord promoted = *c;
promoted.id = newId;
promoted.persistent = true;
promoted.prefabPath =
generatePrefabPath(
c->id,
newId,
c->firstName,
c->lastName);
m_characters.erase(c->id);
m_characters[newId] = promoted;
m_selectedCharacterId = newId;
autoSave();
}
}
if (ImGui::InputText("First Name", fnBuf,
sizeof(fnBuf))) {
c->firstName = fnBuf;
@@ -1848,13 +1887,20 @@ void CharacterRegistry::drawEditor(bool *p_open)
/* Age category from catalog */
CharacterSlotSystem::loadCatalog();
std::string currentAgeCat = c->age;
std::vector<Ogre::String> ageCats = CharacterSlotSystem::getAges();
if (ImGui::BeginCombo("Age Category", currentAgeCat.c_str())) {
std::vector<Ogre::String> ageCats =
CharacterSlotSystem::getAges();
if (ImGui::BeginCombo("Age Category",
currentAgeCat.c_str())) {
for (const auto &ac : ageCats) {
bool isSelected = (currentAgeCat == ac);
if (ImGui::Selectable(ac.c_str(), isSelected)) {
bool isSelected =
(currentAgeCat == ac);
if (ImGui::Selectable(
ac.c_str(),
isSelected)) {
c->age = ac;
autoSave();
markCharacterDirty(
c->id);
}
if (isSelected)
ImGui::SetItemDefaultFocus();
@@ -1862,6 +1908,25 @@ void CharacterRegistry::drawEditor(bool *p_open)
ImGui::EndCombo();
}
/* Outfit level */
{
const char *outfitLabels[] = {
"Nude", "Lingerie", "Clothed"
};
int outfit = c->inlineOutfitLevel;
if (outfit < 0)
outfit = 0;
if (outfit > 2)
outfit = 2;
if (ImGui::Combo("Outfit Level",
&outfit, outfitLabels,
3)) {
c->inlineOutfitLevel = outfit;
autoSave();
markCharacterDirty(c->id);
}
}
/* Family */
ImGui::Separator();
ImGui::Text("Family");
@@ -319,6 +319,13 @@ public:
/* Spawn / Save */
/* ------------------------------------------------------------------ */
flecs::entity findSpawnedEntity(uint64_t id) const;
/**
* Mark the spawned entity for a character as dirty so its visual
* appearance (CharacterSlotsComponent) is rebuilt on the next
* update. Call this after changing outfitLevel, age, or any other
* registry field that affects the character's look.
*/
void markCharacterDirty(uint64_t id);
bool despawnCharacter(uint64_t id);
flecs::entity spawnCharacter(uint64_t id);
flecs::entity spawnInlineCharacter(const CharacterRecord &c,
@@ -441,6 +441,23 @@ static Ogre::String getCharacterAge(flecs::entity e)
return "adult";
}
/**
* Helper: retrieve the character's outfit level from the registry.
* Falls back to 2 (clothed) if no registry entry is found.
*/
static int getCharacterOutfitLevel(flecs::entity e)
{
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<CharacterIdentityComponent>();
const CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(
id.registryId);
if (rec)
return rec->inlineOutfitLevel;
}
return 2;
}
void CharacterSlotSystem::buildCharacter(flecs::entity e,
CharacterSlotsComponent &cs)
{
@@ -473,12 +490,14 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
if (!transform.node)
return;
int outfitLevel = getCharacterOutfitLevel(e);
/* Determine master slot (face preferred, else first non-empty) */
Ogre::String masterSlot;
if (cs.slotSelections.find("face") != cs.slotSelections.end()) {
Ogre::String mesh = resolveMesh(age, cs.sex, "face",
cs.slotSelections["face"],
cs.outfitLevel);
outfitLevel);
if (!mesh.empty())
masterSlot = "face";
}
@@ -486,7 +505,7 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
for (const auto &pair : cs.slotSelections) {
Ogre::String mesh = resolveMesh(age, cs.sex, pair.first,
pair.second,
cs.outfitLevel);
outfitLevel);
if (!mesh.empty()) {
masterSlot = pair.first;
break;
@@ -499,7 +518,7 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
Ogre::String masterMesh = resolveMesh(age, cs.sex, masterSlot,
cs.slotSelections[masterSlot],
cs.outfitLevel);
outfitLevel);
if (masterMesh.empty())
return;
@@ -539,7 +558,7 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
continue;
Ogre::String mesh =
resolveMesh(age, cs.sex, slot, sel, cs.outfitLevel);
resolveMesh(age, cs.sex, slot, sel, outfitLevel);
if (mesh.empty())
continue;
@@ -344,8 +344,8 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity)
}
if (entity.has<GoapBlackboard>()) {
json["goapBlackboard"] = serializeGoapBlackboard(
entity.get<GoapBlackboard>());
json["goapBlackboard"] =
serializeGoapBlackboard(entity.get<GoapBlackboard>());
}
if (entity.has<ItemComponent>()) {
@@ -2201,7 +2201,6 @@ nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity)
nlohmann::json json;
json["sex"] = cs.sex;
json["outfitLevel"] = cs.outfitLevel;
json["slots"] = nlohmann::json::object();
for (const auto &pair : cs.slots)
json["slots"][pair.first] = pair.second;
@@ -2224,26 +2223,28 @@ nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity)
}
void SceneSerializer::deserializeCharacterSlots(flecs::entity entity,
const nlohmann::json &json)
const nlohmann::json &json)
{
CharacterSlotsComponent cs;
cs.sex = json.value("sex", "male");
cs.outfitLevel = json.value("outfitLevel", 2);
if (json.contains("slots") && json["slots"].is_object()) {
for (auto &[slot, mesh] : json["slots"].items())
cs.slots[slot] = mesh.get<std::string>();
}
// Deserialize per-layer slot selections
if (json.contains("slotSelections") && json["slotSelections"].is_object()) {
if (json.contains("slotSelections") &&
json["slotSelections"].is_object()) {
for (auto &[slot, selJson] : json["slotSelections"].items()) {
SlotSelection sel;
if (selJson.contains("layer1Mesh"))
sel.layer1Mesh = selJson.value("layer1Mesh", "");
sel.layer1Mesh =
selJson.value("layer1Mesh", "");
if (selJson.contains("layer2Mesh"))
sel.layer2Mesh = selJson.value("layer2Mesh", "");
sel.layer2Mesh =
selJson.value("layer2Mesh", "");
// Backward compat: old format had layer/requiredTags/excludedTags
if (selJson.contains("layer") && sel.layer1Mesh.empty() &&
sel.layer2Mesh.empty()) {
if (selJson.contains("layer") &&
sel.layer1Mesh.empty() && sel.layer2Mesh.empty()) {
int oldLayer = selJson.value("layer", 2);
if (oldLayer == 1)
sel.layer1Mesh = "auto";
@@ -3684,9 +3685,11 @@ void SceneSerializer::deserializePathFollowing(flecs::entity entity,
pf.walkSpeed = json.value("walkSpeed", 2.5f);
pf.runSpeed = json.value("runSpeed", 5.0f);
pf.useRootMotion = json.value("useRootMotion", true);
pf.currentLocomotionState = json.value("currentLocomotionState", "idle");
pf.currentLocomotionState =
json.value("currentLocomotionState", "idle");
pf.hasTarget = json.value("hasTarget", false);
if (json.contains("targetPosition") && json["targetPosition"].is_object()) {
if (json.contains("targetPosition") &&
json["targetPosition"].is_object()) {
auto &tp = json["targetPosition"];
pf.targetPosition = Ogre::Vector3(tp.value("x", 0.0f),
tp.value("y", 0.0f),
@@ -3696,8 +3699,8 @@ void SceneSerializer::deserializePathFollowing(flecs::entity entity,
pf.path.clear();
for (const auto &pt : json["path"]) {
pf.path.push_back(Ogre::Vector3(pt.value("x", 0.0f),
pt.value("y", 0.0f),
pt.value("z", 0.0f)));
pt.value("y", 0.0f),
pt.value("z", 0.0f)));
}
}
pf.pathIndex = json.value("pathIndex", 0);
@@ -3793,8 +3796,8 @@ void SceneSerializer::deserializeGoapRunner(flecs::entity entity,
const nlohmann::json &json)
{
GoapRunnerComponent runner;
runner.state = static_cast<GoapRunnerComponent::State>(
json.value("state", 0));
runner.state =
static_cast<GoapRunnerComponent::State>(json.value("state", 0));
runner.currentActionIndex = json.value("currentActionIndex", 0);
runner.currentActionName = json.value("currentActionName", "");
runner.actionTimer = json.value("actionTimer", 0.0f);
@@ -4038,7 +4041,8 @@ void SceneSerializer::deserializeInventory(flecs::entity entity,
slot.stackSize = slotJson.value("stackSize", 0);
// Backward compatibility: old slots had inline data
if (slot.itemId.empty() && slotJson.contains("itemName")) {
if (slot.itemId.empty() &&
slotJson.contains("itemName")) {
slot.itemId = slotJson.value("itemName", "");
}
ensureItemInRegistry(slot.itemId, slotJson);
@@ -10,7 +10,6 @@ CharacterSlotsEditor::CharacterSlotsEditor(Ogre::SceneManager *sceneMgr)
{
}
bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
CharacterSlotsComponent &cs)
{
@@ -22,7 +21,6 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
CharacterSlotSystem::loadCatalog();
/* Get current age from registry */
Ogre::String currentAge = "adult";
if (entity.has<CharacterIdentityComponent>()) {
@@ -55,21 +53,6 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
ImGui::Separator();
/* Global outfit level */
const char *outfitLabels[] = { "Nude", "Lingerie", "Clothed" };
int outfit = cs.outfitLevel;
if (outfit < 0)
outfit = 0;
if (outfit > 2)
outfit = 2;
if (ImGui::Combo("Outfit", &outfit, outfitLabels, 3)) {
cs.outfitLevel = outfit;
modified = true;
cs.dirty = true;
}
ImGui::SameLine();
ImGui::TextDisabled("(global layer switch)");
ImGui::Separator();
/* Front-facing axis */
@@ -136,6 +119,17 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
ImGui::Separator();
/* Get outfit level from registry for resolveMesh calls */
int outfitLevel = 2;
if (entity.has<CharacterIdentityComponent>()) {
auto &id = entity.get<CharacterIdentityComponent>();
auto *rec =
CharacterRegistry::getSingleton().findCharacter(
id.registryId);
if (rec)
outfitLevel = rec->inlineOutfitLevel;
}
/* Slot selections */
std::vector<Ogre::String> availableSlots =
CharacterSlotSystem::getSlots(currentAge, cs.sex);
@@ -166,7 +160,7 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
Ogre::String resolved =
CharacterSlotSystem::resolveMesh(
currentAge, cs.sex, slot, sel,
cs.outfitLevel);
outfitLevel);
ImGui::TextDisabled("Resolved: %s",
resolved.empty() ?
"(none)" :
@@ -185,7 +179,7 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
currentAge,
cs.sex, slot,
sel,
cs.outfitLevel);
outfitLevel);
}
modified = true;
cs.dirty = true;
@@ -194,7 +188,8 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
if (useExplicit) {
std::vector<Ogre::String> meshes =
CharacterSlotSystem::getMeshes(
currentAge, cs.sex, slot);
currentAge, cs.sex,
slot);
Ogre::String preview =
sel.explicitMesh.empty() ?
"(none)" :
@@ -229,14 +224,15 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
std::vector<Ogre::String> layer1Meshes =
CharacterSlotSystem::
getMeshesForLayer(
currentAge, cs.sex,
slot, 1);
currentAge,
cs.sex, slot,
1);
Ogre::String l1Preview = "none";
if (!sel.layer1Mesh.empty())
l1Preview = CharacterSlotSystem::
getMeshLabel(
currentAge, cs.sex,
slot,
currentAge,
cs.sex, slot,
sel.layer1Mesh);
if (ImGui::BeginCombo(
"Lingerie (Layer 1)",
@@ -277,14 +273,15 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
std::vector<Ogre::String> layer2Meshes =
CharacterSlotSystem::
getMeshesForLayer(
currentAge, cs.sex,
slot, 2);
currentAge,
cs.sex, slot,
2);
Ogre::String l2Preview = "none";
if (!sel.layer2Mesh.empty())
l2Preview = CharacterSlotSystem::
getMeshLabel(
currentAge, cs.sex,
slot,
currentAge,
cs.sex, slot,
sel.layer2Mesh);
if (ImGui::BeginCombo(
"Clothing (Layer 2)",