Click here to Skip to main content
15,069,581 members
Articles / Desktop Programming / WPF
Article
Posted 17 Dec 2013

Tagged as

Stats

41.8K views
2.6K downloads
43 bookmarked

Magnet: A Mind Teaser in 3D

Rate me:
Please Sign up or sign in to vote.
4.89/5 (29 votes)
17 Dec 2013Ms-RL8 min read
Can you solve this?

Introduction

This piece of work brings together the basic principles of Mathematics and Physics. This app is a mind teaser in three dimensional space, the idea is to push cubes in left/right, front/back, top/down directions in a 3D mesh using a yellow cube aka magnet and make a pattern as shown on the left side in Figure 1. Once a pattern is made, the pattern vanishes and the game concludes.

Background

Here is a video demonstration:

Using the Code

Image 1

Figure 1

The above picture is a snapshot of the application, written in WPF using the MVVM pattern. The codes make use of a camera, lights (point, direction), custom geometry (modelvisual3d), delegatecommand, custom trigger action, dependency property, and many more.

I will drive the article in the form of questions and answers (what the code does along with code piece).

What is perspective-camera, position, lookdirection, fieldofView ?

Perspective camera projects 3D object on 2D surface in more realistic way:

  • Position tells us where in 3D space camera is located, default value is Point3D(00, 125, 855).
  • Look-direction is a 3DVector(magnitude/direction) and tells us where to look from camera position, default value is Vector3D(0, -125, -855).

Image 2Image 3

  • FieldofView is an angle, defines how enlarge the object in 3D view is projected. The figure below shows different fields of view angles formed by same coloured lines, default value is 70, its like zoom-in and zoom-out feature, incrementing fieldofview cause zoom-in, decrementing fieldofview cause zoom-out.

What Pattern to Make and Controls to Move Magnet?

Image 4 Image 5

Build the pattern shown above by moving the magnet and pushing other cubes in any direction. Magnet when active attracts other cubes from all directions in its line towards itself. The second picture shows keyboard keys to move the magnet, to activate magnet press space/enter, use pageup/pagedown to move front and back, alternate keys are (f - left, g - front, v - down, t - up, h - right, y - back, space - magnet).

C#
private Model3DGroup TargetFigure();

How to Build Mesh?

Image 6

Mesh is network of lines, connected together to form a structure with empty blocks, in these empty blocks magnet moves, these lines are actually a cylinder with radius 1, Mesh.cs is responsible for creating, rotating, translating and positioning lines/cylinder to form a 3D mesh.

Sample code from Mesh.cs creates a cylinder3d.

C#
Cylinder3D cylinder = new Cylinder3D();
cylinder.Length = Math.Abs((Constants.NoofBlocksInXdirection) * cubeLength + 4);
cylinder.Radius = cylinderRadius;
cylinder.Material = new DiffuseMaterial(colorBrush);
cylinder.BackMaterial = new DiffuseMaterial(colorBrush);
 
TranslateTransform3D Transalte = new TranslateTransform3D();
Transalte.OffsetX = xCoordinate + 2;
Transalte.OffsetY = yCoordinate + cubeLength * levels;
Transalte.OffsetZ = zCoordinate;
 
RotateTransform3D ROTATE = new RotateTransform3D();
Vector3D vector3d = new Vector3D(0, 0, 1);
ROTATE.Rotation = new AxisAngleRotation3D(vector3d, 90);
 
Transform3DGroup myTransformGroup = new Transform3DGroup();
myTransformGroup.Children.Add(ROTATE);
myTransformGroup.Children.Add(Transalte);
cylinder._content.Transform = myTransformGroup;
modelGroup.Children.Add(cylinder._content);

How Lines Are Made?

Image 7

This is a zoom image of a portion of line which actually is a cylinder, cylinder is MeshGeometry3D, mesh geometry is collection positions and triangles indices.

Positions is collection of coordinates, divide the line length into parts, create circles around the line at part (such that line passes through the centre of circles and line is perpendicular to circle surface), divide the circle circumference into portions, calculate coordinates of points on circumference,

In the image above, circle is divided into four portions and line is divided into two parts 0,1,2,3,4,5,6,7 are positions no (not coordinates). The below code calculates the coordinates:

