Skip to content

Commit

Permalink
Added: Enhance MatchingPairs game with tutorial overlay (#1416)
Browse files Browse the repository at this point in the history
* feat: Allow Button to be clicked more than once if the `clickOnce` prop is set to `false` (`true` by default)

* feat: Add Overlay component with customizable content and close functionality

* feat: Integrate MSW for API mocking in Storybook and update MatchingPairs story to use handlers

* feat: Add tutorial messages to MatchingPairs game and integrate tutorial overlay in UI

* style: Decrease overlay's background color's opacity to increase visibility of game behind it.

* style: Adjust Overlay component width and body margin for improved layout

* test: Add unit tests for Overlay component functionality and rendering

* test: Add tutorial overlay integration tests for MatchingPairs component

* test: Enable previously skipped tests for MatchingPairs component and refactor state initialization

* refactor: Remove in-between turns display from MatchingPairs component

* fix: Fix checking if board is empty

* style: Update Overlay component styles and button text for improved visibility and clarity (dark opaque background with white text instead of a white dialog with black text)

* fix: Correct condition for checking 'seen' status in MatchingPairsGame scoring logic

* style: Update transition effects in Overlay component for smoother animations

* refactor: Turn off tutorial in matching pairs lite game

* lint: Update string quotes for consistency in MatchingPairsICMPC class and remove unused import

* feat: Add MatchingPairs2025 class with tutorial messages and docstrings

* fix: Explicitly set tutorial to None in MatchingPairsGame class

* feat: Add overlay visibility tracking to MatchingPairs game logic

* fix: Remove unused overlay visibility tracking in MatchingPairsGame class as the information was already being stored and this was causing an error

* test: Add test for "overlay_was_shown" value in a result's json data
  • Loading branch information
drikusroor authored Dec 16, 2024
1 parent dc3ca49 commit 4688aab
Show file tree
Hide file tree
Showing 22 changed files with 2,135 additions and 217 deletions.
87 changes: 44 additions & 43 deletions backend/experiment/actions/playback.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,47 @@
from section.validators import audio_extensions

# player types
TYPE_AUTOPLAY = 'AUTOPLAY'
TYPE_BUTTON = 'BUTTON'
TYPE_IMAGE = 'IMAGE'
TYPE_MULTIPLAYER = 'MULTIPLAYER'
TYPE_MATCHINGPAIRS = 'MATCHINGPAIRS'
TYPE_AUTOPLAY = "AUTOPLAY"
TYPE_BUTTON = "BUTTON"
TYPE_IMAGE = "IMAGE"
TYPE_MULTIPLAYER = "MULTIPLAYER"
TYPE_MATCHINGPAIRS = "MATCHINGPAIRS"

# playback methods
PLAY_EXTERNAL = 'EXTERNAL'
PLAY_HTML = 'HTML'
PLAY_BUFFER = 'BUFFER'
PLAY_NOAUDIO = 'NOAUDIO'
PLAY_EXTERNAL = "EXTERNAL"
PLAY_HTML = "HTML"
PLAY_BUFFER = "BUFFER"
PLAY_NOAUDIO = "NOAUDIO"


class Playback(BaseAction):
''' A playback base class for different kinds of players
- sections: a list of sections (in many cases, will only contain *one* section)
- preload_message: text to display during preload
- instruction: text to display during presentation of the sound
- play_from: where in the audio file to start playing/
- show_animation: whether to show animations with this player
- mute: whether to mute the audio
- timeout_after_playback: once playback has finished, add optional timeout (in seconds) before proceeding
- stop_audio_after: stop playback after so many seconds
- resume_play: if the playback should resume from where a previous view left off
'''
"""A playback base class for different kinds of players
- sections: a list of sections (in many cases, will only contain *one* section)
- preload_message: text to display during preload
- instruction: text to display during presentation of the sound
- play_from: where in the audio file to start playing/
- show_animation: whether to show animations with this player
- mute: whether to mute the audio
- timeout_after_playback: once playback has finished, add optional timeout (in seconds) before proceeding
- stop_audio_after: stop playback after so many seconds
- resume_play: if the playback should resume from where a previous view left off
"""

def __init__(
self,
sections,
preload_message='',
instruction='',
preload_message="",
instruction="",
play_from=0,
show_animation=False,
mute=False,
timeout_after_playback=None,
stop_audio_after=None,
resume_play=False,
style=FrontendStyle()
style=FrontendStyle(),
tutorial: Dict | None = None,
):
self.sections = [{'id': s.id, 'url': s.absolute_url(), 'group': s.group}
for s in sections]
self.sections = [{"id": s.id, "url": s.absolute_url(), "group": s.group} for s in sections]
self.play_method = determine_play_method(sections[0])
self.show_animation = show_animation
self.preload_message = preload_message
Expand All @@ -56,24 +56,25 @@ def __init__(
self.stop_audio_after = stop_audio_after
self.resume_play = resume_play
self.style = style
self.tutorial = tutorial


class Autoplay(Playback):
'''
"""
This player starts playing automatically
- show_animation: if True, show a countdown and moving histogram
'''
"""

def __init__(self, sections, **kwargs):
super().__init__(sections, **kwargs)
self.ID = TYPE_AUTOPLAY


class PlayButton(Playback):
'''
"""
This player shows a button, which triggers playback
- play_once: if True, button will be disabled after one play
'''
"""

def __init__(self, sections, play_once=False, **kwargs):
super().__init__(sections, **kwargs)
Expand All @@ -82,11 +83,11 @@ def __init__(self, sections, play_once=False, **kwargs):


class Multiplayer(PlayButton):
'''
"""
This is a player with multiple play buttons
- stop_audio_after: after how many seconds to stop audio
- labels: pass list of strings if players should have custom labels
'''
"""

def __init__(
self,
Expand All @@ -102,50 +103,50 @@ def __init__(
self.style = style
if labels:
if len(labels) != len(self.sections):
raise UserWarning(
'Number of labels and sections for the play buttons do not match')
raise UserWarning("Number of labels and sections for the play buttons do not match")
self.labels = labels


class ImagePlayer(Multiplayer):
'''
"""
This is a special case of the Multiplayer:
it shows an image next to each play button
'''
"""

def __init__(self, sections, images, image_labels=[], **kwargs):
super().__init__(sections, **kwargs)
self.ID = TYPE_IMAGE
if len(images) != len(self.sections):
raise UserWarning(
'Number of images and sections for the ImagePlayer do not match')
raise UserWarning("Number of images and sections for the ImagePlayer do not match")
self.images = images
if image_labels:
if len(image_labels) != len(self.sections):
raise UserWarning(
'Number of image labels and sections do not match')
raise UserWarning("Number of image labels and sections do not match")
self.image_labels = image_labels


class MatchingPairs(Multiplayer):
'''
"""
This is a special case of multiplayer:
play buttons are represented as cards
- sections: a list of sections (in many cases, will only contain *one* section)
- score_feedback_display: how to display the score feedback (large-top, small-bottom-right, hidden)
'''
"""

def __init__(self, sections: List[Dict], score_feedback_display: str = 'large-top', **kwargs):
def __init__(
self, sections: List[Dict], score_feedback_display: str = "large-top", tutorial: Dict | None = None, **kwargs
):
super().__init__(sections, **kwargs)
self.ID = TYPE_MATCHINGPAIRS
self.score_feedback_display = score_feedback_display
self.tutorial = tutorial


def determine_play_method(section):
filename = str(section.filename)
if not is_audio_file(filename):
return PLAY_NOAUDIO
elif filename.startswith('http'):
elif filename.startswith("http"):
return PLAY_EXTERNAL
elif section.duration > 45:
return PLAY_HTML
Expand Down
2 changes: 2 additions & 0 deletions backend/experiment/rules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .matching_pairs_fixed import MatchingPairsFixed
from .matching_pairs_lite import MatchingPairsLite
from .matching_pairs_icmpc import MatchingPairsICMPC
from .matching_pairs_2025 import MatchingPairs2025
from .musical_preferences import MusicalPreferences
from .rhythm_battery_final import RhythmBatteryFinal
from .rhythm_battery_intro import RhythmBatteryIntro
Expand Down Expand Up @@ -59,6 +60,7 @@
MatchingPairsGame.ID: MatchingPairsGame,
MatchingPairsLite.ID: MatchingPairsLite,
MatchingPairsICMPC.ID: MatchingPairsICMPC,
MatchingPairs2025.ID: MatchingPairs2025,
MusicalPreferences.ID: MusicalPreferences,
RhythmBatteryFinal.ID: RhythmBatteryFinal,
RhythmBatteryIntro.ID: RhythmBatteryIntro,
Expand Down
8 changes: 5 additions & 3 deletions backend/experiment/rules/matching_pairs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.utils.translation import gettext_lazy as _

from .base import Base
from experiment.actions import Consent, Explainer, Final, Playlist, Step, Trial
from experiment.actions import Explainer, Final, Playlist, Step, Trial
from experiment.actions.playback import MatchingPairs
from result.utils import prepare_result

Expand All @@ -17,6 +17,7 @@ class MatchingPairsGame(Base):
num_pairs = 8
show_animation = True
score_feedback_display = "large-top"
tutorial = None
contact_email = "aml.tunetwins@gmail.com"
random_seed = None

Expand Down Expand Up @@ -121,6 +122,7 @@ def get_matching_pairs_trial(self, session):
stop_audio_after=5,
show_animation=self.show_animation,
score_feedback_display=self.score_feedback_display,
tutorial=self.tutorial,
)
trial = Trial(title="Tune twins", playback=playback, feedback_form=None, config={"show_continue_button": False})
return trial
Expand All @@ -139,14 +141,14 @@ def calculate_intermediate_score(self, session, result):
second_section = Section.objects.get(pk=second_card["id"])
second_card["filename"] = str(second_section.filename)
if first_section.group == second_section.group:
if "seen" in second_card:
if "seen" in second_card and second_card["seen"]:
score = 20
given_response = "match"
else:
score = 10
given_response = "lucky match"
else:
if "seen" in second_card:
if "seen" in second_card and second_card["seen"]:
score = -10
given_response = "misremembered"
else:
Expand Down
28 changes: 28 additions & 0 deletions backend/experiment/rules/matching_pairs_2025.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.utils.translation import gettext_lazy as _

from .matching_pairs import MatchingPairsGame


class MatchingPairs2025(MatchingPairsGame):
"""This is the working version of the Matching Pairs game for the 2025 Tunetwins experiment. The difference between this version and the original Matching Pairs game is that this version has some additional tutorial messages. These messages are intended to help the user understand the game. The tutorial messages are as follows:
- no_match: This was not a match, so you get 0 points. Please try again to see if you can find a matching pair.
- lucky_match: You got a matching pair, but you didn't hear both cards before. This is considered a lucky match. You get 10 points.
- memory_match: You got a matching pair. You get 20 points.
- misremembered: You thought you found a matching pair, but you didn't. This is considered a misremembered pair. You lose 10 points.
The tutorial messages are displayed to the user in an overlay on the game screen.
"""

ID = "MATCHING_PAIRS_2025"
tutorial = {
"no_match": _(
"This was not a match, so you get 0 points. Please try again to see if you can find a matching pair."
),
"lucky_match": _(
"You got a matching pair, but you didn't hear both cards before. This is considered a lucky match. You get 10 points."
),
"memory_match": _("You got a matching pair. You get 20 points."),
"misremembered": _(
"You thought you found a matching pair, but you didn't. This is considered a misremembered pair. You lose 10 points."
),
}
5 changes: 2 additions & 3 deletions backend/experiment/rules/matching_pairs_icmpc.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from django.utils.translation import gettext_lazy as _

from .matching_pairs import MatchingPairsGame
from experiment.actions.form import TextQuestion


class MatchingPairsICMPC(MatchingPairsGame):
ID = 'MATCHING_PAIRS_ICMPC'
ID = "MATCHING_PAIRS_ICMPC"

def __init__(self):
super().__init__()
self.question_series[0]['keys'].append('fame_name')
self.question_series[0]["keys"].append("fame_name")
27 changes: 11 additions & 16 deletions backend/experiment/rules/matching_pairs_lite.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,28 @@


class MatchingPairsLite(MatchingPairsGame):
ID = 'MATCHING_PAIRS_LITE'
ID = "MATCHING_PAIRS_LITE"
num_pairs = 8
show_animation = False
score_feedback_display = 'small-bottom-right'
contact_email = 'aml.tunetwins@gmail.com'
score_feedback_display = "small-bottom-right"
contact_email = "aml.tunetwins@gmail.com"

def next_round(self, session):
playlist = Playlist(session.block.playlists.all())
info = Info('',
heading='Press start to enter the game',
button_label='Start')
info = Info("", heading="Press start to enter the game", button_label="Start")
if session.get_rounds_passed() < 1:
trial = self.get_matching_pairs_trial(session)
return [playlist, info, trial]
else:
return final_action_with_optional_button(session, final_text='End of the game', title='Score', button_text='Back to dashboard')
return final_action_with_optional_button(
session, final_text="End of the game", title="Score", button_text="Back to dashboard"
)

def select_sections(self, session):
pairs = list(session.playlist.section_set.order_by().distinct(
'group').values_list('group', flat=True))
selected_pairs = pairs[:self.num_pairs]
originals = session.playlist.section_set.filter(
group__in=selected_pairs, tag='Original'
)
degradations = session.playlist.section_set.exclude(tag='Original').filter(
group__in=selected_pairs
)
pairs = list(session.playlist.section_set.order_by().distinct("group").values_list("group", flat=True))
selected_pairs = pairs[: self.num_pairs]
originals = session.playlist.section_set.filter(group__in=selected_pairs, tag="Original")
degradations = session.playlist.section_set.exclude(tag="Original").filter(group__in=selected_pairs)
if degradations:
sections = list(originals) + list(degradations)
return self.shuffle_sections(sections)
Expand Down
Loading

0 comments on commit 4688aab

Please sign in to comment.