Author Topic: Handling Transient Events and Effects: an example  (Read 20509 times)

Perdurabo

  • Rogueliker
  • ***
  • Posts: 99
  • Karma: +0/-0
    • View Profile
    • Email
Handling Transient Events and Effects: an example
« on: August 06, 2009, 12:38:51 PM »
Hey all, I wrote the class before to handle transient effects (like drinking a potion of might) in Kharne. It was a pain to grasp and define the concepts, so I'm guessing others may have problems with it as well. Written in Delphi, but should compile in FPC/Lazarus as well without too many changes. (apart from defining an ICommonObject interface with a getstringvalue method of course)

Its in the public domain so knock yourself out with it.


unit UnitTimers;

interface

uses SysUtils;

{ Timer Handling

  A TGameTimer represents a transient event that has a duration.

  An example would be the side-effects of drinking a Potion of Might - it increases
  strength for a limited number of turns. We optionally define a number of procedure
  pointers to point to events that occur at the beginning and end of the duration,
  and also on every turn.

  For example, upon drinking a Potion of Might, the event might display a message
  stating you feel mighty (as well as increasing your strength). As the duration
  of the effects decrease, further messages will be displayed stating that the
  effects of the potion are wearing off, and then when the duration has expired,
  your strength reverts back to normal

  To implement this, use the follwing steps as a guide:

  1. Set up a variable:

  DrinkMightPotionEvent: TGameTimer;

  2. Define three events as follows:

  procedure MightPotionDrink(const Turns: Integer = 0);
  procedure MightPotionTick(const Turns: Integer = 0);
  procedure MightPotionEnd(const Turns: Integer = 0);

  procedure MightPotionDrink(const Turns: Integer);
  begin
    DisplayMessage('You feel mighty!');
    Player.Strength := Player.Strength + 10;
  end;

  procedure MightPotionTick(const Turns: Integer);
  begin
    if (DrinkMightPotionEvent.Progress = 50) then
      DisplayMessage('The effects are wearing off!');
    Player.Strength := Player.Strength - 5;
  end;

  procedure MightPotionEnd(const Turns: Integer);
  begin
    DisplayMessage('You no longer feel so mighty!');
    Player.Strength := Player.Strength - 5;
  end;

  3. Instantiate the event:

  DrinkMightPotionEvent := TGameTimer.Create(timMight,
                                             DURATION_MIGHT_POTION,
                                             MightPotionTick,
                                             MightPotionDrink,
                                             MightPotionEnd);

  4. Then, on every turn that passes, simply call

     if (Assigned(DrinkMightPotionEvent)) then DrinkMightPotionEvent.Tick;

   }
   

{ Define the types of timers as an enum for simplicity }
type TGameTimerType = (timSpeed,
                         timConfusion,
                       timBlindness,
                       timSeeInvisible,
                       timParalysis,
                       timFreeAction,
                       timCombatMastery,
                       timReflexes,
                       timMight);

{ Procedure Pointer for Event Hook }      
type TGameTimerEvent = procedure(const Turns: Integer = 0);

{ Class Definition - it inherits the ICommonObject interface to gain access
  to the StringValue method to allow easy persistance }
type TGameTimer = class(ICommonObject)
private
  FTimerType: TGameTimerType;              // Timer Type, from the enum previously defined
  FTimerDuration: Integer;              // Starting Duration, in turns
  FTimerDurationLeft: Integer;            // Duration Left, in turns
  FTimerTickEvent: TGameTimerEvent;     // Optional Event to call on each decrement
  FTimerStartEvent: TGameTimerEvent;    // Optional Event to call on starting the timer
  FTimerEndEvent: TGameTimerEvent;      // Optional Event to call on ending the timer (i.e. turns left = 0)
 
  { Private functions to support class properties defined below }
  function GetStatus: Boolean;
  function GetProgress: Integer;
