CRM64Pro Tutorial 16.5

Scene War Game.

Build a strategy map with weighted A* pathfinding, animated grid movement, manual camera scrolling, terrain diagnostics, and fog of war.

Final result

CRM64Pro Tutorial 16.5 Scene War Game
Runtime controls

Right-click moves the player unit; cursor keys scroll the map; F1 toggles the grid and terrain weights; F2 toggles fog of war; and Q or ESC exits.

Prerequisites

  • CRM64Pro GDK installed and configured with a supported C++17 compiler.
  • Tutorial package downloaded and fully extracted, preserving its folder structure.
  • Understand Scene tile layers and fixed-rate logic.
  • Included assets available under bin/Base/: map-war1.png, map-war1-heightmap.png, sprite.png.
  • Keep Tutorial_bg in Tutorial.cdc.

What you will learn

  • Import a visual map as a Scene tile layer.
  • Build movement costs from a heightmap image.
  • Find weighted eight-direction routes with A*.
  • Render a smoothly moving sprite over a logical grid.
  • Maintain visible and explored fog cells.

Step 1: Store grid and visual movement state

The unit keeps its current cell, destination, in-cell pixel offset, and current A* path.

struct Player
{
    Sint32 iIDSprite;
    Sint32 iX, iY;             // Grid coordinates (cells)
    float  fOffX, fOffY;       // Visual pixel offset from center of current cell
    Sint32 iTargetX, iTargetY; // Destination (grid cells)
    bool   bIsMoving;
    vector<SDL_Point> vPath;   // Path to follow (list of grid points)
    Sint32 iCurrentPathIndex;  // Current step in the path
};

Step 2: Order the A* open set

The priority queue places the node with the lowest estimated total cost first.

struct Node
{
    Sint32 iX, iY;
    float  fF, fG; // f = total cost (g + h), g = path cost from start
    // Reverse comparison so priority_queue selects the lowest cost first.
    bool operator<(const Node& rOther) const
    {
        return fF > rOther.fF;
    }
};

Step 3: Prepare the weighted search

Cost and predecessor grids cover the map. Eight directions allow cardinal and diagonal movement.

// Data structures for A*
priority_queue<Node> vOpenSet;
vector<vector<float>> vvfCostSoFar(iHeight, vector<float>(iWidth, -1.0f));
vector<vector<SDL_Point>> vvCameFrom(iHeight, vector<SDL_Point>(iWidth, { -1, -1 }));

// Start node
vOpenSet.push({ iStartX, iStartY, 0.0f, 0.0f });
vvfCostSoFar[iStartY][iStartX] = 0.0f;

// Directions (8-way movement: cardinal + diagonals)
const Sint32 iDirs[8][2] = { {0,1}, {1,0}, {0,-1}, {-1,0}, {1,1}, {1,-1}, {-1,1}, {-1,-1} };

Step 4: Expand cheaper routes

Blocked cells are skipped. Terrain weight and diagonal distance determine the accumulated cost.

Uint32 iWeight = pMap->iCellMap[iNY][iNX];
if(iWeight == 255) continue; // Unwalkable terrain

// Cost: grid distance (1.0 or 1.414 for diagonal) * terrain weight
float fDist = (rDir[0] != 0 && rDir[1] != 0) ? 1.414f : 1.0f;
float fNewCost = vvfCostSoFar[mCurrent.iY][mCurrent.iX] + (fDist * (float)iWeight);

if(vvfCostSoFar[iNY][iNX] == -1.0f || fNewCost < vvfCostSoFar[iNY][iNX])
{
    vvfCostSoFar[iNY][iNX] = fNewCost;
    float fPriority = fNewCost + (abs(iEndX - iNX) + abs(iEndY - iNY)); // Manhattan distance heuristic
    vOpenSet.push({ iNX, iNY, fPriority, fNewCost });
    vvCameFrom[iNY][iNX] = { mCurrent.iX, mCurrent.iY };
}

Step 5: Reconstruct the route

Predecessors are followed from the destination to the start, then reversed for movement.

if(vvCameFrom[iEndY][iEndX].x != -1)
{
    SDL_Point mCurr = { iEndX, iEndY };
    while(mCurr.x != iStartX || mCurr.y != iStartY)
    {
        vPath.push_back(mCurr);
        mCurr = vvCameFrom[mCurr.y][mCurr.x];
    }
    reverse(vPath.begin(), vPath.end());
}

Step 6: Convert the heightmap into costs

Each map cell samples the center of its source block. Red is blocked; grayscale becomes a movement weight.

// Sample the center of each tile.
Sint32 iPX = iX * g_pState->iTileSize + (g_pState->iTileSize / 2);
Sint32 iPY = iY * g_pState->iTileSize + (g_pState->iTileSize / 2);
if(iPX >= sH->w) iPX = sH->w - 1;
if(iPY >= sH->h) iPY = sH->h - 1;

Uint8 iR = 0, iG = 0, iB = 0, iA = 0;
if(!SDL_ReadSurfacePixel(sH, iPX, iPY, &iR, &iG, &iB, &iA))
{
    mImageMgr.close(idImgH);
    mLog.msg(LL_ERROR, "Failed to read heightmap image pixels.\n");
    return false;
}

