DEV Community

loading...
Cover image for To Jigsaw or not to Jigsaw?

To Jigsaw or not to Jigsaw?

Dr Abstract
Inventor, Founder of ZIM JavaScript Canvas Framework and Nodism, Professor of Interactive Media at Sheridan, Canadian New Media Awards Programmer and Educator
・7 min read

I have been making interactive media since 1995... I have always avoided making jigsaw puzzles as they are the classic example and quite overdone. I would encourage my students to go beyond and at least make the pieces moving video parts or something different.

Well, on Discord https://zimjs.com/discord, someone was posting problems using code with a blog post from a while back - https://www.emanueleferonato.com/2018/03/13/build-a-html5-jigsaw-puzzle-game-with-zim-framework/ - I started to help but then felt it would be better to update the code as the blog post was quite popular. The result is found on CodePen at - come give it a fork and heart!

https://codepen.io/danzen/pen/rNjQWRY

It is fairly tricky to do a jigsaw puzzle. We have an alternative in ZIM called the Scrambler() which is just a few lines of code to implement. See https://zimjs.com/cat/scrambler.html. But in doing the Jigsaw, we really had to use many parts of interactive media.

Alt Text

The puzzle parts themselves are drawn with a Shape. We figured it would be good use a class here and make it so we can pass the format of the piece as an array of four sides with 1 being a bump out, 0 being no bump and -1 being a bump in:

// ~~~~~~~~~~~~~~~~~~~~~
// CLASS to make JigSaw piece with bumps out or in or no bump on any four edges
class Piece extends Shape {
    // format is 1 for bump out, -1 for bump in and 0 for no bump
    constructor(w=100,h=100,format=[1,1,1,1],s=black,ss=4,f=white) {
        super(w,h);
        const p = Piece.part; // static property - defined below class
        const g = Piece.gap;
        // s() stroke, ss() strokeStyle, f() fill
        // mt() moveTo, lt() lineTo, ct() curveTo()
        this.s(s).ss(ss).f(f).mt(0,0);
        if (format[0]==0) this.lt(w,0); // top left-right
        else {
            this.lt(w*p,0);
            let s = format[0]==1?-1:1; // sign                
            this.ct(w*(p-g/2), s*w*g, w/2, s*w*g); // curve left to middle
            this.ct(w*(p+g+g/2), s*w*g, w*(1-p), 0); // curve middle to right           
            this.lt(w,0)
        }            
        if (format[1]==0) this.lt(w,h); // right top-bottom
        else {
            this.lt(w,h*p);
            let s = format[1]==1?1:-1; 
            this.ct(w+s*w*g, h*(p-g/2), w+s*w*g, h/2);
            this.ct(w+s*w*g, h*(p+g+g/2), w, h*(1-p));                
            this.lt(w,h)
        }            
        if (format[2]==0) this.lt(0,h); // bottom right-left
        else {
            this.lt(w*(1-p),h);
            let s = format[2]==1?1:-1;           
            this.ct(w*(p+g+g/2), h+s*w*g, w/2, h+s*w*g);
            this.ct(w*(p-g/2), h+s*w*g, w*p, h+0);                
            this.lt(0,h)
        }            
        if (format[3]==0) this.lt(0,0); // left bottom-top
        else {
            this.lt(0,h*(1-p));
            let s = format[3]==1?-1:1;             
            this.ct(s*w*g, h*(p+g+g/2), s*w*g, h/2);
            this.ct(s*w*g, h*(p-g/2), 0, h*p);             
            this.lt(0,0)
        }
        this.cp(); // close path
    }        
}
Piece.part = .37; // part of the edge with no gap ratio
Piece.gap = 1-Piece.part*2; // gap ratio of edge
Enter fullscreen mode Exit fullscreen mode

As you can see, it is tricky to do the four sides using absolute positioning. Would have perhaps been better to use ZIM Generator() which does relative position - so we could use the same code for each side and rotate 90 before going to the next side. Anyway - it was not that bad, just a bit of mental twisting.

Alt Text

We wanted to have a version of the puzzle without the images that we could use as a hint. So the puzzle piece class did not use images - but rather just the shape.

Placing the shapes in position was also a challenge. We do not want bumps on the edges and the bumps have to align going from one piece to the next. We put a system in place inside the loop to handle the opposite of the last bump. Then we made sure there were no bumps for the edges.

// ~~~~~~~~~~~~~~~~~~~~~
// PIECES
// makePieces gets called from Tile - for each piece
let count=0;
let lastX = rand()>.5?1:-1; // 1 or -1 for out or in horizontally
let lastYs = []; // 1 or -1 vertically - remember with array and modulus
loop(numX, i=>{lastYs.push(rand()>.5?1:-1);});
function makePiece() {   

    // prepare format for jigsaw piece [1,0,-1,0] 
    // 1 bump out, 0 no bump, -1 bump in, etc.
    let currentX = lastX*-1; // opposite of last x
    let currentY = lastYs[count%numX]*-1; // opposite of last y
    let nextX = rand()>.5?1:-1; // randomize the next 1 or -1 for out or in horizontally
    let nextY = rand()>.5?1:-1; // and vertically
    // top, right, bottom, left
    let format = [currentY, nextX, nextY, currentX]; 
    lastX = nextX;
    lastYs[count%numX] = nextY;       

    // override edges to 0
    if (count < numX) format[0] = 0;
    else if (count >= numX*numY-numX) format[2] = 0;
    if (count%numX==0) format[3] = 0;
    else if ((count-numX+1)%numX==0) format[1] = 0;

    // make a container to hold jigsaw shape and later picture part
    let piece = new Container(w,h).centerReg({add:false});
    piece.puzzle = new Piece(w, h, format).addTo(piece);      
    piece.mouseChildren = false;  
    count++;
    return piece;
}

