Repository: MyreMylar/pygame_gui (python)

Issue:
Add 'shake' text chunk effect
Now there is a new, text chunk capable, text effects system it should be possible to build a 'shake' effect (similar to a screen shake) for this system.

Original issue comment mentioning shake effect:

https://github.com/MyreMylar/pygame_gui/issues/24#issuecomment-582462344 

Screen shake effect example code:
https://jonny.morrill.me/en/blog/gamedev-how-to-implement-a-camera-shake-effect/

I will link the text effects code later once 0.6.0 is merged in.


Pull request description:
Add shake effect to text box chunks
fixes #223

Patch:
diff --git a/pygame_gui/__init__.py b/pygame_gui/__init__.py
index 8d6d4f6..fee3647 100644
--- a/pygame_gui/__init__.py
+++ b/pygame_gui/__init__.py
@@ -24,6 +24,7 @@ from pygame_gui._constants import UI_COLOUR_PICKER_COLOUR_CHANNEL_CHANGED
 from pygame_gui._constants import UI_CONSOLE_COMMAND_ENTERED, UI_TEXT_EFFECT_FINISHED
 from pygame_gui._constants import TEXT_EFFECT_TYPING_APPEAR, TEXT_EFFECT_FADE_IN
 from pygame_gui._constants import TEXT_EFFECT_FADE_OUT, TEXT_EFFECT_BOUNCE, TEXT_EFFECT_TILT
+from pygame_gui._constants import TEXT_EFFECT_SHAKE
 from pygame_gui._constants import TEXT_EFFECT_EXPAND_CONTRACT
 from pygame_gui._constants import UITextEffectType
 from pygame_gui.core.utility import PackageResource
@@ -61,5 +62,6 @@ __all__ = ['UIManager',
            'TEXT_EFFECT_FADE_OUT',
            'TEXT_EFFECT_BOUNCE',
            'TEXT_EFFECT_TILT',
-           'TEXT_EFFECT_EXPAND_CONTRACT'
+           'TEXT_EFFECT_EXPAND_CONTRACT',
+           'TEXT_EFFECT_SHAKE'
            ]
diff --git a/pygame_gui/_constants.py b/pygame_gui/_constants.py
index 1a07291..542a17f 100644
--- a/pygame_gui/_constants.py
+++ b/pygame_gui/_constants.py
@@ -81,6 +81,7 @@ TEXT_EFFECT_FADE_OUT = UITextEffectType('fade_out')
 TEXT_EFFECT_TILT = UITextEffectType('tilt')
 TEXT_EFFECT_BOUNCE = UITextEffectType('bounce')
 TEXT_EFFECT_EXPAND_CONTRACT = UITextEffectType('expand_contract')
+TEXT_EFFECT_SHAKE = UITextEffectType('shake')
 
 
 __all__ = ['UI_BUTTON_PRESSED',
@@ -110,6 +111,7 @@ __all__ = ['UI_BUTTON_PRESSED',
            'TEXT_EFFECT_TILT',
            'TEXT_EFFECT_BOUNCE',
            'TEXT_EFFECT_EXPAND_CONTRACT',
+           'TEXT_EFFECT_SHAKE',
            'UITextEffectType',
            'OldType'
            ]
diff --git a/pygame_gui/core/text/__init__.py b/pygame_gui/core/text/__init__.py
index ec3b867..74e7708 100644
--- a/pygame_gui/core/text/__init__.py
+++ b/pygame_gui/core/text/__init__.py
@@ -10,6 +10,7 @@ from pygame_gui.core.text.text_box_layout_row import TextBoxLayoutRow
 from pygame_gui.core.text.text_effects import TextEffect, TypingAppearEffect
 from pygame_gui.core.text.text_effects import FadeOutEffect, FadeInEffect
 from pygame_gui.core.text.text_effects import BounceEffect, TiltEffect, ExpandContractEffect
+from pygame_gui.core.text.text_effects import ShakeEffect
 from pygame_gui.core.text.html_parser import HTMLParser
 
 
@@ -30,4 +31,5 @@ __all__ = ['TextLayoutRect',
            'FadeInEffect',
            'BounceEffect',
            'TiltEffect',
-           'ExpandContractEffect']
+           'ExpandContractEffect',
+           "ShakeEffect"]
diff --git a/pygame_gui/core/text/text_effects/__init__.py b/pygame_gui/core/text/text_effects/__init__.py
index a3730d5..32fc59e 100644
--- a/pygame_gui/core/text/text_effects/__init__.py
+++ b/pygame_gui/core/text/text_effects/__init__.py
@@ -5,6 +5,7 @@ from pygame_gui.core.text.text_effects.fade_out_effect import FadeOutEffect
 from pygame_gui.core.text.text_effects.bounce_effect import BounceEffect
 from pygame_gui.core.text.text_effects.tilt_effect import TiltEffect
 from pygame_gui.core.text.text_effects.expand_contract_effect import ExpandContractEffect