C#
for (int i =0; i <= lengthDivision; i++)
           {
               double y = minYCoor + i * dy;

               for (int j = 0; j < circumferenceDivision; j++)
               {
                   double t = j * dt;

                   mesh.Positions.Add(GetPosition(t, y));
               }
           }
C#
Point3D GetPosition(double t, double y)
       {
           double x = Radius * Math.Cos(t);
           double z = Radius * Math.Sin(t);
           return new Point3D(x, y, z);
       }

TriangleIndices is collection of positions, WPF rendering system picks 3 positions from triangles indices in continuation and joins them to form a surface and render them (by following right hand thumb rule to decide front and back surface), in the figure above, 0 4 1 1 4 5 1 5 2 2 5 6 2 6 3 3 6 7 3 7 0 0 7 4 are triangle indices, by connecting positions in triangle indices surfaces are made. This code below connects positions to form triangles.

C#
for (int i = 0; i < lengthDivision; i++)
            {
                for (int j = 0; j < circumferenceDivision; j++)
                {
                   int x0 = j % circumferenceDivision + i * circumferenceDivision;//0
                   int x1 = (j + 1) % circumferenceDivision + i * circumferenceDivision;//1
                    int x2 = j + circumferenceDivision + i * circumferenceDivision;//4

                    int x3 = x1;//1
                    int x4 = x3 + circumferenceDivision;//5
                    int x5 = x2;//4
                   
                    mesh.TriangleIndices.Add(x0);
                    mesh.TriangleIndices.Add(x2);
                    mesh.TriangleIndices.Add(x1);
 
                    mesh.TriangleIndices.Add(x3);
                    mesh.TriangleIndices.Add(x5);
                    mesh.TriangleIndices.Add(x4);
               }
            }

How to Make Cubes?

Image 8 Image 9

Cube is MeshGeometry3D formed by positions and triangle-indices, 0,1,2,3,4,5,6,7 are positions on a cube every point is relative to starting coordinate, just need to provide starting coordinates and widthheightdepth to cube.cs, it will draw itself.

C#
public static DependencyProperty StartingPointCubeProperty = 
    DependencyProperty.Register("StartingPointCube", typeof(Point3D), typeof(Base3D), 
    new PropertyMetadata(OnPoint3dChanged));					

public Point3D StartingPointCube
        {
            get 
            { 
		       return (Point3D)GetValue(StartingPointCubeProperty); 
		    }
            set 
            {           
               SetValue(StartingPointCubeProperty, value);
            }
        }

The magnet is illuminated in special way, there is point light inside it to give illumination.

C#
PointLight light = new PointLight();
light.Position = new Point3D(point3d.X + widthHeightDepth, 
                 point3d.Y + widthHeightDepth, point3d.Z + widthHeightDepth);
light.Color = Colors.Red;
modelGroup.Children.Add(light);

Placement of Cubes How?

In mesh, there are 125 empty blocks where cubes can be placed, imagine there are 5 floors, on each floor, there are 25 blocks (5 blocks in x direction * 5 blocks in z direction):

C#
public static int BlocksInXdirection = 5;
public static int BlocksInZdirection = 5;
public static int NoofFloor = 5; 

There are 12 cubes, 4 of each color (red,blue,green) and one moving cube magnet, these cubes are placed randomly in any of the 125 blocks, the below code does the same.

C#
int xcoor, ycoor, zcoor;
int floorNo = -1;
int positionOnFloor = randomCube.Next(0, cubesPerFloor);
 
Random randomSteps = new Random();
 
for (int i = 1; i <= TotalCubes; i++)
    {
          positionOnFloor = randomCube.Next(0, cubesPerFloor);
 
          Color color = ColorsCollection[i % 3];
 
          floorNo = (floorNo + 3) % Constants.NoofFloor;
 
          //This position is unoccupied
      if (position[floorNo][positionOnFloor] == null)
      {
        xcoor = (int)(positionOnFloor % (Constants.BlocksInXdirection));
        ycoor = floorNo;
        zcoor = (int)(positionOnFloor / (Constants.BlocksInZdirection));
 
 position[floorNo][positionOnFloor] = PlaceCube(xcoor, floorNo, zcoor, color);
      }        
    }

We maintain each placed cube in data structure position, position is a dictionary whose keys are floor no and values are dictionary<int,cube> keys of internal dictionary represents position on floor.