const pieces = new Tile({
    obj:makePiece, 
    cols:numX, 
    rows:numY,
    clone:false // otherwise makes clone of piece
})
    .center()
    .drag(stage).animate({
        props:{alpha:1},
        time:.1,
        sequence:.05
    });
Enter fullscreen mode Exit fullscreen mode

ZIM has dynamic parameters (called ZIM VEE values - with Pick() https://zimjs.com/docs.html?item=Pick). That means we can pass a function into the obj parameter of the Tile and each time it goes to make an item it will take the return value of the function. This is one of the formats of a dynamic parameter in ZIM. You can also pass in an array which it will randomly pick from or a series that it pics from in order, or a {min, max} object or a combination of these. Very powerful!

For figuring if the puzzle piece is in the right place we made little hit boxes and added them to the hint version of the puzzle which always stays in the right place. We could have used hitTestGrid() to calculate the right place but the hit boxes is easier to visualize and work with.

// ~~~~~~~~~~~~~~~~~~~~~
// HINT AND SNAP HIT BOX
const hint = pieces.clone(true) // exact
    .center()
    .ord(-1) // under pieces     
    .cache(-5,-5,pic.width+10,pic.height+10) // cache by default does not include outside border 
    .alp(.2)
    .vis(0); // checkbox below to show

// make a little box to do hit test to see if in right place
const snap = 50; // pixel distance considered correct
loop(hint, h=>{
    h.box = new Rectangle(snap,snap).centerReg(h).vis(0); // do not use alpha=0 as that will make it not hittable        
});
Enter fullscreen mode Exit fullscreen mode

We then want to scramble the pieces both in position and rotation and add events to test if the piece is in the right place and if the whole puzzle is done. This is tricky as a single tap should rotate the piece but this should not happen if the piece is dragged. ZIM has tap() built in to handle a quick tap at one location. We are also animating rotation and do not want that process interrupted as that might leave us with a partially rotated piece.

// ~~~~~~~~~~~~~~~~~~~~~
// ADD PICTURE TO PIECES, ADD EVENTS, ROTATE AND SCRAMBLE
const padding = 50;
const rotate = true;
loop(pieces, (piece,i)=>{
    piece.alp(0); // sequence animation above will animate in alpha
    pics[i].addTo(piece).setMask(piece.puzzle);  
    // test on mobile and see if you need to cache...
    // usually this is just cache() but the bumps are outside the piece 
    // and the cache size really does not make a difference if rest is background transparent 
    if (mob) piece.cache(-100,-100,piece.width+200,piece.width+200);
    if (rotate) {
        piece.rotation = shuffle([0,90,180,270])[0];
        piece.tap({
            time:.5, // within .5 seconds
            call:() => {   
                pieces.noMouse(); // do not let anything happen while animating until done
                piece.animate({
                    props:{rotation:String(frame.shiftKey?-90:90)}, // string makes relative
                    time:.2,
                    call:() => {
                        pieces.mouse();
                        test(piece);
                    }
                });                
                stage.update();
            }, 
            call2:() => { // if no tap
                test(piece);   
            }                
        }); 
    } else {
        piece.on("pressup", () => {
            test(piece); 
        });
    }        
    piece.on("pressdown", () => {
        // shadows are expensive on mobile
        // could add it to container so shadow inside container 
        // then cache the container but might not be worth it
        if (!mob) piece.sha("rgba(0,0,0,.4)",5,5,5);
    });

    // scramble location     
    piece.loc(padding+w/2+rand(stageW-w-padding*2)-pieces.x, padding+h/2+rand(stageH-h-padding*2)-pieces.y);        
}); 
Enter fullscreen mode Exit fullscreen mode

There are several places where we want to test the piece so we have put this functionality in a... function! We also quickly made an emitter for a reward here is the emitter code, followed by the test.

// EMITTER    
const emitter = new Emitter({
    obj:new Poly({min:40, max:70}, [5,6], .5, [orange, blue, green]),
    num:2,
    force:6,
    startPaused:true
}); 
Enter fullscreen mode Exit fullscreen mode
// TEST FOR PIECE IN RIGHT PLACE AND END
function test(piece) {
    piece.sha(-1);
    let box = hint.items[piece.tileNum].box;
    if (piece.rotation%360==0 && box.hitTestReg(piece)) {
        piece.loc(box).bot().noMouse();
        emitter.loc(box).spurt(30);
        placed++;
        if (placed==num) {
            stats.text = `Congratulations all ${num} pieces placed!`;
            timeout(1, function () {
                emitter.emitterForce = 8;
                emitter.center().mov(0,-170).spurt(100)
            })
            timeout(2, function () {
                hintCheck.removeFrom();
                picCheck.removeFrom();
                picCheck.checked = true;                    
                pieces.animate({alpha:0}, .7);
                outline.animate({alpha:0}, .7);
                hint.animate({alpha:0}, .7);
                pic.alp(0).animate({alpha:1}, .7);   
                new Button({
                    label:"AGAIN", 
                    color:white, 
                    corner:[60,0,60,0],
                    backgroundColor:blue.darken(.3), 
                    rollBackgroundColor:blue
                })
                    .sca(.5)
                    .pos(150,30,LEFT,BOTTOM)
                    .alp(0)
                    .animate({alpha:1})
                    .tap(()=>{zgo("index.html")})
            });                             
        } else stats.text = `Placed ${placed} piece${placed==1?"":"s"} of ${num}`;
    } else stage.update();    
}         
Enter fullscreen mode Exit fullscreen mode

To see the full code please fork and heart the CodePen page at

https://codepen.io/danzen/pen/rNjQWRY

All the best,

Dr Abstract - finally, a maker of a Jigsaw Puzzle.

Discussion (0)