// Red blocks movement; grayscale controls movement cost.
Uint32 iWeight = 255;
if(iR <= 200 || iG >= 50 || iB >= 50)
{
    const Sint32 iBrightness = (iR + iG + iB) / 3;
    iWeight = 128 - static_cast<Uint32>(static_cast<float>(iBrightness) * 0.439f);
    if(iWeight < 16) iWeight = 16;
}
pLayerH->setCellValue(iY, iX, iWeight);

Step 7: Reveal and remember terrain

Previously visible cells become explored, then a circular area around the unit becomes fully visible.

// 1. Downgrade currently visible cells (0) to explored (g_pState->iFogFirstGID). 
// Using full map scan (Option A) which is sufficient for this map size.
for(Sint32 iY = 0; iY < iH; iY++)
{
    for(Sint32 iX = 0; iX < iW; iX++)
    {
        // If cell is currently visible (0), fade it to semi-transparent (explored)
        if(pFog->iCellMap[iY][iX] == 0)
        {
            pFog->iCellMap[iY][iX] = g_pState->iFogFirstGID;
        }
    }
}
// 2. Reveal new cells (circular radius)
Sint32 iRadius = 3;
Sint32 iR2 = iRadius * iRadius;

for(Sint32 iY = -iRadius; iY <= iRadius; iY++)
{
    for(Sint32 iX = -iRadius; iX <= iRadius; iX++)
    {
        // Check circular distance
        if(iX * iX + iY * iY <= iR2)
        {
            Sint32 iGX = iGridX + iX;
            Sint32 iGY = iGridY + iY;

            if(iGX >= 0 && iGX < iW && iGY >= 0 && iGY < iH)
            {
                pFog->iCellMap[iGY][iGX] = 0; // Set to 0 (No Tile) = Fully Visible
            }
        }
    }
}

Step 8: Render between cells

The logical cell plus pixel offset produces smooth world movement. The Scene context converts it to the viewport.

// Calculate absolute world position using Grid + Sub-cell Offset
float fWorldX = (static_cast<float>(g_pState->pPlayer->iX * g_pState->iTileSize)) + g_pState->pPlayer->fOffX;
float fWorldY = (static_cast<float>(g_pState->pPlayer->iY * g_pState->iTileSize)) + g_pState->pPlayer->fOffY;

// Apply scroll and viewport offset to get screen coordinates
SDL_FPoint pScreen = rContext.worldToScreen(fWorldX, fWorldY, true);
float fScreenX = pScreen.x;
float fScreenY = pScreen.y;

Step 9: Build fog tiles at runtime

One generated image contains explored and opaque fog tiles; the Scene assigns their global IDs.

// Tile 1: semi-transparent (50% alpha)
SDL_Rect r1 = { 0, 0, g_pState->iTileSize, g_pState->iTileSize };
if(!SDL_FillSurfaceRect(sFog, &r1, SDL_MapRGBA(SDL_GetPixelFormatDetails(sFog->format), NULL, 0, 0, 0, 128)))
{
    SDL_DestroySurface(sFog);
    mLog.msg(LL_ERROR, "Failed to draw the fog image.\n");
    return false;
}

// Tile 2: opaque (black 100% alpha)
SDL_Rect r2 = { g_pState->iTileSize, 0, g_pState->iTileSize, g_pState->iTileSize };
if(!SDL_FillSurfaceRect(sFog, &r2, SDL_MapRGBA(SDL_GetPixelFormatDetails(sFog->format), NULL, 0, 0, 0, 255)) ||
   pFogImage->assignSurface(sFog, 1) < 0)
{
    SDL_DestroySurface(sFog);
    mLog.msg(LL_ERROR, "Failed to finish the fog image.\n");
    return false;
}

Step 10: Convert clicks into path requests

A right-button edge is converted through Scene coordinates and only starts movement when A* returns a route.

if(g_pState->pScene->mouseToCell(&iTX, &iTY, 1))
{
    // Only log if target changes
    if(iTX != g_pState->pPlayer->iTargetX || iTY != g_pState->pPlayer->iTargetY)
    {
        mLog.msg(LL_INFO, "Moving to grid cell: (%d, %d)\n", iTX, iTY);
    }

    // Pathfinding logic: trigger movement only if target changes
    if(iTX != g_pState->pPlayer->iX || iTY != g_pState->pPlayer->iY) {
        g_pState->pPlayer->vPath = findPath(g_pState->pPlayer->iX, g_pState->pPlayer->iY, iTX, iTY);
        if(!g_pState->pPlayer->vPath.empty()) {
            g_pState->pPlayer->iCurrentPathIndex = 0;
            g_pState->pPlayer->bIsMoving = true;
        }
    }
}

Step 11: Save and clean up

The final Scene and CDC are checked when saved. Cleanup also handles partial initialization.

if(g_pState->pScene->save(OUTPUTDIR"wargame.tmx", "", "wargame.cdc", true) < 0)
{
    mLog.msg(LL_ERROR, "Failed to save the War Game Scene.\n");
    closeTutorial();
    return -1;
}
static void closeTutorial()
{
    Main& mC64 = Main::instance();
    mC64.sceneMgr().close(0);
    mC64.spriteMgr().close(0);
    mC64.tileMgr().close(0);
    mC64.fontMgr().close(0);
    mC64.imageMgr().close(0);
    Main::terminate();
}

Complete source

Previous tutorial

Add scrolling, projectiles and object collisions.

Go to Tutorial 16.4: Scene Shooter

Tutorial index

Back to tutorials

Next tutorial

Add animated lights, occluders and particle effects to a Scene.

Go to Tutorial 16.6: Scene Lighting