Image 10

The code below creates Cube at specified coordinate in 3D space.

C#
private Cube PlaceCube(int xCoor, int yCoor, int zCoor, Color color)
{
      double cubeLength =Constants.CubeLength;
      Cube cube3d = new Cube();
      cube3d.Transform = Translate;
      cube3d.color = color;
      cube3d.WidthHeightDepth = Constants.CubeLength;
      cube3d.opacity = 1;
      cube3d.StartingPointCube = new Point3D(cubeLength * xCoor, 
                                 cubeLength * yCoor,cubeLength * zCoor);
      cubesCollection.Add(cube3d);
}

By default, the chosen position of magnet is floor 2 and 22 is position on floor.

C#
this.Magnet = PlaceCube(Constants.MagnetBlockXDirection,
     Constants.MagnetBlockYDirection, Constants.MagnetBlockZDirection, Colors.Yellow); 
           
this.MagnetFloorNo = Constants.MagnetBlockYDirection;
this.MagnetPositionOnFloor = Constants.BlocksInXdirection * 
     Constants.MagnetBlockZDirection + Constants.MagnetBlockXDirection;
         
this.position[this.MagnetFloorNo][this.MagnetPositionOnFloor] = this.Magnet;
this.Magnet.IsMovingCube = true;

Movement of Magnet How?

Magnet can push other cubes in direction of its movement, before magnet moves, we check if there is any vacant position in line of it movement.

Example 1: Let's say magnet moves left from current position 24 on floor no 1, it can move (if any one of positions 23,22,21,20 on the same floor is vacant).

Example 2: Let's say magnet moves back from current position 22 on floor no 1, it can move (if any one of positions 2,7,12,17 on same floor is vacant).

Example 3: Let's say magnet moves up from current position 10 on floor no 1, it can move (if any one of position 10 on floor no 2,3,4,5 is vacant), the below code snippet does the same.

C#
case Direction.Up:
for (counter = MagnetFloorNo + 1; counter < Constants.NoofFloor; counter++)
 {
    if (position[counter][MagnetPositionOnFloor] == null)
          {
            emptyPositionOrFloor = counter;
            canMove = true;
            break;
          }
 }   

