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