1 /**
2 	ObjectInteractionMenu
3 	Handles the inventory exchange and general interaction between the player and buildings, vehicles etc.
4 
5 	@author Zapper
6 */
7 
8 local Name = "$Name$";
9 local Description = "$Description$";
10 
11 static const InteractionMenu_SideBarSize = 40; // in tenth-em
12 
13 static const InteractionMenu_Contents = 2;
14 static const InteractionMenu_Custom = 4;
15 
16 /*
17 	This contains an array with a proplist for every player number.
18 	The attributes are also always attached to every interaction menu on creation (menu.minimized = InteractionMenu_Attributes[plr].minimized;).
19 	The following properties are either used or nil:
20 		minimized (bool): whether the player minimized the menu.
21 			A minimized menu does not show some elements (like the description box).
22 */
23 static InteractionMenu_Attributes;
24 
25 local current_objects;
26 
27 /*
28 	current_menus is an array with two fields
29 	each field contain a proplist with the attributes:
30 		target: the target object, needs to be in current_objects
31 		menu_object: target of the menu (usually a dummy object) (the ID is always 1; the menu's sidebar has the ID 2)
32 		menu_id
33 		forced: (boolean) Whether the menu was forced-open (f.e. by an extra-slot object) and is not necessarily attached to an outside-world object.
34 			Such an object might be removed from the list when the menu is closed.
35 		menus: array with more proplists with the following attributes:
36 			flag: bitwise flag (needed for content-menus f.e.)
37 			title
38 			decoration: ID of a menu decoration definition
39 			Priority: priority of the menu (Y position)
40 			BackgroundColor: background color of the menu
41 			callback: function called when an entry is selected, the function is passed the symbol of an entry and the user data
42 			callback_hover: function called when hovering over an entry, the function is passed everything "callback" gets plus the target of the description box menu
43 			callback_target: object to which the callback is made, ususally the target object (except for contents menus)
44 			menu_object: MenuStyle_Grid object, used to add/remove entries later
45 			entry_index_count: used to generate unique IDs for the entries
46 			entries_callback: (callback) function that can be used to retrieve a list of entries for that menu (at any point - it might also be called later).
47 				The function is called in the object that the menu was opened for and passes the player's Clonk as the first argument.
48 				This callback should return an array of entries shown in the menu, the entries are proplists with the following attributes:
49 				symbol: icon of the item
50 				extra_data: custom user data (internal: in case of inventory menus this is a proplist containing some extra data (f.e. the one object for unstackable objects))
51 				text: text shown on the object (f.e. count in inventory)
52 				custom (optional): completely custom menu entry that is passed to the grid menu - allows for custom design
53 				unique_index: generated from entry_index_count (not set by user)
54 				fx: (optional) effect that gets a "OnMenuOpened(int menu_id, object menu_target, int subwindow_id)" callback once which can be used to update a specific entry only
55 			entries_callback_parameter (optional):
56 				Passed as second argument to entries_callback. Can be used for custom information.
57 			entries_callback_target (optional):
58 				By default the object for which the menu is opened, can be changed for more involved constructions.
59 			entries: last result of the callback function described above
60 				additional properties that are added are:
61 				ID: (menu) id of the entry as returned by the menu_object - can be used for updating
62 */
63 local current_menus;
64 /*
65 	current_description_box contains information about the description box at the bottom of the menu:
66 		target: target object of the description box
67 		symbol_target: target object of the symbol sub-box
68 		desc_target: target object of the description sub-box
69 */
70 local current_description_box;
71 // this is the ID of the root window that contains the other subwindows (i.e. the menus which contain the sidebars and the interaction-menu)
72 local current_main_menu_id;
73 // This holds the dummy object that is the target of the center column ("move all left/right").
74 local current_center_column_target;
75 // The Clonk who the menu was opened for.
76 local cursor;
77 
Close()78 public func Close() { return RemoveObject(); }
IsContentMenu()79 public func IsContentMenu() { return true; }
Show()80 public func Show() { this.Visibility = VIS_Owner; return true; }
Hide()81 public func Hide() { this.Visibility = VIS_None; return true; }
82 // Called when the menu is open and the player clicks outside.
OnMouseClick()83 public func OnMouseClick() { return Close(); }
84 
Construction()85 func Construction()
86 {
87 	current_objects = [];
88 	current_menus = [];
89 	current_description_box = {target=nil};
90 }
91 
Destruction()92 func Destruction()
93 {
94 	// we need only to remove the top-level menu target of the open menus, since the submenus close due to a clever use of OnClose!
95 	for (var menu in current_menus)
96 	{
97 		if (menu && menu.menu_object)
98 		{
99 			// Notify the object of the deleted menu.
100 			DoInteractionMenuClosedCallback(menu.target);
101 			menu.menu_object->RemoveObject();
102 		}
103 	}
104 	// remove all remaining contained dummy objects to prevent script warnings about objects in removed containers
105 	var i = ContentsCount(), obj = nil;
106 	while (obj = Contents(--i))
107 		obj->RemoveObject(false);
108 	// Remove check objects effect.
109 	if (cursor)
110 		RemoveEffect("IntCheckObjects", cursor);
111 }
112 
113 // used as a static function
CreateFor(object cursor)114 func CreateFor(object cursor)
115 {
116 	var obj = CreateObject(GUI_ObjectInteractionMenu, AbsX(0), AbsY(0), cursor->GetOwner());
117 	obj.Visibility = VIS_Owner;
118 
119 	if (InteractionMenu_Attributes == nil)
120 		InteractionMenu_Attributes = [];
121 
122 	// Transfer some attributes from the player configuration.
123 	if (GetLength(InteractionMenu_Attributes) > cursor->GetOwner())
124 	{
125 		var config = InteractionMenu_Attributes[cursor->GetOwner()];
126 		obj.minimized = GetProperty("minimized", config) ?? false;
127 	}
128 	else
129 	{
130 		obj.minimized = false;
131 	}
132 
133 	obj->Init(cursor);
134 	cursor->SetMenu(obj);
135 	return obj;
136 }
137 
138 
Init(object cursor)139 func Init(object cursor)
140 {
141 	this.cursor = cursor;
142 	var checking_effect = AddEffect("IntCheckObjects", cursor, 1, 10, this);
143 	// Notify the Clonk. This can be used to create custom entries in the objects list via helper objects. For example the "Your Environment" tab.
144 	// Note that the cursor is NOT informed when the menu is closed again. Helper objects can be attached to the livetime of this menu by, f.e., effects.
145 	cursor->~OnInteractionMenuOpen(this);
146 	// And then quickly refresh for the very first time. Successive refreshs will be only every 10 frames.
147 	EffectCall(cursor, checking_effect, "Timer");
148 }
149 
FxIntCheckObjectsStart(target,effect fx,temp)150 func FxIntCheckObjectsStart(target, effect fx, temp)
151 {
152 	if (temp) return;
153 	EffectCall(target, fx, "Timer");
154 }
155 
FxIntCheckObjectsTimer(target,effect fx)156 func FxIntCheckObjectsTimer(target, effect fx)
157 {
158 	// If contained, leave the search area intact, because otherwise we'd have to pass a "nil" parameter to FindObjects (which needs additional hacks).
159 	// This is a tiny bit slower (because the area AND the container have to be checked), but the usecase of contained Clonks is rare anyway.
160 	var container_restriction = Find_NoContainer();
161 	var container = target->Contained();
162 	if (container)
163 	{
164 		container_restriction = Find_Or(Find_Container(container), Find_InArray([container]));
165 	}
166 
167 	var new_objects = FindObjects(Find_AtRect(target->GetX() - 5, target->GetY() - 10, 10, 21), container_restriction, Find_Layer(target->GetObjectLayer()),
168 		// Find all containers and objects with a custom menu.
169 		Find_Or(Find_Func("IsContainer"), Find_Func("HasInteractionMenu")),
170 		// Do not show objects with an extra slot though - even if they are containers. They count as items here and can be accessed via the surroundings tab.
171 		Find_Not(Find_And(Find_Property("Collectible"), Find_Func("HasExtraSlot"))),
172 		// Show only objects that the player can see.
173 		Find_Func("CheckVisibility", GetOwner()),
174 		// Normally sorted by z-order. But some objects may have a lower priority.
175 		Sort_Reverse(Sort_Func("GetInteractionPriority", target))
176 		);
177 	var equal = GetLength(new_objects) == GetLength(current_objects);
178 
179 	if (equal)
180 	{
181 		for (var i = GetLength(new_objects) - 1; i >= 0; --i)
182 		{
183 			if (new_objects[i] == current_objects[i]) continue;
184 			equal = false;
185 			break;
186 		}
187 	}
188 	if (!equal)
189 		UpdateObjects(new_objects);
190 }
191 
192 // updates the objects shown in the side bar
193 // if an object which is in the menu on the left or on the right is not in the side bar anymore, another object is selected
UpdateObjects(array new_objects)194 func UpdateObjects(array new_objects)
195 {
196 	// need to close a menu?
197 	for (var i = 0; i < GetLength(current_menus); ++i)
198 	{
199 		if (!current_menus[i]) continue; // todo: I don't actually know why this can happen.
200 		if (current_menus[i].forced) continue;
201 
202 		var target = current_menus[i].target;
203 		// Still existant? Nothing to do!
204 		if (GetIndexOf(new_objects, target) != -1) continue;
205 		// not found? close!
206 		// sub menus close automatically (and remove their dummy) due to a clever usage of OnClose
207 		current_menus[i].menu_object->RemoveObject();
208 		current_menus[i] = nil;
209 		// Notify the target of the now closed menu.
210 		DoInteractionMenuClosedCallback(target);
211 	}
212 
213 	current_objects = new_objects;
214 
215 	// need to fill an empty menu slot?
216 	for (var i = 0; i < 2; ++i)
217 	{
218 		// If the menu already exists, just update the sidebar.
219 		if (current_menus[i] != nil)
220 		{
221 			RefreshSidebar(i);
222 			continue;
223 		}
224 		// look for next object to fill slot
225 		for (var obj in current_objects)
226 		{
227 			// but only if the object's menu is not already open
228 			var is_already_open = false;
229 			for (var menu in current_menus)
230 			{
231 				if (!menu) continue; // todo: I don't actually know why that can happen.
232 				if (menu.target != obj) continue;
233 				is_already_open = true;
234 				break;
235 			}
236 			if (is_already_open) continue;
237 			// use object to create a new menu at that slot
238 			OpenMenuForObject(obj, i);
239 			break;
240 		}
241 	}
242 }
243 
FxIntCheckObjectsStop(target,effect,reason,temp)244 func FxIntCheckObjectsStop(target, effect, reason, temp)
245 {
246 	if (temp) return;
247 	if (this)
248 		this->RemoveObject();
249 }
250 
251 /*
252 	This is the entry point.
253 	Create a menu for an object (usually from current_objects) and also create everything around it if it's the first time a menu is opened.
254 */
OpenMenuForObject(object obj,int slot,bool forced)255 func OpenMenuForObject(object obj, int slot, bool forced)
256 {
257 	forced = forced ?? false;
258 	// clean up old menu
259 	var old_menu = current_menus[slot];
260 	var other_menu = current_menus[1 - slot];
261 	if (old_menu)
262 	{
263 		// Notify other object of the closed menu.
264 		DoInteractionMenuClosedCallback(old_menu.target);
265 
266 		// Re-enable entry in (other!) sidebar.
267 		if (other_menu)
268 		{
269 			GuiUpdate({Symbol = nil}, current_main_menu_id, 1 + 1 - slot, old_menu.target);
270 		}
271 		// ..and close old menu.
272 		old_menu.menu_object->RemoveObject();
273 	}
274 	current_menus[slot] = nil;
275 	// before creating the sidebar, we have to create a new entry in current_menus, even if it contains not all information
276 	current_menus[slot] = {target = obj, forced = forced};
277 	// clean up old inventory-check effects that are not needed anymore
278 	var effect_index = 0, inv_effect = nil;
279 	while (inv_effect = GetEffect("IntRefreshContentsMenu", this, effect_index))
280 	{
281 		if (inv_effect.obj != current_menus[0].target && inv_effect.obj != current_menus[1].target)
282 			RemoveEffect(nil, nil, inv_effect);
283 		else
284 			++effect_index;
285 	}
286 	// Create a menu with all interaction possibilities for an object.
287 	// Always create the side bar AFTER the main menu, so that the target can be copied.
288 	var main = CreateMainMenu(obj, slot);
289 	// To close the part_menu automatically when the main menu is closed. The sidebar will use this target, too.
290 	current_menus[slot].menu_object = main.Target;
291 	// Now, the sidebar.
292 	var sidebar = CreateSideBar(slot);
293 
294 	var sidebar_size_em = ToEmString(InteractionMenu_SideBarSize);
295 	var part_menu =
296 	{
297 		Left = "0%", Right = "50%-3em",
298 		Bottom = "100%-7em",
299 		sidebar = sidebar, main = main,
300 		Target = current_menus[slot].menu_object,
301 		ID = 1
302 	};
303 
304 	if (slot == 1)
305 	{
306 		part_menu.Left = "50%-1em";
307 		part_menu.Right = "100%-2em";
308 	}
309 
310 	if (this.minimized)
311 	{
312 		part_menu.Bottom = nil; // maximum height
313 	}
314 
315 
316 	// need to open a completely new menu?
317 	if (!current_main_menu_id)
318 	{
319 		if (!current_description_box.target)
320 		{
321 			current_description_box.target = CreateDummy();
322 			current_description_box.symbol_target = CreateDummy();
323 			current_description_box.desc_target = CreateDummy();
324 		}
325 		if (!current_center_column_target)
326 			current_center_column_target = CreateDummy();
327 
328 		var root_menu =
329 		{
330 			_one_part = part_menu,
331 			Target = this,
332 			Decoration = GUI_MenuDeco,
333 			BackgroundColor = RGB(0, 0, 0),
334 			minimize_button =
335 			{
336 				Bottom = "100%",
337 				Top = "100% - 2em",
338 				Left = "100% - 2em",
339 				Tooltip = "$Minimize$",
340 				Symbol = Icon_Arrow,
341 				GraphicsName = "Down",
342 				BackgroundColor = {Std = nil, OnHover = 0x50ffff00},
343 				OnMouseIn = GuiAction_SetTag("OnHover"),
344 				OnMouseOut = GuiAction_SetTag("Std"),
345 				OnClick = GuiAction_Call(this, "OnToggleMinimizeClicked")
346 			},
347 			center_column =
348 			{
349 				Left = "50%-3em",
350 				Right = "50%-1em",
351 				Top = "1.75em",
352 				Bottom = "100%-7em",
353 				Style = GUI_VerticalLayout,
354 				move_all_left =
355 				{
356 					Target = current_center_column_target,
357 					ID = 10 + 0,
358 					Right = "2em", Bottom = "3em",
359 					Style = GUI_TextHCenter | GUI_TextVCenter,
360 					Symbol = Icon_MoveItems, GraphicsName = "Left",
361 					Tooltip = "",
362 					BackgroundColor ={Std = 0, Hover = 0x50ffff00},
363 					OnMouseIn = GuiAction_SetTag("Hover"),
364 					OnMouseOut = GuiAction_SetTag("Std"),
365 					OnClick = GuiAction_Call(this, "OnMoveAllToClicked", 0)
366 				},
367 				move_all_right =
368 				{
369 					Target = current_center_column_target,
370 					ID = 10 + 1,
371 					Right = "2em", Bottom = "3em",
372 					Style = GUI_TextHCenter | GUI_TextVCenter,
373 					Symbol = Icon_MoveItems,
374 					Tooltip = "",
375 					BackgroundColor ={Std = 0, Hover = 0x50ffff00},
376 					OnMouseIn = GuiAction_SetTag("Hover"),
377 					OnMouseOut = GuiAction_SetTag("Std"),
378 					OnClick = GuiAction_Call(this, "OnMoveAllToClicked", 1)
379 				}
380 			},
381 			description_box =
382 			{
383 				Top = "100%-5em",
384 				Right = "100% - 2em",
385 				Margin = [sidebar_size_em, "0em"],
386 				BackgroundColor = RGB(25, 25, 25),
387 				symbol_part =
388 				{
389 					Right = "5em",
390 					Symbol = nil,
391 					Margin = "0.5em",
392 					ID = 1,
393 					Target = current_description_box.symbol_target
394 				},
395 				desc_part =
396 				{
397 					Left = "5em",
398 					Margin = "0.5em",
399 					ID = 1,
400 					Target = current_description_box.target,
401 					real_contents = // nested one more time so it can dynamically be replaced without messing up the layout
402 					{
403 						ID = 1,
404 						Target = current_description_box.desc_target
405 					}
406 				}
407 			}
408 		};
409 
410 		// Allow the menu to be closed with a clickable button.
411 		var close_button = GuiAddCloseButton(root_menu, this, "Close");
412 
413 		// Special setup for a minimized menu.
414 		if (this.minimized)
415 		{
416 			root_menu.Top = "75%";
417 			root_menu.minimize_button.Tooltip = "$Maximize$";
418 			root_menu.minimize_button.GraphicsName = "Up";
419 			root_menu.center_column.Bottom = nil; // full size
420 			root_menu.description_box = nil;
421 		}
422 
423 		current_main_menu_id = GuiOpen(root_menu);
424 	}
425 	else // menu already exists and only one part has to be added
426 	{
427 		GuiUpdate({_update: part_menu}, current_main_menu_id, nil, nil);
428 	}
429 
430 	// Show "put/take all items" buttons if applicable. Also update tooltip.
431 	var show_grab_all = current_menus[0] && current_menus[1];
432 	// Both objects have to be containers.
433 	show_grab_all = show_grab_all
434 					&& (current_menus[0].target->~IsContainer())
435 					&& (current_menus[1].target->~IsContainer());
436 	// And neither must disallow interaction.
437 	show_grab_all = show_grab_all
438 					&& !current_menus[0].target->~RejectInteractionMenu(cursor)
439 					&& !current_menus[1].target->~RejectInteractionMenu(cursor);
440 	if (show_grab_all)
441 	{
442 		current_center_column_target.Visibility = VIS_Owner;
443 		for (var i = 0; i < 2; ++i)
444 			GuiUpdate({Tooltip: Format("$MoveAllTo$", current_menus[i].target->GetName())}, current_main_menu_id, 10 + i, current_center_column_target);
445 	}
446 	else
447 	{
448 		current_center_column_target.Visibility = VIS_None;
449 	}
450 
451 	// Now tell all user-provided effects for the new menu that the menu is ready.
452 	// Those effects can be used to update only very specific menu entries without triggering a full refresh.
453 	for (var menu in current_menus[slot].menus)
454 	{
455 		if (!menu.entries) continue;
456 
457 		for (var entry in menu.entries)
458 		{
459 			if (!entry.fx) continue;
460 			EffectCall(nil, entry.fx, "OnMenuOpened", current_main_menu_id, entry.ID, menu.menu_object);
461 		}
462 	}
463 
464 	// Finally disable object for selection in other sidebar, if available.
465 	if (other_menu)
466 	{
467 		GuiUpdate({Symbol = Icon_Cancel}, current_main_menu_id, 1 + 1 - slot, obj);
468 	}
469 
470 	// And notify the object of the fresh menu.
471 	DoInteractionMenuOpenedCallback(obj);
472 }
473 
DoInteractionMenuOpenedCallback(object obj)474 private func DoInteractionMenuOpenedCallback(object obj)
475 {
476 	if (!obj) return;
477 	if (obj._open_interaction_menus == nil)
478 		obj._open_interaction_menus = 0;
479 
480 	obj._open_interaction_menus += 1;
481 
482 	var is_first = obj._open_interaction_menus == 1;
483 	obj->~OnShownInInteractionMenuStart(is_first);
484 }
485 
DoInteractionMenuClosedCallback(object obj)486 private func DoInteractionMenuClosedCallback(object obj)
487 {
488 	if (!obj) return;
489 	obj._open_interaction_menus = Max(0, obj._open_interaction_menus - 1);
490 
491 	var is_last = obj._open_interaction_menus == 0;
492 	obj->~OnShownInInteractionMenuStop(is_last);
493 }
494 
495 // Toggles the menu state between minimized and maximized.
OnToggleMinimizeClicked()496 public func OnToggleMinimizeClicked()
497 {
498 	var config = nil;
499 	if (GetLength(InteractionMenu_Attributes) <= GetOwner())
500 	{
501 		config = {minimized = false};
502 		InteractionMenu_Attributes[GetOwner()] = config;
503 	}
504 	else
505 	{
506 		config = InteractionMenu_Attributes[GetOwner()];
507 	}
508 	config.minimized = !(GetProperty("minimized", config) ?? false);
509 
510 	// Reopen with new layout..
511 	var last_cursor = this.cursor;
512 	RemoveObject();
513 	GUI_ObjectInteractionMenu->CreateFor(last_cursor);
514 }
515 
516 // Tries to put all items from the other menu's target into the target of menu menu_id. Returns nil.
OnMoveAllToClicked(int menu_id)517 public func OnMoveAllToClicked(int menu_id)
518 {
519 	// Sanity checks..
520 	for (var i = 0; i < 2; ++i)
521 	{
522 		if (!current_menus[i] || !current_menus[i].target)
523 			return;
524 		if (!current_menus[i].target->~IsContainer())
525 			return;
526 	}
527 	// Take all from the other object and try to put into the target.
528 	var other = current_menus[1 - menu_id].target;
529 	var target = current_menus[menu_id].target;
530 
531 	// Get all contents in a separate step in case the object's inventory changes during the transfer.
532 	// Also do not use FindObject(Find_Container(...)), because this way an object can simply overload Contents to return an own collection of items.
533 	var contents = [];
534 	var index = 0, obj;
535 	while (obj = other->Contents(index++)) PushBack(contents, obj);
536 
537 	var transfered = TransferObjectsFromToSimple(contents, other, target);
538 
539 	if (transfered > 0)
540 	{
541 		PlaySoundTransfer();
542 		return;
543 	}
544 	else
545 	{
546 		PlaySoundError();
547 		return;
548 	}
549 }
550 
551 // generates a proplist that defines a custom GUI that represents a side bar where objects
552 // to interact with can be selected
CreateSideBar(int slot)553 func CreateSideBar(int slot)
554 {
555 	var other_menu = current_menus[1 - slot];
556 
557 	var em_size = ToEmString(InteractionMenu_SideBarSize);
558 	var sidebar =
559 	{
560 		Priority = 10,
561 		Right = em_size,
562 		Style = GUI_VerticalLayout,
563 		Target = current_menus[slot].menu_object,
564 		ID = 2
565 	};
566 	if (slot == 1)
567 	{
568 		sidebar.Left = Format("100%% %s", ToEmString(-InteractionMenu_SideBarSize));
569 		sidebar.Right = "100%";
570 	}
571 
572 	// Now show the current_objects list as entries.
573 	// If there is a forced-open menu, also add it to bottom of sidebar..
574 	var sidebar_items = nil;
575 	if (current_menus[slot].forced)
576 	{
577 		sidebar_items = current_objects[:];
578 		PushBack(sidebar_items, current_menus[slot].target);
579 	}
580 	else
581 		sidebar_items = current_objects;
582 
583 	for (var obj in sidebar_items)
584 	{
585 		var background_color = nil;
586 		var symbol = {Std = SidebarIconStandard(), OnHover = SidebarIconOnHover()};
587 		// figure out whether the object is already selected
588 		// if so, highlight the entry
589 		if (current_menus[slot].target == obj)
590 		{
591 			background_color = RGBa(255, 255, 0, 10);
592 			symbol = SidebarIconSelected();
593 		}
594 		var priority = 10000 - obj.Plane;
595 		// Cross-out the entry?
596 		var deactivation_symbol = nil;
597 		if (other_menu && other_menu.target == obj)
598 			deactivation_symbol = Icon_Cancel;
599 		// Always show Clonk at top.
600 		if (obj == cursor) priority = 1;
601 		var entry =
602 		{
603 			// The object is added as the target of the entry, so it can easily be identified later.
604 			// For example, to apply show the grey haze to indicate that it cannot be clicked.
605 			Target = obj,
606 			Right = em_size, Bottom = em_size,
607 			Symbol = symbol,
608 			Priority = priority,
609 			Style = GUI_TextBottom | GUI_TextHCenter,
610 			BackgroundColor = background_color,
611 			OnMouseIn = GuiAction_SetTag("OnHover"),
612 			OnMouseOut = GuiAction_SetTag("Std"),
613 			OnClick = GuiAction_Call(this, "OnSidebarEntrySelected", {slot = slot, obj = obj}),
614 			Text = obj->GetName(),
615 			obj_symbol = {Symbol = obj, Margin = "0.25em", Priority = 1},
616 			obj_symbol_deactivated = {Symbol = deactivation_symbol, Margin = "0.5em", Priority = 2, Target = obj, ID = 1 + slot}
617 		};
618 
619 		GuiAddSubwindow(entry, sidebar);
620 	}
621 	return sidebar;
622 }
623 
624 // Updates the sidebar with the current objects (and closes the old one).
RefreshSidebar(int slot)625 func RefreshSidebar(int slot)
626 {
627 	if (!current_menus[slot]) return;
628 	// Close old sidebar? This call will just do nothing if there is no sidebar present.
629 	GuiClose(current_main_menu_id, 2, current_menus[slot].menu_object);
630 
631 	var sidebar = CreateSideBar(slot);
632 	GuiUpdate({sidebar = sidebar}, current_main_menu_id, 1, current_menus[slot].menu_object);
633 }
634 
OnSidebarEntrySelected(data,int player,int ID,int subwindowID,object target)635 func OnSidebarEntrySelected(data, int player, int ID, int subwindowID, object target)
636 {
637 	if (!data.obj) return;
638 
639 	// can not open object twice!
640 	for (var menu in current_menus)
641 		if (menu.target == data.obj) return;
642 	OpenMenuForObject(data.obj, data.slot);
643 }
644 
645 /*
646 	Generates and creates one side of the menu.
647 	Returns the proplist that will be put into the main menu on the left or right side.
648 */
CreateMainMenu(object obj,int slot)649 func CreateMainMenu(object obj, int slot)
650 {
651 	var big_menu =
652 	{
653 		Target = CreateDummy(),
654 		Priority = 5,
655 		Right = Format("100%% %s", ToEmString(-InteractionMenu_SideBarSize)),
656 		container =
657 		{
658 			Top = "1em",
659 			Style = GUI_VerticalLayout,
660 			BackgroundColor = RGB(25, 25, 25),
661 		},
662 		headline =
663 		{
664 			Bottom = "1em",
665 			Text = obj->GetName(),
666 			Style = GUI_TextHCenter | GUI_TextVCenter,
667 			BackgroundColor = 0xff000000
668 		}
669 
670 	};
671 	var container = big_menu.container;
672 
673 	if (slot == 0)
674 	{
675 		big_menu.Left = ToEmString(InteractionMenu_SideBarSize);
676 		big_menu.Right = "100%";
677 	}
678 
679 	// Do virtually nothing if the building/object is not ready to be interacted with. This can be caused by several things.
680 	var error_message = obj->~RejectInteractionMenu(cursor);
681 
682 	if (error_message)
683 	{
684 		if (GetType(error_message) != C4V_String)
685 			error_message = "$NoInteractionsPossible$";
686 		container.Style = GUI_TextVCenter | GUI_TextHCenter;
687 		container.Text = error_message;
688 		current_menus[slot].menus = [];
689 		return big_menu;
690 	}
691 
692 	var menus = obj->~GetInteractionMenus(cursor) ?? [];
693 	// get all interaction info from the object and put it into a menu
694 	// contents first
695 	if (obj->~IsContainer() && !obj->~RejectContentsMenu())
696 	{
697 		var info =
698 		{
699 			flag = InteractionMenu_Contents,
700 			title = "$Contents$",
701 			entries = [],
702 			entries_callback = nil,
703 			callback = "OnContentsSelection",
704 			callback_target = this,
705 			decoration = GUI_MenuDecoInventoryHeader,
706 			Priority = 10
707 		};
708 		PushBack(menus, info);
709 	}
710 
711 	current_menus[slot].menus = menus;
712 
713 	// now generate the actual menus from the information-list
714 	for (var i = 0; i < GetLength(menus); ++i)
715 	{
716 		var menu = menus[i];
717 		if (!menu.flag)
718 		{
719 			menu.flag = InteractionMenu_Custom;
720 		}
721 		if (menu.entries_callback)
722 		{
723 			var call_from = menu.entries_callback_target ?? obj;
724 			menu.entries = call_from->Call(menu.entries_callback, cursor, menu.entries_callback_parameter);
725 		}
726 		if (menu.entries == nil)
727 		{
728 			FatalError(Format("An interaction menu did not return valid entries. %s -> %v() (object %v)", obj->GetName(), menu.entries_callback, obj));
729 			continue;
730 		}
731 		menu.menu_object = CreateObject(MenuStyle_Grid);
732 		if (menu.flag == InteractionMenu_Contents)
733 		{
734 			menu.menu_object->SetTightGridLayout();
735 		}
736 
737 		menu.menu_object.Top = "+1em";
738 		menu.menu_object.Priority = 2;
739 		menu.menu_object->SetPermanent();
740 		menu.menu_object->SetFitChildren();
741 		menu.menu_object->SetMouseOverCallback(this, "OnMenuEntryHover");
742 		for (var e = 0; e < GetLength(menu.entries); ++e)
743 		{
744 			var entry = menu.entries[e];
745 			entry.unique_index = ++menu.entry_index_count;
746 			// This also allows the interaction-menu user to supply a custom entry with custom layout f.e.
747 			var added_entry = menu.menu_object->AddItem(entry.symbol, entry.text, entry.unique_index, this, "OnMenuEntrySelected", { slot = slot, index = i }, entry["custom"]);
748 			// Remember the menu entry's ID (e.g. for passing it to an update effect after the menu has been opened).
749 			entry.ID = added_entry.ID;
750 		}
751 
752 		var all =
753 		{
754 			Priority = menu.Priority ?? i,
755 			Style = GUI_FitChildren,
756 			title_bar =
757 			{
758 				Priority = 1,
759 				Style = GUI_TextVCenter | GUI_TextHCenter,
760 				Bottom = "+1em",
761 				Text = menu.title,
762 				BackgroundColor = 0xa0000000,
763 				//Decoration = menu.decoration
764 				hline = {Bottom = "0.05em", BackgroundColor = RGB(100, 100, 100)}
765 			},
766 			Margin = [nil, nil, nil, "0.25em"],
767 			real_menu = menu.menu_object,
768 			spacer = {Left = "0em", Right = "0em", Bottom = "3em"} // guarantees a minimum height
769 		};
770 		if (menu.flag == InteractionMenu_Contents)
771 		{
772 			all.BackgroundColor = RGB(0, 50, 0);
773 		}
774 		else if (menu.BackgroundColor)
775 		{
776 			all.BackgroundColor = menu.BackgroundColor;
777 		}
778 		else if (menu.decoration)
779 		{
780 			menu.menu_object.BackgroundColor = menu.decoration->FrameDecorationBackClr();
781 		}
782 		GuiAddSubwindow(all, container);
783 	}
784 
785 	// add refreshing effects for all of the contents menus
786 	for (var i = 0; i < GetLength(menus); ++i)
787 	{
788 		if (!(menus[i].flag & InteractionMenu_Contents))
789 			continue;
790 		AddEffect("IntRefreshContentsMenu", this, 1, 1, this, nil, obj, slot, i);
791 	}
792 
793 	return big_menu;
794 }
795 
GetEntryInformation(proplist menu_info,int entry_index)796 func GetEntryInformation(proplist menu_info, int entry_index)
797 {
798 	if (!current_menus[menu_info.slot]) return;
799 	var menu;
800 	if (!(menu = current_menus[menu_info.slot].menus[menu_info.index])) return;
801 	var entry;
802 	for (var possible in menu.entries)
803 	{
804 		if (possible.unique_index != entry_index) continue;
805 		entry = possible;
806 		break;
807 	}
808 	return {menu=menu, entry=entry};
809 }
810 
OnMenuEntryHover(proplist menu_info,int entry_index,int player)811 func OnMenuEntryHover(proplist menu_info, int entry_index, int player)
812 {
813 	var info = GetEntryInformation(menu_info, entry_index);
814 	if (!info.entry) return;
815 	if (!info.entry.symbol) return;
816 	// update symbol of description box
817 	GuiUpdate({Symbol = info.entry.symbol}, current_main_menu_id, 1, current_description_box.symbol_target);
818 	// and update description itself
819 	// clean up existing description window in case it has been cluttered by sub-windows
820 	GuiClose(current_main_menu_id, 1, current_description_box.desc_target);
821 	// now add new subwindow to replace the recently removed one
822 	GuiUpdate({new_subwindow = {Target = current_description_box.desc_target, ID = 1}}, current_main_menu_id, 1, current_description_box.target);
823 	// default to description of object
824 	if (!info.menu.callback_target || !info.menu.callback_hover)
825 	{
826 		var text = Format("%s:|%s", info.entry.symbol->GetName(), info.entry.symbol.Description);
827 
828 		// For contents menus, we can sometimes present additional information about objects.
829 		if (info.menu.flag == InteractionMenu_Contents)
830 		{
831 			// Get the first valid object of the clicked stack.
832 			var obj = nil;
833 			if (info.entry.extra_data && info.entry.extra_data.objects)
834 			{
835 				for (var possible in info.entry.extra_data.objects)
836 				{
837 					if (possible == nil) continue;
838 					obj = possible;
839 					break;
840 				}
841 			}
842 			// ..and use that object to fetch some more information.
843 			if (obj)
844 			{
845 				var additional = nil;
846 				if (obj->Contents())
847 				{
848 					additional = "$Contains$ ";
849 					var i = 0, count = obj->ContentsCount();
850 					// This currently justs lists contents one after the other.
851 					// Items are not stacked, which should be enough for everything we have ingame right now. If this is filed into the bugtracker at some point, fix here.
852 					for (;i < count;++i)
853 					{
854 						if (i > 0)
855 							additional = Format("%s, ", additional);
856 						additional = Format("%s%s", additional, obj->Contents(i)->GetName());
857 					}
858 				}
859 				if (additional != nil)
860 					text = Format("%s||%s", text, additional);
861 			}
862 		}
863 
864 		GuiUpdateText(text, current_main_menu_id, 1, current_description_box.desc_target);
865 	}
866 	else
867 	{
868 		info.menu.callback_target->Call(info.menu.callback_hover, info.entry.symbol, info.entry.extra_data, current_description_box.desc_target, current_main_menu_id);
869 	}
870 }
871 
OnMenuEntrySelected(proplist menu_info,int entry_index,int player)872 func OnMenuEntrySelected(proplist menu_info, int entry_index, int player)
873 {
874 	var info = GetEntryInformation(menu_info, entry_index);
875 	if (!info.entry) return;
876 
877 	var callback_target;
878 	if (!(callback_target = info.menu.callback_target)) return;
879 	if (!info.menu.callback) return; // The menu can actually decide to handle user interaction itself and not provide a callback.
880 	var result = callback_target->Call(info.menu.callback, info.entry.symbol, info.entry.extra_data, cursor);
881 
882 	// todo: trigger refresh for special value of result?
883 }
884 
OnContentsSelection(symbol,extra_data)885 private func OnContentsSelection(symbol, extra_data)
886 {
887 	if (!current_menus[extra_data.slot]) return;
888 	var target = current_menus[extra_data.slot].target;
889 	if (!target) return;
890 	// no target to swap to?
891 	if (!current_menus[1 - extra_data.slot]) return;
892 	var other_target = current_menus[1 - extra_data.slot].target;
893 	if (!other_target) return;
894 
895 	// Only if the object wants to be interacted with (hostility etc.)
896 	if (other_target->~RejectInteractionMenu(cursor)) return;
897 
898 	// Allow transfer only into containers.
899 	if (!other_target->~IsContainer())
900 	{
901 		cursor->~PlaySoundDoubt(true, nil, cursor->GetOwner());
902 		return;
903 	}
904 
905 	var transfer_only_one = GetPlayerControlState(GetOwner(), CON_ModifierMenu1) == 0; // Transfer ONE object of the stack?
906 	var to_transfer = nil;
907 
908 	if (transfer_only_one)
909 	{
910 		for (var possible in extra_data.objects)
911 		{
912 			if (possible == nil) continue;
913 			to_transfer = [possible];
914 			break;
915 		}
916 	}
917 	else
918 	{
919 		to_transfer = extra_data.objects;
920 	}
921 
922 	var successful_transfers = TransferObjectsFromTo(to_transfer, target, other_target);
923 
924 	// Did we at least transfer one item?
925 	if (successful_transfers > 0)
926 	{
927 		PlaySoundTransfer();
928 		return true;
929 	}
930 	else
931 	{
932 		PlaySoundTransferIncomplete();
933 		return false;
934 	}
935 }
936 
TransferObjectsFromToSimple(array to_transfer,object source,object destination)937 func TransferObjectsFromToSimple(array to_transfer, object source, object destination)
938 {
939 	// Now try transferring each item once.
940 	var successful_transfers = 0;
941 	for (var obj in to_transfer)
942 	{
943 		// Sanity, can actually happen if an item merges with others during the transfer etc.
944 		if (!obj || !destination) continue;
945 
946 		var handled = destination->Collect(obj, true);
947 		if (handled)
948 			++successful_transfers;
949 	}
950 	return successful_transfers;
951 }
952 
TransferObjectsFromTo(array to_transfer,object source,object destination)953 func TransferObjectsFromTo(array to_transfer, object source, object destination)
954 {
955 	var successful_transfers = 0;
956 
957 	// Try to transfer all the previously selected items.
958 	for (var obj in to_transfer)
959 	{
960 		if (!obj) continue;
961 		// Our target might have disappeared (e.g. a construction site completing after the first item).
962 		if (!destination) break;
963 
964 		// Does the object not want to leave the other container anyway?
965 		if (!obj->Contained() || !obj->~QueryRejectDeparture(source))
966 		{
967 			var handled = false;
968 
969 			// If stackable, always try to grab a full stack.
970 			// Imagine armory with 200 arrows, but not 10 stacks with 20 each but 200 stacks with 1 each.
971 			// TODO: 200 stacks of 1 arrow would each merge into the stacks that are already in the target
972 			//       when they enter the target. For this reason that special case is, imo, not needed here.
973 			if (obj->~IsStackable())
974 			{
975 				var others = FindObjects(Find_Container(source), Find_ID(obj->GetID()), Find_Exclude(obj));
976 				for (var other in others)
977 				{
978 					if (obj->IsFullStack()) break;
979 					other->TryAddToStack(obj);
980 				}
981 			}
982 
983 			// More special handling for Stackable items..
984 			handled = obj->~MergeWithStacksIn(destination);
985 			// Try to normally collect the object otherwise.
986 			if (!handled && destination && obj)
987 				handled = destination->Collect(obj, true);
988 
989 			if (handled)
990 				successful_transfers += 1;
991 		}
992 	}
993 
994 	return successful_transfers;
995 }
996 
FxIntRefreshContentsMenuStart(object target,proplist effect,temp,object obj,int slot,int menu_index)997 func FxIntRefreshContentsMenuStart(object target, proplist effect, temp, object obj, int slot, int menu_index)
998 {
999 	if (temp) return;
1000 	effect.obj = obj; // the property (with this name) is externally accessed!
1001 	effect.slot = slot;
1002 	effect.menu_index = menu_index;
1003 	effect.last_inventory = [];
1004 }
1005 
FxIntRefreshContentsMenuTimer(object target,effect,int time)1006 func FxIntRefreshContentsMenuTimer(object target, effect, int time)
1007 {
1008 	// Remove effect if menu is gone or menu at slot and index is not a contents menu.
1009 	if (!effect.obj)
1010 		return FX_Execute_Kill;
1011 	if (!current_menus[effect.slot] || !current_menus[effect.slot].menus[effect.menu_index])
1012 		return FX_Execute_Kill;
1013 	if (!(current_menus[effect.slot].menus[effect.menu_index].flag & InteractionMenu_Contents))
1014 		return FX_Execute_Kill;
1015 	// Helper object used to track extra-slot objects. When this object is removed, the tracking stops.
1016 	var extra_slot_keep_alive = current_menus[effect.slot].menu_object;
1017 	// The fast interval is only used for the very first check to ensure a fast startup.
1018 	// It can't be just called instantly though, because the menu might not have been opened yet.
1019 	if (effect.Interval == 1) effect.Interval = 5;
1020 	var inventory = [];
1021 	var obj, i = 0;
1022 	while (obj = effect.obj->Contents(i++))
1023 	{
1024 		var symbol = obj;
1025 		var extra_data = {slot = effect.slot, menu_index = effect.menu_index, objects = []};
1026 
1027 		// check if already exists (and then stack!)
1028 		var found = false;
1029 		// Never stack containers with (different) contents, though.
1030 		var is_container = obj->~IsContainer();
1031 		// For extra-slot objects, we should attach a tracking effect to update the UI on changes.
1032 		if (obj->~HasExtraSlot())
1033 		{
1034 			var j = 0, e = nil;
1035 			var found_tracker = false;
1036 			while (e = GetEffect("ExtraSlotTracker", obj, j++))
1037 			{
1038 				if (e.keep_alive != extra_slot_keep_alive) continue;
1039 				found_tracker = true;
1040 				break;
1041 			}
1042 			if (!found_tracker)
1043 			{
1044 				var e = AddEffect("ExtraSlotTracker", obj, 1, 30 + Random(60), nil, GetID());
1045 				e.keep_alive = extra_slot_keep_alive;
1046 				e.callback_effect = effect;
1047 				e.obj = effect.obj;
1048 			}
1049 		}
1050 		// How many objects are this object?!
1051 		var object_amount = obj->~GetStackCount() ?? 1;
1052 		// Infinite stacks work differently - showing an arbitrary amount would not make sense.
1053 		if (object_amount > 1 && obj->~IsInfiniteStackCount())
1054 			object_amount = 1;
1055 		// Empty containers can be stacked.
1056 		for (var inv in inventory)
1057 		{
1058 			if (!inv.extra_data.objects[0]->CanBeStackedWith(obj)) continue;
1059 			if (!obj->CanBeStackedWith(inv.extra_data.objects[0])) continue;
1060 			inv.count += object_amount;
1061 			inv.text = Format("%dx", inv.count);
1062 			PushBack(inv.extra_data.objects, obj);
1063 
1064 			// This object has a custom symbol (because it's a container)? Then the normal text would not be displayed.
1065 			if (inv.custom != nil)
1066 			{
1067 				inv.custom.top.Text = inv.text;
1068 				inv.custom.top.Style = inv.custom.top.Style | GUI_TextRight | GUI_TextBottom;
1069 			}
1070 
1071 			found = true;
1072 			break;
1073 		}
1074 
1075 		// Add new!
1076 		if (!found)
1077 		{
1078 			PushBack(extra_data.objects, obj);
1079 
1080 			// Do we need a custom entry when the object has contents?
1081 			var custom = nil;
1082 			if (is_container)
1083 			{
1084 				// Use a default grid-menu entry as the base.
1085 				custom = MenuStyle_Grid->MakeEntryProplist(symbol, nil);
1086 				// Pack it into a larger frame to allow for another button below.
1087 				// The priority offset makes sure that double-height items are at the front.
1088 				custom = {Right = custom.Right, Bottom = "4em", top = custom, Priority = -10000 + obj->GetValue()};
1089 				// Then add a little container-symbol (that can be clicked).
1090 				custom.bottom =
1091 				{
1092 					Top = "2em",
1093 					BackgroundColor = {Std = 0, Selected = RGBa(255, 100, 100, 100)},
1094 					OnMouseIn = GuiAction_SetTag("Selected"),
1095 					OnMouseOut = GuiAction_SetTag("Std"),
1096 					OnClick = GuiAction_Call(this, "OnExtraSlotClicked", {slot = effect.slot, objects = extra_data.objects, ID = obj->GetID()}),
1097 					container =
1098 					{
1099 						Symbol = Chest,
1100 						Priority = 1
1101 					}
1102 				};
1103 
1104 				// And if the object has contents, show the first one, too.
1105 				if (obj->ContentsCount() != 0)
1106 				{
1107 					var first_contents = obj->Contents(0);
1108 					// Add to GUI.
1109 					custom.bottom.contents =
1110 					{
1111 						Symbol = first_contents ,
1112 						Margin = "0.125em",
1113 						Priority = 2
1114 					};
1115 					// Possibly add text for stackable items - this is an special exception for the Library_Stackable.
1116 					var count = first_contents->~GetStackCount();
1117 					// Infinite stacks display an own overlay.
1118 					if ((count > 1) && (first_contents->~IsInfiniteStackCount())) count = nil;
1119 
1120 					count = count ?? obj->ContentsCount(first_contents->GetID());
1121 					if (count > 1)
1122 					{
1123 						custom.bottom.contents.Text = Format("%dx", count);
1124 						custom.bottom.contents.Style = GUI_TextBottom | GUI_TextRight;
1125 					}
1126 					var overlay = first_contents->~GetInventoryIconOverlay();
1127 					if (overlay)
1128 						custom.bottom.contents.overlay = overlay;
1129 					// Also make the chest smaller, so that the contents symbol is not obstructed.
1130 					custom.bottom.container.Bottom = "1em";
1131 					custom.bottom.container.Left = "1em";
1132 				}
1133 			}
1134 			// Enable objects to provide a custom overlay for the icon slot.
1135 			// This could e.g. be used by special scenarios or third-party mods.
1136 			var overlay = obj->~GetInventoryIconOverlay();
1137 			if (overlay != nil)
1138 			{
1139 				if (!custom)
1140 				{
1141 					custom = MenuStyle_Grid->MakeEntryProplist(symbol, nil);
1142 					custom.Priority = obj->GetValue();
1143 					custom.top = {};
1144 				}
1145 				custom.top._overlay = overlay;
1146 			}
1147 
1148 			// Add to menu!
1149 			var text = nil;
1150 			if (object_amount > 1)
1151 				text = Format("%dx", object_amount);
1152 			PushBack(inventory,
1153 				{
1154 					symbol = symbol,
1155 					extra_data = extra_data,
1156 					custom = custom,
1157 					count = object_amount,
1158 					text = text
1159 				});
1160 		}
1161 	}
1162 
1163 	// Add a contents counter on top.
1164 	var contents_count_bar =
1165 	{
1166 		BackgroundColor = RGBa(0, 0, 0, 100),
1167 		Priority = -1,
1168 		Bottom = "1em",
1169 		text  =
1170 		{
1171 			Priority = 2,
1172 			Style = GUI_TextRight | GUI_TextVCenter
1173 		}
1174 	};
1175 
1176 	if (effect.obj.MaxContentsCount)
1177 	{
1178 		var count = effect.obj->ContentsCount();
1179 		var max = effect.obj.MaxContentsCount;
1180 		contents_count_bar.text.Text = Format("<c eeeeee>%3d / %3d</c>", count, max);
1181 		contents_count_bar.bar =
1182 		{
1183 			Priority = 1,
1184 			BackgroundColor = RGBa(0, 255, 0, 50),
1185 			Right = ToPercentString(1000 * count / max, 10)
1186 		};
1187 	}
1188 	else contents_count_bar.text.Text = Format("<c eeeeee>%3d</c>", effect.obj->ContentsCount());
1189 
1190 	PushBack(inventory, {symbol = nil, text = nil, custom = contents_count_bar});
1191 
1192 	// Check if nothing changed. If so, we don't need to update.
1193 	if (GetLength(inventory) == GetLength(effect.last_inventory))
1194 	{
1195 		var same = true;
1196 		for (var i = GetLength(inventory) - 1; i >= 0; --i)
1197 		{
1198 			if (inventory[i].symbol == effect.last_inventory[i].symbol
1199 				&& inventory[i].text == effect.last_inventory[i].text) continue;
1200 			same = false;
1201 			break;
1202 		}
1203 		if (same)
1204 			return FX_OK;
1205 	}
1206 
1207 	effect.last_inventory = inventory[:];
1208 	DoMenuRefresh(effect.slot, effect.menu_index, inventory);
1209 	return FX_OK;
1210 }
1211 
FxExtraSlotTrackerTimer(object target,proplist effect,int time)1212 func FxExtraSlotTrackerTimer(object target, proplist effect, int time)
1213 {
1214 	if (!effect.keep_alive)
1215 		return -1;
1216 }
1217 
1218 // This is called by the extra-slot library.
FxExtraSlotTrackerUpdate(object target,proplist effect)1219 func FxExtraSlotTrackerUpdate(object target, proplist effect)
1220 {
1221 	// Simply overwrite the inventory cache of the IntRefreshContentsMenu effect.
1222 	// This will lead to the inventory being upated asap.
1223 	if (effect.callback_effect)
1224 		effect.callback_effect.last_inventory = [];
1225 }
1226 
OnExtraSlotClicked(proplist extra_data)1227 func OnExtraSlotClicked(proplist extra_data)
1228 {
1229 	var menu = current_menus[extra_data.slot];
1230 	if (!menu || !menu.target) return;
1231 	var obj = nil;
1232 	for (var possible in extra_data.objects)
1233 	{
1234 		if (possible == nil) continue;
1235 		if (possible->Contained() != menu.target && !menu.target->~IsObjectContained(possible)) continue;
1236 		obj = possible;
1237 		break;
1238 	}
1239 	if (!obj) return;
1240 	OpenMenuForObject(obj, extra_data.slot, true);
1241 }
1242 
1243 // This function is supposed to be called when the menu already exists (is open) and some sub-menu needs an update.
1244 // Note that the parameter "new_entries" is optional. If not supplied, the /entries_callback/ for the specified menu will be used to fill the menu.
DoMenuRefresh(int slot,int menu_index,array new_entries)1245 func DoMenuRefresh(int slot, int menu_index, array new_entries)
1246 {
1247 	// go through new_entries and look for differences to currently open menu
1248 	// then try to only adjust the existing menu when possible
1249 	// the assumption is that ususally only few entries change
1250 	var menu = current_menus[slot].menus[menu_index];
1251 	var current_entries = menu.entries;
1252 	if (!new_entries && menu.entries_callback)
1253 	{
1254 		var call_from = menu.entries_callback_target ?? current_menus[slot].target;
1255 		new_entries = call_from->Call(menu.entries_callback, this.cursor, menu.entries_callback_parameter);
1256 	}
1257 
1258 	// step 0.1: update all items where the symbol and extra_data did not change but other things (f.e. the text)
1259 	// this is done to maintain a consistent order that would be shuffled constantly if the entry was removed and re-added at the end
1260 	for (var c = 0; c < GetLength(current_entries); ++c)
1261 	{
1262 		var old_entry = current_entries[c];
1263 		if (!old_entry) continue;
1264 
1265 		var found = false;
1266 		var symbol_equal_index = -1;
1267 		for (var ni = 0; ni < GetLength(new_entries); ++ni)
1268 		{
1269 			var new_entry = new_entries[ni];
1270 			if (!new_entry) continue;
1271 
1272 			if (!EntriesEqual(new_entry, old_entry))
1273 			{
1274 				// Exception for the inventory menus.. extra_data includes all the found objects, but they are allowed to differ here.
1275 				// So we check for equality excluding the objects.
1276 				var extra1 = new_entry.extra_data;
1277 				if (GetType(extra1) == C4V_PropList) extra1 = new extra1 {objects = nil};
1278 				var extra2 = old_entry.extra_data;
1279 				if (GetType(extra2) == C4V_PropList) extra2 = new extra2 {objects = nil};
1280 				// We also allow the symbols to change as long as the actual ID stays intact.
1281 				var symbol1 = new_entry.symbol;
1282 				var symbol2 = old_entry.symbol;
1283 				var symbols_equal = symbol1 == symbol2;
1284 				if (!symbols_equal && symbol1 && symbol2 && GetType(symbol1) == C4V_C4Object && GetType(symbol2) == C4V_C4Object)
1285 					symbols_equal = symbol1->~GetID() == symbol2->~GetID();
1286 
1287 				if (symbols_equal && DeepEqual(extra1, extra2) && DeepEqual(new_entry.custom, old_entry.custom) && (new_entry.fx == old_entry.fx))
1288 					symbol_equal_index = ni;
1289 				continue;
1290 			}
1291 			found = true;
1292 			break;
1293 		}
1294 		// if the entry exist just like that, we do not need to do anything
1295 		// same, if we don't have anything to replace it with, anyway
1296 		if (found || symbol_equal_index == -1) continue;
1297 		// now we can just update the symbol with the new data
1298 		var new_entry = new_entries[symbol_equal_index];
1299 		menu.menu_object->UpdateItem(new_entry.symbol, new_entry.text, old_entry.unique_index, this, "OnMenuEntrySelected", { slot = slot, index = menu_index }, new_entry["custom"], current_main_menu_id);
1300 		new_entry.unique_index = old_entry.unique_index;
1301 		// make sure it's not manipulated later on
1302 		current_entries[c] = nil;
1303 	}
1304 	// step 1: remove (close) all current entries that have been removed
1305 	for (var c = 0; c < GetLength(current_entries); ++c)
1306 	{
1307 		var old_entry = current_entries[c];
1308 		if (!old_entry) continue;
1309 
1310 		// check for removal
1311 		var removed = true;
1312 		for (var new_entry in new_entries)
1313 		{
1314 			if (!EntriesEqual(new_entry, old_entry)) continue;
1315 			removed = false;
1316 			break;
1317 		}
1318 		if (removed)
1319 		{
1320 			if (old_entry.fx)
1321 				RemoveEffect(nil, nil, old_entry.fx);
1322 			menu.menu_object->RemoveItem(old_entry.unique_index, current_main_menu_id);
1323 			current_entries[c] = nil;
1324 		}
1325 	}
1326 
1327 	// step 2: add new entries
1328 	for (var c = 0; c < GetLength(new_entries); ++c)
1329 	{
1330 		var new_entry = new_entries[c];
1331 		// the entry was already updated before?
1332 		if (new_entry.unique_index != nil) continue;
1333 
1334 		var existing = false;
1335 		for (var old_entry in current_entries)
1336 		{
1337 			if (old_entry == nil) // might be nil as a result of step 1
1338 				continue;
1339 			if (!EntriesEqual(new_entry, old_entry)) continue;
1340 			existing = true;
1341 
1342 			// fix unique indices for the new array
1343 			new_entry.unique_index = old_entry.unique_index;
1344 			break;
1345 		}
1346 		if (existing) continue;
1347 
1348 		new_entry.unique_index = ++menu.entry_index_count;
1349 		var added_entry = menu.menu_object->AddItem(new_entry.symbol, new_entry.text, new_entry.unique_index, this, "OnMenuEntrySelected", { slot = slot, index = menu_index }, new_entry["custom"], current_main_menu_id);
1350 		new_entry.ID = added_entry.ID;
1351 
1352 		if (new_entry.fx)
1353 		{
1354 			EffectCall(nil, new_entry.fx, "OnMenuOpened", current_main_menu_id, new_entry.ID, menu.menu_object);
1355 		}
1356 	}
1357 	menu.entries = new_entries;
1358 }
1359 
EntriesEqual(proplist entry_a,proplist entry_b)1360 func EntriesEqual(proplist entry_a, proplist entry_b)
1361 {
1362 	return entry_a.symbol == entry_b.symbol
1363 	&& entry_a.text == entry_b.text
1364 	&& DeepEqual(entry_a.extra_data, entry_b.extra_data)
1365 	&& DeepEqual(entry_a.custom, entry_b.custom);
1366 }
1367 
CreateDummy()1368 func CreateDummy()
1369 {
1370 	var dummy = CreateContents(Dummy);
1371 	dummy.Visibility = VIS_Owner;
1372 	dummy->SetOwner(GetOwner());
1373 	return dummy;
1374 }
1375 
RemoveDummy(object dummy,int player,int ID,int subwindowID,object target)1376 func RemoveDummy(object dummy, int player, int ID, int subwindowID, object target)
1377 {
1378 	if (dummy)
1379 		dummy->RemoveObject();
1380 }
1381 
1382 // updates the interaction menu for an object iff it is currently shown
UpdateInteractionMenuFor(object target,callbacks)1383 func UpdateInteractionMenuFor(object target, callbacks)
1384 {
1385 	for (var slot = 0; slot < GetLength(current_menus); ++slot)
1386 	{
1387 		var current_menu = current_menus[slot];
1388 		if (!current_menu || current_menu.target != target) continue;
1389 		if (!callbacks) // do a full refresh
1390 			OpenMenuForObject(target, slot);
1391 		else // otherwise selectively update the menus for the callbacks
1392 		{
1393 			for (var callback in callbacks)
1394 			{
1395 				for (var menu_index = 0; menu_index < GetLength(current_menu.menus); ++menu_index)
1396 				{
1397 					var menu = current_menu.menus[menu_index];
1398 					if (menu.entries_callback != callback)
1399 					{
1400 						continue;
1401 					}
1402 					DoMenuRefresh(slot, menu_index);
1403 				}
1404 			}
1405 		}
1406 	}
1407 }
1408 
1409 /*
1410 	Updates all interaction menus that are currently attached to an object.
1411 	This function can be called at all times, not only when a menu is open, making it more convenient for users, because there is no need to track open menus.
1412 	If the /callbacks/ parameter is supplied, only menus that use those callbacks are updated. That way, a producer can f.e. only update its "queue" menu.
1413 */
UpdateInteractionMenus(callbacks)1414 global func UpdateInteractionMenus(callbacks)
1415 {
1416 	if (!this) return;
1417 	if (callbacks && GetType(callbacks) != C4V_Array) callbacks = [callbacks];
1418 	for (var interaction_menu in FindObjects(Find_ID(GUI_ObjectInteractionMenu)))
1419 		interaction_menu->UpdateInteractionMenuFor(this, callbacks);
1420 }
1421 
1422 // Sounds
1423 
PlaySoundTransfer()1424 func PlaySoundTransfer()
1425 {
1426 	Sound("Hits::SoftTouch*", true, nil, GetOwner());
1427 }
1428 
PlaySoundTransferIncomplete()1429 func PlaySoundTransferIncomplete()
1430 {
1431 	Sound("Hits::Materials::Wood::DullWoodHit*", true, nil, GetOwner());
1432 }
1433 
PlaySoundError()1434 func PlaySoundError()
1435 {
1436 	Sound("Objects::Balloon::Pop", true, nil, GetOwner());
1437 }
1438 
1439 // Overloadable functions for customization
1440 
SidebarIconStandard()1441 func SidebarIconStandard()
1442 {
1443 	return Icon_Menu_RectangleRounded;
1444 }
1445 
SidebarIconOnHover()1446 func SidebarIconOnHover()
1447 {
1448 	return Icon_Menu_RectangleBrightRounded;
1449 }
1450 
SidebarIconSelected()1451 func SidebarIconSelected()
1452 {
1453 	return Icon_Menu_RectangleBrightRounded;
1454 }
1455