If magnet can move, many things take place.

  1. Camera position, lookdirection, fieldofview are animated, the below code snippet does the same:
    C#
    this.ViewModelCamera.BeginAnimation(PerspectiveCamera.PositionProperty, 
         animationKeyFramesCameraPosition, HandoffBehavior.Compose);
    this.ViewModelCamera.BeginAnimation(PerspectiveCamera.LookDirectionProperty, 
         animationKeyFramesCameraLookDirection, HandoffBehavior.Compose);
    this.ViewModelCamera.BeginAnimation(PerspectiveCamera.FieldOfViewProperty, 
         animationKeyFramesFieldofView, HandoffBehavior.Compose);  
  2. On magnet movement, datastructure position is updated (old positions made empty and new positions are filled), the below code snippet does the same.
    C#
    case Direction.Left:
    if (cubeLocation.X >= 0)
     {
    for (counter = emptyPositionOrFloor + 1; counter < MagnetPositionOnFloor; counter++)
         {
               this.MoveBlocks(position[MagnetFloorNo][counter], 1, Direction.Left);
               position[MagnetFloorNo][counter - 1] = position[MagnetFloorNo][counter];
         }
     
    position[MagnetFloorNo][MagnetPositionOnFloor - 1] = 
                position[MagnetFloorNo][MagnetPositionOnFloor];
                position[MagnetFloorNo][MagnetPositionOnFloor] = null;
                MagnetPositionOnFloor--;
                goto default;
     }....  
  3. Magnet movement is animated, the below code snippet does the same:
    C#
    MovingCube.BeginAnimation(Cube.StartingPointCubeProperty, 
                              animationKeyFrames, HandoffBehavior.Compose); 
  4. When all animation stops, only then new movement of magnet can happen. The below code snippet ensures the same.
    C#
    if (this.cubeAnimationCompleted == true && 
        this.positionAnimationCompleted == true && 
        this.cameraLookdirectionAnimationCompleted == true && 
        this.fieldViewAnimationCompleted == true && 
        CountMovingBlocks == 0)

    The following events ensure that animation has completed, only then new movements can take place:

    C#
    private void AnimationKeyFramesCameraPosition_Completed(object sender, EventArgs e)
     {
         cameraLookdirectionAnimationCompleted = true;
     } 
    C#
    private void AnimationKeyFramesCameraLookDirection_Completed(object sender, EventArgs e)
     {
          positionAnimationCompleted = true;
     } 
    C#
    private void AnimationFieldView_Completed(object sender, EventArgs e)
     {
          fieldViewAnimationCompleted = true;
     } 
    C#
    private void AnimationKeyFramesBox_Completed(object sender, EventArgs e)
     {
          cubeAnimationCompleted = true;
          CheckCompletness();
     } 
  5. MagnetPositiononFloor, MagnetFloorNo are updated on magnet movement.
    • When magnet moves back.
      C#
      MagnetPositionOnFloor = MagnetPositionOnFloor - Constants.BlocksInXdirection; 
    • When magnet moves up.
      C#
      MagnetFloorNo++;   
    • When magnet moves down.
      C#
      MagnetFloorNo--; 
    • When magnet move left.
      C#
      MagnetPositionOnFloor--; 
    • When magnet move right.
      C#
      MagnetPositionOnFloor++;
    • When magnet move front.
      C#
      MagnetPositionOnFloor = MagnetPositionOnFloor + Constants.BlocksInXdirection; 
  6. Old events are unsubscribed and new events subscribed.
    C#
    if (animationFieldView != null)
        {
          animationFieldView.Completed -= new EventHandler(AnimationFieldView_Completed);
        }
                 
    animationFieldView = new DoubleAnimationUsingKeyFrames();
    animationFieldView.Completed += new EventHandler(AnimationFieldView_Completed);...	
  7. Magnet pushes other cubes, here cube3d is instance of cube that is pushed by magnet by number of steps in direction (Left, Right, Up, Down, Front, Back).
    C#
    private void MoveBlocks(Cube cube3d, int step, Direction direction).... 
  8. When magnet move pattern completeness is checked on completion of magnet animation as shown in event above.
    C#
    private void CheckCompletness(); 
  9. StepCount is updated, tells how many steps magnet have moved, binded to view.
    C#
    public int StepCount
           {
               get
               {
                   return stepCount;
               }
               set
               {
                   this.stepCount = value;
                   NotifyPropertyChanged("StepCount");
               }
           }
    

How to Pass keyboard Event with keyargs to ViewModel?

On keydown event in main window, we want to invoke a function in viewmodel and pass key args, for that Keyboard event is binded to Delegate command using System.Windows.Interactivity DLL.

XML
<i:Interaction.Triggers> 
<i:EventTrigger EventName="KeyDown">
<local:InvokeDelegateCommandAction 
 Command="{Binding KeyDownCommand}"
 CommandName="KeyDownCommand"
 CommandParameter="{Binding RelativeSource={RelativeSource Self}, Path=InvokeParameter}" />
</i:EventTrigger>									   </i:Interaction.Triggers>

InvokeDelegateCommandAction is custom trigger action for passing keyargs, taken help from this Post.

How To Make Sure Cubes Are Visible When They Fall in Line of Sight?

On every move of magnet, we calculate distance of others cubes from camera position and maintain old distance.

C#
private List<Cube> CalculateDistance(PerspectiveCamera camera)
     {
          Cube cube;
            Vector3D vector;
            List<Cube> cubeCollection = new List<Cube>();
            for (int floor = 0; floor < Constants.NoofFloor; floor++)
            {
                for (int i = 0; i < Constants.NoofBlocksInXdirection * 
                                Constants.NoofBlocksInZdirection; i++)
                {
                    if (!((position[floor][i] == null) || 
                       (floor == MagnetFloorNo && i == MagnetPositionOnFloor)))
                    {
                        cube = position[floor][i] as Cube;
                        vector = Point3D.Subtract(camera.Position, cube.Point3DCircuit);
                        cube.OldDistanceFromViewer = cube.NewDistanceFromViewer;
                        cube.NewDistanceFromViewer = vector.Length;
                        cubeCollection.Add(cube);
                    }
                }
            }
 
            return cubeCollection.OrderByDescending(x => x.NewDistanceFromViewer).ToList();
        }

