@@ -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 1– 999 — 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.0– 1.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 1– 9999 — 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 0– 9\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