+from pygame_gui.core.text.text_effects.shake_effect import ShakeEffect
 
 
 __all__ = ['TextEffect',
@@ -13,4 +14,5 @@ __all__ = ['TextEffect',
            'FadeOutEffect',
            'BounceEffect',
            'TiltEffect',
-           'ExpandContractEffect']
+           'ExpandContractEffect',
+           "ShakeEffect"]
diff --git a/pygame_gui/core/text/text_effects/bounce_effect.py b/pygame_gui/core/text/text_effects/bounce_effect.py
index d3bcfa5..cfb21aa 100644
--- a/pygame_gui/core/text/text_effects/bounce_effect.py
+++ b/pygame_gui/core/text/text_effects/bounce_effect.py
@@ -43,7 +43,7 @@ class BounceEffect(TextEffect):
 
     def update(self, time_delta: float):
         """
-        Updates the fade with amount of time passed since the last call to update.
+        Updates the effect with amount of time passed since the last call to update.
 
         :param time_delta: time in seconds since last frame.
         """
@@ -74,8 +74,8 @@ class BounceEffect(TextEffect):
 
     def has_text_changed(self) -> bool:
         """
-        Lets us know when the fade alpha has changed enough (i.e. by a whole int) to warrant us
-        redrawing the text box with the new alpha value.
+        Lets us know when the effect has changed enough to warrant us
+        redrawing the text.
 
         :return: True if it is is time to redraw our text.
         """
diff --git a/pygame_gui/core/text/text_effects/expand_contract_effect.py b/pygame_gui/core/text/text_effects/expand_contract_effect.py
index 91384d9..80ea66a 100644
--- a/pygame_gui/core/text/text_effects/expand_contract_effect.py
+++ b/pygame_gui/core/text/text_effects/expand_contract_effect.py
@@ -44,7 +44,7 @@ class ExpandContractEffect(TextEffect):
 
     def update(self, time_delta: float):
         """
-        Updates the fade with amount of time passed since the last call to update.
+        Updates the effect with amount of time passed since the last call to update.
 
         :param time_delta: time in seconds since last frame.
         """
@@ -76,8 +76,7 @@ class ExpandContractEffect(TextEffect):
 
     def has_text_changed(self) -> bool:
         """
-        Lets us know when the fade alpha has changed enough (i.e. by a whole int) to warrant us
-        redrawing the text box with the new alpha value.
+        Lets us know when the effect has changed enough  to warrant us redrawing the text.
 
         :return: True if it is is time to redraw our text.
         """
