Forum Discussion

Jonathan_Hill's avatar
Jonathan_Hill
Super Hero
22 days ago

'Cup and Ball' game using sliders with dynamic Z-index control

Hello!

For a little while now, I've been experimenting with customised sliders as a more accessible alternative to drag-and-drop.

By changing the appearance of the slider thumb and making the track invisible, I've created everything from claw machines, to catapults  and animated superhero characters

Believe it or not, this 'Cup and Ball' game is also built using sliders... and a bit of Javascript:

Play Beat the Machines here

Why sliders?


Sliders are one of the most versatile, accessible, and customisable objects in Storyline.  Straight out of the box, they come with:

  • A built-in variable
  • Key press controls (tab and arrow keys)
  • State changes (including Disabled and Hidden)
  • Fill, outline and transparency settings

and they can also be animated with Entrance and Exit animations and motion paths.

However, despite this level of customisation, using more than one slider in the same area can be tricky, as the thumb or track can block the elements beneath them, even when they are invisible. This is why, until now, I have only ever used one slider at a time.

But then I discovered that it was possible to control the z-index of sliders using Javascript, to move the active slider above the other sliders.

Even pirates have table manners

Recently, I used this technique to change the relative position three sliders for the E-Learning Heroes 'How to Set a Dinner Table Challenge'. You can read more about this here.  (Thanks again for your help, Nedim!) 

Controlling the z-index of each slider means the goblet always passes in front of the knife and fork when it is dragged

Cups and balls

This demo required a slightly different approach, as the three items are identical in size and shape and at times can completely overlap each other.

This reduced the 'grabbable' area of each thumb as you can see below:

To mitigate this, this code manages the z-index of the three sliders, ensuring the one being hovered over or touched appears on top of the others.

const object1 = document.querySelector("[data-model-id='6RsJm5AiIs6']"); const object2 = document.querySelector("[data-model-id='5aVi8WZUooi']"); const object3 = document.querySelector("[data-model-id='6regUP43dQ1']"); function bringToFront(element) { const elements = [object1, object2, object3]; elements.forEach(obj => { obj.style.setProperty("z-index", "100", "important") }); element.style.setProperty("z-index", "300", "important"); } // Add hover/touch listeners to each slider [object1, object2, object3].forEach(slider => { // For mouse users slider.addEventListener('mouseover', () => bringToFront(slider)); // For touch devices slider.addEventListener('touchstart', () => bringToFront(slider)); }); // Initial setup bringToFront(object1);

And this code also ensures that when each slider is active (being clicked, grabbed and moved) it jumps to the front:

const object1 = document.querySelector("[data-model-id='6RsJm5AiIs6']"); const object2 = document.querySelector("[data-model-id='5aVi8WZUooi']"); const object3 = document.querySelector("[data-model-id='6regUP43dQ1']"); function bringToFront(element) { const elements = [object1, object2, object3]; elements.forEach(obj => { obj.style.setProperty("z-index", "100", "important"); }); element.style.setProperty("z-index", "300", "important"); } bringToFront(object1);

This lends the appearance of the cups passing in front of one another when dragged:

This works a little better on a computer with a mouse than with a touchscreen, but on both platforms it's no 'stickier' than a traditional drag-and-drop.

Collision detection

So, you may have noticed that it's pretty much impossible to place the three cups on top of each other. This is because the position of each slider is being constantly monitored by this code, which then amends the position of each slider as necessary:

let activeSlider = null;
let lastMovedSlider = null;
let moveCount = 0;
let checkInterval = null;
const MAX_MOVES = 3;
const MIN_SPACING = 2;

function resetState() {
    activeSlider = null;
    lastMovedSlider = null;
    moveCount = 0;
    if (checkInterval) {
        clearInterval(checkInterval);
    }
    checkInterval = setInterval(checkPartialOverlaps, 10);
}

function cleanup() {
    if (checkInterval) {
        clearInterval(checkInterval);
        checkInterval = null;
    }
    activeSlider = null;
    lastMovedSlider = null;
    moveCount = 0;
}

function validatePositions(positions) {
    for(let i = 1; i < positions.length; i++) {
        if(Math.abs(positions[i].pos - positions[i-1].pos) < MIN_SPACING) {
            return false;
        }
    }
    return true;
}

function findEmptySpaces(positions) {
    let spaces = [];
    for(let i = 0; i <= 10; i++) {
        if(!positions.find(p => p.pos === i)) {
            spaces.push(i);
        }
    }
    return spaces;
}

