2
\$\begingroup\$

I am working on a browser strategy game as a hobby and came up with a decision to use a tile-based map for the world map.

I have several questions so far:

  • I am using double-cache (file and apc) for performance issues. Isn't it too much? Or should I consider using Redis or anything similar?

  • What would be the best way to add gradients/transition zones to the images I am generating? For example if we have sand surrounded by sea cells, I would like the borders to be smooth between the cells, if you know what I mean :)

Thanks for any help in advance. Please see below for the code of the MapTile class that I am working on (the rest of the code can be found here).

<?php

    /**
     * TODO:
     * - add gradients for neighbours tiles
     */

    // namespace declaration
    namespace Application\Model\Map;

    use \PDO;
    use \RecursiveDirectoryIterator;
    use Application\Core\Cache;
    use Application\Model\BaseModel;
    use Application\Model\iModel;
    use Application\Utils\Registry;

    // file access protection
    if (!defined('APP_PATH')) exit(_('Access denied'));

    class MapTile extends BaseModel implements iModel
    {
        protected $table = 'map_coords_net';

        const CELL_SIZE         = 90;
        const ZOOM_DEFAULT      = 20;
        const CACHE_PREFIX_IMG  = 'tile_image_';
        const CACHE_PREFIX_OBJ  = 'tile_object_';
        const CACHE_DIRECTORY   = 'map/tiles';

        public $coordsX         = 0;
        public $coordsY         = 0;
        public $mapGroundType   = '';

        protected $zoom         = 0;
        protected $image        = null;

        // neighbours types array for gradient rendering
        protected $neighbours = array(
            'north' => null,
            'south' => null,
            'west'  => null,
            'east'  => null,
        );

        /**
         * Get current tile and neighbours (4) details from db
         *
         * @return void
         */
        public function getDetails()
        {
            // fetch from db
            $stmt = $this->db->prepare("
                SELECT
                    self.coordsX,
                    self.coordsY,
                    mgt.type as mapGroundType
                FROM map_coords_net self
                    INNER JOIN map_ground_type mgt
                        ON self.mapGroundType = mgt.mapGroundTypeId
                WHERE 1 = 1
                    -- get self and 5 neighbours if those exist
                    AND coordsX IN (:coordsX - 1, :coordsX, :coordsX + 1)
                    AND coordsY IN (:coordsY - 1, :coordsY, :coordsY + 1)
            ");
            $stmt->bindParam(':coordsX', $this->coordsX, PDO::PARAM_INT);
            $stmt->bindParam(':coordsY', $this->coordsY, PDO::PARAM_INT);
            $stmt->execute();

            // fetch request results
            $tiles = $stmt->fetchAll(
                PDO::FETCH_CLASS,
                "\Application\Model\Map\MapTile"
            );

            // set neighbours array
            foreach ($tiles as $tile)
            {
                // typecast
                $tile->coordsX = (int) $tile->coordsX;
                $tile->coordsY = (int) $tile->coordsY;

                if ($this->equals($tile))
                {
                    // copy tile properties
                    $this->mapGroundType = $tile->mapGroundType;
                }
                else {
                    // fill neignours array
                    $this->placeNeighbour($tile);
                }
            }
        }

        /**
         * Get mapGroundTypeId from the `map_ground_type` table
         *
         * @return int
         */
        public static function getMapGroundTypeByName($groundTypeName)
        {
            $groundTypeName = trim($groundTypeName);

            $stmt = Registry::get('db')->prepare("
                SELECT mapGroundTypeId
                FROM map_ground_type
                WHERE type = :type
            ");
            $stmt->bindParam(':type', $groundTypeName, PDO::PARAM_STR);
            $stmt->execute();

            return (int) $stmt->fetchColumn();
        }

        /**
         * Place neighbour in the corresponding array element
         *
         * @param MapTile $tile
         *
         * @return void
         */
        protected function placeNeighbour(MapTile $tile)
        {
            if ($this->coordsX == $tile->coordsX - 1)
            {
                $this->neighbours['north'] = $tile;
            }

            if ($this->coordsX == $tile->coordsX + 1)
            {
                $this->neighbours['south'] = $tile;
            }

            if ($this->coordsY == $tile->coordsY - 1)
            {
                $this->neighbours['west'] = $tile;
            }

            if ($this->coordsY == $tile->coordsY + 1)
            {
                $this->neighbours['east'] = $tile;
            }
        }

        /**
         * Compare the passed tile to this one
         *
         * @param MapTile $tile
         * @param string  $compareBy - comparison method
         *
         * @return bool
         */
        protected function equals(MapTile $tile, $compareBy = 'coords')
        {
            switch ($compareBy)
            {
                default:
                case 'coords':
                    // return comapre results
                    return (
                        $this->coordsX == $tile->coordsX
                        && $this->coordsY == $tile->coordsY
                    );
                break;
            }

            return false;
        }

        /**
         * Set tile coords
         *
         * @param int $x
         * @param int $y
         * @param int $z
         *
         * @return void
         */
        public function setCoords($x, $y, $z = 20)
        {
            $this->coordsX = (int) $x;
            $this->coordsY = (int) $y;
            $this->zoom = (int) $z;
            $this->getDetails();
        }

        /**
         * Set image header for the output
         *
         * @return void
         */
        public static function setImageHeader()
        {
            $cacheLifeTime = Cache::DEFAULT_TTL;
            $ts = gmdate("D, d M Y H:i:s", time() + $cacheLifeTime) . " GMT";
            header("Expires: {$ts}");
            header("Pragma: cache");
            header("Cache-Control: max-age={$cacheLifeTime}");
            header('Content-type: image/jpeg');
        }

        /**
         * Create image object
         *
         * Double-cached in files and apc
         *
         * @return void
         */
        public function render()
        {
            $cacheKey = self::CACHE_PREFIX_IMG.$this->coordsX.':'.$this->coordsY;
            $cacheFilePath = CACHE_PATH.self::CACHE_DIRECTORY.DIRECTORY_SEPARATOR.$cacheKey.'.jpg';

            $id = array(
                'coordsX' => $this->coordsX,
                'coordsY' => $this->coordsY,
            );

            self::setImageHeader();

            // check if cached file exists
            if (file_exists($cacheFilePath)) 
            {
                if (Cache::exists($cacheKey)) {
                    $imageContent = unserialize(Cache::get($cacheKey));
                }
                else {
                    // get from file
                    $imageContent = file_get_contents($cacheFilePath);

                    // save in apc
                    Cache::set($cacheKey, serialize($imageContent));
                }

                // show file contents
                echo $imageContent;
                return;
            }
            // or create an image
            else {
                // create image object
                $this->image = ImageCreateTrueColor(self::CELL_SIZE, self::CELL_SIZE);

                if ($this->exists($id))
                {
                    switch ($this->mapGroundType)
                    {
                        default:
                        case 'sea':
                            $backgroundColor = array_map('hexdec', str_split('0099FF', 2));
                            $noizeColor = array_map('hexdec', str_split('004C80', 2));
                        break;

                        case 'stone':
                        case 'mountain':
                            $backgroundColor = array_map('hexdec', str_split('969BA0', 2));
                            $noizeColor = array_map('hexdec', str_split('5A5D60', 2));
                        break;

                        case 'sand':
                            $backgroundColor = array_map('hexdec', str_split('CC9900', 2));
                            $noizeColor = array_map('hexdec', str_split('996600', 2));
                        break;

                        case 'forest':
                            $backgroundColor = array_map('hexdec', str_split('336600', 2));
                            $noizeColor = array_map('hexdec', str_split('003300', 2));
                        break;

                        case 'grass':
                            $backgroundColor = array_map('hexdec', str_split('339933', 2));
                            $noizeColor = array_map('hexdec', str_split('006600', 2));
                        break;
                    }

                    // create colors from RGB
                    $backgroundColorRGB = ImageColorAllocate($this->image, $backgroundColor[0], $backgroundColor[1], $backgroundColor[2]);
                    $noizeColorRGB = ImageColorAllocate($this->image, $noizeColor[0], $noizeColor[1], $noizeColor[2]);

                    // add noize
                    for ($w = 0; $w <= self::CELL_SIZE; $w++)
                    {
                        for ($h = 0; $h <= self::CELL_SIZE; $h++)
                        {
                            if (mt_rand(1, 100) >= 30) {
                                ImageSetPixel($this->image, $w, $h, $backgroundColorRGB);
                            }
                            else {
                                ImageSetPixel($this->image, $w, $h, $noizeColorRGB);
                            }
                        }
                    }

                    // black text for couloured tiles
                    $textcolor = ImageColorAllocate($this->image, 0, 0, 0);
                }
                else {
                    // display black rectangle for non-existing tile
                    $backgroundColorRGB = imagecolorallocate($this->image, 0, 0, 0);
                    imagefilledrectangle($this->image, 0, 0,
                        self::CELL_SIZE, self::CELL_SIZE,
                        $backgroundColorRGB
                    );

                    // white text for couloured tiles
                    $textcolor = ImageColorAllocate($this->image, 255, 255, 255);
                }
            }

            // set text
            $coordsString = "{$this->coordsX}:{$this->coordsY}";
            ImageString($this->image, 1, 5, 5, $coordsString, $textcolor);

            // save to cache
            ImageJpeg($this->image, $cacheFilePath);

            // view 
            ImageJpeg($this->image);
            ImageDestroy($this->image);
        }

        /**
         * Clear tile cache, saved in files
         *
         * @return bool
         *
         */
        public static function flushFileCache()
        {
            $tiles = new RecursiveDirectoryIterator(CACHE_PATH.self::CACHE_DIRECTORY);

            foreach ($tiles as $fileInfo) {
                unlink($fileInfo->getRealPath());
            }

            return true;
        }
    }
?>
\$\endgroup\$
  • \$\begingroup\$ Given they are tiles, is it possible to pre-render all the tiles including the variations, sand-to-sea, sand-to-forest etc, then just return the correct url of the tile? This way you could use a cdn to cache the tiles for you. (Obviously this would depend on the complexity of the tiles, and i have only just skimmed over the code base). \$\endgroup\$ – bumperbox Jan 21 '14 at 8:56
  • \$\begingroup\$ I am pre-rendering and caching the tile files already. But even on localhost it seems to be slow. I will publish the code somewhere shortly and will post a link to check/test. Thanks for an effort, anyway :) \$\endgroup\$ – t1gor Jan 21 '14 at 10:03
  • \$\begingroup\$ If it is slow on localhost, then you might have some issues in the real world.. Have you isolated where the slow is, eg database, tile generation, network issue, cpu? I know php isn't the best language for image processing have you tried imagick vs gd for image processing. Not sure if you are using apache, but mod x-sendfile could help offload some of the work from php. \$\endgroup\$ – bumperbox Jan 22 '14 at 1:26
  • \$\begingroup\$ @bumperbox, I considered switching to imagick, but this project will most probably be started from the shared hosting environment, which is why I decided not to use the one. Could you please give me a hint on what mod x-sendfile is? \$\endgroup\$ – t1gor Jan 22 '14 at 14:43
  • \$\begingroup\$ x-sendfile just allows you to offload files to apache. Rather then keeping a php script running just to return the file. I use it for larger files, pdf's etc, I am not sure of the performance gain you would get from small tiles. If you are using shared hosting, it is most likely you wont have access to this module anyway \$\endgroup\$ – bumperbox Jan 22 '14 at 20:09
3
\$\begingroup\$

I had a look through the code, if it runs slow then it is most likely a network issue from what I can tell.

I think you are caching the wrong thing.

A better solution might be to change the design of the tiles and remove the coordinates from them.

You have 6 types of tiles Sea, sTone, Mountain, sanD, Forest, Grass

First up, pre-render all six tiles S.jpg T.jpg M.jpg D.jpg F.jpg G.jpg

Next render all the possible combos and combos of tiles eg sea/sand etc, naming them in centre-north-east-south-west naming convention.

SSDSS.jpg would be Sea surrounded by sea (north), sand (east), sea (south), sea (west)

Next is the game loop

  • A request comes in for a tile at coords x,y
  • Perform a database query to find the neighbours
  • From the neighbours lookup you should be able to build a filename, eg SSDSS.jpg
  • Cache the tile name against the coordinates to save us any further database lookup
  • header('Location: http://cdn.mygame.com/tiles/SSDSS.jpg');

This way the browser does the img caching not your server, so the network bottleneck is no longer an issue. There are a limited number of combinations that the browser needs to cache.

I am making some big assumptions about your design here

If you treat the tile as a div background, you can use plain html to render the coords over every tile if required.

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ Good thinking, and it is worth re-iterating, that the browser should be treated as part of a massive computational cluster. The more you push to the browser the better, and especially when there are multiple browsers using one server. \$\endgroup\$ – rolfl Jan 23 '14 at 1:20
  • \$\begingroup\$ @bumperbox, thanks for the suggestion. BTW - I am setting browser cache headers - isn't it enough? \$\endgroup\$ – t1gor Jan 23 '14 at 12:36
  • \$\begingroup\$ Regarding the combos - I will have a bit more complicated structute (corners also count), so it will be 8 nighbours. I already started here: bitbucket.org/t1gor/strategy/src/…. Anyway, the idea is clear and I will try that. \$\endgroup\$ – t1gor Jan 23 '14 at 12:46
  • 1
    \$\begingroup\$ @t1gor your caching headers are fine. The main point is that because each tile has the coordinates rendered on it they are all unique. If you have a 100x100 grid, then your app will need to generate 10,000 unique jpgs. That is a lot of images, far more then if you used a set of common images. \$\endgroup\$ – bumperbox Jan 23 '14 at 19:30

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.