Polishing our Word Puzzle Game (continued)

So, last time we improved the game we started last month with some visual interest and a pretty massive update to our dictionary of words to remove a bunch of garbage. The end result was pretty nice

But, we've still got more stuff to do since when you actually find all the words you care about, it just sort of well...

Yeah. So, today's focus will be on improving that. We'll be doing a very very minor refactor before we get into the meat of things, but don't worry, it won't take nearly as much time as the refactoring in the last post! But as always, use the nav below to skip around if there's something you're more interested in than not.

It's late, my head aches, and my apartment is 10 degrees warmer than it should be. Let's code.

Timer code refactor

As per usual, while working on the last post there were a couple things ruminating in the back of my mind as I found myself writing code. You may recall that we wrote code like this a few times:

constructor(...) {
    this.fadeInTimer = 0;
    this.maxAnimationTimeMs = 250;
    ...
}

update(cursor, elaspedMillis) {
    ...
    this.fadeInTimer += elaspedMillis;
    if (this.fadeInTimer > this.maxAnimationTimeMs) {
        this.fadeInTimer = this.maxAnimationTimeMs;
    }
}

And I wasn't going to say anything since we only did this in WordRow, LetterSelect, and in LetterButton. But, a general timer tool would be pretty handy when it comes to some of the other stuff we're going to tackle today. So let's think about what sort of timing needs we have so far:

ClassNeeds
WordRow
  • increments by millis
  • has max amount of time to tick
  • does not loop
  • get percentage (as 0.0 - 1.0)
LetterButton
  • increments by millis
  • has max amount of time to tick
  • loops back to 0 when max reached
  • get percentage (as 0.0 - 1.0)
LetterSelect
  • increments by millis
  • has max amount of time to tick
  • does not loop
  • get percentage (as 0.0 - 1.0)
  • can be reset to run again after finished

So there's obviously a lot of common ground. But there's also a pretty key difference. Looping. The simple pulsing of the letter buttons we added in is a constant thing until a user stops hovering or activating buttons. But the other two run once. Or at least, WordRow only ever runs once, and then LetterSelect runs once, but then is ready to run again the next time we try to do a selection. I think that these last two cases can be handled as one unit. Since if you just never reset the clock then you've got both. So. Let's make a little helper class and see if we can use it in our existing code to prove out its worth before we use it for a few other things.

export class Timer {
    constructor(maxTime, loopEnabled) {
        this.accumulator = 0;
        this.maxTime = maxTime;
        this.loopEnabled = loopEnabled;
    }

    progressPercentage() {
        return this.accumulator / this.maxTime;
    }

    update(elaspedTime) {
        if (!this.accumulator) {
            this.accumulator = 0;
        }
        if (this.loopEnabled) {
            if (this.accumulator >= this.maxTime) {
                this.accumulator = 0;
            }
        }
        this.accumulator += elaspedTime;
    }

    reset() {
        this.accumulator = 0;
    }
}

I wrote up this class looking at the table above, so let's see if it actually works as a replacement for those three clases that need it. The best place to start is one immediately testable: LetterButton

constructor() {
    ... these two lines 
    this.millis = 0;
    this.hoverAnimationPeriodMillis = 2000;
    ... become this 
    this.timer = new Timer(2000, true);
}

The radius method using the calculated percentage from the two previous fields needs a tweak:

radius() {
    const radius = 29;
    if (this.state === BUTTON_STATE.HOVER) {
        const dx = sinusoidal(this.timer.progressPercentage());
        const extension = 2;
        return radius + extension * dx;
    }

    if (this.state === BUTTON_STATE.ACTIVATED) {
       // dx was sinusoidal(this.millis / this.hoverAnimationPeriodMillis);
        const dx = sinusoidal(this.timer.progressPercentage());
        const extension = 1.5;
        return radius + extension * dx;
    }

    return radius;
}

And of course, for this to do anything and work as expected, we need the update method to update the timer:

// this  
update(cursor, elaspedMillis) {
    if (!this.millis) {
        this.millis = 0;
    }
    this.millis = (this.millis + elaspedMillis) % this.hoverAnimationPeriodMillis;
    ...

// becomes this
update(cursor, elaspedMillis) {
    this.timer.update(elaspedMillis);

Thankfully, this works without a problem:

But then again, it should definitely work for that one. Let's see if it works for LetterSelect. This should be a good check since unlike the LetterButton it doesn't loop and requires us using the reset method. Setting up:

// the initial code
this.matchedAnimationTimer = 0;
this.matchedAnimationTimerMaxMS = 500;
this.matchedAnimationEnabled = false;
this.matchedAnimationColor = 'white'; 

// becomes
this.timer = new Timer(500, false);
this.matchedAnimationEnabled = false;
this.matchedAnimationColor = 'white'; 

And I'm pausing already. Staring at that matchedAnimationEnabled field. I suppose we do already have everything we need, in the sense that .reset() can be called when we turn the timer off, but I kind of dislike that because looking at the other code that'll change we end up with stuff like this:

on_match(eventName, word) {
    if (eventName === EVENT_NAME.MATCHED) {
        this.matchedAnimationEnabled = true;
        this.timer.reset();
        this.matchedAnimationColor = 'white';
    }
    if (eventName === EVENT_NAME.NO_MATCH) {
        this.matchedAnimationEnabled = true;
        this.timer.reset();
        this.matchedAnimationColor = 'red';
    }
}

See how .reset() always comes when we tweak the enabled variable to true? The code in the update method is similar:

if (this.matchedAnimationEnabled) {
    this.timer.update(elaspedMillis);
    if (this.matchedAnimationTimer > this.matchedAnimationTimerMaxMS) {
        this.timer.reset();
        this.matchedAnimationEnabled = false;
    }
}

and in the draw method we have the same sort of guard before we use the percentage:

if (this.matchedAnimationEnabled) {
    // this we'll just be changing to do this.timer.getProgress()
    const percentage = this.matchedAnimationTimer / this.matchedAnimationTimerMaxMS % 1.0;

The fact that these two things are always paired together makes me wonder if it would make sense to push the "enabled" state itself into the timer. After all, I was sort of thinking about that before and the reset functionality is part of that. It makes intuitive sense to me that you could enable a timer and disable it to prevent it from accumulating for a while. The point of making the timer class isn't to jam in everything of course, it's a utility we're going to use for something else, but... staring at the class itself for a bit, I'm starting to feel like it makes sense to do this. Like, if you were to pause the game, being able to pause the timers would be good.

I've convinced myself I suppose. So, updating the timer to have an enabled field that's set to true by default, and then tweaking the update method for the timer to return if the timer isn't enabled. Also, I've fixed a bit of an error when loops are disabled, we shouldn't just let the accumulator build up time, it should cap out at the max.

 update(elaspedTime) {
    if (!this.enabled) {
        return;
    }

    if (!this.accumulator) {
        this.accumulator = 0;
    }
    if (this.loopEnabled) {
        if (this.accumulator >= this.maxTime) {
            this.accumulator = 0;
        }
    } else {
        if (this.accumulator >= this.maxTime) {
            this.accumulator = this.maxTime;
        }
    }
    this.accumulator += elaspedTime;
}

Or, should it? Should we automatically disable the timer if it's not looping and it hits the max time? Is that really the same thing as being disabled? Disabled, versus "Done" feels different. Staring at the update method in the LetterSelect for where we'd tweak the usage:

if (this.timer.enabled()) {
    this.timer.update(elaspedMillis);
    if (this.timer.progressPercentage() === 1.0) {
        this.timer.reset();
        this.timer.disable();
    }
}

What do I gain from pushing the enabled state into the timer? And am I just overcomplicating things here? Or are these questions to be solved now? I imagine that anyone reading this has probably had similar feelings before while working on something. One of those, Malcolm in the middle gif moments, where your mind is like the Dad running around to hardware stores and such to try to fix the lightbulb but ends up fixing his car's tire instead.

Akemi from Snack Basue asking if she's a dumbass.

Ahem. So anyway, I've had a night or two to sleep on this (work was busy and my mental capacity was low after hours, so I've just been playing Xenoblade a lot) and realized that I'm being a bit silly. Yes, I'm being silly. If the timer isn't set to looping mode, and it reaches the end, then it is at 100% and should stay there for any UI elements or downstream interested parties to use. Why the heck was I thinking about about if the timer should reset or not? Eh? Huh?

Anyway. While I could certainly edit all of the above and make it look like my plan was flawless from the start, I don't think that's actually interesting to anyone reading this. If I'm going to learn from my mistakes, I want you to as well just in case you were right there with me nodding along as I started walking off a cliff. So, codingwise, I want to add one more little helper to the timer:

maxTimeReached() {
    return this.progressPercentage() >= 1.0;
}

And then use that to tweak the LetterSelect code to enable and disable the timer as expected:

// in update()
if (this.timer.isEnabled()) {
    this.timer.update(elaspedMillis);
    if (this.timer.maxTimeReached()) {
        this.timer.reset();
        this.timer.disable();
    }
}

// in draw()
if (this.timer.isEnabled()) {
    const percentage = this.timer.progressPercentage();
    const gradient = ctx.createRadialGradient(
        this.x, this.y, this.backgroundRadius() * percentage, 
        this.x, this.y, this.backgroundRadius()
    );
    ...

Does it work?

LetterSelect.js:97  Uncaught TypeError: Failed to execute 'createRadialGradient' on 'CanvasRenderingContext2D': The provided double value is non-finite.
    at LetterSelect.draw (LetterSelect.js:97:34)
    at index.html:133:39
    at Array.forEach (<anonymous>)
    at draw_entities (index.html:133:22)
    at game_loop (index.html:113:13)
    at HTMLDocument.<anonymous> (index.html:259:13)
    at HTMLDocument.startMenuNewGameHandler (index.html:243:22)
    at MatchState.on_match (index.html:162:30)
    at WordRow.on_select (WordRow.js:33:41)
    at StartSelect.update (StartSelect.js:83:37)

Nope. Because... the elapsed time is occasionally NaN. Which is then added to the accumulator and then divided, which causes the progressPercentage to be NaN and the gradient to explode. Well. At least it's an easy to fix problem. We can just ignore any updates that have nonsensical elaspedTime arguments from within the Timer's update method like so:

if (isNaN(elaspedTime)) {
    return;
}

And we're back in business. Though... it appears I've found a minor bug. Notice how we see the "You Win" message, and then afterwards the white pulse of a matched letter? That's a bit odd. Since, well, LetterSelect should have been replaced by a new entity since when we start a new round we clear out the list that's running those updates and replace it with the victory screen for a bit:

const victory = new VictoryScreen(canvas);
entities.length = 0;
entities.push(victory);

And then setup_entities is called after an entire second sitting on that screen. So... Where's the on_match event coming from that's triggering this then? Well. Remember in the constructor of Timer we do this?

constructor(maxTime, loopEnabled) {
    this.accumulator = 0;
    this.maxTime = maxTime;
    this.loopEnabled = loopEnabled;
    this.enabled = true;
}

Yeah. That last line. I didn't stop and think about it. Of course the timer should be enabled when you make it! Why wouldn't it be? And, honestly, whenever I've booted up the game and dragged over the S T A R T buttons, it's been doing this gradiant pulse. But it felt good so I didn't even think twice about it.

So that's a bug as a feature!

With that, I can say that LetterSelect is refactored and happy. So just the other, slightly easier case, to deal with then: WordRow. Much like LetterButton it only has the two variables tracking its state:

this.fadeInTimer = 0;
this.maxAnimationTimeMs = 250;

So of course, both of those can be replaced with this.timer = new Timer(250, false); Then move along to its draw method, where we've got the usual replacement going on:

// const percentage = this.fadeInTimer  / this.maxAnimationTimeMs;
const percentage = this.timer.progressPercentage();

And in the update method

this.fadeInTimer += elaspedMillis;
if (this.fadeInTimer > this.maxAnimationTimeMs) {
    this.fadeInTimer = this.maxAnimationTimeMs;
}
// becomes
this.timer.update(elaspedMilli);

Which is a nice tidy update and then we can remove the two values from the class since they're unused anywhere else at this point. Right? Well. Erm. No, we've broken something again:

WordRow.js:67  Uncaught IndexSizeError: Failed to execute 'addColorStop' on 'CanvasGradient': 
    The provided value (1.0668) is outside the range (0.0, 1.0).
    at WordRow.draw (WordRow.js:67:30)
    at index.html:133:39
    at Array.forEach (<anonymous>)
    at draw_entities (index.html:133:22)
    at game_loop (index.html:113:13)

Well. We've kind of dealt with this one before in the last post. Only that time it was because our function to do the animation logic was bouncing outside the range. This time, it's our accumulated value of elapsed time. Which is then reflected in the progress percentage itself by the code:

const percentage = this.timer.progressPercentage();
if (percentage !== 1.0) {
    gradient.addColorStop(percentage - percentage / (j + 1), WORD_ROW_STYLE.LETTER.FILL);
    gradient.addColorStop(percentage, WORD_ROW_STYLE.LETTER.STROKE);
    gradient.addColorStop(1.0, "white");
    ctx.fillStyle = gradient;
} else {
    ctx.fillStyle = WORD_ROW_STYLE.LETTER.FILL;
}

Well, percentage surely isn't 1.0 when it's 1.0664000000000013. So, we can avoid the exact match and tweak it to do the gradient related color stop fun only when the percentage is actually within the range that such things support. Which I suppose would have been smart to do before. We're learning!

if (0 <= percentage && percentage < 1.0)

Lovely! So this works 1 and we're free to move along to our next step with our handy dandy timer class at the ready!

A proper victory screen

The most lackluster thing about the game right now is that it just says "you win" then dumps you back into a new round after a brief moment. This is all well and good for prototyping, but I think we should do two things:

  1. Put a pause before we move to the new screen
  2. Update the victory screen to need user input to go to the next round

That first item is why I wanted to make our little Timer class, since now we'll have more than 3 places that need this type of thing if we're going to be doing some animation, or nice things like that on the victory screen. Right now, the creation of the victory screen is just an instant clear of the entities and a push to the win screen:

function victoryOnAllMatchHandler() {
    const victory = new VictoryScreen(canvas);
    entities.length = 0;
    entities.push(victory);
    setTimeout(() => {
        document.dispatchEvent(new Event(SCENE_EVENTS.ROUNDOVER));
    }, 1000);
}

To accomplish point one from above, all we have to do is not clear the entities right away. Then, do another timeout to let the win screen hang out for a moment.

function victoryOnAllMatchHandler() {
    const victory = new VictoryScreen(canvas);
    setTimeout(() => {
        entities.length = 0;
        entities.push(victory);
        setTimeout(() => {
            document.dispatchEvent(new Event(SCENE_EVENTS.ROUNDOVER));
        }, 1000)
    }, 1000);
}

The extra timeout is temporary until we do the second point from our TODO list for this section. So, let's get to cracking on the victory screen! The big question is: do we make a button like the Start button to continue? Or do we just make it a single click affair? Having the user spell out N E X T or A G A I N would be similar to the start button; but the start button is the way it is because it also acts as a short tutorial on what the game expects you to do.

With that in mind, I'd say we should make the next round button as frictionless as possible so that a user can easily move on. With that in mind, our current VictoryScreen is going to need some updates! Right now it's:

export class VictoryScreen {
    constructor(canvas) {
        this.canvas = canvas;
    }
    draw(ctx) {
        ctx.save();
        ctx.font = '48px Impact';
        ctx.fillText('You win', this.canvas.width / 2, this.canvas.height / 2);
        ctx.restore();
    }
}

So let's add an update method to it so that we'll be able to pass that along to the child entities this will contain. We'll need to do the same thing for the draw method as well. And, while I'm at it, let's see what happens if we try to use our LetterButton for our next button too, we'll just place it somewhere random for now:

import { LetterButton } from "ui";

export class VictoryScreen {
    constructor(canvas) {
        this.canvas = canvas;
        this.entities = [
            new LetterButton(this.canvas.width / 2, this.canvas.height - 200, 'Next')
        ];
    }

    update(cursor, elaspedMillis) {
        for (let i = 0; i < this.entities.length; i++) {
            const entity = this.entities[i];
            if (entity.update) {
                entity.update(cursor, elaspedMillis);
            }
        }
    }

    draw(ctx) {
        ctx.save();
        ctx.font = '48px Impact';
        ctx.fillText('You win', this.canvas.width / 2, this.canvas.height / 2);
        ctx.restore();

        for (let i = 0; i < this.entities.length; i++) {
            const entity = this.entities[i];
            if (entity.draw) {
                entity.draw(ctx);
            }
        }
    }
}

Hm. I think the LetterButton class, true to its name, really only wants a single letter in it. My first instinct is to make a new class for the button that needs more than just a single letter. But... stopping for a second, if basically the only difference is going to be that we're just going to base the width on the number of letters it has anyway... is it possible to do that without screwing up the existing letters?

I think it's going to definitely be a bit tricky, because my first instinct is to make a "pill" sort of button, where we've got a long rectangle in the middle and then two half-circles on either side. Right now, the LetterButton uses just the radius() method for its collision detection as well as it drawing. So that'll throw a wrench into that idea pretty rapidly. So. Let's go with my first instinct and make a new ui component. If after we finish it there's some shared DNA, then we can refactor it at that point.

Putting pen to paper is simple enough. Let's get the idea of the "pill" box first:

// Length of the sides, not inclusive of the arc of the "pill"
length() {
    return this.word.length * 50;
}

Since we're using a 48pt font, I figure 50px should be enough for the character and then a tiny bit of padding. In order to tell that the mouse is in the button, we can update the within to look at the left and right arcs of the pill, as well as a basic check against the rectangle in the middle:

within(mx, my) {
    // Left pill side 
    if (this.x - this.radius() <= mx && mx <= this.x + this.radius()) {
        if (this.y - this.radius() <= my && my <= this.y + this.radius()) {
            return true;
        }
    }

    // middle piece 
    const length = this.length();
    if (this.x <= mx && mx <= this.x + length) {
        if (this.y - this.radius() <= my && my <= this.y + this.radius()) {
            return true;
        }
    }

    // right pill side
    if (this.x + length - this.radius() <= mx && mx <= this.x + length + this.radius()) {
        if (this.y - this.
            radius() <= my && my <= this.y + this.radius()) {
            return true;
        }
    }
    return false;
}

Trust me, this looks far more complication than it actually is. There's no "height" in this, we're still using the radius for that. Think about it, we want this to be one continuous piece right? Then it follows that, if we were to draw the rectangle from its center point, then the radius, or, well, half of it, would extend upwards. Otherwise the circle and the rectangle wouldn't line up properly. To prove it to you, let's draw this:

draw(ctx) {
    ctx.save();
    const style = this.state_color[this.state];
    ctx.fillStyle = style.BACKGROUND.FILL;
    ctx.strokeStyle = style.BACKGROUND.FILL;
    ctx.font = "48px serif";
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius(), 0, Math.PI * 2);
    ctx.arc(this.x + this.length(), this.y, this.radius(), 0, Math.PI * 2);
    ctx.rect(this.x, this.y - this.radius(), this.length(), this.radius() * 2)
    ctx.fill();
    ctx.stroke();

    ctx.fillStyle = style.FONT.FILL;
    ctx.strokeStyle = style.FONT.FILL;
    ctx.lineWidth = 4;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(this.word, this.x + this.length() / 2, this.y);
    ctx.restore();
}

If we set the strokeStyle to not be the same as the fillStyle we can see the outlines of the individual shapes, it's pretty easy to see what's happening:

But, nix that and you've got what appears to be one solid button:

Is my estimate of 50px per letter far too much? Yes. But I don't hate the way it looks! So I'm going to keep it for now while we consider the question we asked before. Should this be its own button class, or does it share the same DNA?

The code I haven't shown you is the update, radius, subscriber, notify, and the constructor methods. Because, besides changing the word letter to word in the constructor arguments, they're all exactly the same! Does this mean we should do some inheritance or something similar? Maybe. But we have two buttons in this entire game. I'm not convinced we really need to be too worried about sharing these methods at the moment. It would be better to put our efforts towards the rest of the VictoryScreen class.

For one, we need to hook this next button up so that it triggers the new round, rather than the timeout doing that. So, over in our double nested setTimeout code from before, we can just delete the second one so that we don't trigger it prematurely.

function victoryOnAllMatchHandler() {
    const victory = new VictoryScreen(canvas);
    setTimeout(() => {
        entities.length = 0;
        entities.push(victory);
    }, 1000);
}

And then in VictoryScreen we simply change the entity list to use the PillButton and then subscribe ourselves to events from it. If we get a release from any button (because we only have one) then we move along to the next screen via the SCENE_EVENTS.ROUNDOVER constant.

export class VictoryScreen {
    constructor(canvas) {
        this.canvas = canvas;
        const nextButton = new PillButton(
            this.canvas.width / 2 - 100, 
            this.canvas.height - 200, 
            'Next'
        );
        nextButton.subscribe(this);
        this.entities = [
            nextButton
        ];
    }

    on_button(event, btn) {
        if (event === EVENT_NAME.RELEASED) {
            document.dispatchEvent(new Event(SCENE_EVENTS.ROUNDOVER));
        }
    }
    ...
}

With the next button, and the ability to wait for the user to interact to move onto the next round. Let's also make this lackluster "You win" text be more exciting. Right now it just looks like This:

Besides centering the text, let's also spruce it up a bit. My thought is that we should make letters curved around a center point, sort of like the letter select, but then animate them sliding into place if I can figure out how to do it. I think we're about to get into math, so, let's refer to this handy dandy angle chart

Given that we have 7 letters to display (counting the space) we can place the space's final position at π / 2 and then the other three letters at each of the 15 degree angles away from that and see how it feels. So. Without any AI assistance2

I sat down and did my best to code something up real quick and

Hm. Can you see where I went wrong?

class YouWin {
    constructor(centerPointX, centerPointY) {
        this.text = 'You Win';
        this.timer = new Timer(1250, false);
        this.x = centerPointX;
        this.y = centerPointY;
    }

    update(cursor, elaspedMillis) {
        this.timer.update(elaspedMillis);
    }

    draw(ctx) {
        ctx.save();
        let startRadian = Math.PI;
        const fromTo = [
            [Math.PI, Math.PI * 3.0/4.0],
            [Math.PI, Math.PI * 2.0/3.0],
            [Math.PI, Math.PI * 7.0/12.0],
            [Math.PI, Math.PI * 1.0/2.0],
            [Math.PI, Math.PI * 5.0/12.0],
            [Math.PI, Math.PI * 1.0/3.0],
            [Math.PI, Math.PI * 1.0/4.0]
        ];
        ctx.font = '48px Impact';
        for (let i = 0; i < this.text.length; i++) {
            const letter = this.text[i];
            const t = this.timer.progressPercentage();
            const [from, to] = fromTo[i];
            const placement = lerp(from, to, t);
            const radius = 100;
            ctx.fillText(
                letter, 
                this.x + Math.cos(placement) * radius, 
                this.y + Math.sin(placement) * radius
            );
        }
        ctx.restore();
    }
}

Hint: It's not my angle placements. It's a factor of two things.

this.y + Math.sin(placement) * radius

Is wrong. Why? Because the canvas's Y value increases in the positive direction downward. So, if we flip that to a minus then:

The radius also needs to increase. But even doing so, we'll still have the other problem that I alluded to earlier:

We faced this when we set up the LetterSelect as well. But the kerning is off because our font is trying to render left aligned from the position we're giving it, and that position is along the arc of the line where the center of the letter should be placed. Not the left anchor point. Thankfully, the same style that fixed that can be used here:

draw(ctx) {
    ctx.save();
    let startRadian = Math.PI;
    const fromTo = [
        [Math.PI, Math.PI * 3.0/4.0],
        [Math.PI, Math.PI * 2.0/3.0],
        [Math.PI, Math.PI * 7.0/12.0],
        [Math.PI, Math.PI * 1.0/2.0],
        [Math.PI, Math.PI * 5.0/12.0],
        [Math.PI, Math.PI * 1.0/3.0],
        [Math.PI, Math.PI * 1.0/4.0]
    ];
    ctx.font = '48px Impact';
    ctx.lineWidth = 4;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    for (let i = 0; i < this.text.length; i++) {
        const letter = this.text[i];
        const t = this.timer.progressPercentage();
        const [from, to] = fromTo[i];
        const placement = lerp(from, to, t);
        const radius = 200;
        ctx.fillText(
            letter, 
            this.x + Math.cos(placement) * radius, 
            this.y + Math.sin(placement) * radius
        );
    }
    ctx.restore();
}

And this will place the letters in a more uniform way:

Is it perfect? Nah, not yet. But it's certainly better than it was I think. I think I'd like to try to make it so the letters aren't all bundled up at once and only appear as the other letter arrives at the position it should be. We could probably do some step-function-y type stuff... or we could see if just changing the opacity of each letter according to their lerp percentage does the trick.

ctx.globalAlpha = !fromTo[i - 1] ? 1 : placement < fromTo[i - 1][1] ? t : 0;

Eh... it's all a bit too fast. I think maybe what I should do is go back to that easing website and snag a bouncy function to use:

// https://easings.net/#easeOutBounce
export function easeOutBounce(x){
    const n1 = 7.5625;
    const d1 = 2.75;

    if (x < 1 / d1) {
        return n1 * x * x;
    } else if (x < 2 / d1) {
        return n1 * (x -= 1.5 / d1) * x + 0.75;
    } else if (x < 2.5 / d1) {
        return n1 * (x -= 2.25 / d1) * x + 0.9375;
    } else {
        return n1 * (x -= 2.625 / d1) * x + 0.984375;
    }
}

And then updating the code to ditch the transparency thing and just tweak the percentage to become influenced by the ease-out:

for (let i = 0; i < this.text.length; i++) {
    const letter = this.text[i];
    const t = easeOutBounce(this.timer.progressPercentage());
    const [from, to] = fromTo[i];
    const placement = lerp(from, to, t);
    const radius = 250;
    ctx.fillText(
        letter, 
        this.x + Math.cos(placement) * radius, 
        this.y + Math.sin(placement) * radius
    );
}

And the result?

I like it. We could add brighter colors, do that gradient fun that we did with the WordRow maybe. Really, all we need to make this feel better is probably a score being shown in the center of the invisible circle that we've arc-ed around. But that will have to come after we actually add in a score. For now, let's just add a linear gradient because I think it looks cool. First off. Constants for the colors (just black and white):

// in our colors.js file
export const VICTORY_SCREEN_STYLE = {
    NEXT_BUTTON: {
        ... same as the letter select button ...
    },
    YOU_WIN_TEXT: {
        FONT: {
            FILL: FONT_COLOR,
            STROKE: FONT_COLOR_INVERT
        }
    }
};

Then we can add a new timer to the YouWin class in the constructor

this.textTimer = new Timer(3000, true);

Then in the draw method loop, rather than having fillStyle set to the FONT_COLOR we change it instead to a gradient that moves along with the timer:

const textAnimationPercentage = this.textTimer.progressPercentage();
const gradient = ctx.createLinearGradient(this.x, this.y, this.x + radius, this.y - radius * 2);
const STYLE = VICTORY_SCREEN_STYLE.YOU_WIN_TEXT; // this just here to format for this post 
gradient.addColorStop(0, STYLE.FONT.FILL);
if (0.1 < textAnimationPercentage && textAnimationPercentage < 0.90) {
    gradient.addColorStop(textAnimationPercentage - 0.1, STYLE.FONT.FILL);
    gradient.addColorStop(textAnimationPercentage, STYLE.FONT.STROKE);
    gradient.addColorStop(textAnimationPercentage + 0.1, STYLE.FONT.FILL);
}
gradient.addColorStop(1, STYLE.FONT.FILL);
ctx.fillStyle = gradient;

And the effect is pretty cool if I say so myself:

Great. Let's move on from shiny graphical florishes and to actual gameplay features!

Giving up on purpose

This should be a short one. Basically. Sometimes I just don't know what the heck the word is. Maybe it's too obscure. Maybe my brain's malfunctioning. Either way, I don't want to be sitting on this puzzle anymore and want a new one. And I'd like a button to give up quickly so I can get back to dopamine harvesting the easy words again.

Only question is... where do we put it? I suppose, a good place where it probably won't be clicked on by accident would be the bottom left of the screen. I'm biasing towards righthanded people here, but I think regardless, when you're holding your phone and tapping on it, the bottom two corners of the screen are mostly accident free.

So let's re-use our PillButton and update setup_entities in the index file to set up the bomb button.

const giveUp = new PillButton(padding,canvas.height - 100, 'Give up');
entities.push(giveUp);

Wups. I think I need to reassess our width function. It was alright before with the next button, but this needs a bit of love I think.

length() {
    return this.word.length * 25; // Was 50
}

Doing this will offset the next button on the victory screen, but if we change new PillButton(this.canvas.width / 2 - 100, this.canvas.height - 200, 'Next') to have only -50 instead of 100 it will center itself nicely and still look okay. But... sort of sucks doesn't it? Having the width be a leaky abstraction like this? I think if we make this button value configurable, rather than hardcoded to 50 or 25, then we'll be able to handle this a bit more flexibly.

setLetterWidth(letterWidth) {
    this.letterWidth = letterWidth;
}

length() {
    return this.word.length * this.letterWidth;
}

Then it's just a matter of defaulting it to either 25 or 50, then calling the setter in the appropriate place. Then we can have our cake (wide button on victory screen) and eat it too! (smaller button on main screen). For my purposes, I think the tighter feel should be the default, so this.letterWidth = 25 goes in the constructor, and then in the VictoryScreen class, I just call nextButton.setLetterWidth(50) and things work out again.

In order to get room for the giveup button though, we need to shift the LetterSelect up. There's no particular science to this. I just added a -50 in

const origin_y = input_area_start + letter_box_width * 3 - 50;

And then pushed the giveup button down by -50 instead of -100 to get it closer to the bottom of the screen:

It still feels a bit close. But I haven't tried playing the game too much with it here yet, so I'll leave it alone for now. I don't really mind that it's going off the side either. What I do mind though, is this:

I'm triggering that by just hovering over the give up button while selecting letters. Now, that sounds like a poor user experience just waiting to happen. So, let's take a look at the state of things again. The reason why this button is behaving like this is due to its state table still behaving the same as a LetterButton's, which are designed to stay active even if the cursor is no longer within their boundaries.

You can imagine that the solution is pretty straightforward. Let's fight back against our copy-paste!

update(cursor, elaspedMillis) {
    this.timer.update(elaspedMillis);
    
    if (this.state === BUTTON_STATE.IDLE) {
        if (this.within(cursor.x, cursor.y)) {
            this.state = BUTTON_STATE.HOVER;
        }
    } else if (this.state === BUTTON_STATE.HOVER) {
        if (!this.within(cursor.x, cursor.y)) {
            this.state = BUTTON_STATE.IDLE;
        } else if (cursor.isDown()) {
            this.notify(EVENT_NAME.ACTIVATED, this);
            document.dispatchEvent(new Event(SOUND_EVENTS.ACTIVATE));
            this.state = BUTTON_STATE.ACTIVATED;
        }
    } else if (this.state === BUTTON_STATE.ACTIVATED) {
        // this condition is now more precise:
        if (cursor.isUp() && this.within(cursor.x, cursor.y)) {
            this.state = BUTTON_STATE.IDLE;
            this.notify(EVENT_NAME.RELEASED, this);
        }
        // this is new vvvvv
        if (cursor.isDown() && !this.within(cursor.x, cursor.y)) {
            this.state = BUTTON_STATE.IDLE;
        }
    }
}

As noted by the comments, we've tweak the logic when the button is in an ACTIVATED state. Rather than sending out a release event to everyone when the cursor is released (isUp). We now actually check that the cursor is inside of our boundaries first. Something that you'd be doing normally anyway, but not something we caught since all of our buttons3 were happy as the only buttons on their respective pages and it was all mostly fine.

With this update though, both the Next button on the Victory page, and our new Give Up button will respect the user's probable intent and not release a click event if the user isn't actively within their boundaries for the full duration of said click.

This is great. But we also still need to make this button actually give up. We've got two options. First, we update the MatchState to subscribe to the give up button and have it

// In setup_entities:
const giveUp = new PillButton(padding,canvas.height - 50, 'Give up');
giveUp.named = 'giveup';
entities.push(giveUp);
giveUp.subscribe(matchState);

// in MatchState class
on_button(event, button) {
    if (event === EVENT_NAME.RELEASED && button.named === 'giveup') {
        setup_entities();
    }
}

The ability to add random properties onto existing data in an adhoc way is one of the somewhat useful properties of Javascript, which we can use to make sure that the on_button handler doesn't reset the game if it wasn't our specific button that was pressed.

But... Does it feel kind of gross to anyone else? It certainly works:

But it feels a tad hackey to me. So, our other option is to just make a dedicated listened for this and call it a day:

class GiveupListener {
    on_button(event, button) {
        if (event === EVENT_NAME.RELEASED) {
            setup_entities();
        }
    }
}

// in setup_entities
const giveUp = new PillButton(padding,canvas.height - 50, 'Give up');
const giveUpListener = new GiveupListener();
giveUp.subscribe(giveUpListener);
entities.push(giveUp);

And this works just the same, but we haven't had to do anything that felt "hacky". Feels better to me to do it this way right now. I do think that if I had a sort of GameScreen class, similar to the VictoryScreen class, then it'd be simpler to do something like

this.giveup = new PillButton();
...
on_match(event, button) {
    if (button === this.giveup) {
        ...
    }
}

But since we haven't done that. Let's set such thoughts aside. I want to add features today, and we spent the first part of this blog post refactoring so I'm itching to go! Speaking of, let's move onto our next new feature!

Shuffling letters

When you're trying to figure out what the words are. Sometimes you just end up running down the same pathways over and over again. When that happens, sometimes it's nice to be able to rearrange say, C E A into the more familiar A C E, or even E C A, where if you walk backwards you can find the word hidden in there.

So let's make a dedicated shuffle button. Pressing it will cause all the buttons to randomly re-arrange themselves on the letter selection panel. Potentially, this will help give a user a new perspective and they'll be able to figure out the pattern they were missing before.

const shuffle = new PillButton(canvas.width / 2 + 50, canvas.height - 50, 'Shuffle');
entities.push(shuffle);

This feels like it balances the game window out kind of nicely. We'll see if I agree or not whenever I finally get around to playing this on my phone or not, but for now I'll let it sit in the bottom right corner. Now we face the same problem as before. How do we actually get the LetterSelect to shuffle its buttons around?

Let's update our list of possible events:

export const EVENT_NAME = {
    ACTIVATED: 'activated',
    RELEASED: 'released',
    SELECTED: 'selected',
    MATCHED: 'matched',
    NO_MATCH: 'nomatch',
    SHUFFLE: 'shuffle',             // <--- NEW
};

Then we can tell the letter select to listen for the shuffle event:

on_button(name, button) {
    if (name === EVENT_NAME.ACTIVATED) {
        this.active_buttons.push(button);
    }
    if (name === EVENT_NAME.RELEASED) {
        this.check_words = true;
    } else {
        this.check_words = false;
    }
    if (name === EVENT_NAME.SHUFFLE) {
        this.shuffleButtons();
    }
}

I'll leave that shuffleButtons undefined for the moment, we'll come back there right after we've wired things up. We basically need to adapt the activation button event into a shuffle event. So, let's do that in a similar fashion to the GiveupListener:

class ShuffleListener {
    constructor() {
        this.subscribers = [];
    }

    subscribe(subscriber) {
        this.subscribers.push(subscriber);
    }

    on_button(event, button) {
        if (event === EVENT_NAME.ACTIVATED) {
            for (let i = 0; i < this.subscribers.length; i++) {
                const subscriber = this.subscribers[i];
                subscriber.on_button(EVENT_NAME.SHUFFLE, this);    
            }
        }
    }
}

And in our setup_entities method:

const shuffle = new PillButton(canvas.width / 2 + 50, canvas.height - 50, 'Shuffle');
const shuffleListener = new ShuffleListener();
shuffle.subscribe(shuffleListener);
shuffleListener.subscribe(letterSelections);
entities.push(shuffle);

Nothing to write home about here. The pill button will send its events over to the shuffle listener, the shuffle listerner... listens... and then sends things along to the letterSelections where our code is waiting to:

LetterSelect.js:48  Uncaught TypeError: this.shuffleButtons is not a function
    at LetterSelect.on_button (LetterSelect.js:48:18)
    at ShuffleListener.on_button (index.html:203:36)
    at PillButton.notify (PillButton.js:86:33)
    at PillButton.update (PillButton.js:69:22)
    at index.html:127:23
    at Array.forEach (<anonymous>)
    at update_entities (index.html:125:22)
    at game_loop (index.html:112:13)

So now we face the question of "What does it mean to shuffle the buttons". Or, moreso, how do we go about doing that? Well, if we take a peak at our setup code for the LetterSelect we set the x and y coordinates of each button during the construction step:

const number_of_letters = letters.length;
const radians_per_letter = (2 * Math.PI) / number_of_letters;
const letter_box_width = 50;
for (let i = 0; i < letters.length; i++) {
    const letter = letters[i];
    const lx = x + Math.cos(radians_per_letter * i) * letter_box_width * 2;
    const ly = y + Math.sin(radians_per_letter * i) * letter_box_width * 2;
    const letterButton = new LetterButton(lx, ly, letter);
    letterButton.subscribe(this);
    this.buttons.push(letterButton);
}

And then they're set in stone forever. So. That's the part we should tackle then! Let's make it possible to change the position of the individual LetterButtons.

setPosition(x, y) {
    this.x = x;
    this.y = y;
}

And then with our newfound powers make the letters shuffle around:

shuffleList(list) {
    const coinFlip = () => Math.random() > 0.5; 
    for (let i = 0; i < list.length; i++) {
        list.sort((a, b) => {
            return coinFlip() ? -1 : coinFlip() ? 1 : 0;
        });
    }
}

shuffleButtons() {
    const number_of_letters = this.letters.length;
    const radians_per_letter = (2 * Math.PI) / number_of_letters;
    const letter_box_width = 50;
    this.shuffleList(this.buttons);
    for (let i = 0; i < this.buttons.length; i++) {
        const button = this.buttons[i];
        const lx = this.x + Math.cos(radians_per_letter * i) * letter_box_width * 2;
        const ly = this.y + Math.sin(radians_per_letter * i) * letter_box_width * 2;
        button.setPosition(lx, ly);
    }
}

Ignore the fact that I probably shouldn't be making a new flipping function every iteration. This is a pretty simple way to quickly re-order the list randomly. Though, it doesn't actually guarantee that it changes anything:

Especially when you've got a simple word like Inn here and most of the letters are the same and there's so few to move around. I suppose, if instead of doing random flips, if we very purposefully picked pairs and performed swaps, then I think we'll have some vague possibility to always getting something shuffled. Since I think the 3 letter options are probably the most likely candidates of annoyance, let's just build in a special case for them:

shuffleList(list) {
    if (list.length === 3) {
        const first = list[0];
        const second = list[1];
        const third = list[2];
        list[1] = first;
        list[2] = second;
        list[0] = third;
        return;
    }

    const coinFlip = () => Math.random() > 0.5; 
    for (let i = 0; i < list.length; i++) {
        list.sort((a, b) => {
            return coinFlip() ? -1 : coinFlip() ? 1 : 0;
        });
    }
}

Part of me wonders if maybe a sort of subdividing algorithm would make sense for this. Like, if we just take subslices of any length array until we get one that's 3 long, do the shift of its elements, and then combine it all back together, would that work better than the coin flipping sort?

Maybe! But on the list of things that I find interesting to do, that's lower than the fact that we've been putting all this work into adding animation and visual feedback that the sudden swap for the letters with no animation at all feels wrong. So, rather than setting the position, let's set the destination

setDestination(x, y) {
    this.destinationX = x;
    this.destinationY = y;
    this.movingTimer = new Timer(500, false);
}

move(elaspedMillis) {
    this.movingTimer.update(elaspedMillis);
    this.x = lerp(this.x, this.destinationX, this.movingTimer.progressPercentage());
    this.y = lerp(this.y, this.destinationY, this.movingTimer.progressPercentage());
}

update(cursor, elaspedMillis) {
    this.timer.update(elaspedMillis);
    this.move(elaspedMillis);
    ...

See? Told you we'd be getting some major mileage out of the refactor we did today. Swapping the shuffling method to call setDestination instead of setPosition results in a very nice animation if I do say so myself:

I think we've just got one more quality of life and one more gameplay mechanic to add and then we can call it a day.

A mute button

Is there anything more annoying than when there's no way to quickly and easily shut off the sound in a game? I'm not going to try to make a fullblown option screen or volume control since I don't have the patience for it right now and it's my word puzzle clone and you're all just along for the ride. If you want to allow the user to move or drag a slide around and stuff, by all means go for it. I'm just going to give a mute button and be done with it.

Since we've done this a few times, let's start backwards with the listener:

class MuteListener {
    on_button(event, button) {
        if (event === EVENT_NAME.ACTIVATED) {
            SOUNDS.activate.toggleMute();
            SOUNDS.release_wrong.toggleMute();
            SOUNDS.release_right.toggleMute();
            SOUNDS.victory.toggleMute();
        }
    }
}

It's been a hot second since we originally setup the sounds, so as a reminder. They're global! We use a single instance of our Sound class to control these, and they're all hooked up from the main index file to try to play on window events:

import { SOUNDS } from 'sound';
document.addEventListener(SOUND_EVENTS.ACTIVATE, () => {
    SOUNDS.activate.play();
});

They already have a canPlay field since we have to wait until the sound is loaded to attempt to play it, so we can easily implement mute with a simple toggle method:

export class Sound {
    constructor(path) {
        this.audio = new Audio(path); 
        this.canPlay = false;
        const ref = this;
        this.audio.addEventListener("canplaythrough", (event) => {
            ref.canPlay = true;
        });
    }

    play() {
        if (this.canPlay) {
            this.audio.play();
        }
    }

    toggleMute() {                      // <--- this is new
        this.canPlay = !this.canPlay;
    }
}

This means there is, technically, a bug, since if you're fast enough to hit mute before any audio is ever loaded, then you'd be able to trigger the .play() method despite the event canplaythrough not having been fired. But... I'm fairly doubtful that this bug will ever happen in practice. Until proven otherwise (you will have to tweet me a screenshot or something) I'm going to work under this assumption and finish off this feature with another PillButton

// Mute button
const mute = new PillButton(padding, canvas.height / 2, 'Mute');
const muteListener = new MuteListener();
mute.subscribe(muteListener);
entities.push(mute);

This places the mute button above and to the left of the circle, though it's a tight squeeze:

Our options are to either find a new home for it, or reduce the number of potential words that we show to the user in each round. We could place it on the right, but with a sufficiently long word, then that'll end up still running into the squeeze.

Though, admittedly, it looks less offensive than I thought. And I really don't want to reduce the number of potential words for a user to find. So I'm kind of fine with it. And hey, I'm the programmer and product owner here! So let's leave it for now and if I run into problems the next time I'm on a bus or idling away the hours matching words, I'll decide to change it.

Wrap up

I had originally intended to include a section where we added a score to the game. With the plan to keep track of how many words you've gotten in a row without any false guesses, but this post is (according to an only word counter) about 50 minutes to read if you're not counting the source code snippets. So I imagine that you, dearest Reader, having made it this far, are a bit tired. Perhaps as tired as I am of the strange headaches and ear problems I've had of late.

There are other little side projects that have been occupying space in my mind as of late, and I can feel the itch to work on them building every word I write about this silly little Javascript project. It's been a lot of fun, and this whole thing was inspired simultaneously by randomly deciding to read about JS modules and by how scummy the amount of ads and poor UX experience a game I downloaded and was playing recently was.

I'd encourage anyone and everyone out there if you always find yourself saying "I want to code, but I don't have any ideas" to just make clones. Download a random game, try to make it. Take a stroll down retro-lane and play an NES or Gameboy game with fun mechanics that you like, pick out one of those and see if you can implement it. That's how this all started for me. The drag and select letters off a circle of buttons was interesting to me, and then the Trie ended up being even moreso. It's a lot of fun to see something on the surface and try to take a running jump to see if you can reach it.

So, this might be the last blogpost about the word puzzle for a while. I think I have a fix for the game not working as expected on my phone:

addEventListener("touchstart", (e) => {
    cursor.click = CLICK_STATE.DOWN;  
});
addEventListener("mousedown", (e) => {
    cursor.click = CLICK_STATE.DOWN;
});

addEventListener("touchend", (e) => {
    cursor.click = CLICK_STATE.UP;  
});
addEventListener("mouseup", (e) => {
    cursor.click = CLICK_STATE.UP;
});

But I won't know until I give it a shot. And that'll be after I upload the bundle of JS files up to neocities. Anyway, I hope you all enjoyed this, and maybe hopefully learned something or just enjoyed the ride. If you want to play the game. The links below.

Send bug reports to twitter or tell me when I'm streaming during the week. See ya!