function adjustPosition(position, direction, min, max) {
    return Math.max(min, Math.min(max, position + (direction === 'Right' ? 2 : -2)));
}

function checkPartialOverlaps() {
    if (!activeSlider) {
        moveCount = 0;
        const slider1 = GetPlayer().GetVar("Slider1");
        const slider2 = GetPlayer().GetVar("Slider2");
        const slider3 = GetPlayer().GetVar("Slider3");
        const direction = GetPlayer().GetVar("Direction");

        let positions = [
            {id: 1, pos: slider1, lastMoved: lastMovedSlider === 1},
            {id: 2, pos: slider2, lastMoved: lastMovedSlider === 2},
            {id: 3, pos: slider3, lastMoved: lastMovedSlider === 3}
        ].sort((a, b) => a.pos - b.pos);

        for(let i = 1; i < positions.length; i++) {
            if(Math.abs(positions[i].pos - positions[i-1].pos) < MIN_SPACING && moveCount < MAX_MOVES) {
                moveCount++;
                const displacedSlider = !positions[i].lastMoved ? positions[i] : positions[i-1];
                
                let newPos = displacedSlider.pos;
                if(direction === "Right") {
                    newPos = Math.max(0, displacedSlider.pos - MIN_SPACING);
                } else {
                    newPos = Math.min(10, displacedSlider.pos + MIN_SPACING);
                }

                let testPositions = positions.map(p => ({...p}));
                const sliderIndex = testPositions.findIndex(p => p.id === displacedSlider.id);
                testPositions[sliderIndex].pos = newPos;
                testPositions.sort((a, b) => a.pos - b.pos);

                if(validatePositions(testPositions)) {
                    GetPlayer().SetVar(`Slider${displacedSlider.id}`, newPos);
                    GetPlayer().SetVar("Teeter", "Item " + displacedSlider.id);
                    return;
                }

                if(positions[2].pos >= 9 || positions[0].pos <= 1) {
                    const inactiveSliders = positions.filter(p => !p.lastMoved);
                    let validMove = true;
                    
                    inactiveSliders.forEach((slider, idx) => {
                        const newPosition = direction === "Right" ? 
                            Math.max(0, slider.pos - MIN_SPACING) : 
                            Math.min(10, slider.pos + MIN_SPACING);
                            
                        let testPositions = positions.map(p => ({...p}));
                        const index = testPositions.findIndex(p => p.id === slider.id);
                        testPositions[index].pos = newPosition;
                        testPositions.sort((a, b) => a.pos - b.pos);
                        
                        if(validatePositions(testPositions)) {
                            GetPlayer().SetVar(`Slider${slider.id}`, newPosition);
                            GetPlayer().SetVar("Teeter", "Item " + slider.id);
                        } else {
                            validMove = false;
                        }
                    });
                    
                    if(validMove) return;
                }
            }
        }
    }
}

document.addEventListener('mousedown', (e) => {
    const slider = e.target.closest('[data-model-id]');
    if (slider) {
        activeSlider = parseInt(slider.getAttribute('data-model-id').slice(-1));
        lastMovedSlider = activeSlider;
    }
});

document.addEventListener('mouseup', () => {
    activeSlider = null;
    checkPartialOverlaps();
});

// Reset state when slide loads
resetState();

// Cleanup when slide unloads/revisits
window.addEventListener('unload', cleanup);

// Listen for slide revisits
const slideObserver = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
        if (mutation.target.style.display === 'block') {
            resetState();
        }
    });
});

