Skip to content

Instantly share code, notes, and snippets.

@looki
Created January 4, 2018 19:22
Show Gist options
  • Save looki/ad8d187f6c224c8408ead3d1370fb7dd to your computer and use it in GitHub Desktop.
Save looki/ad8d187f6c224c8408ead3d1370fb7dd to your computer and use it in GitHub Desktop.
Super old Knytt Stories map reader and renderer classes for PHP
<?php
class KnyttBinReader
{
private $rootDir = '';
private $binSize = 0;
private $files = null;
//Destructor
function __destruct()
{
unset($files);
}
//Read .knytt.bin
public function Read($file, $skipSongs=true)
{
$this->rootDir = '';
$this->binSize = 0;
$this->files = null;
//File doesn't exist, quit
if(!file_exists($file)) return false;
//Read size
$this->binSize = filesize($file);
//Open file for reading
$handle = fopen($file, 'r');
//Check for indicator 'NF'
if(fread($handle, 2) != 'NF') return false;
//Get root directory
while(ord($chr = fread($handle, 1)))
$this->rootDir .= $chr;
//Skip number of files (incorrect anyway?)
$fileCount = fread($handle, 4);
//Loop through file
while(ftell($handle) < $this->binSize)
{
//Skip NF
fread($handle, 2);
$filePath = '';
//Add character to file path until we hit a terminator
while(ord($chr = fread($handle, 1)))
$filePath .= ($chr == '\\') ? '/' : $chr;
//Read size of following file
$sizeBytes = fread($handle, 4);
$this->files[$filePath]['size'] = ord($sizeBytes{0}) + ord($sizeBytes{1})*256 + ord($sizeBytes{2})*65536 + ord($sizeBytes{3})*16777216;
//Read file
if(!$skipSongs)
$this->files[$filePath]['data'] = fread($handle, $this->files[$filePath]['size']);
else
{
if(substr($filePath, 0, 5)!='Music' && substr($filePath, 0, 8)!='Ambiance')
$this->files[$filePath]['data'] = fread($handle, $this->files[$filePath]['size']);
else
$this->files[$filePath]['data'] = fseek($handle, $this->files[$filePath]['size'], SEEK_CUR);
}
}
//Done. Close file
fclose($handle);
return $fileCount;
}
//Save
public function SaveFile($file, $output='')
{
//File not found, quit
if(!isset($this->files[$file])) return false;
//If no output path is specified, use file name
if(!$output) $output = basename($file);
//Write file
$handle = fopen($output, 'w');
fwrite($handle, $this->files[$file]['data']);
fclose($handle);
return true;
}
//Get root directory
public function GetRoot()
{
return $this->rootDir;
}
//Get file
public function GetFile($file)
{
//File not found, quit
if(!isset($this->files[$file])) return false;
return $this->files[$file]['data'];
}
//Get file size
public function GetFileSize($file)
{
//File not found, quit
if(!isset($this->files[$file])) return 0;
return $this->files[$file]['size'];
}
//Get list of filenames
public function GetFileList()
{
//No files read yet, quit
if(!isset($this->files)) return false;
return array_keys($this->files);
}
//Get list of filenames that metch regexp, e.g. /.png$/i
public function GetFilesRegexp($regexp)
{
//No files read yet, quit
if(!isset($this->files)) return false;
$return = array();
$keys = array_keys($this->files);
//Loop through all files and check
foreach($keys as $k => $v)
{
if(preg_match($regexp, $v))
$return[$k] = $v;
}
return $return;
}
public function ReadINI($file)
{
//File not found, quit
if(!isset($this->files[$file])) return false;
$lines = explode("\n", $this->files[$file]['data']);
$return = array();
$inSect = false;
foreach($lines as $line)
{
$line = trim($line);
if(!$line || $line[0]=="; ")
continue;
if($line[0] == '[' && $endIdx = strpos($line, ']'))
{
$inSect = substr($line, 1, $endIdx-1);
continue;
}
if(!strpos($line, '='))
continue;
$tmp = explode('=', $line, 2);
if($inSect)
$return[$inSect][trim($tmp[0])] = ltrim($tmp[1]);
else
$return[trim($tmp[0])] = ltrim($tmp[1]);
}
return $return;
}
}
?>
<?php
/*
This class reads Map.bin files which contain all the information on every single room in the level.
Plus, it can render rooms of the level using the php_gd plug-in. Also, for rendering multiple rooms,
there is a cache feature that allows you to re-use already used graphic files which heavily decreases
the time it takes to render all rooms. This is crucial for rendering, say, the whole map of a level!
Usage:
$km = new KnyttMap;
$km->LoadMap('Map.bin');
$im->RenderScreen('x1000y1000');
header('Content-type: image/png');
imagepng($im->GetCanvas());
Interaction with KnyttBinReader:
$km = new KnyttMap;
$km->LoadData($kb->GetFile('Map.bin'));
*/
define('KM_TILESETS', 0);
define('KM_OBJECTS', 1);
define('KM_GRADIENTS', 2);
define('KM_IMAGE', 0);
define('KM_ALPHA', 1);
class KnyttMap
{
private $mapData;
private $maps; //Light version of $mapData, only contains map names as Array that contains X and Y.
private $mapBoundaries;
private $mapFolder;
private $dataFolder; // = './Data'
private $ownDataFolder; // = './'
private $canvas;
private $cache;
private $ini;
//Callbacks
private $getDataCB = null;
private $customDataCB = null;
public $renderFilter;
//Constructor. Specify map, data, and custom data folder.
function __construct($mapfolder = './', $datafolder = './Data/', $ownfolder = './')
{
$this->mapFolder = $mapfolder == '' ? $mapfolder : str_replace('//', '/', $mapfolder.'/');
$this->dataFolder = $datafolder == '' ? $datafolder : str_replace('//', '/', $datafolder.'/');
$this->ownDataFolder = $ownfolder == '' ? $ownfolder : str_replace('//', '/', $ownfolder.'/');
$this->cache = null;
}
//Delete all imagess.
function __destruct()
{
if($this->canvas)
imagedestroy($this->canvas);
if(is_array($cache = $this->cache[KM_TILESETS]))
foreach($cache as $img)
imagedestroy($img[KM_IMAGE]);
if(is_array($cache = $this->cache[KM_OBJECTS]))
foreach($cache as $img)
imagedestroy($img[KM_IMAGE]);
if(is_array($cache = $this->cache[KM_GRADIENTS]))
foreach($cache as $img)
imagedestroy($img);
}
//Get a setting of a screen: Tileset A/B, Ambiance A/B, Music and Gradient.
public function GetSetting($screen, $setting)
{
return $this->mapData[$screen]['settings'][$setting];
}
//Get a tile or object on a screen.
public function GetTile($screen, $layer, $tile)
{
return ord($this->mapData[$screen]['data'][$layer][$tile]);
}
//Get the path of a resource, checks for custom files first.
public function GetData($path)
{
//No callback
if($this->getDataCB == null)
{
if(file_exists($this->ownDataFolder.$path))
return $this->ownDataFolder.$path;
}
//Use callback
else
{
if(call_user_func($this->customDataCB, $this->ownDataFolder.$path))
return $this->ownDataFolder.$path;
}
//Use default data
if(file_exists($this->dataFolder.$path))
return $this->dataFolder.$path;
//Error
else return false;
}
//Return the key of a screen in the map array.
public function GetScreen($screen)
{
$key = -1;
for($i = 0; $i < count($this->maps); $i++)
{
//Found, break loop
if($screen == 'x'.$this->maps[$i][0].'y'.$this->maps[$i][1])
{
$key = $i;
break;
}
}
return $key;
}
//Check if a screen specified by its name exists.
public function ScreenExists($screen)
{
return $this->getScreen($screen) > -1;
}
//Load a Map.bin file.
//$map - Map.bin file to load
//$calcBounds - If true, GetMapBoundaries() will return the boundaries of the map
//$useAbs - If true, an absolute path instead of a path relative to the map folder in the constructor will be used.
//$wantedMaps - If specified, only the specified rooms will be returned (e.g. array('x1000y1000'))
//Return value: An array with all the available data about every room.
public function LoadMap($map = 'Map.bin', $calcBounds = false, $useAbs = false, $wantedMaps = null)
{
//Attach $mapFolder if not using absolute path.
if(!$useAbs)
$map = $this->mapFolder.$map;
//Check for a valid gz file
$gz = fopen($map, 'r');
$magic = fread($gz, 2);
fclose($gz);
//Check for magic number
if(ord($magic[0]) != 31 || ord($magic[1]) != 139)
return false;
//Read map and store data
$mapContents = '';
$gz = gzopen($map, 'r');
while(!gzeof($gz))
$mapContents .= gzgetc($gz);
gzclose($gz);
//Simple check: first map name begins with 'x'?
if($mapContents[0]!='x')
return false;
$this->mapData = array();
$this->maps = array();
$mapLength = strlen($mapContents);
//$pos is used to run through the whole map file.
$pos = 0;
//While $pos is inside of the file...
while($pos < $mapLength)
{
//Hold position to get mapname.
$hold = $pos;
//Find 0-byte to terminate the string.
$pos = strpos($mapContents, chr(0), $pos);
//Get mapname.
$mapName = substr($mapContents, $hold, $pos-$hold);
//Skip 0-byte and length of the workspace stored as long integer (4 bytes), whose value is constant: 3006.
$pos += 5;
$mapData = substr($mapContents, $pos, 3006);
//Skip data.
$pos += 3006;
//Put data;
$lightName = explode('y', substr($mapName, 1));
//Invalid map
if(count($lightName) != 2)
return false;
if($wantedMaps == null || in_array('x'.$lightName[0].'y'.$lightName[1], $wantedMaps))
{
array_push($this->maps, $lightName);
array_push($this->mapData,
//Name
array(
'name' => $mapName,
//Screen data
'data' => array(
//Layers, layers 4-7 need twice as many bytes as layers 0-3 since they need to store the object and bank IDs.
0 => substr($mapData, 0, 250),
1 => substr($mapData, 250, 250),
2 => substr($mapData, 500, 250),
3 => substr($mapData, 750, 250),
4 => substr($mapData, 1000, 500),
5 => substr($mapData, 1500, 500),
6 => substr($mapData, 2000, 500),
7 => substr($mapData, 2500, 500)
),
//Settings
'settings' => array(
'tilesetA' => ord($mapData{3000}),
'tilesetB' => ord($mapData{3001}),
'ambianceA' => ord($mapData{3002}),
'ambianceB' => ord($mapData{3003}),
'music' => ord($mapData{3004}),
'gradient' => ord($mapData{3005}),
)
)
);
}
//Repeats until parsing is done.
}
//Get boundaries of the map.
if($calcBounds)
{
$bl = 9999; $bt = 9999; $br = 0; $bb = 0;
for($i = 0; $i < count($this->maps); $i++)
if($this->maps[$i][0] < $bl) $bl = $this->maps[$i][0];
for($i = 0; $i < count($this->maps); $i++)
if($this->maps[$i][0] > $br) $br = $this->maps[$i][0];
for($i = 0; $i < count($this->maps); $i++)
if($this->maps[$i][1] < $bt) $bt = $this->maps[$i][1];
for($i = 0; $i < count($this->maps); $i++)
if($this->maps[$i][1] > $bb) $bb = $this->maps[$i][1];
//Store data
$this->mapBoundaries = array('left' => $bl, 'right' => $br, 'top' => $bt, 'bottom' => $bb,
'width' => $br-$bl+1, 'height' => $bb-$bt+1);
}
else $this->mapBoundaries = null;
return true;
}
//Load a map from a compressed string. Useful in combination with my KnyttBinReader class.
//Parameters are the same as LoadMap()
public function LoadData($mapContents, $calcBounds = false, $wantedMaps = null)
{
//Write data to a temporary file
$tmp = tempnam('/tmp', 'ksm');
$handle = fopen($tmp, 'w');
fwrite($handle, $mapContents);
fclose($handle);
//Load temporary file
$output = $this->LoadMap($tmp, $calcBounds, true, $wantedMaps);
unlink($tmp);
return $output;
}
//This is the default callback for ::RenderScreen. Change it via ::SetDataCB.
private function GetPNG($path, $wantAlpha = true)
{
//Read file
$png = file_get_contents($path);
$image = imagecreatefromstring($png);
//We want alpha information.
if($wantAlpha)
{
//Check for alpha channel
$alpha = (ord($png[25])==6);
//Return array with information
return array(0=>$image, 1=>$alpha);
}
else
return $image;
}
//Render a specific screen.
//$screen - The coordinates of the screen, e.g. 'x1000y1000'
//$removeInternal - Doesn't render special invisible objects like 'Sign A'.
//$removeGhosts - Doesn't render ghosts (actual ghosts found in the Ghost bank).
//Returns true on success, otherwise false.
public function RenderScreen($screen, $removeInternal = true, $removeGhosts = true)
{
//Check for custom callback
if($this->getDataCB == null)
$readCallback = array($this, 'GetPNG');
else
$readCallback = $this->getDataCB;
//Find map in array.
$key = $this->getScreen($screen);
//Map found!
if($key >= 0)
{
//Delete old image if existent; allocate new one
if($this->canvas)
imagedestroy($this->canvas);
$this->canvas = imagecreatetruecolor(600, 240);
//Draw gradient
$gradientID = $this->GetSetting($key, 'gradient');
$gradientPath = $this->GetData('Gradients/Gradient'.$gradientID.'.png');
//Use cache if array allocated
if(is_array($this->cache[KM_GRADIENTS]))
{
//Store if not done yet
if(!isset($this->cache[KM_GRADIENTS][$gradientID]))
$this->cache[KM_GRADIENTS][$gradientID] = call_user_func($readCallback, $gradientPath, false);
//Copy image address for later use
$gradient = $this->cache[KM_GRADIENTS][$gradientID];
}
//No cache
else
{
$gradient = call_user_func($readCallback, $gradientPath, false);
}
//Draw gradient
$gradientWidth = imagesx($gradient);
$loop = ceil(600/$gradientWidth);
for($i = 0; $i < $loop; $i++)
imagecopy($this->canvas, $gradient, $i*$gradientWidth, 0, 0, 0, $gradientWidth, 240);
//Free memory
if(!is_array($this->cache[KM_GRADIENTS]))
imagedestroy($gradient);
//Load tilesets
$tilePathA = $this->GetData('Tilesets/Tileset'.$this->GetSetting($key, 'tilesetA').'.png');
$tilePathB = $this->GetData('Tilesets/Tileset'.$this->GetSetting($key, 'tilesetB').'.png');
//Use cache if array allocated
if(is_array($this->cache[KM_TILESETS]))
{
$tileA = $this->GetSetting($key, 'tilesetA');
$tileB = $this->GetSetting($key, 'tilesetB');
//Store tileset A
if(!isset($this->cache[KM_TILESETS][$tileA]))
{
$this->cache[KM_TILESETS][$tileA] = call_user_func($readCallback, $tilePathA);
//If there's no alpha channel, set transparent color to pink
if(!$this->cache[KM_TILESETS][$tileA][KM_ALPHA])
$this->MakeTransparent($this->cache[KM_TILESETS][$tileA][KM_IMAGE]);
}
//Store tileset B
if(!isset($this->cache[KM_TILESETS][$tileB]))
{
$this->cache[KM_TILESETS][$tileB] = call_user_func($readCallback, $tilePathB);
//If there's no alpha channel, set transparent color to pink
if(!$this->cache[KM_TILESETS][$tileB][KM_ALPHA])
$this->MakeTransparent($this->cache[KM_TILESETS][$tileB][KM_IMAGE]);
}
//Copy image address for later use
$tileset[0] = $this->cache[KM_TILESETS][$tileA];
$tileset[1] = $this->cache[KM_TILESETS][$tileB];
}
//No cache
else
{
$tileset[0] = call_user_func($readCallback, $tilePathA);
$tileset[1] = call_user_func($readCallback, $tilePathB);
//If there's no alpha channel, set transparent color to pink
if(!$tileset[0][KM_ALPHA])
$this->MakeTransparent($tileset[0][KM_IMAGE]);
if(!$tileset[1][KM_ALPHA])
$this->MakeTransparent($tileset[1][KM_IMAGE]);
}
if($tileset[0] && $tileset[1])
{
for($lay = 0; $lay < 4; $lay++)
{
for($i = 0; $i < 250; $i++)
{
//Tile is visible and layer isn't listed in renderFilter
if($this->GetTile($key, $lay, $i)%128 > 0 && strpos('x'.$this->renderFilter, (string)$lay) === false)
{
//Tileset A or B?
$a_or_b = floor($this->getTile($key, $lay, $i)/128);
//Alpha
if($tileset[$a_or_b][KM_ALPHA])
{
//Copy tile into canvas
imagecopy($this->canvas,
$tileset[$a_or_b][KM_IMAGE], //Tileset
($i%25)*24, floor($i/25)*24, // Destination XY
(($this->getTile($key, $lay, $i)%128)%16)*24, //Source X
floor(($this->getTile($key, $lay, $i)%128)/16)*24, // Source Y
24, 24); //Size
}
else
{
//Copy tile into canvas
imagecopymerge($this->canvas,
$tileset[$a_or_b][KM_IMAGE], //Tileset
($i%25)*24, floor($i/25)*24, // Destination XY
(($this->getTile($key, $lay, $i)%128)%16)*24, //Source X
floor(($this->getTile($key, $lay, $i)%128)/16)*24, // Source Y
24, 24, 100); //Size
}
}
}
}
//Free memory
if(!is_array($this->cache[KM_TILESETS]))
{
imagedestroy($tileset[0][KM_IMAGE]);
imagedestroy($tileset[1][KM_IMAGE]);
}
}
else
return false;
//Draw objects
for($lay = 4; $lay < 8; $lay++)
{
for($i = 0; $i < 250; $i++)
{
//Object is visible and layer isn't listed in renderFilter
if($this->GetTile($key, $lay, $i) > 0 && strpos('x'.$this->renderFilter, (string)$lay) === false)
{
//Check if object exists
$objectBank = $this->GetTile($key, $lay, $i+250);
$objectID = $this->GetTile($key, $lay, $i);
//Custom object
if($objectBank==255)
{
//Get custom object's settings
$ini = $this->ini['Custom Object '.$objectID];
$coPath = $this->GetData('Custom Objects/'.$ini['Image']);
if($coPath===false)
continue;
$tilew = $ini['Tile Width']?(int)$ini['Tile Width']:24;
$tileh = $ini['Tile Height']?(int)$ini['Tile Height']:24;
$ox = (int)$ini['Offset X'];
$oy = (int)$ini['Offset Y'];
$frame = (int)$ini['Init AnimFrom'];
if(is_array($this->cache[KM_OBJECTS]))
{
//Store if not done yet
if(!isset($this->cache[KM_OBJECTS][$objectID+65280]))
{
$co = call_user_func($readCallback, $coPath, false);
$this->cache[KM_OBJECTS][$objectID+65280] = $co;
}
//Copy image address for later use
$co = $this->cache[KM_OBJECTS][$objectID+65280];
}
//No cache
else
{
$co = call_user_func($readCallback, $coPath, false);
}
//Calculate width & source XY
$cow = imagesx($co);
$srcx = ($frame%($cow/$tilew))*$tilew;
$srcy = floor($frame/($cow/$tilew))*$tileh;
//Draw custom object
imagecopy($this->canvas, $co, //Object
($i%25)*24+12-floor($tilew/2)+$ox, floor($i/25)*24+12-floor($tileh/2)+$oy, //Destination XY
$srcx, $srcy, //Source XY
$tilew, $tileh); //Size
//Free memory
if(!is_array($this->cache[KM_OBJECTS]))
imagedestroy($co);
}
//In-built object
else
{
//Filter
$drawObj = true;
if($removeInternal)
{
$b = $objectBank;
$o = $objectID;
//System
if($b == 0 && ($o == 2 || ($o >= 11 && $o <= 20) || $o >= 25))
$drawObj = false;
// Ghost [X] Wall
elseif($b == 12 && $o == 17)
$drawObj = false;
//Invisible
elseif($b == 16)
$drawObj = false;
//Fly A B
elseif($b == 2 && ($o == 3 || $o == 4))
$drawObj = false;
//Decoration
elseif($b == 8 && ($o >= 15 && $o <= 17))
$drawObj = false;
//Nature FX
elseif($b == 7 && ($o == 1 || $o == 10 || $o == 12 || $o == 14 || $o == 16 || $o == 3 || $o == 6 || $o == 8))
$drawObj = false;
//Ghosts
elseif($b == 12 && $removeGhosts)
$drawObj = false;
//Robots
elseif($b == 13 && ($o == 7 || $o == 10))
$drawObj = false;
//Robots (redirections)
elseif($b == 13)
{
//Laser
if($o == 8 || $o == 11)
$objectID++;
}
//Objects & Areas (redirections)
elseif($b == 15)
{
//Password switches
if($o >= 14 && $o <= 21)
$objectID = 13;
//Disappearing blocks
elseif($o >= 8 && $o <= 11)
$objectID -= 7;
//Blue blocks
elseif($o == 6)
$drawObj = false;
elseif($o == 7)
$objectID = 6;
}
//Traps (redirections)
elseif($b == 6 && $o == 6)
$b = 8;
}
if($drawObj)
{
$objectPath = $this->getData('Objects/Bank'.$objectBank.'/Object'.$objectID.'.png');
if($objectPath)
{
if(is_array($this->cache[KM_OBJECTS]))
{
//Store if not done yet
if(!isset($this->cache[KM_OBJECTS][$objectID+$objectBank*256]))
{
$obj = call_user_func($readCallback, $objectPath);
//If there's no alpha channel, set the ransparent color to pink
if(!$obj[KM_ALPHA]) $this->MakeTransparent($obj[KM_IMAGE]);
$this->cache[KM_OBJECTS][$objectID+$objectBank*256] = $obj;
}
//Copy image address for later use
$obj = $this->cache[KM_OBJECTS][$objectID+$objectBank*256];
$objsx = imagesx($obj[KM_IMAGE]);
$objsy = imagesy($obj[KM_IMAGE]);
}
//No cache
else
{
$obj = call_user_func($readCallback, $objectPath);
$objsx = imagesx($obj[KM_IMAGE]);
$objsy = imagesy($obj[KM_IMAGE]);
//If there's no alpha channel, set the ransparent color to pink
if(!$obj[KM_ALPHA]) $this->MakeTransparent($obj[KM_IMAGE]);
}
//Image uses alpha channel.
if($obj[KM_ALPHA])
{
imagecopy($this->canvas, $obj[KM_IMAGE], //Object
($i%25)*24, floor($i/25)*24, // Destination XY
0, 0,
$objsx, $objsy); //Size
}
//Image's transparency is based on pink.
else
{
imagecopymerge($this->canvas, $obj[KM_IMAGE], //Object
($i%25)*24, floor($i/25)*24, // Destination XY
0, 0,
$objsx, $objsy, 100); //Size
}
//Free memory
if(!is_array($this->cache[KM_OBJECTS]))
imagedestroy($obj[KM_IMAGE]);
}
}
}
}
}
}
}
return $key >= 0;
}
//Returns the canvas rendered to by ::RenderScreen.
public function GetCanvas()
{
return $this->canvas;
}
//Returns an array with the map's dimensions and boundaries.
public function GetMapBoundaries()
{
return $this->mapBoundaries;
}
//Returns an array with each map's name and data (tiles, objects, ambiance...).
public function GetMapData()
{
return $this->mapData;
}
//Returns an array of all map names.
public function GetMaps()
{
return $this->maps;
}
//Use a cache array. This is useful for rendering several screens at once - the renderer won't reload tilesets every time.
//$types - An array with the data types to cache. Array(KM_TILESETS, KM_OBJECTS, KM_GRADIENTS) would cache all types of data.
public function UseCache($types)
{
if(is_array($this->cache))
return false;
//Allocate arrays
foreach($types as $t)
$this->cache[$t] = array();
return true;
}
//Returns a copy of the cache array.
public function GetCache()
{
return $this->cache;
}
//Set a callback for reading data (e.g. a tileset). See ::RenderScreen, default is ::GetPNG.
public function SetDataCB($callback)
{
$this->getDataCB = $callback;
}
//Set a callback for deciding whether to use custom data or not. See ::GetData.
public function SetCustomDataCB($callback)
{
$this->customDataCB = $callback;
}
//Set World.ini (as array!) for custom objects.
public function SetINI($ini)
{
if(is_array($ini))
$this->ini = $ini;
else
$this->ini = null;
}
//Set pink to transparent.
private function MakeTransparent($img)
{
$pink = imagecolorexact($img, 255, 0, 255);
//Image is not indexed, try to allocate pink
if($pink == -1)
$pink = imagecolorallocate($img, 255, 0, 255);
imagecolortransparent($img, $pink);
}
}
?>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment