Skip to content

Commit bbcf35a

Browse files
authored
Improve object preparation logic
Fix comments for clarity and update object handling logic.
1 parent d85b3ee commit bbcf35a

1 file changed

Lines changed: 97 additions & 56 deletions

File tree

src/main.cpp

Lines changed: 97 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -555,18 +555,16 @@ class AIGeneratorPopup : public Popup {
555555
// "color": "#FF8800", -- hex color to set it to
556556
// "duration": 0.5, -- transition time in seconds
557557
// "blending": false, -- (optional) additive blending
558-
// "opacity": 1.0 -- (optional) 0.0 1.0
558+
// "opacity": 1.0 -- (optional) 0.0 - 1.0
559559
// }
560560
if (advFeatures && gameObj->m_objectID == 899) {
561561
auto* effectObj = static_cast<EffectGameObject*>(gameObj);
562562

563-
// Color channel (which GD color channel to modify, 1-999)
564563
auto channelResult = objData["color_channel"].asInt();
565564
if (channelResult) {
566565
effectObj->m_targetColor = std::clamp((int)channelResult.unwrap(), 1, 999);
567566
}
568567

569-
// Hex color "#RRGGBB"
570568
auto colorHexResult = objData["color"].asString();
571569
if (colorHexResult) {
572570
GLubyte r = 255, g = 255, b = 255;
@@ -575,27 +573,23 @@ class AIGeneratorPopup : public Popup {
575573
}
576574
}
577575

578-
// Duration (seconds, 0 = instant)
579576
auto durResult = objData["duration"].asDouble();
580577
if (durResult) {
581578
effectObj->m_duration = std::clamp(
582579
static_cast<float>(durResult.unwrap()), 0.0f, 30.0f);
583580
}
584581

585-
// Blending (additive color blend)
586582
auto blendResult = objData["blending"].asBool();
587583
if (blendResult) {
588584
effectObj->m_usesBlending = blendResult.unwrap();
589585
}
590586

591-
// Opacity (0.0 – 1.0)
592587
auto opacityResult = objData["opacity"].asDouble();
593588
if (opacityResult) {
594589
effectObj->m_opacity = std::clamp(
595590
static_cast<float>(opacityResult.unwrap()), 0.0f, 1.0f);
596591
}
597592

598-
// Touch-triggered by default so it fires when the player reaches it
599593
effectObj->m_isTouchTriggered = true;
600594
}
601595

@@ -604,40 +598,34 @@ class AIGeneratorPopup : public Popup {
604598
// {
605599
// "type": "move_trigger", "x": ..., "y": 0,
606600
// "target_group": 1, -- int 1-9999, group of objects to move
607-
// "move_x": 150, -- horizontal offset in units (30 = 1 cell)
601+
// "move_x": 50, -- horizontal offset in units (10 = 1 cell)
608602
// "move_y": 0, -- vertical offset in units
609603
// "duration": 0.5, -- transition time in seconds
610-
// "easing": 0 -- (optional) 0=None,1=EaseInOut,2=EaseIn,3=EaseOut,
611-
// -- 4=ElasticInOut,5=ElasticIn,6=ElasticOut,
612-
// -- 7=BounceInOut,8=BounceIn,9=BounceOut
604+
// "easing": 0 -- (optional) 0=None ... 9=BounceOut
613605
// }
614606
if (advFeatures && gameObj->m_objectID == 901) {
615607
auto* effectObj = static_cast<EffectGameObject*>(gameObj);
616608

617-
// Which group to move
618609
auto targetGroupResult = objData["target_group"].asInt();
619610
if (targetGroupResult) {
620611
effectObj->m_targetGroupID = std::clamp((int)targetGroupResult.unwrap(), 1, 9999);
621612
}
622613

623-
// X and Y movement offset (in GD units; 30 units = 1 grid cell)
614+
// X and Y movement offset (in GD units; 10 units = 1 grid cell)
624615
auto moveXResult = objData["move_x"].asDouble();
625616
auto moveYResult = objData["move_y"].asDouble();
626617
float offsetX = moveXResult ? static_cast<float>(moveXResult.unwrap()) : 0.0f;
627618
float offsetY = moveYResult ? static_cast<float>(moveYResult.unwrap()) : 0.0f;
628-
// Clamp to a sane range (±32,767 units)
629619
offsetX = std::clamp(offsetX, -32767.0f, 32767.0f);
630620
offsetY = std::clamp(offsetY, -32767.0f, 32767.0f);
631621
effectObj->m_moveOffset = CCPoint(offsetX, offsetY);
632622

633-
// Duration
634623
auto durResult = objData["duration"].asDouble();
635624
if (durResult) {
636625
effectObj->m_duration = std::clamp(
637626
static_cast<float>(durResult.unwrap()), 0.0f, 30.0f);
638627
}
639628

640-
// Easing type (integer 0–18, cast directly as the game does)
641629
auto easingResult = objData["easing"].asInt();
642630
if (easingResult) {
643631
int easingVal = std::clamp((int)easingResult.unwrap(), 0, 18);
@@ -646,7 +634,6 @@ class AIGeneratorPopup : public Popup {
646634
effectObj->m_easingType = EasingType::None;
647635
}
648636

649-
// Touch-triggered so it fires when the player passes
650637
effectObj->m_isTouchTriggered = true;
651638
}
652639

@@ -667,12 +654,14 @@ class AIGeneratorPopup : public Popup {
667654

668655
for (size_t i = 0; i < objectCount; ++i) {
669656
try {
670-
auto objData = objectsArray[i];
671-
672-
// Resolve type name → numeric object ID
673-
// "color_trigger" maps to ID 899 (GD color trigger)
674-
// All other types look up OBJECT_IDS map
675-
auto typeResult = objData["type"].asString();
657+
// FIX: resolve type -> ID by writing directly into objectsArray[i],
658+
// then read id/x/y back from objectsArray[i] — NOT from a local copy
659+
// captured before the write. The old code captured `auto objData =
660+
// objectsArray[i]` at the top of the loop, wrote the ID into
661+
// objectsArray[i]["id"], then read `objData["id"]` from the stale
662+
// copy that didn't have the ID yet — causing every object to be
663+
// filtered out and logging "Prepared 0 valid objects".
664+
auto typeResult = objectsArray[i]["type"].asString();
676665
if (typeResult) {
677666
const std::string& typeName = typeResult.unwrap();
678667
if (typeName == "color_trigger") {
@@ -685,31 +674,62 @@ class AIGeneratorPopup : public Popup {
685674
}
686675
}
687676

688-
auto idResult = objData["id"].asInt();
689-
auto xResult = objData["x"].asDouble();
690-
auto yResult = objData["y"].asDouble();
677+
// Read from the authoritative array slot (not a stale local copy)
678+
auto idResult = objectsArray[i]["id"].asInt();
679+
auto xResult = objectsArray[i]["x"].asDouble();
680+
auto yResult = objectsArray[i]["y"].asDouble();
691681

692682
if (!idResult || !xResult || !yResult) continue;
693683

694684
int objectID = idResult.unwrap();
695685
float x = static_cast<float>(xResult.unwrap());
696686
float y = static_cast<float>(yResult.unwrap());
697687

698-
// Clamp Y so objects are never placed underground
699-
if (y < 0.0f) y = 0.0f;
700-
701688
if (objectID < 1 || objectID > 10000) {
702689
log::warn("Invalid object ID {} at index {} — skipping", objectID, i);
703690
continue;
704691
}
705692

706-
m_deferredObjects.push_back({objectID, CCPoint{x, y}, objData});
693+
// Capture the full slot (with id now set) for applyObjectProperties
694+
m_deferredObjects.push_back({objectID, CCPoint{x, y}, objectsArray[i]});
707695
} catch (...) {
708696
log::warn("Failed to prepare object at index {}", i);
709697
}
710698
}
711699

700+
// If any object sits below ground, shift the entire set upward so the
701+
// lowest object lands exactly at Y=0. This preserves the relative layout
702+
// instead of clamping each object individually (which would crush objects
703+
// that were below ground onto the same floor level as each other).
704+
if (!m_deferredObjects.empty()) {
705+
float minY = m_deferredObjects[0].position.y;
706+
for (auto& obj : m_deferredObjects)
707+
minY = std::min(minY, obj.position.y);
708+
709+
if (minY < 0.0f) {
710+
float shift = -minY;
711+
log::info("Shifting all objects up by {:.1f} units (lowest was at Y={:.1f})", shift, minY);
712+
for (auto& obj : m_deferredObjects)
713+
obj.position.y += shift;
714+
}
715+
}
716+
712717
log::info("Prepared {} valid objects", m_deferredObjects.size());
718+
719+
// Only start the creation loop if there is actually something to create.
720+
// If nothing was prepared, show an actionable error immediately instead
721+
// of setting m_isCreatingObjects=true with an empty queue — which would
722+
// cause the popup to freeze on "Starting object creation..." forever
723+
// because updateObjectCreation early-returns on m_deferredObjects.empty().
724+
if (m_deferredObjects.empty()) {
725+
onError("No Valid Objects",
726+
"The AI response contained no usable objects.\n\n"
727+
"This can happen when the model uses unknown type names or omits "
728+
"required fields (x, y). Try rephrasing your prompt or switching "
729+
"to a more capable model.");
730+
return;
731+
}
732+
713733
m_isCreatingObjects = true;
714734
showStatus("Starting object creation...", false);
715735
}
@@ -734,16 +754,27 @@ class AIGeneratorPopup : public Popup {
734754
"Available objects: {}\n\n"
735755
"JSON Format:\n"
736756
"{{\n"
737-
" \"analysis\": \"Brief reasoning\",\n"
757+
" \"analysis\": \"Brief reasoning about layout and design choices\",\n"
738758
" \"objects\": [\n"
739-
" {{\"type\": \"block_black_gradient_square\", \"x\": 0, \"y\": 30}},\n"
740-
" {{\"type\": \"spike_black_gradient_spike\", \"x\": 150, \"y\": 0}}\n"
759+
" {{\"type\": \"block_black_gradient_square\", \"x\": 0, \"y\": 10}},\n"
760+
" {{\"type\": \"spike_black_gradient_spike\", \"x\": 50, \"y\": 0}}\n"
741761
" ]\n"
742762
"}}\n\n"
743-
"Coordinates: X=horizontal (30 units=1 grid cell), Y=vertical (0=ground, 30=1 block above ground).\n"
744-
"Y must be >= 0. Never place objects below Y=0.\n"
745-
"Spacing: EASY=150-200, MEDIUM=90-150, HARD=60-90, EXTREME=30-60 X units between obstacles.\n"
746-
"Length: SHORT=500-1000, MEDIUM=1000-2000, LONG=2000-4000, XL=4000-8000, XXL=8000+ X units.",
763+
"COORDINATE SYSTEM — read carefully:\n"
764+
" X = horizontal position. 10 units = 1 grid cell. Level runs left to right.\n"
765+
" Y = vertical position. 0 = ground. 10 = one block above ground. Y must always be >= 0.\n"
766+
" One grid cell is 10 units. A block sitting ON the ground has Y=0.\n"
767+
" A block one cell above ground has Y=10. Two cells above = Y=20.\n\n"
768+
"OBSTACLE SPACING (X distance between obstacles):\n"
769+
" EASY=50-70 MEDIUM=30-50 HARD=20-30 EXTREME=10-20\n\n"
770+
"LEVEL LENGTH (total X span):\n"
771+
" SHORT=200-400 MEDIUM=400-800 LONG=800-1600 XL=1600-3200 XXL=3200+\n\n"
772+
"RULES:\n"
773+
" - Only use type names from the available objects list above. Never invent new names.\n"
774+
" - Y must be >= 0. Never place objects with negative Y.\n"
775+
" - Vary object types to create interesting, playable layouts.\n"
776+
" - A spike or hazard sitting on the ground has Y=0.\n"
777+
" - Platforms and blocks used as stepping stones should be at Y=10 or Y=20.",
747778
objectList
748779
);
749780

@@ -753,49 +784,49 @@ class AIGeneratorPopup : public Popup {
753784
"ADVANCED FEATURES (enabled):\n\n"
754785

755786
"JSON FORMAT RULES — follow exactly or the response will fail to parse:\n"
756-
" Return ONLY the JSON object — no markdown, no comments, no trailing commas\n"
757-
" All string values must use double quotes\n"
758-
" Numbers must not be quoted: \"x\": 150 not \"x\": \"150\"\n"
759-
" Arrays use square brackets: \"groups\": [1, 5]\n"
760-
" Do not include fields with null values — omit them entirely\n\n"
787+
" * Return ONLY the JSON object — no markdown, no comments, no trailing commas\n"
788+
" * All string values must use double quotes\n"
789+
" * Numbers must not be quoted: \"x\": 50 not \"x\": \"50\"\n"
790+
" * Arrays use square brackets: \"groups\": [1, 5]\n"
791+
" * Do not include fields with null values — omit them entirely\n\n"
761792

762793
"1. GROUP IDs — Assign up to 10 group IDs per object with the optional \"groups\" array.\n"
763794
" Objects must be in a group for a move/toggle trigger to target them.\n"
764-
" Example: {\"type\": \"platform\", \"x\": 100, \"y\": 30, \"groups\": [1]}\n\n"
795+
" Example: {\"type\": \"platform\", \"x\": 100, \"y\": 10, \"groups\": [1]}\n\n"
765796

766797
"2. COLOR TRIGGERS (type \"color_trigger\", ID 899)\n"
767798
" Place at x positions where the color should change; set y to 0 (ground-level).\n"
768799
" They fire automatically when the player reaches them.\n"
769800
" Required fields:\n"
770-
" \"color_channel\": integer 1999 — which GD channel to change\n"
801+
" \"color_channel\": integer 1-999 — which GD channel to change\n"
771802
" (1=Background, 2=Ground1, 3=Line, 4=Object, 1000=Player1, 1001=Player2)\n"
772803
" \"color\": \"#RRGGBB\" — target hex color (6 hex digits after #)\n"
773804
" Optional fields:\n"
774805
" \"duration\": float seconds (default 0.5)\n"
775806
" \"blending\": true/false — additive blend (default false)\n"
776-
" \"opacity\": 0.01.0 (default 1.0)\n"
777-
" Example: {\"type\":\"color_trigger\",\"x\":500,\"y\":0,\"color_channel\":1,\"color\":\"#FF4400\",\"duration\":1.0}\n\n"
807+
" \"opacity\": 0.0-1.0 (default 1.0)\n"
808+
" Example: {\"type\":\"color_trigger\",\"x\":170,\"y\":0,\"color_channel\":1,\"color\":\"#FF4400\",\"duration\":1.0}\n\n"
778809

779810
"3. MOVE TRIGGERS (type \"move_trigger\", ID 901)\n"
780811
" Move a group of objects by an offset over time. Objects must already have a\n"
781812
" matching group ID assigned via the \"groups\" field.\n"
782813
" Place the trigger at y=0 so the player activates it on the ground.\n"
783814
" Required fields:\n"
784-
" \"target_group\": integer 19999 — group ID to move (must match object groups)\n"
785-
" \"move_x\": float — horizontal distance in GD units (30 = 1 grid cell, negative = left)\n"
815+
" \"target_group\": integer 1-9999 — group ID to move (must match object groups)\n"
816+
" \"move_x\": float — horizontal distance in GD units (10 = 1 grid cell, negative = left)\n"
786817
" \"move_y\": float — vertical distance in GD units (positive = up)\n"
787818
" Optional fields:\n"
788819
" \"duration\": float seconds (default 0.5)\n"
789-
" \"easing\": integer 09\n"
820+
" \"easing\": integer 0-9\n"
790821
" 0=None, 1=EaseInOut, 2=EaseIn, 3=EaseOut,\n"
791822
" 4=ElasticInOut, 5=ElasticIn, 6=ElasticOut,\n"
792823
" 7=BounceInOut, 8=BounceIn, 9=BounceOut\n"
793824
" IMPORTANT: The trigger and the objects it moves are SEPARATE. Place the trigger\n"
794825
" where the player will reach it, and place the objects being moved wherever they\n"
795826
" should start (they will shift by move_x/move_y when the trigger fires).\n"
796827
" Example:\n"
797-
" Objects to move: {\"type\":\"block_black_gradient_square\",\"x\":800,\"y\":90,\"groups\":[2]}\n"
798-
" Trigger to fire it: {\"type\":\"move_trigger\",\"x\":500,\"y\":0,\"target_group\":2,\"move_x\":0,\"move_y\":90,\"duration\":0.5,\"easing\":1}\n\n"
828+
" Objects to move: {\"type\":\"block_black_gradient_square\",\"x\":270,\"y\":30,\"groups\":[2]}\n"
829+
" Trigger to fire it: {\"type\":\"move_trigger\",\"x\":170,\"y\":0,\"target_group\":2,\"move_x\":0,\"move_y\":30,\"duration\":0.5,\"easing\":1}\n\n"
799830

800831
"Use advanced features purposefully to enhance the level. Color triggers set the mood\n"
801832
"at natural section changes (drops, transitions). Move triggers add dynamic platforming\n"
@@ -1087,13 +1118,23 @@ class AIGeneratorPopup : public Popup {
10871118
aiResponse = textResult.unwrap();
10881119

10891120
} else if (provider == "ollama") {
1121+
// FIX: treat an incomplete response (done=false) as a hard error.
1122+
// Previously this only logged a warning and continued trying to parse
1123+
// the truncated JSON, which always failed with "Failed to parse level
1124+
// data." — confusing users into thinking the mod was broken rather
1125+
// than the model running out of context window.
1126+
auto doneResult = json["done"].asBool();
1127+
if (doneResult && !doneResult.unwrap()) {
1128+
onError("Incomplete Response",
1129+
"Ollama stopped generating before the level was complete.\n\n"
1130+
"Try requesting a shorter level, reducing the max-objects setting, "
1131+
"or using a model with a larger context window (e.g. llama3.1:8b or mistral:7b).");
1132+
return;
1133+
}
1134+
10901135
auto responseResult = json["response"].asString();
10911136
if (!responseResult) { onError("Invalid Response", "Failed to extract Ollama response."); return; }
10921137
aiResponse = responseResult.unwrap();
1093-
1094-
auto doneResult = json["done"].asBool();
1095-
if (doneResult && !doneResult.unwrap())
1096-
log::warn("Ollama response marked as incomplete");
10971138
}
10981139

10991140
// Strip markdown code fences if present
@@ -1245,7 +1286,7 @@ class $modify(AILevelEditorLayer, LevelEditorLayer) {
12451286

12461287
$execute {
12471288
log::info("========================================");
1248-
log::info(" Editor AI v2.1.5");
1289+
log::info(" Editor AI v2.1.6");
12491290
log::info("========================================");
12501291
log::info("Loaded {} object types", OBJECT_IDS.size());
12511292
log::info("Object library: {}", OBJECT_IDS.size() > 10 ? "local file" : "defaults (5 objects)");

0 commit comments

Comments
 (0)