public

  { Standard Constructor }
  constructor Create(TimerType: TGameTimerType;
                     TimerDuration: Integer;
                     TimerTickEvent: TGameTimerEvent = nil;
                     TimerStartEvent: TGameTimerEvent = nil;
                     TimerEndEvent: TGameTimerEvent = nil);
                   
  { Decrement the Timer by one turn. Will return true if the timer has not expired }
  function Tick: Boolean;
 
  { Interface Method for Persistance }
  function GetStringValue: String;
 
  { Properties }
  property TimerType: TGameTimerType read FTimerType;      // Return the enum
  property TimerDuration: Integer read FTimerDurationLeft; // Number of Turns left
  property TimerProgress: Integer read GetProgress;        // Number of Turns left as a percantage (0-100) of the original duration
  property Active: Boolean read GetStatus;                 // True if Number of Turns left is > 0
end;

implementation

{ Standard Constructor - this is deliberately the only way to set up the duration etc }
constructor TGameTimer.Create(TimerType: TGameTimerType;
                              TimerDuration: Integer;
                              TimerTickEvent: TGameTimerEvent;
                              TimerStartEvent: TGameTimerEvent;
                                TimerEndEvent: TGameTimerEvent);
begin
  { Load the private member data }
  FTimerType := TimerType;
  FTimerDuration := TimerDuration;
  FTimerDurationLeft := TimerDuration;
  FTimerTickEvent := TimerTickEvent;
  FTimerStartEvent := TimerStartEvent;
  FTimerEndEvent := TimerEndEvent;

  { If we have a start event defined then execute it now }
  if (Assigned(FTimerStartEvent)) then
    FTimerStartEvent(FTimerDurationLeft);
end;