diff --git a/pygame_gui/core/text/text_effects/shake_effect.py b/pygame_gui/core/text/text_effects/shake_effect.py
new file mode 100644
index 0000000..9d286bb
--- /dev/null
+++ b/pygame_gui/core/text/text_effects/shake_effect.py
@@ -0,0 +1,134 @@
+import random
+from typing import Dict, Any, Optional
+
+import pygame
+
+from pygame_gui._constants import UI_TEXT_EFFECT_FINISHED, TEXT_EFFECT_SHAKE
+from pygame_gui.core.interfaces.text_owner_interface import IUITextOwnerInterface
+from pygame_gui.core.text.text_effects.text_effect import TextEffect
+from pygame_gui.core.text.text_line_chunk import TextLineChunkFTFont
+
+
+class ShakeEffect(TextEffect):
+    """
+    A shake effect
+    """
+    def __init__(self, text_owner: IUITextOwnerInterface,
+                 params: Optional[Dict[str, Any]] = None,
+                 text_sub_chunk: Optional[TextLineChunkFTFont] = None):
+        super().__init__()
+        self.text_owner = text_owner
+        self.text_sub_chunk = text_sub_chunk
+        self.loop = True
+        self.frequency = 24
+        self.duration = 1.2
+        self.time_acc = 0.0
+        self.amplitude = 4
+        self.text_changed = False
+        self.text_owner.set_text_offset_pos((0, 0), self.text_sub_chunk)
+        self._load_params(params)
+
+        sample_count = int(self.duration * self.frequency)
+
+        self.x_samples = []
+        for _ in range(0, sample_count):
+            self.x_samples.append(random.uniform(-1.0, 1.0))
+
+        self.y_samples = []
+        for _ in range(0, sample_count):
+            self.y_samples.append(random.uniform(-1.0, 1.0))
+
+        self.shake = (0, 0)
+
+    def _load_params(self, params: Optional[Dict[str, Any]]):
+        if params is not None:
+            if 'loop' in params:
+                if isinstance(params['loop'], bool):
+                    self.loop = params['loop']
+                elif isinstance(params['loop'], str):
+                    self.loop = bool(int(params['loop']))
+                else:
+                    self.loop = bool(params['loop'])
+            if 'frequency' in params:
+                self.frequency = int(params['frequency'])
+            if 'amplitude' in params:
+                self.amplitude = int(params['amplitude'])
+            if 'duration' in params:
+                self.duration = float(params['duration'])
+
+    def _decay(self, time_passed: float):
+        return min(1.0, max((self.duration - time_passed) / self.duration, 0.0))
+
+    def _x_noise(self, index):
+        if index >= len(self.x_samples):
+            return 0
+        return self.x_samples[index]
+
+    def _y_noise(self, index):
+        if index >= len(self.y_samples):
+            return 0
+        return self.y_samples[index]
+
+    def _x_amplitude(self, time_passed: float):
+
+        s = time_passed * self.frequency
+        s0 = int(s)
+        s1 = s0 + 1
+
+        k = 1.0 if self.loop else self._decay(time_passed)
+
+        return (self._x_noise(s0) + (s - s0) * (self._x_noise(s1) - self._x_noise(s0))) * k
+
+    def _y_amplitude(self, time_passed: float):
+
+        s = time_passed * self.frequency
+        s0 = int(s)
+        s1 = s0 + 1
+
+        k = 1.0 if self.loop else self._decay(time_passed)
+
+        return (self._y_noise(s0) + (s - s0) * (self._y_noise(s1) - self._y_noise(s0))) * k
+
+    def update(self, time_delta: float):
+        """
+        Updates the effect with amount of time passed since the last call to update.
+
+        :param time_delta: time in seconds since last frame.
+        """
+        self.time_acc += time_delta
+        if self.time_acc < self.duration:
+            new_shake = (int(self._x_amplitude(self.time_acc) * self.amplitude),
+                         int(self._y_amplitude(self.time_acc) * self.amplitude))
+            if new_shake != self.shake:
+                self.shake = new_shake
+                self.text_changed = True
+        elif self.loop:
+            self.time_acc = 0.0
+        else:
+            # finished effect
+            self.text_owner.stop_finished_effect(self.text_sub_chunk)
+
+            event_data = {'ui_element': self.text_owner,
+                          'ui_object_id': self.text_owner.get_object_id(),
+                          'effect': TEXT_EFFECT_SHAKE}
+            if self.text_sub_chunk is not None:
+                event_data['effect_tag_id'] = self.text_sub_chunk.effect_id
+            pygame.event.post(pygame.event.Event(UI_TEXT_EFFECT_FINISHED, event_data))
+
+    def has_text_changed(self) -> bool:
+        """
+        Lets us know when the effect has changed enough to warrant us redrawing the text.
+
+        :return: True if it is is time to redraw our text.
+        """
+        if self.text_changed:
+            self.text_changed = False
+            return True
+        else:
+            return False
+
+    def apply_effect(self):
+        """
+        Apply the effect to the text
+        """
+        self.text_owner.set_text_offset_pos(self.shake, self.text_sub_chunk)
diff --git a/pygame_gui/core/text/text_effects/tilt_effect.py b/pygame_gui/core/text/text_effects/tilt_effect.py
index 0831ed9..fe56bb2 100644
--- a/pygame_gui/core/text/text_effects/tilt_effect.py
+++ b/pygame_gui/core/text/text_effects/tilt_effect.py
@@ -44,7 +44,7 @@ class TiltEffect(TextEffect):
 
     def update(self, time_delta: float):
         """
-        Updates the fade with amount of time passed since the last call to update.
+        Updates the effect with amount of time passed since the last call to update.
 
         :param time_delta: time in seconds since last frame.
         """
