Peg Solitaire Narrative - Peg Dragging

Peg Solitaire Narrative

The fourth version of PegSolitaire adds the dragging of pegs around. There is no rule logic, so the user can move pegs from spot to spot.

The dragging is done via an NSImage. A peg is drawn in the image, along with a shadow, and then the image is moved around the view. When the mouse pointer is over a valid drop spot (currently any open slot), the spot will highlight. Releasing the mouse button will move the peg to that slot. (in reality, just set that slot's state to YES.)

Details on creating the image and doing the dragging are down in the code snippets.


CHPegBoard.h: The class has grown four new instance variables:
@interface CHPegBoard : NSView
{
    BOOL pegStates[PEG_COUNT];	// YES - occupied, NO - empty
    NSBezierPath *pegPaths[PEG_COUNT];	// where the peg lives

    BOOL recalcPegPaths;
    int selectedPeg;

    NSImage *dragImage; // non-nil means we're in a drag
    int dragPeg;        // index of where the drag came from
                        // selectedPeg is what the mouse is currently over
    NSPoint dragPoint;  // where to draw the drag image
    NSPoint lastMouse;  // previous mouse coordinates
}

@end // CHPegBoard
dragImage is the NSImage that gets moved around while the user drags. No actual 'object' is directly moved by the user, just the illusion. The dragPoint is the key to that illusion, which is where the image gets drawn. The lastMouse position is kept, so that if the mouse gets dragged, we know the delta of the drag. The image then gets shifted by that amount. dragPeg is the origin of the drag.
CHPegBoard.m:

The default zero value set during object creation is fine, except for the dragPeg. It won't change the logic of the program, but it's good to have a peg index not be a legal peg value if it's not being used.

- (id) initWithFrame: (NSRect) frame
{
    if (self = [super initWithFrame: frame]) {
        int i;
        // alternate them on and off, for initial development
        for (i = 0; i < PEG_COUNT; i++) {
            pegStates[i] = ((i % 2) == 0) ? YES : NO;
        }

        recalcPegPaths = YES;
        selectedPeg = CHPegBoard_NO_PEG;
        dragPeg = CHPegBoard_NO_PEG;
    }

    return (self);

} // initWIthFrame

I rewrote drawPeg:. Rather than have it make the determiniation whether to draw the peg as selected by looking at an instance variable, it's now passed as a parameter. This way the peg can be rendered into a different context (say the drag image) without having the selectedPeg instance variable confusing things. The drawing has been tweaked slightly, with the black pegs stroked with a white ring. This makes pegs a little easier to see when being dragged around.

The code uses a new Panther feature, using NSColor to set the stroke and fill color independently. This simplifies the code; otherwise the fill and stroke calls would need to be intermixed with the color setting, or local variables used to hold the fill and stroke colors, which are then set before the drawing operation.


- (void) drawPeg: (int) pegIndex
        selected: (BOOL) isSelected
{
    assert (pegIndex>= 0 && pegIndex< PEG_COUNT);

    // right now, just make 'on' pegs black, and 'off' pegs white
    // eventually we can do much snazzier pegs

    if (isSelected) {
        [[NSColor yellowColor] setFill];
        [[NSColor redColor] setStroke];
    } else {
        if (pegStates[pegIndex]) {
            [[NSColor blackColor] setFill];
            [[NSColor whiteColor] setStroke];
        } else {
            [[NSColor whiteColor] setFill];
            [[NSColor blackColor] setStroke];
        }
    }
    
    [pegPaths[pegIndex] fill];
    [pegPaths[pegIndex] stroke];

} // drawPeg

OK, now for mouse tracking. In the mouseMoved, there was a loop looking for the peg under a point. The mouse tracking code needs this logic too, so it gets split out into its own method. Also, the loop loses the check for a peg being occupied, since hit-testing pegs shouldn't care about the occupied / non-occupied state.
- (int) pegIndexAtPoint: (NSPoint) point
{
    // see if it is over a peg
    int pegIndex = CHPegBoard_NO_PEG;

    int i;
    for (i = 0; i < PEG_COUNT; i++) {
        if ([pegPaths[i] containsPoint: point]) {
            pegIndex = i;
            break;
        }
    }

    return (pegIndex);

} // pegIndexAtPoint

mouseMoved: was updated to track this change
- (void) mouseMoved: (NSEvent *) event
{
    // get the mouse position in view coordinates
    NSPoint mouse;
    mouse = [self convertPoint: [event locationInWindow]  fromView: nil];

    int candidatePeg = CHPegBoard_NO_PEG;

    // only redraw if the currently selected peg (if any) has changed
    int candidatePeg = [self pegIndexAtPoint: mouse];

    int i;
    for (i = 0; i < PEG_COUNT; i++) {
  
    if (candidatePeg != CHPegBoard_NO_PEG) {
        // only allow occupied spots
        if (!pegStates[candidatePeg]) {
            candidatePeg = CHPegBoard_NO_PEG;
        }
    }

    if (selectedPeg != candidatePeg) {
        selectedPeg = candidatePeg;
        [self setNeedsDisplay: YES];
    }

} // mouseMoved

The mouse tracking involves overriding three methods, each taking an event. mouseDown:, for when the button gets clicked. mouseDragged: is called when the user moves the mouse around (while the button is down), and then mouseUp: when the button is released.

So first the mouseDown: First see if there's a peg at the mouse down location. Convert from window to view coordinates, and use the pegIndexAtPoint: method to see what peg is under the mouse.


- (void) mouseDown: (NSEvent *) event
{
    // get the mouse position in view coordinates
    NSPoint mouse;
    mouse = [self convertPoint: [event locationInWindow]  fromView: nil];

    dragPeg = [self pegIndexAtPoint: mouse];
If we're over a location (drag peg is a valid number) and it is unoccupied (the state is YES), the start the drag. Make the image that we're going to drag around
    if (dragPeg != CHPegBoard_NO_PEG
        && pegStates[dragPeg]) {

        // make the image of a peg with a drop shadow
        dragImage = [self makeDragImageForPeg: dragPeg
                          dragPoint: &dragPoint];
        [dragImage retain];
The makeDragImageForPeg:dragPoint: method modifies the drag point so that it is at the bottom-left of the image. That is, where the image should be composited so that the peg will be drawn in the right place:

Hang on to the mouse location so that the image can be moved by the delta of with the next mouse position. Also clear out the selectedPeg instance variable. We'll be using that to keep track of the current drop destination (which will highlight when moused over). Also clear out the source slot, since we've pulled the peg out of there. Finally schedule a redraw.
        lastMouse = mouse;

        // clear out the selected peg.  This will be the current drop
        // destination
        selectedPeg = CHPegBoard_NO_PEG;

        // clear out the source slot, since the peg that lives there is
        // currently moving
        pegStates[dragPeg] = NO;

        [self setNeedsDisplay: YES];
    }

} // mouseDown

For the mouse drag, convert the mouse point as usual. Also look at the dragImage - if it is nil, we're not dragging a peg, so don't do any work.
- (void) mouseDragged: (NSEvent *) event
{
    // get the mouse position in view coordinates
    NSPoint mouse;
    mouse = [self convertPoint: [event locationInWindow]  fromView: nil];

    if (dragImage != nil) {
but if we are dragging a peg, calculate the delta from the last known mouse position, and offset the dragPoint. This will have the side effect of moving the image:

In this confusing diagram, you can see the mouse got dragged a long distance down and to the left. The delta in location between the lastMouse and the current mouse location is the same as the delta in location from the old dragPoint to the new dragPoint. By adding the delta of the mouse points to the dragPoint, the image in a sense gets moved.
        // update the drag image
        float deltaX, deltaY;
        deltaX = mouse.x - lastMouse.x;
        deltaY = mouse.y - lastMouse.y;

        dragPoint.x += deltaX;
        dragPoint.y += deltaY;
Hang on to the current mouse position so we can calculate this delta on the next mouse drag. Also highlight the peg location if we're over one. And finally schedule a redraw.
        // remember the mouse position so we can calculate the delta
        // next time we get a drag
        lastMouse = mouse;

        // highlight the destination if we're over an empty cell
        selectedPeg = [self pegIndexAtPoint: mouse];

        // make sure the destination isn't already occupied
        if (selectedPeg != CHPegBoard_NO_PEG
            && pegStates[selectedPeg]) {

            selectedPeg = CHPegBoard_NO_PEG;
        }

        [self setNeedsDisplay: YES];
    }

} // mouseDragged

In mouseUp:, if we're dragging and we have a selectedPeg (set in mouseDragged:), 'move' the peg we're dragging to that spot (really just setting its state to YES). Otherwise 'move' the peg back to where it came from, which is just setting the state for dragPeg to YES. Then release the drag image (since we don't need it anymore) and then schedule a redraw.
- (void) mouseUp: (NSEvent *) event
{
    if (dragImage != nil) {

        // if we're over a peg, return it from whence it came
        if (selectedPeg == CHPegBoard_NO_PEG) {
            pegStates[dragPeg] = YES;

        } else {
            // otherwise 'move' the peg to the new stop
            pegStates[selectedPeg] = YES;
        }

        // clear the selection
        selectedPeg = CHPegBoard_NO_PEG;

        // get rid of the drag image
        [dragImage release];
        dragImage = nil;

        // and redraw to get rid of the drag indicator
        [self setNeedsDisplay: YES];
    }

} // mouseUp

Drawing the peg view hasn't changed too much, just drawing the drag image:
- (void) drawRect: (NSRect) rect
{
    // get the bounds, since the rect might be a smaller area to redraw
    NSRect bounds = [self bounds];

    // make a nice white background to draw on
    [[NSColor whiteColor] set];
    [NSBezierPath fillRect: bounds];

    // this area is where the triangular pegboard will go
    NSRect squareRect = [self makeCenterSquareRect];

    if (recalcPegPaths) {
        [self recalcPegPathsForRect: squareRect];
        recalcPegPaths = NO;
    }

    // draw the board
    [self drawBoardInRect: squareRect];

    // draw the pegs on top of the board
    int i;
    for (i = 0; i < PEG_COUNT; i++) {
        [self drawPeg: i
            selected: (i == selectedPeg)];
    }

    // draw any dragged peg on top of everything

    if (dragImage != nil) {
        [dragImage dissolveToPoint: dragPoint  fraction: 1.0];
    }

    [[NSColor blackColor] set];
    [NSBezierPath strokeRect: bounds];

} // drawRect

And the last method is the creation of the drag image. This is probably overkill for our little app, but it was some stuff I wanted to play around with. We could have easily just "dragged" a disc around by modifying the drawPeg:selected: method to draw at an arbitrary point. Instead, we'll make a transparent image, draw the peg and use NSShadow to make a drop shadow. An NSAffineTransform is used to shift the coordinate system when drawing the peg. First off, the method comments, signature, and some sanity checking.
// creates an image, with a clear background, of a peg with a drop-shadow
// underneath it.
// The dragLocation is adjusted so that if the image is drawn with its
// bottom-left at that point, the peg will appear to be under the mouse
// where the user clicked.

- (NSImage *) makeDragImageForPeg: (int) pegIndex
                        dragPoint: (NSPoint *) dragLocation
{
    assert (pegIndex != CHPegBoard_NO_PEG);
    assert (pegIndex >= 0 && pegIndex < PEG_COUNT);
Now get the rectangle that surrounds the peg
    NSRect pathBounds = [pegPaths[pegIndex] bounds];
The actual image will need to be bigger than this to allow room for the shadow. By experiments, 15 was a good amount.
    // the size of the dragged image is the size of the peg,
    // plus a skootch so that the stroked line around the peg
    // stays round.  Plus some space for the drop shadow
#define IMAGE_EXPANSION 15

    // expand it to give us room for the drop image
    NSRect bounds;
    bounds = NSInsetRect (pathBounds, -IMAGE_EXPANSION, -IMAGE_EXPANSION);
NSShadow is a new class in Panther that will draw a shadow on any drawing that happens. Configure the shadow to look good.
    // make a drop shadow (this is new in Panther)
    NSShadow *shadow = [[NSShadow alloc] init];
    [shadow setShadowBlurRadius: 15.0];
    [shadow setShadowOffset: NSMakeSize (10, -10)];
Lock focus on the image. Any drawing calls will now be accumulated into the image. Tell the shadow to set itself. When unlockFocus is called the shadow will be added to the image.
    // draw into the image
    [image lockFocus]; {
        // all drawing will get a shadow
        [shadow set];
Now to play with the coordinate system. drawPeg:selected: draws the peg at the location in the window where the peg lives. The image wants stuff draw with an origin of (zero, zero). Rather than change the way drawPeg: works, we'll just shift the coordinate system. The peg will draw itself where it wants (say at 100, 100), but we'll shift the corrdinate system by -100, -100, so that the peg will draw at 0, 0.
        // since the peg wants to draw at the point it was defined at,
        // and the image wants us to draw at (0, 0), shift the
        // coordinate system over so the drawing does happen at (0, 0)
        NSAffineTransform *transform = [NSAffineTransform transform];
        [transform translateXBy: -bounds.origin.x
                   yBy: -bounds.origin.y];
        [transform concat];
Fill the image with the clear color, so that parts that don't get drawn will be transparent.
        // make it transparent where drawing doesn't touch
        [[NSColor clearColor] set];
        [NSBezierPath fillRect: bounds];
And draw the peg
        [self drawPeg: pegIndex  selected: NO];
Unlock the focus, indicating the image drawing is done. This also will tell the shadow to draw itself.
    } [image unlockFocus];
When we draw the image, the bottom-left of the image will be located at the point we tell it to draw at. Offset the dragLocation such that it is the bottom-left of the image.
    // offset the given drag location to account for the extra padding
    // put on the image
    dragLocation->x = pathBounds.origin.x - IMAGE_EXPANSION;
    dragLocation->y = pathBounds.origin.y - IMAGE_EXPANSION;
Finally clean up after the shadow and return the image.
    [shadow release];
    
    return ([image autorelease]);

} // makeDragImageForPeg


webmonster@cocoaheads.org