By differentiating between old distance and new distance opacity is changed, if cube has move farther from old position opacity is increased otherwise decreased.

Opacity is animated not changed in one go.

C#
 if ((cubeCollection[i].NewDistanceFromViewer - 
          cubeCollection[i].OldDistanceFromViewer) > 0)
                {
                    oldOpacity = .8;
                    delta = (.1) / factor;
                }
                else
                {
                    oldOpacity = 1;
                    delta = -(.2) / factor;
                }
 
                for (int count = 1; count < factor; count++)
                {
                    newOpacity = oldOpacity + delta * count;
                    if (newOpacity > 0 && newOpacity <= 1)
                    {
                        LinearDoubleKeyFrame linearkeyFrame = 
                                       new LinearDoubleKeyFrame(newOpacity);
 
                        opacitykeyFrame.KeyFrames.Add(linearkeyFrame);
                    }
                }
 
cubeCollection[i].BeginAnimation(Cube.opacityProperty, opacitykeyFrame, 
                                 HandoffBehavior.SnapshotAndReplace); 

Why Do We Need to Animate position, look-direction, fieldofView Property of Camera?

To give perfect view of moving magnet in 3D mesh, there is need to animate camera, when magnet moves left/right/up/down/front/back camera is moved left/right/up/down/front/back respectively to stay focused on magnet.

There is special handling on front movement of magnet fieldofview is decreased and on back movement of cube fieldofview is increased by fixed coordinates.

E.g., Let's say cube has moved to left mesh block from current position, camera.position.x coordinate is decreased by delta but camera.lookdirection.x coordinate is increased by delta.

Note: We ensure the camera.lookdirection.magnitude is constant while animating camera.position, camera.lookdirection and camera.fieldofview.

How Do We Animate position, look-direction, fieldofView Property of Camera ?

Point3DAnimationUsingKeyFrames, Vector3DAnimationUsingKeyFrames, DoubleAnimationUsingKeyFrames are used to animated position, look-direction, fieldofView respectively.

Whichever way the magnet moves, e.g., cube moves in up direction by x coordinates same as cube length:

C#
this.ViewModelCamera.BeginAnimation(PerspectiveCamera.PositionProperty, 
     animationKeyFramesCameraPosition, HandoffBehavior.Compose);
C#
this.ViewModelCamera.BeginAnimation(PerspectiveCamera.LookDirectionProperty, 
     animationKeyFramesCameraLookDirection, HandoffBehavior.Compose); 
C#
this.ViewModelCamera.BeginAnimation(PerspectiveCamera.FieldOfViewProperty, 
     animationKeyFramesFieldofView, HandoffBehavior.Compose); 

Following properties in view model are binded to dependency property of Perspective Camera.

C#
public Point3D CameraPosition
       {
           get
           {
               return this.cameraPosition;
           }
           set
           {
               this.cameraPosition = value;
               NotifyPropertyChanged("CameraPosition");
           }
       }
C#
public double FieldofView
       {
           get
           {
               return this.fieldofView;
           }
           set
           {
               this.fieldofView = value;
               NotifyPropertyChanged("FieldofView");
           }
       }
C#
public Vector3D CameraLookDirection
        {
            get
            {
                return this.cameraLookDirection;
            }
            set
            {
                this.cameraLookDirection = value;
                NotifyPropertyChanged("CameraLookDirection");
            }
        }

Camera property encapsulates the above properties and registers a Changed event.

C#
private PerspectiveCamera ViewModelCamera
        {
            get
            {
                if (camera == null)
                {
                    camera = new PerspectiveCamera
                    (CameraPosition, CameraLookDirection, 
                    new Vector3D(0, 0, 0), FieldofView);
                    camera.Changed += new EventHandler(Camera_changed);
                }
 
                return camera;
            }
        }

Whenever there is change in any of ViewModelCamera properties, Changed event is raised and we notify the MainWindow.xml to update itself.

MVVM Pattern?

Code is based on MVVM pattern, instance of MagnetViewModel is set as datacontext.

C#
MagnetViewModel viewModel = new MagnetViewModel();
C#
this.DataContext = viewModel;

