Creating a dynamic task list with listbox

Started by rongel, Fri 30/08/2024 14:11:23

Previous topic - Next topic

rongel

Hello! I'm making a Task list with a listbox. When a task is completed, a button with an image of a line appears on top of it, marking it as completed. For each of the tasks, I have an invisible button image ready. This works fine:

Code: ags
function RemoveTaskListItem(String nameofentry) {
  int index;
  while (index < lstTaskList.ItemCount) {
    
    // Button with a line appears 
    if(lstTaskList.Items[index].CompareTo(nameofentry) ==0) {
      if(index ==0) btnTaskList_0.Visible = true;
      if(index ==1) btnTaskList_1.Visible = true;
      if(index ==2) btnTaskList_2.Visible = true;
    }
    else index ++;
  }
}

However, I'm trying to make it a bit more fancy and add categories to it: There is the topic and maybe three tasks under it. Now it can happen that a new task is added inside a topic, using InsertItemAt command. That will move all the listbox items under it nicely, but it will break the button placement, if there already is a completed task below.

My question is how to link my button images to the Listbox items? When the order of the list changes, the buttons placement needs to be updated, but I can't really figure out a good way to do this.

Any ideas? Thanks!
Dreams in the Witch House on Steam & GOG

Crimson Wizard

#1
The traditional approach to this kind of task is in separating the Data and a View, where Data is stored in variables and View is a representation that shows the data on screen.

In practical sense this means that you should not make buttons rely on actual items inside ListBox, and instead make both controls rely on what is in variables, and which range of data are you displaying on screen right now.

Whenever you display a GUI on screen, you read tasks from the variables, and appoint ListBox contents and Buttons accordingly.

Whenever anything changes, while GUI is on screen (tasks added, removed, task state changes, etc), you run the update on it, and again read data from variables and reappoint ListBox and Buttons.

Because ListBox may have a lot of items in it but display only certain range at once, you'd also need to know that range and adjust buttons whenever ListBox is scrolled by the player. I don't know if you deal with this now, and if yes then how. In the simple case you just need to know the range: first index to display, and displayed count.

This approach, while seemingly more complex, will give you much more freedom in what and how do you display on screen.


How may be a list of tasks stored in variables? This may be an array of "Task" structs that describe them.
To give an example (I am making things up here, not knowing what do you have in your tasks).

Code: ags
struct Task
{
   String Name;
   bool Active;
   bool Complete;
   int NumSubTasks; // number of subtasks in this task
};

Task GameTasks[MAX_TASKS];

In this example, the subtasks are stored in the same "Task" array, following their parent tasks, and NumSubTasks is set in parent Task to tell how many subtasks does it have.
This is kind of a ugly structure, but works for a simpler case. If you need a bigger, expandable tree-like structure of tasks and subtasks, another structure would be required, but that's a broader topic.

On a game start initialize this array. Subtasks should follow the parent tasks, for example:
Code: ags
function game_start()
{
  GameTasks[0].Name = "Go shopping";
  GameTasks[0].NumSubTasks = 3; // next 3 tasks are subtasks of this one
  GameTasks[1].Name = "Buy food";
  GameTasks[2].Name = "Buy a dress";
  GameTasks[3].Name = "Buy a new carpet";
}

Whenever you add or remove your tasks, you do so inside this array of structs, e.g. by setting their Active bool to true or false.


Then the ListBox may be filled by running over GameTasks array, and fill in only items that you like to display: All, only Active, only Completed, etc, based on variables in struct.
The Buttons then are updated based on:
- the top index of displayed task
- the task's state


For a very crude example:
Code: ags
function UpdateTasksGUI()
{
  lstTaskList.Clear();
  for (int i = 0; i < MAX_TASKS; i++)
  {
    if (GameTasks[i].Active)
    {
       lstTaskList.AddItem(GameTasks[i].Name);
    }
  }

  UpdateButtons();
}

function OnTaskListScrolled()
{
  UpdateButtons();
}