@@ -74,8 +74,7 @@ class TiltEffect(TextEffect):
 
     def has_text_changed(self) -> bool:
         """
-        Lets us know when the fade alpha has changed enough (i.e. by a whole int) to warrant us
-        redrawing the text box with the new alpha value.
+        Lets us know when the effect has changed enough to warrant us redrawing the text.
 
         :return: True if it is is time to redraw our text.
         """
diff --git a/pygame_gui/elements/ui_text_box.py b/pygame_gui/elements/ui_text_box.py
index b6773b2..b7673c2 100644
--- a/pygame_gui/elements/ui_text_box.py
+++ b/pygame_gui/elements/ui_text_box.py
@@ -9,7 +9,7 @@ from pygame_gui.core import ObjectID
 from pygame_gui._constants import UI_TEXT_BOX_LINK_CLICKED, OldType, UITextEffectType
 from pygame_gui._constants import TEXT_EFFECT_TYPING_APPEAR, TEXT_EFFECT_TILT
 from pygame_gui._constants import TEXT_EFFECT_FADE_IN, TEXT_EFFECT_FADE_OUT, TEXT_EFFECT_BOUNCE
-from pygame_gui._constants import TEXT_EFFECT_EXPAND_CONTRACT
+from pygame_gui._constants import TEXT_EFFECT_EXPAND_CONTRACT, TEXT_EFFECT_SHAKE
 
 from pygame_gui.core.utility import translate
 from pygame_gui.core.interfaces import IContainerLikeInterface, IUIManagerInterface
@@ -24,7 +24,7 @@ from pygame_gui.core.text.html_parser import HTMLParser
 from pygame_gui.core.text.text_box_layout import TextBoxLayout
 from pygame_gui.core.text.text_line_chunk import TextLineChunkFTFont
 from pygame_gui.core.text.text_effects import TextEffect, TypingAppearEffect, FadeInEffect, FadeOutEffect
-from pygame_gui.core.text.text_effects import BounceEffect, TiltEffect, ExpandContractEffect
+from pygame_gui.core.text.text_effects import BounceEffect, TiltEffect, ExpandContractEffect, ShakeEffect
 
 
 class UITextBox(UIElement, IUITextOwnerInterface):
@@ -962,13 +962,15 @@ class UITextBox(UIElement, IUITextOwnerInterface):
                                 effect = TiltEffect(self, params, chunk)
                             elif effect_type == TEXT_EFFECT_EXPAND_CONTRACT:
                                 effect = ExpandContractEffect(self, params, chunk)
+                            elif effect_type == TEXT_EFFECT_SHAKE:
+                                effect = ShakeEffect(self, params, chunk)
                             else:
                                 warnings.warn('Unsupported effect name: ' + str(
                                     effect_type) + ' for text chunk')
-                            chunk.grab_pre_effect_surface()
-                            self.active_text_chunk_effects.append({'chunk': chunk,
-                                                                   'effect': effect})
                             if effect is not None:
+                                chunk.grab_pre_effect_surface()
+                                self.active_text_chunk_effects.append({'chunk': chunk,
+                                                                       'effect': effect})
                                 effect.text_changed = True
                                 self.update_text_effect(0.0)
         else:
diff --git a/setup.py b/setup.py
index 5aa3b60..9698584 100644
--- a/setup.py
+++ b/setup.py
@@ -21,6 +21,7 @@ setup(
                 'pygame_gui.core.interfaces',
                 'pygame_gui.core.drawable_shapes',
                 'pygame_gui.core.text',
+                'pygame_gui.core.text.text_effects',
                 'pygame_gui.elements',
                 'pygame_gui.windows',
                 'pygame_gui.data',


Test patch:
diff --git a/tests/test_core/test_text/test_text_effects.py b/tests/test_core/test_text/test_text_effects.py
index 90ff581..b32e8f4 100644
--- a/tests/test_core/test_text/test_text_effects.py
+++ b/tests/test_core/test_text/test_text_effects.py
@@ -5,11 +5,13 @@ import pytest
 from pygame_gui.core.text import TypingAppearEffect, FadeInEffect, FadeOutEffect, TextEffect
 from pygame_gui.core.text import TextLineChunkFTFont
 from pygame_gui.core.text.text_effects import BounceEffect, TiltEffect, ExpandContractEffect
+from pygame_gui.core.text.text_effects import ShakeEffect
 from pygame_gui.ui_manager import UIManager
 from pygame_gui.elements.ui_text_box import UITextBox
 from pygame_gui.elements.ui_label import UILabel
 from pygame_gui import TEXT_EFFECT_FADE_OUT, TEXT_EFFECT_FADE_IN, TEXT_EFFECT_TYPING_APPEAR
 from pygame_gui import TEXT_EFFECT_BOUNCE, TEXT_EFFECT_TILT, TEXT_EFFECT_EXPAND_CONTRACT
+from pygame_gui import TEXT_EFFECT_SHAKE
 from pygame_gui import UITextEffectType, UI_TEXT_EFFECT_FINISHED
 
 
@@ -746,18 +748,162 @@ class TestExpandContractEffect:
                 assert event.effect == TEXT_EFFECT_EXPAND_CONTRACT
 
 
+class TestShakeEffect:
+    def test_creation(self, _init_pygame, default_ui_manager: UIManager):
+        text_box = UITextBox('<effect id=test>Hello world</effect>',
+                             pygame.Rect((10, 10), (200, 100)),
+                             default_ui_manager)
+
+        with pytest.warns(UserWarning, match="Unsupported effect name: shake for whole text box"):
+            text_box.set_active_effect(TEXT_EFFECT_SHAKE)
+
+        text_box.set_active_effect(TEXT_EFFECT_SHAKE, effect_tag='test')
+
+        assert isinstance(text_box.active_text_chunk_effects[0]['effect'], ShakeEffect)
+
+    def test_switching_from_full_to_tagged(self, _init_pygame, default_ui_manager: UIManager):
+
+        text_box = UITextBox('<effect id=test>Hello world</effect>',
+                             pygame.Rect((10, 10), (200, 100)),
+                             default_ui_manager)
+
+        text_box.set_active_effect(TEXT_EFFECT_FADE_IN)
+
+        text_box.update(0.02)
+
+        text_box.set_active_effect(TEXT_EFFECT_SHAKE, effect_tag='test')
+
+        text_box.update(0.02)
+
+        assert isinstance(text_box.active_text_chunk_effects[0]['effect'], ShakeEffect)
+
+    def test_update(self, _init_pygame, default_ui_manager: UIManager):
+        text_box = UITextBox('<effect id=test>Hello world</effect>',
+                             pygame.Rect((10, 10), (200, 100)),
+                             default_ui_manager)
+
+        text_box.set_active_effect(TEXT_EFFECT_SHAKE, effect_tag='test')
+
+        effect = text_box.active_text_chunk_effects[0]['effect']
+
+        assert effect.time_acc == 0.0
+
+        effect.update(time_delta=0.06)
+        effect.update(time_delta=0.06)
+
+        assert effect.time_acc == 0.12
+
+        text_box = UITextBox('<effect id=test>Hello world</effect>',
+                             pygame.Rect((10, 10), (200, 100)),
+                             default_ui_manager)
+
+        text_box.set_active_effect(TEXT_EFFECT_SHAKE, params={'loop': "1"}, effect_tag='test')
+        text_box.set_active_effect(TEXT_EFFECT_SHAKE, params={'loop': 1}, effect_tag='test')
+
+        effect = text_box.active_text_chunk_effects[0]['effect']
+
+        assert effect.time_acc == 0.0
+
+        effect.update(time_delta=0.59)
+        effect.update(time_delta=0.59)
+        effect.update(time_delta=0.5)
+
+        assert effect.time_acc == 0.0
+
+    def test_has_text_changed(self, _init_pygame, default_ui_manager: UIManager):
+        text_box = UITextBox('<effect id=test>Hello world</effect>',
+                             pygame.Rect((10, 10), (200, 100)),
+                             default_ui_manager)
+
+        text_box.set_active_effect(TEXT_EFFECT_SHAKE, effect_tag='test')
+        effect: ShakeEffect = text_box.active_text_chunk_effects[0]['effect']
+
+        assert not effect.has_text_changed()
+
+        old_shake = effect.shake
+        effect.update(time_delta=0.6)
+
+        if effect.shake != old_shake:
+            assert effect.has_text_changed()
+
+        effect.update(time_delta=0.06)
+        effect.update(time_delta=0.06)
+        effect.update(time_delta=0.06)
+        effect.update(time_delta=0.06)
+        effect.update(time_delta=0.06)
+
+    def test_apply_effect(self, _init_pygame, default_ui_manager: UIManager):
+        text_box = UITextBox('<effect id=test>Hello world</effect>',
+                             pygame.Rect((10, 10), (200, 100)),
+                             default_ui_manager)
+
+        text_box.set_active_effect(TEXT_EFFECT_SHAKE, effect_tag='test')
+        effect = text_box.active_text_chunk_effects[0]['effect']
+        chunk = text_box.active_text_chunk_effects[0]['chunk']
+
+        assert chunk.effects_offset_pos == effect.shake
+
+        effect.update(time_delta=0.06)
+        effect.update(time_delta=0.06)
+        effect.apply_effect()
+
+        assert chunk.effects_offset_pos == effect.shake
+
+    def test_params(self, _init_pygame, default_ui_manager: UIManager):
+        text_box = UITextBox('<effect id=test>Hello world</effect>',
+                             pygame.Rect((10, 10), (200, 100)),
+                             default_ui_manager)
+
+        text_box.set_active_effect(TEXT_EFFECT_SHAKE,
+                                   params={'loop': False,
+                                           'frequency': 10,
+                                           'amplitude': 9,
+                                           'duration': 19.0},
+                                   effect_tag='test')
+
+        assert isinstance(text_box.active_text_chunk_effects[0]['effect'], ShakeEffect)
+
+    def test_finish_effect(self, _init_pygame, default_ui_manager: UIManager):
+        text_box = UITextBox('<effect id=test>Hello world</effect> other <effect id=test2>text</effect>',
+                             pygame.Rect((10, 10), (200, 100)),
+                             default_ui_manager)
+
+        text_box.set_active_effect(TEXT_EFFECT_SHAKE, effect_tag='test',
+                                   params={'loop': False})
+        text_box.set_active_effect(TEXT_EFFECT_SHAKE, effect_tag='test2',
+                                   params={'loop': False})
+        effect: BounceEffect = text_box.active_text_chunk_effects[0]['effect']
+
+        assert not effect.has_text_changed()
+
+        effect.update(time_delta=0.06)
+        effect.update(time_delta=20.0)
+        effect.update(time_delta=0.06)
+
+        for event in pygame.event.get():
+            if event.type == UI_TEXT_EFFECT_FINISHED:
+                assert event.effect == TEXT_EFFECT_SHAKE
+
+        text_box.set_active_effect(TEXT_EFFECT_SHAKE, effect_tag='test',
+                                   params={'loop': False})
+
+        text_box.clear_all_active_effects()
+
+        assert text_box.active_text_effect is None
+
+
 class TestTextEffectType:
 
-    def basic_tests(self, _init_pygame):
+    def test_basic_tests(self, _init_pygame):
         test_effect_type = UITextEffectType('test_effect')
 
         assert 'this is test_effect' == 'this is ' + test_effect_type
 
         assert 'test_effect this is' == test_effect_type + ' this is'
 
-        assert 'text_effect' == test_effect_type
+        assert 'test_effect' == test_effect_type
 
-        assert 'text_effect' == str(test_effect_type)
+        assert 'test_effect' == str(test_effect_type)
 
         with pytest.raises(AttributeError, match="Can't append to anything other than a string"):
             test_effect_type + 5