The entire code was initially written in code behind but later moved to MVVM pattern to make code fully testable but still there is a little code in codebehind file which caters to addition and removal of cubes in ViewPort3d. Addition of all cube to ViewPort3D is a one time activity, removal of cube from ViewPort3D happens once a pattern is made and cubes need to be removed from Viewport3D.

C#
void ViewModel_CollectionChangedChanged
(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            List<Cube> removeCubes = sender as  List<Cube>;
 
            for (int k = 0; k < removeCubes.Count; k++)
            {
                this.ViewPort3dPentagon.Children.Remove(removeCubes[k]);
            }
        } 

CollectionChanged events is raised from view model, (registered and listened) in code behind file to remove cube.

Points of Interest

  • There could be many variants to this game, highlighted only the basic one.
  • Most important thing to note performance of app in term of usability and system resource.

The most fun part is, it started with something and ended up with something else.

Do vote if you like this article. Cheers!

History

  • 17th December, 2013: Initial version

License

This article, along with any associated source code and files, is licensed under Microsoft Reciprocal License

Share

About the Author

ATUL_LOONA
India India
"It's impossible," said pride. "It's risky," said experience . "It's pointless," said reason "Give it a try." whispered the heart


Another piece of work here are links.

1) http://monk.parseapp.com/index.htm

2) http://picassa.parseapp.com/index.htm

3) http://circles.parseapp.com/

4) https://play.google.com/store/apps/details?id=com.complex

5) https://www.facebook.com/pages/MagForce/1406500216306629

Comments and Discussions

 
GeneralMy vote of 5 Pin
Programm3r13-Jan-14 19:37
MemberProgramm3r13-Jan-14 19:37 
Questionentrée en.png Pin
hack2root9-Jan-14 19:59
Memberhack2root9-Jan-14 19:59 
AnswerRe: entrée en.png Pin
ATUL_LOONA9-Jan-14 20:30
MemberATUL_LOONA9-Jan-14 20:30 
GeneralRe: entrée en.png Pin
hack2root12-Jan-14 5:37
Memberhack2root12-Jan-14 5:37 
GeneralNice concept Pin
Meshack Musundi4-Jan-14 4:41
mvaMeshack Musundi4-Jan-14 4:41 
GeneralRe: Nice concept Pin
ATUL_LOONA5-Jan-14 20:56
MemberATUL_LOONA5-Jan-14 20:56 
QuestionNice very Nice Pin
Shivprasad koirala30-Dec-13 16:10
MemberShivprasad koirala30-Dec-13 16:10 
AnswerRe: Nice very Nice Pin
ATUL_LOONA2-Jan-14 19:23
MemberATUL_LOONA2-Jan-14 19:23 
GeneralMy vote of 5 Pin
Ramsin23-Dec-13 8:39
MemberRamsin23-Dec-13 8:39 
GeneralRe: My vote of 5 Pin
ATUL_LOONA2-Jan-14 19:23
MemberATUL_LOONA2-Jan-14 19:23 
SuggestionExcellent idea ! Pin
Perić Željko19-Dec-13 3:41
professionalPerić Željko19-Dec-13 3:41 
GeneralRe: Excellent idea ! Pin
ATUL_LOONA20-Dec-13 0:22
MemberATUL_LOONA20-Dec-13 0:22 
Thanks very much for appreciating my workSmile | :)

Cheers.
GeneralMy vote of 5 Pin
fredatcodeproject19-Dec-13 2:44
professionalfredatcodeproject19-Dec-13 2:44 
Questionsource code Pin
fredatcodeproject17-Dec-13 23:44
professionalfredatcodeproject17-Dec-13 23:44 
AnswerRe: source code Pin
ATUL_LOONA18-Dec-13 0:11
MemberATUL_LOONA18-Dec-13 0:11 
QuestionWhere is the code???? Pin
Sacha Barber17-Dec-13 23:21
mvaSacha Barber17-Dec-13 23:21 
AnswerRe: Where is the code???? Pin
ATUL_LOONA17-Dec-13 23:55
MemberATUL_LOONA17-Dec-13 23:55 
QuestionThe link states, "Download the source code, but there is only an executable image. Pin
Bill_Hallahan17-Dec-13 15:08
MemberBill_Hallahan17-Dec-13 15:08 
Generalgreat! Pin
Southmountain17-Dec-13 9:49
MemberSouthmountain17-Dec-13 9:49 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.