function UpdateButtons()
{
  int top_index = ListBox.TopItem; // an index of active tasks visible on GUI
  int row_count = ListBox.RowCount; // how many active tasks are visible on GUI
  int active_task_index = 0; // an index of active tasks in a list
  int button_index = 0; // the next index of a button to use

  for (int i = 0; i < MAX_TASKS; i++)
  {
    if (GameTasks[i].Active)
    {
       if (active_task_index >= top_index)
       {
         // Find the next available button on GUI
         int but_id = btnTaskList_0.ID + button_index;
         Button *button = taskListGUI.Controls[but_id].AsButton;
         button.Visible = GameTasks[i].Completed;
         button_index++; // button used, moved to next
       }
       active_task_index++
    }

    if (active_task_index >= top_index + row_count)
    {
       break; // no more items are visible on gui
    }
  }
}

rongel

Wonderful! Thanks so much for this CW! I was kind of transitioning to some system like this, but didn't know how (in my previous game the simple and crude task list worked well enough). I'll start testing this approach now.

QuoteBecause ListBox may have a lot of items in it but display only certain range at once, you'd also need to know that range and adjust buttons whenever ListBox is scrolled by the player. I don't know if you deal with this now, and if yes then how. In the simple case you just need to know the range: first index to display, and displayed count.
I was thinking to simulate a paper task list, and if the page is full, it will continue on the next page. I'm sure it would cause all sorts of problems, but a scrolling list doesn't really fit into the game's atmosphere.
Dreams in the Witch House on Steam & GOG

eri0o

#3
That's the idea of separation between data and view

You would just grab a fixed amount of items that can be fit in a page (or some other approach if the amount of text is like a lot) and then just make the navigation to be page based. Like say you can get how many lines each item has and you have a "page line budget", you get enough to fill a page and then it goes to the next one, and so on.

If the view and data are decoupled you can reason and break the problem more easily.

rongel

Did some quick testing and it seems to work nicely! Buttons appear in the right places etc. I had to do some adjustments to make it work. This for example was giving me error in the UpdateButtons function:

Code: ags
int top_index = ListBox.TopItem; // an index of active tasks visible on GUI
int row_count = ListBox.RowCount; // how many active tasks are visible on GUI

I changed it into this, and got rid of the error:

Code: ags
int top_index = lstTaskList.TopItem; // an index of active tasks visible on GUI
int row_count = lstTaskList.RowCount; // how many active tasks are visible on GUI

Also not 100% sure what the "NumSubTasks" does actually? What would be the difference if I wouldn't use it?

I'll continue with this next week, and probably have more questions. But huge thanks already!
Dreams in the Witch House on Steam & GOG

Crimson Wizard

Quote from: rongel on Fri 30/08/2024 18:15:35Also not 100% sure what the "NumSubTasks" does actually? What would be the difference if I wouldn't use it?

This was more a prototype. But that may come useful if you need to distinguish subtasks. For example, you may want to indent a subtask with couple of spaces in a listbox. Then you'll need to find out which items are subtasks.

rongel

One issue I noticed now: In the game, the player can get these tasks quite freely in a non-linear order. The problem is that now the task placement is fixed to the order of the array at game_start. So when a player gets a new task, it could jump at the beginning of the list, or in the middle of it, rather than appearing at the bottom (like when using ListBox.AddItem).

The optimal situation would be that the new tasks appear at the bottom of the list, unless they are subtasks. Subtasks would appear in the correct category, and rearrange the list.
Dreams in the Witch House on Steam & GOG

Crimson Wizard

#7
You could add a separate array containing indexes of tasks in the order of them being received.
And then when you are updating GUI you iterate this array instead.

For example:
Code: ags
int TaskReceived[MAX_TASKS];
int TaskReceivedCount;

function AddTask(int taskIndex, bool addSubtasks)
{
    TaskReceived[TaskReceivedCount++] = taskIndex;
    if (addSubtasks)
    {
        // add subtasks too (they have sequential indexes)
        for (int i = 0; i < GameTasks[taskIndex].NumSubTasks; i++)
        {
            TaskReceived[TaskReceivedCount++] = taskIndex + i;
        }
    }
}

If a subtask has to be inserted later, then insert one in the middle, and shift all indexes

Code: ags
function AddSubTask(int parentIndex, int subIndex)
{
    int insertAt = -1;
    for (int i = 0; i < TaskReceivedCount; i++)
    {
        if (TaskReceived[i] == parentIndex)
        {
            insertAt = i + 1;
            // Find if other subtasks are already added and skip these
            int maxSubs = GameTasks[parentIndex].NumSubTasks;
            for (int j = i + 1; (j < TaskReceivedCount) && (TaskReceived[j] < parentIndex + maxSubs); j++)
            {
                insertAt++;
            }
        }
    }

    // Copy everything to the right to free the index, in the reverse order
    for (int i = TaskReceivedCount; i > insertAt; i--)
    {
        TaskReceived[i] = TaskReceived[i - 1];
    }

    // And insert a subtask on its place
    TaskReceived[insertAt] = subIndex;
    TaskReceivedCount++;
}