{ Return true if the timer hasn't expired }
function TGameTimer.GetStatus: Boolean;
begin
  Result := FTimerDurationLeft > 0;
end;

{ Decrease the duration of the timer by a turn }
function TGameTimer.Tick: Boolean;
begin
  Dec(FTimerDurationLeft);

  { Trigger events if they are defined }
  if (FTimerDurationLeft > 0) then
  begin
    { Interval Event }
    if (Assigned(FTimerTickEvent)) then
      FTimerTickEvent(FTimerDurationLeft);
  end
  else if (FTimerDurationLeft = 0) then
  begin
    { End Event }
    if (Assigned(FTimerEndEvent)) then
      FTimerEndEvent(FTimerDurationLeft);
  end;

  { Return true if the Timer is still active }
  Result := GetStatus;
end;

{ Returns the number of Turns left as a percantage (0-100) of the original duration }
function TGameTimer.GetProgress: Integer;
var
  Percentage: Double;
begin
  Percentage := FTimerDurationLeft / FTimerDuration;
  Result := Trunc(Percentage * 100);
end;

{ Get the Timer as a String }
function TGameTimer.GetStringValue: String;
begin
  { TODO: We will need to extend this to allow hashing of the attached procedures }
  Result := Format('%d%d', [Ord(FTimerType), FTimerDuration]);
end;

end.



Here's a noddy Delphi app (with two buttons and a memo on a form) as an example:


unit Unit1;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, UnitTimers, StdCtrls;

type
  TForm1 = class(TForm)
    Memo1: TMemo;
    Button1: TButton;
    Button2: TButton;
    procedure Button1Click(Sender: TObject);
    procedure Button2Click(Sender: TObject);
  private
    { Private declarations }
    Event: TGameTimer;

  public
    { Public declarations }
  end;

procedure EventStart(const Turns: Integer = 0);
procedure EventTick(const Turns: Integer = 0);
procedure EventEnd(const Turns: Integer = 0);

var
  Form1: TForm1;

implementation

{$R *.dfm}

{ TForm1 }

procedure EventEnd(const Turns: Integer);
begin
  Form1.Memo1.Lines.Add('End ' + IntToStr(Turns) + ' Turns Left');
end;

procedure EventStart(const Turns: Integer);
begin
  Form1.Memo1.Lines.Add('Start ' + IntToStr(Turns) + ' Turns Left');
end;

procedure EventTick(const Turns: Integer);
begin
  Form1.Memo1.Lines.Add('Tick ' + IntToStr(Turns) + ' Turns Left');
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  Event := TGameTimer.Create(timSpeed,
                             20,
                             EventTick,
                             EventStart,
                             EventEnd);
end;

procedure TForm1.Button2Click(Sender: TObject);
begin
  if (Assigned(Event)) then Event.Tick;
end;

end.



Perdurabo

  • Rogueliker
  • ***
  • Posts: 99
  • Karma: +0/-0
    • View Profile
    • Email
Re: Handling Transient Events and Effects: an example
« Reply #1 on: August 07, 2009, 12:08:50 AM »
I've created a video example of this code in action at:

http://kharne-rl.blogspot.com/2009/08/implementing-transient-events-example.html


Z

  • Rogueliker
  • ***
  • Posts: 905
  • Karma: +0/-0
    • View Profile
    • Z's Roguelike Stuff
Re: Handling Transient Events and Effects: an example
« Reply #2 on: August 07, 2009, 10:07:27 AM »
I assume that DURATION_MIGHT_POTION is either an even number from 2 to 100, or an odd number between 51 and 199, since otherwise MightPotionTick might be called not exactly once... That's very bug-prone.

Why give the events the parameter 'turns' if you are then accessing the original timer object instead of using this parameter?

What if the player drinks a new potion of might while the old one was still active? It seems hard to do correctly in your current way. If you don't implement this case specially, the strength bonus of the old one will remain in action forever.

I don't think that the abstraction of timer you did is helpful in this case at all. Your implementation of the potion of might is not simpler than just having a MightTimer:integer property, which is decreased on each turn (if >0), increased on drinking, and a method called on each change (note that this implementation would also be better because the problem with drinking multiple potions it solved more easily). If you now want to create a Potion of Agility, you will have to repeat a lot of functionality, and in many places. Abstraction is helpful if it makes doing many similar things easier and without repetitions, and I think this goal is not achieved in this case.

Instead of

Code: [Select]
begin
  Percentage := FTimerDurationLeft / FTimerDuration;
  Result := Trunc(Percentage * 100);
end;

why not just Result := (FTimeDurationLeft * 100) div FTimerDuration? You don't need doubles for this calculation...

Perdurabo

  • Rogueliker
  • ***
  • Posts: 99
  • Karma: +0/-0
    • View Profile
    • Email
Re: Handling Transient Events and Effects: an example
« Reply #3 on: August 07, 2009, 10:44:49 AM »
I assume that DURATION_MIGHT_POTION is either an even number from 2 to 100, or an odd number between 51 and 199, since otherwise MightPotionTick might be called not exactly once... That's very bug-prone.

Sorry, I don't understand exactly what you mean.

I do think however I need to post-decrement the tick after the tick event instead of pre-decrementing it (as I'm doing now):


  { Trigger events if they are defined }
  if (FTimerDurationLeft > 0) then
  begin
    { Interval Event }
    if (Assigned(FTimerTickEvent)) then
      FTimerTickEvent(FTimerDurationLeft);
  end
  else if (FTimerDurationLeft = 0) then
  begin
    { End Event }
    if (Assigned(FTimerEndEvent)) then
      FTimerEndEvent(FTimerDurationLeft);
  end;

  { Decrement the Timer }
  Dec(FTimerDurationLeft);

  { Return true if the Timer is still active }
  Result := GetStatus;


This means now that with an event of duration of one turn, you get the start event (at 1 turn), a single tick (from 1->0 turns) and then the end event (at 0 turns).

Why give the events the parameter 'turns' if you are then accessing the original timer object instead of using this parameter?

I'm not - turns in the Events is the parameter passed in - the Events currently don't know anything about the timer object (Turns was a "let's just stick a parameter in here to show that it works" thing).

I'm going to change this however a pointer back to the original calling event. That way a global "drink" start event, for example, could be used for all potion types (see below).

What if the player drinks a new potion of might while the old one was still active? It seems hard to do correctly in your current way. If you don't implement this case specially, the strength bonus of the old one will remain in action forever.

True. However, and its my fault for not expressing this more clearly (or at all, even), I'm intending to store active Timers in a TObjectList, and the add method will have gatekeeping added to it so that only one Timer of type timMight can be active at any one time.

I don't think that the abstraction of timer you did is helpful in this case at all. Your implementation of the potion of might is not simpler than just having a MightTimer:integer property, which is decreased on each turn (if >0), increased on drinking, and a method called on each change (note that this implementation would also be better because the problem with drinking multiple potions it solved more easily).

If you now want to create a Potion of Agility, you will have to repeat a lot of functionality, and in many places. Abstraction is helpful if it makes doing many similar things easier and without repetitions, and I think this goal is not achieved in this case.

It was a poor example I used (for which I apologise). As mentioned above, I don't indent to have seperate MightEvents, AgilityEvents and so on, but use one global timer handling event for potions instead, especially if I pass in the Event Object itself which can be subsequently be used in a case statement to determine what to do.

Instead of

Code: [Select]
begin
  Percentage := FTimerDurationLeft / FTimerDuration;
  Result := Trunc(Percentage * 100);
end;

why not just Result := (FTimeDurationLeft * 100) div FTimerDuration? You don't need doubles for this calculation...


True. I was being particularily dense.

The problem with the Progress property is that its useless at low granularities, however. If you had an event with a duration of seven turns you end up with the percentages returned being the equivalent of 1/7, 2/7 etc, i.e. not very nice round numbers.

Z

  • Rogueliker
  • ***
  • Posts: 905
  • Karma: +0/-0
    • View Profile
    • Z's Roguelike Stuff
Re: Handling Transient Events and Effects: an example
« Reply #4 on: August 07, 2009, 11:56:45 AM »
I assume that DURATION_MIGHT_POTION is either an even number from 2 to 100, or an odd number between 51 and 199, since otherwise MightPotionTick might be called not exactly once... That's very bug-prone.

Sorry, I don't understand exactly what you mean.


I just found another bug... in MightPotionTick, you probably want to call "Player.Strength := Player.Strength - 5" only if DrinkMightPotionEvent.Progress = 50?

Suppose that yes, and that DURATION_MIGHT_POTION is 35, then MightPotionTick will be called for the following values of progress: 0 2 5 8 11 14 17 20 22 25 28 31 34 37 40 42 45 48 51 54 57 60 62 65 68 71 74 77 80 82 85 88 91 94 97 100. That means that Progress will never be 50, and player strength will never be reduced by 5 by that method, thus the player will have his strength permanently increased by 5 after drinking the potion.

And if DURATION_MIGHT_POTION is, say, 200, then he will have his strength permanently decreased by 5. The problem is with low granulaties. Sorry if I misunderstood something.

Perdurabo

  • Rogueliker
  • ***
  • Posts: 99
  • Karma: +0/-0
    • View Profile
    • Email
Re: Handling Transient Events and Effects: an example
« Reply #5 on: August 07, 2009, 12:20:32 PM »
I assume that DURATION_MIGHT_POTION is either an even number from 2 to 100, or an odd number between 51 and 199, since otherwise MightPotionTick might be called not exactly once... That's very bug-prone.

Sorry, I don't understand exactly what you mean.


I just found another bug... in MightPotionTick, you probably want to call "Player.Strength := Player.Strength - 5" only if DrinkMightPotionEvent.Progress = 50?

Suppose that yes, and that DURATION_MIGHT_POTION is 35, then MightPotionTick will be called for the following values of progress: 0 2 5 8 11 14 17 20 22 25 28 31 34 37 40 42 45 48 51 54 57 60 62 65 68 71 74 77 80 82 85 88 91 94 97 100. That means that Progress will never be 50, and player strength will never be reduced by 5 by that method, thus the player will have his strength permanently increased by 5 after drinking the potion.

And if DURATION_MIGHT_POTION is, say, 200, then he will have his strength permanently decreased by 5. The problem is with low granulaties. Sorry if I misunderstood something.


Ah right, yes.

You are completely correct.

The progress mechanism is horrid, isn't it?

Someone else flagged this up as well:

http://groups.google.co.uk/group/rec.games.roguelike.development/msg/c83d1c9b31bf0ff1

In reality, to get around the strength thing, I think I'd only ever manipulate a "temporary strength pool" (Player.Strength is a calculated attribute and this temporary additional strength pool is one factor that makes up the strength, and it would normally be 0).

I think what I need to do instead of an atomic progress attribute is to capture the transitions between specified thresholds.

For example, if you define a threshhold event at 50% then as soon as the tick drops progress from 50%+ to <=50% then the event occurs.