slideObserver.observe(document.querySelector('.slide-container'), {
    attributes: true,
    attributeFilter: ['style']

Additionally, this code runs every 0.5 secs to resolve any overlaps and 'bunching together' of the cups:

function restoreSpacing() {
    const positions = [
        {id: 1, pos: GetPlayer().GetVar("Slider1")},
        {id: 2, pos: GetPlayer().GetVar("Slider2")},
        {id: 3, pos: GetPlayer().GetVar("Slider3")}
    ];

    const needsSpacing = positions.some((p1, i) => 
        positions.some((p2, j) => i !== j && Math.abs(p1.pos - p2.pos) < 2)
    );

    if(needsSpacing) {
        const sorted = [...positions].sort((a, b) => a.pos - b.pos);
        
        sorted[0].pos = Math.max(0, sorted[0].pos);
        sorted[1].pos = Math.min(8, Math.max(sorted[0].pos + 2, sorted[1].pos));
        sorted[2].pos = Math.min(10, Math.max(sorted[1].pos + 2, sorted[2].pos));

        positions.forEach(item => {
            GetPlayer().SetVar(`Slider${item.id}`, Math.round(item.pos));
        });
        GetPlayer().SetVar("Teeter", "All Items");
        
        // Reset Teeter after 500ms
        setTimeout(() => {
            GetPlayer().SetVar("Teeter", "");
        }, 500);
    }
}

setInterval(restoreSpacing, 500);

And, finally, this code monitors the direction the active slider is travelling in and also counts the number of times the cups are swapped:

let lastSliderValues = {
    1: GetPlayer().GetVar("Slider1"),
    2: GetPlayer().GetVar("Slider2"),
    3: GetPlayer().GetVar("Slider3")
};

let lastSignificantPositions = {...lastSliderValues};
const MOVEMENT_THRESHOLD = 2;

function checkSliderChanges() {
    const slider1New = GetPlayer().GetVar("Slider1");
    const slider2New = GetPlayer().GetVar("Slider2");
    const slider3New = GetPlayer().GetVar("Slider3");
    
    if (slider1New !== lastSliderValues[1] ||
        slider2New !== lastSliderValues[2] ||
        slider3New !== lastSliderValues[3]) {
        
        let changedValue, oldValue, sliderId;
        
        if (slider1New !== lastSliderValues[1]) {
            changedValue = slider1New;
            oldValue = lastSliderValues[1];
            sliderId = 1;
        }
        if (slider2New !== lastSliderValues[2]) {
            changedValue = slider2New;
            oldValue = lastSliderValues[2];
            sliderId = 2;
        }
        if (slider3New !== lastSliderValues[3]) {
            changedValue = slider3New;
            oldValue = lastSliderValues[3];
            sliderId = 3;
        }

        // Only count as swap if movement exceeds threshold
        if (Math.abs(changedValue - lastSignificantPositions[sliderId]) >= MOVEMENT_THRESHOLD) {
            const currentSwaps = GetPlayer().GetVar("Number_Swaps");
            GetPlayer().SetVar("Number_Swaps", currentSwaps + 1);
            lastSignificantPositions[sliderId] = changedValue;
        }
        
        if (changedValue >= 3 && changedValue <= 6) {
            GetPlayer().SetVar("Direction", "Middle");
        } else {
            GetPlayer().SetVar("Direction", changedValue > oldValue ? "Right" : "Left");
        }
        
        lastSliderValues = {1: slider1New, 2: slider2New, 3: slider3New};
    }
}

setInterval(checkSliderChanges, 5);


Performance is variable

Knowing the position of each slider based on its underlying variable is another reason why I like using sliders in this way. I have used this information to make the robot turn its head as it tries to follow your movements and, ultimately, score your performance.

The entire demo is just three slides long, and players revisit the game slide up to three times to reach the end.

I haven't shared my master file as I am still tinkering with it, and it will probably become a portfolio piece. But it's been a while since I have used Storyline to create such a game, and I thought others would like to see what is going on under the hood.

If you have any questions at all, please ask in the comments below.

Enjoy your chips!

  • Hahahah Jonathan_Hill I'm absolutely cracking up at the storytelling behind this (and also craving potato chips, thanks for that). 

    Love this expansion on the sliders-as-drag-and-drop-workaround. Something that keeps kicking around in my brain: imagine if the sliders moved in multiple directions, not just left to right but up & down! Then you could create a grid 🤔

    • Jonathan_Hill's avatar
      Jonathan_Hill
      Super Hero

      Yeah, I find the narrative around AI a little negative and who doesn't love chips (in both the UK and US sense.) The story helps sell what is otherwise a pointless exercise with zero educational value.

      That's what the naysayers on LinkedIn will likely say if I ever share this there. But I'm convinced if we can find a fully accessible alternative to drag-and-drop, that also preserves what makes drag-and-drop so tactile and fun, we'll be onto a winner.

      • Noele_Flowers's avatar
        Noele_Flowers
        Staff

        Haha yes I see the drag and drop slider example as having perhaps a bit more clear educational applications, but I agree with you that in general something tactile like this really does give an edge to what it feels like for a learner to engage with interactive content. Applauding your progress on this and excited to chat with you next week. 

  • I'd also like to thank ChrisHodgson for playtesting this for me and highlighting that slower, more purposeful movements defeated the collision detection system. I reworked the code as a result.

    There's a 'slow knife' analogy somewhere in this story for the Dune fans among you.