and then gui update:
Code: ags
function UpdateTasksGUI()
{
  lstTaskList.Clear();
  for (int i = 0; i < TaskReceivedCount; i++)
  {
    int taskID = TaskReceived[i];
    lstTaskList.AddItem(GameTasks[taskID].Name);
  }

And UpdateButtons changed accordingly.

Code: ags
function UpdateButtons()
{
<...>

  for (int i = 0; i < TaskReceivedCount; i++)
  {
    int taskID = TaskReceived[i];
    if (active_task_index >= top_index)
    {
         // Find the next available button on GUI
         int but_id = btnTaskList_0.ID + button_index;
         Button *button = taskListGUI.Controls[but_id].AsButton;
         button.Visible = GameTasks[taskID].Completed;
         button_index++; // button used, moved to next
       }
       active_task_index++
    }

<...>
}

Crimson Wizard

On a separate note, I think that above structure may also be used if there are more nested subtask levels.
The Task struct could use a "ParentID" field for easier finding a parent in case we need to go from child to parent when reading tasks.
And some of the operations from above will have to become recursive (calling same function from inside a function for the nested child level of tasks).

rongel

Thanks again for the great help! That kind of system seems perfect for my purposes. I did some testing with it and got partial success.
First of all, I got an error saying "Error parsing path; unexpected token after array index" from these lines in the AddTask function:

Code: ags
TaskReceived[TaskReceivedCount++] = taskIndex;

TaskReceived[TaskReceivedCount++] = taskIndex + i;

I managed to get rid of the error by formatting it into TaskReceived[TaskReceivedCount +1], but I couldn't get any of the tasks appear with it. I simplified the code for testing purposes and got the "basic" addTask function to work by doing this:

Code: ags
function AddTask(int taskIndex)
{
  TaskReceived[TaskReceivedCount] = taskIndex;
  TaskReceivedCount ++;
}

Now I can add tasks and they appear at the end of the task list. I tried to do the same (and many other things) with the addSubtasks bool, but couldn't get it to work properly, it was giving me duplicates etc.

The function AddSubTask(int parentIndex, int subIndex) seems to work nicely, with that I can insert subtasks under the main task in correct position.

So there's some kind of formatting problem with the AddTask function. Any ideas how to properly fix it, and get the addSubtasks part working as well?
Dreams in the Witch House on Steam & GOG

Crimson Wizard

#10
It appears that the old compiler cannot handle ++ operator inside brackets.

Code: ags
TaskReceived[TaskReceivedCount] = taskIndex;
TaskReceivedCount++;
^ this is a correct equivalent.

A full function will look like
Code: ags
function AddTask(int taskIndex, bool addSubtasks)
{
    TaskReceived[TaskReceivedCount] = taskIndex;
    TaskReceivedCount++;
    if (addSubtasks)
    {
        // add subtasks too (they have sequential indexes)
        for (int i = 0; i < GameTasks[taskIndex].NumSubTasks; i++)
        {
            TaskReceived[TaskReceivedCount] = taskIndex + 1 + i;
            TaskReceivedCount++;
        }
    }
}

EDIT: Ah, it should be "taskIndex + 1 + i;" for subtasks, fixed that mistake.

rongel

Ok, things seem to work much better now! A couple of questions:

1. How exactly should I use the subTask feature at game start? Let's say I have three subtasks, two of them active at game start and one will get activated later on. For example:

Code: ags
GameTasks[0].Name = "Go shopping";
GameTasks[0].NumSubTasks = 3; // next 3 tasks are subtasks of this one
GameTasks[1].Name = "Buy food";
GameTasks[2].Name = "Buy a dress";
GameTasks[3].Name = "Buy a new carpet";

AddTask(0, true);

Now all the subtasks will be visible already from the beginning, which is bad. The carpet option should be added later. Is this the correct way to do it:

Code: ags
GameTasks[0].NumSubTasks = 2;

Now the third option doesn't get shown yet. And then later on I'll do this:

Code: ags
AddSubTask(0, 3);

2. I'd prefer that the later on added subtask would go to the bottom of the substack list. Right now it seems to go in the middle, or one row below the first item? Is there a way to do it by editing the insertAt int?

Code: ags
if (TaskReceived[i] == parentIndex)
  {
  insertAt = i + 1;
  // Find if other subtasks are already added and skip these
  int maxSubs = GameTasks[parentIndex].NumSubTasks;
  for (int j = i + 1; (j < TaskReceivedCount) && (TaskReceived[j] < parentIndex + maxSubs); j++)
  {
  insertAt++;
  }
}
Dreams in the Witch House on Steam & GOG

Crimson Wizard

If you want certain subtasks to not be visible, do not call AddTask with "addSubtasks" true.
Instead call it with "false" and then do AddSubTask for each subtask that you want to make active.

QuoteI'd prefer that the later on added subtask would go to the bottom of the substack list. Right now it seems to go in the middle, or one row below the first item? Is there a way to do it by editing the insertAt int?

When I wrote the code the intent was that the new active subtasks is appended after all other active subtasks, or right after parent if no other subtasks are active.

If it does not work like that, then there's a mistake in code.

EDIT: probably the condition is wrong, should be:
Code: ags
  for (int j = i + 1; (j < TaskReceivedCount) && (TaskReceived[j] > parentIndex && TaskReceived[j] < parentIndex + maxSubs); j++)

rongel

Quote from: Crimson Wizard on Sun 01/09/2024 14:58:44EDIT: probably the condition is wrong, should be:
Code: ags
  for (int j = i + 1; (j < TaskReceivedCount) && (TaskReceived[j] > parentIndex && TaskReceived[j] < parentIndex + maxSubs); j++)

I tested the new code, but got similar results. The added subtask goes on in the middle of the subtask list, not bottom. It could be that I've made some mistake, so just to clarify, here's what I've done:

At game_start:

Code: ags
  GameTasks[0].Name = "Go shopping";
  GameTasks[0].NumSubTasks = 3; // next 3 tasks are subtasks of this one
  GameTasks[1].Name = " - Buy food";
  GameTasks[2].Name = " - Buy a dress";
  GameTasks[3].Name = " - Buy a new carpet";
  
  AddTask(0, false); // shopping
  AddTask(2, false); // dress
  AddTask(3, false); // carpet
  
  UpdateTasksGUI_v2();

Then I add the third subtask later in the game:

Code: ags
  AddSubTask(0, 1);  // food
  UpdateTasksGUI_v2();

Which does this:

Code: ags
// ADD SUBTASK
function AddSubTask(int parentIndex, int subIndex)
{
  int insertAt = -1;
  for (int i = 0; i < TaskReceivedCount; i++)
  {
    if (TaskReceived[i] == parentIndex)
    {
        insertAt = i + 1;
        // Find if other subtasks are already added and skip these
        int maxSubs = GameTasks[parentIndex].NumSubTasks;
        for (int j = i + 1; (j < TaskReceivedCount) && (TaskReceived[j] > parentIndex && TaskReceived[j] < parentIndex + maxSubs); j++)
        {
          insertAt++;
        }
    }
  }

  // Copy everything to the right to free the index, in the reverse order
  for (int i = TaskReceivedCount; i > insertAt; i--)
  {
      TaskReceived[i] = TaskReceived[i - 1];
  }

  // And insert a subtask on its place
  TaskReceived[insertAt] = subIndex;
  TaskReceivedCount++;
}

The end result is still that the added subtask goes between subtask 2 (dress) and subtask 3 (carpet) instead of going to the bottom of the list. If I add subtask 1 (food) and subtask 2 (dress) at game_start, and the add subtask 3 (carpet) later, only then it will go to the bottom. This is a quite minor issue, but it would be great to get it working 100%!
Dreams in the Witch House on Steam & GOG

Crimson Wizard

#14
I had to make a game and test this script to find mistakes.

The updated script:
Code: ags
function AddTask(int taskIndex, bool addSubtasks)
{
  TaskReceived[TaskReceivedCount] = taskIndex;
  TaskReceivedCount++;
  if (addSubtasks)
  {
    // add subtasks too (they have sequential indexes)
    for (int i = 0; i < GameTasks[taskIndex].NumSubTasks; i++)
    {
      TaskReceived[TaskReceivedCount] = taskIndex + 1 + i;
      TaskReceivedCount++;
    }
  }
}

function AddSubTask(int parentIndex, int subIndex)
{
  int insertAt = -1;
  for (int i = 0; i < TaskReceivedCount; i++)
  {
    if (TaskReceived[i] == parentIndex)
    {
      // Find if other subtasks are already added and skip these
      int maxSubs = GameTasks[parentIndex].NumSubTasks;
      for (insertAt = i + 1;
        (insertAt < TaskReceivedCount) && (TaskReceived[insertAt] > parentIndex && TaskReceived[insertAt] <= (parentIndex + maxSubs));
        insertAt++)
      {
      }
    }
  }

  // Copy everything to the right to free the index, in the reverse order
  for (int i = TaskReceivedCount; i > insertAt; i--)
  {
    TaskReceived[i] = TaskReceived[i - 1];
  }

  // And insert a subtask on its place
  TaskReceived[insertAt] = parentIndex + 1 + subIndex;
  TaskReceivedCount++;
}


Also, this will only work properly if you use AddSubTask when adding sub-tasks, and not AddTask.
Subtask's index is also 0-based:

Code: ags
  AddTask(0, false); // shopping
  AddSubTask(0, 1);  // dress
  AddSubTask(0, 2);  // carpet
  UpdateTasksGUI();
  
  AddSubTask(0, 0);  // food
  UpdateTasksGUI();

rongel

I've done some testing with this, it seems to work perfectly now. Thank you very much CW!

I'm now working on setting up the Task List page limit, so that each page can hold only limited amount of tasks.
I did some awful coding, but it *seems* to work. Currently I'm testing with 5 tasks per page limit:

Code: ags
function UpdateTasksGUI()
{
  lstTaskList.Clear();
  
  int i = 0; 
  
  // Tasklist page 1
  if(Tasklist_page == 1) {
    while (i < TaskReceivedCount && i < 5) 
    
    {
      int taskID = TaskReceived[i];
      lstTaskList.AddItem(GameTasks[taskID].Name);
      i ++;
    }
  }
  // Tasklist page 2
  else if(Tasklist_page == 2) {
    i = 5;
    while (i  < TaskReceivedCount && i < 10) 
    
    {
      int taskID = TaskReceived[i];
      lstTaskList.AddItem(GameTasks[taskID].Name);
      i ++;
    }
  }
  
  UpdateButtons();
}

I added a similar check to the UpdateButtons function and the button behavior *seems* to work as well. I think I can make the code a bit nicer, but can you already see if there's a problem brewing? Or is this approach ok?
Dreams in the Witch House on Steam & GOG

Crimson Wizard

#16
The rule of thumb is: when something depends on a condition, put only that under condition, and leave the rest out.

What does depend on a "page" condition? Only the range of items does. Therefore the only thing that should be done under "if(Tasklist_page == X)" is assigning "first item" and "end item" or "item count" variables.

Code: ags
int start_at;
int end_at;
if(Tasklist_page == 1) {
    start_at = 0;
    end_at = 5;
}
else if(Tasklist_page == 2) {
    start_at = 5;
    end_at = 10;
}
<...>

for (int i = start_at; i < TaskReceivedCount && i < end_at; i++)    
{
    int taskID = TaskReceived[i];
    lstTaskList.AddItem(GameTasks[taskID].Name);
}


But in your case even that is not necessary. As you may see, the start and end indexes have a linear dependency on Tasklist_page value. So the code becomes:

Code: ags
int start_at = Tasklist_page * 5;
int end_at = start_at + 5;

for (int i = start_at; i < TaskReceivedCount && i < end_at; i++)    
{
    int taskID = TaskReceived[i];
    lstTaskList.AddItem(GameTasks[taskID].Name);
}

rongel

Quote from: Crimson Wizard on Tue 03/09/2024 12:00:26But in your case even that is not necessary. As you may see, the start and end indexes have a linear dependency on Tasklist_page value. So the code becomes:

Code: ags
int start_at = Tasklist_page * 5;
int end_at = start_at + 5;

for (int i = start_at; i < TaskReceivedCount && i < end_at; i++)    
{
    int taskID = TaskReceived[i];
    lstTaskList.AddItem(GameTasks[taskID].Name);
}

Beautiful! I'll start working on the proper version now, hopefully won't run into nasty problems anymore.

I'll add you in the game credits for sure!
Dreams in the Witch House on Steam & GOG

SMF spam blocked by CleanTalk