DIR : /home/kozerus/public_html/go/dgs/scripts/README.developers
/home/kozerus/public_html/go/dgs/scripts
-----------------
General instructions for developers:
-----------------
----------------- HTML, W3C validator
An aim on HTML used for DGS is to keep it conform to:
"-//W3C//DTD HTML 4.01 Transitional//EN"
Validating can be done using a locally installed w3c-markup-validator
(Linux ubuntu-package) for example.
----------------- SCM (Git/CVS), filenames, database names:
Don't use uppercase letters in the filenames (unless a special needed
meaning). In such cases, contact the other developers first.
The developers using MS Window systems may not be able to distinguish
filenames different only in their upper- and lower-case letters.
The same constraint appears with the database name and its tables names.
Actually, those names contain uppercase letters. That is not a problem
until one does not insert an other object with the same name except the
case (e.g. the two tables: Forum and forum).
On the other hand, the column, variable and alias names seem to be well
handled in a case sensitive way under the MS Windows systems.
----------------- Working with Git:
Configure git-config with your full name and a SourceForge email-address as built below:
# USER = your SourceForge account
git config user.name "FIRSTNAME LASTNAME"
git config user.email "USER@users.sf.net"
For commits follow the Git guidelines, i.e. first line is a summary of a change
followed by an empty line and the details of the change.
For using the shared work-area on SourceForge, read the 'README'-file in the DGS-rootdir.
----------------- Working with Git - the Integration-Manager Worflow
Note: following is for users that want to mirror the official master branch of the main repository
in order to contribute to DGS.
Forking off dgs-main to have a variant DGS server is not discussed here.
The root README document explains how to work with the dgs-work repository.
Here's an alternative to working with the dgs-main (rather than the dgs-work) repository.
1. Clone the dgs-main repository into your own (public) Git repository on SF or other Git server
that you will use to publish your changes.
2. Clone the dgs-main repository into a local (private) repository where you will work on your branches.
3. Fetch updates from dgs-main into your private repository.
4. Push your private branches to your public repository, and nicely ask an Integrator
(see [1]) to merge your branches.
Using the dgs-work repository as described in the root README document works in the same way,
except that the dgs-work repository is used as your (public) repository.
Your local master branch will reflect the latest changes from dgs-main. So from time to time you need
to update your master branch:
> (if necessary, git stash...)
> git checkout master
> git pull dgs-main
> (git stash pop...)
When working on bugs/issues:
1. create a local branch based on master (for significant changes, consider a stable tag to start from)
> git checkout -b <newbranch> master
2. work on <newbranch>, commit, etc...
3. as long as your branch hasn't been published, consider rebasing with the latest master content
4. when the branch is ready, publish your branch in your public repository
> git push <my_public_rep> <newbranch>:<newbranch>
5. Be aware that a merge may be refused and you might need to re-merge your work taking recent
changes in dgs-main/master into account.
Remember to never rebase a published branch if there is any chance it might have been fetched elsewhere!
For more details on this workflow, see also [2].
[1] http://www.dragongoserver.net/people.php
[2] http://git-scm.com/book/en/Distributed-Git-Distributed-Workflows#Integration-Manager-Workflow
----------------- SourceForge tips
There is no apparent way in the SF UI to find back merge requests once they have been handled.
Append /merge-requests/ to the target repository URL to get the interface.
Forking on SF provides additional tools such as Merge Requests and associated discussion threads.
----------------- PHP: Guest-User
When adding new pages, take handling of the guest-user (uid <= GUESTS_ID_MAX)
into account. It's not necessary, that ALL write-operations are forbidden,
but carefully think what a guest can do and must not do. Best is to check
a bit around in the current code, how it's handled for other pages:
- game.php : viewing ok, toggle-observe ok (though write-op)
- admin-pages : forbidden
- interaction with regular users : forbidden
If in doubt, forbid the guest-user explicitly for the whole page;
for example see: edit_picture.php
Keep in mind, that the guest-user is used by different users,
so some dirty words could also be seen by others and damage the
reputation of the server.
So for example, the game-toggle-observing is allowed, because no "harm"
can be done to other users.
----------------- DGS clones:
Keep in mind, that DGS is open source and there are some DGS clones
out there, running under different operating systems (like Linux, Windows,
MacOS), which have different handling of "things".
----------------- JavaScript:
Site-design policy for DGS is to stay independent of JavaScript for its main functionality.
That means, JavaScript is allowed for a "MAIN" feature, but only if there is also a way
to use the feature WITHOUT relying on JavaScript. This is mainly done to support a wide
range of mobile devices or handhelds (and older browser), that often have a poor support
for JavaScript.
So what is considered "main"-functionality?
JavaScript is allowed for convenience functionality and smaller features, for example:
* toggling the row-color in table-listings on double-click
(this has no counterpart for non-JavaScript users)
* for new-game-page or invitations the komi value is changed when switching the ruleset,
and the max-handicap default-text is adjusted when changing the board-size;
also some radio-buttons are automatically selected if user changes values on respective lines
for manual-handicap, time-settings, require rated opponents
(this has no counterpart for non-JavaScript users)
* on view-ladder-page for ladder-tournaments the rank-info is shown as pop-up per user
(this has no counterpart for non-JavaScript users)
* on selecting bookmarks from the top-bar it automatically fires the submit;
also on games-page view-move has an auto-submit
(non-JavaScript would be to manually do the submit after selection)
* on games-list info-icons it opens a thumbnail-snapshot of the game
(non-JavaScript would need to open the game-page to see the game)
* on shape-icons it opens a thumbnail-snapshot of the shape
(non-JavaScript would need to open the shape-page with the shape-pattern)
* on forum posts for changing likes
* on forum potsts for adding & removing tags
* on replyable messages for quoting parent message text
The server-operator can also choose to completely disable all JavaScript functionality
with the server-config 'ALLOW_JAVASCRIPT' in config-file 'include/config-local.php'.
A user can enable or disable the use of JavaScript with a flag in the user-profile,
which is stored in (Players.UserFlags & 0x1); see also 'specs/db/table-Players.txt'.
So when implementing JavaScript-stuff, you have to check for the global config-flag and
the user-specific flag, for example with:
if (ALLOW_JAVASCRIPT && is_javascript_enabled() )
; # do your stuff
Therefore more JavaScript-based functionality can be added in the future,
but it must be switchable in the user-profile (probably additional options can be added
to switch off/on only certain features).
Currently (Dec-2013), using features of JavaScript-versions > 1.8.1
should not be used to keep compatibility with older browsers.
If newer JS-features are required explicitly test if there are available,
and if possible add an implementation for older JS-versions.
See 'js/lang-ext.js' for examples.
Non-external JavaScript-libs should follow the PHP-layout guidelines (see below)
with some exceptions allowed ... just try to keep the layouts close:
- if-expression/loop-expression can use '{..}' on same line as statements
----------------- Layout:
DGS "layout"-style for sources (PHP, JavaScript similar): (see also next section)
- TAB-width is 3 spaces, written with spaces.
- max. line-length should be 120 (exceptions possible, e.g. for texts)
- No trailing spaces in line.
- CR, LF or CR+LF as end-of-line should not cause problem,
though the preferred way is to use LF only as line-break.
- Keep the files in ISO-8859-1 (don't use utf-8 encoding)
- single-line if-statements are ok, preferred way is:
if ( expression )
action;
This is the preferred way, though single-line if-statements are ok as well
in some cases.
- avoid nested '?'-operator (though sometimes used if expression not too complex)
$result = ( expression ) ? expr1 : expre2;
$result = ( expression )
? expr1
: expre2;
- includes / require write without braces and single apostrophe if constant-string:
# prefer 'require_once' over 'require'
require_once 'include/utilities.php';
require 'include/utilities.php';
# prefer 'include_once' over 'include'
include_once 'test.php';
include 'test.php';
- space after statement-keywords: if / else if / elseif / for / foreach / while / switch
- functions (no space between method-name and opening brace):
function method( args )
{
...
}
$result = method( args );
- place each brace (for statements) on a separate line.
Braces may be omitted for single enclosed statements:
foreach/for/while ( ... )
{
}
- if-layout (braces may be omitted for single enclosed statements):
if ( expression )
{
...
}
elseif ( ... ) // use if expression operating on same arg
{
...
}
else if ( ... )
{
...
}
else
{
...
}
if ( expression )
action;
else
action;
- statement-layout for expressions longer than one line:
# try to keep the expressions less complex by using separate vars
# for long expressions:
# break line at operator (preferred) with operator into next line with 2 indent-steps
if ( long_expression ......
&& another long expression
|| expression continued )
{
...
}
- switch-layout:
switch ( (type)expression )
{
case option:
{ // braces optional
...
break;
}
}
- return-layout (leave one space after return-keyword, braces allowed for clarity):
return simple-expression;
return ( expr1 op expr2 );
- space between operands and operators is preferred.
- Commenting functions and classes (and files) is done using Doxygen-style.
Read 'specs/doxygen-usage.txt' for the most common commands and references!
Comments in '#'-style only used for commenting debugging-stuff (rarely used).
Comments in /*..*/-style for many multi-line comments.
Comments on single-lines (or few lines) in //-style.
- Best to orient the layout at the existing sources.
----------------- Editor settings:
To implement the layout rules above, here are some setups for popular editors:
- VIM: add in ~/.vimrc
:set expandtab " put spaces for each <TAB> pressed
:set shiftwidth=3 " autoindent steps
:set softtabstop=3 " also insert spaces while editing
:set tabstop=8 " KEEP the default (or whatever you like); tabstop defines the number of spaces for existing tabs
----------------- Database:
Study the document 'specs/db/optimize-sql.txt' and the given references.
It might help in writing optimized SQL-statements and creating optimized
database-table structures.
----------------- PHP, Database:
By convention, write all SQL-keywords in upper-case.
----------------- PHP, Database:
Try to avoid the extract($row) function. Or just use it inside a
short function. At top level, the values of $row become global
variables. This is really risky. Even inside a function, as the $row
come most of the time from a mysql_query("SELECT *,..."), if we add
a column to the database table, the resulting var will maybe disturb
your code.
We know that there is still a lot of extract() in our code but we are
working to remove them. Instead we clearly rename the $row var with,
let say, $game_row then we use $game_row['ID'] when needed.
Feel free to replace in such way any extract() that you find.
This is a query issued from some bad experiences.
----------------- PHP, URL:
Take care of URI_AMP and URI_AMP_IN.
- URI_AMP_IN is what you use when you decode an URL coming
from the server (e.g. $_SERVER['REQUEST_URI']).
- Use URI_AMP when you build a URL to be gave to the server.
In short, URI_AMP_IN = ini_get('arg_separator.input')
and URI_AMP = ini_get('arg_separator.output')
This is because the ampersand is not allowed in an URL (curiously!)
Some server replace it with a ';' but it seems that, now a day, the
'&' become the standard. When the server receive a '&', it
translate it with a '&'. But this is purely server dependant. We even
can't be sure that it is a one char string. So use:
if ( substr($str, -strlen(URI_AMP)) != URI_AMP )
$str .= URI_AMP;
instead of:
if ( substr($str, -1, 1) != URI_AMP )
$str .= URI_AMP;
By the way, make_url($page, array()) is a function that works fine
if $page is already a well formed URL and is safer because it use
an urlencode().
----------------- PHP: type-hinting on function arguments
With PHP 5 type-hinting on function arguments was introduced.
You should use it if possible, but be aware of the following issues.
It can not be used on function arguments that have ambiguity, e.g. when an argument
can be a string or an array or a numerical value. In that case at least document
the different possible types an argument can be and what it means.
When a type-hint is used on a function argument and the argument can be NULL,
then you have to declare a default value. Otherwise during runtime PHP can not
check the type if the value is NULL.
If a default value is used in a function's signature be advised that arguments
that come after it must also have default values. Otherwise the order of
function arguments need to be rearranged.
This is especially tricky if a function argument is called by reference
and with a type-hint, for example to pass out data via the variable.
For example:
function change_variable( array &$var )
{
$var = array( 1, 2, 3 );
}
change_variable( $some_array );
// ^^ this will result in fatal PHP error, because $some_array is not defined
To make this work you have to define a default-value in the function signature:
function change_variable( array &$var = null )
Or instead you have to ensure that when calling the function the variable is defined
with the correct type like so:
$some_array = array();
change_variable( $some_array );
----------------- PHP: check for uninitialized objects
It's good to "throw" an error if objects are not properly initialized.
This prevents runtime-errors, that can be nasty for a user or difficult
to debug. You can and should use the following DGS-error-codes (for example,
please search the code):
// error-check after ConfigTableColumns::load_config(..)
error('user_init_error', 'label.init.config_table_cols');
// error-check after ConfigPages::load_config_pages(..)
error('user_init_error', 'label.init.config_pages');
// error-check after ConfigBoard::load_config_board(..)
// NOTE: on pages, that do not have a way to change the state of ConfigBoard,
// you may use ConfigBoard::load_config_board_or_default(..) to get the default-object
// in case of a found inconsitency (preventing the page fails too often for users).
error('user_init_error', 'label.init.config_board');
// error-check after loading a user, e.g. after User::load_user(..)
error('unknown_user', 'label.USE-CASE-DETAILS');
// error-check on used URL-arguments or function-arguments:
error('invalid_args', 'label.USE-CASE-DETAILS');
// in all other cases, you may either define a new error-code or just use the universal
// 'internal_error'.
//
// NOTE: But before you do create a new error-code, check the known error-codes and
// choose one that may match your need from 'include/error_codes.php'.
// There are already a variety for specific use-cases; some examples:
// unknown_game, unknown_forum, unknown_message, unknown_user,
// unknown_tournament, unknown_entry
error('internal_error', 'label.init.USE-CASE-DETAILS');
----------------- PHP, DGS, Translations:
Take care of the T_() function and translation features.
Use the T_() enclosure each time the string need a translation.
Don't use such an enclosure anywhere else: most of the PHP files
are scanned to find it and the strings to translate.
During the scan process, the $TranslateGroups[] declaration at the top of
files is used as well as the TranslationPages-table in the
database. These are the two places to modify when you add a new
PHP file containing T_() translations. As a start point, have a
look at the documentation 'scripts/README.translations'.
Thumb of rule is, that admin-related pages do not have translated
texts. Though T_(..) can and should be used, the admin pages
lack the line (or have commented it out) to set $TranslateGroups[].
Because of that no translation-texts are created for admin-pages,
but something like T_('something#suffix') can still be used,
if later someone want to enable translations for admin-pages.
When adding a new string, try to first find if a similar one
is already existing in the database.
You may encounter the following or similar notations around
texts:
//T_//('text') - the T_// is to "hide" the translation to the
scanning translation-functions. Otherwise it
would appear as text-to-be-translated.
Comments are skipped while scanning.
/*T_*/('text') - The commented out parts should be kept to indicate,
that it's a potential text to translate or once it
was one and could be again in the future.
Maybe this is not the best way to do translations but it works quite
well, and is hard to modify. A big advantage is, that it does not need
much database-access, but only once to export all translated texts
into the "cache"-directory 'translations' (initially or on changed texts
in the source-code).
To understand translations better please also read:
- next section "Using translations in libraries/classes"
- database-tables for translations in 'specs/db/table-Translations.txt'
- see 'scripts/README.translations' of tasks to execute
----------------- PHP, Date-Translations:
The PHP date()-function can be used to format timestamps into a date-string.
However if the date-format contains 'D' (weekday name) or 'M' (month name)
the text is not translated. To achieve a translated weekday and month name
you have to use a dedicated function to format the date:
require_once 'include/gui_functions.php';
echo format_translated_date('D, d-M-Y H:i:s', time());
----------------- PHP, Using translations in libraries/classes:
For using translated text with T_('some text') first the current
player_row has to be loaded to determine the players language-setting.
That allows the T_(..) only to be used during execution time when
the player-information has been loaded.
That may need lazy-initialization for some translated texts.
For examples of lazy-init have a look at 'include/countries.php'.
Also keep in mind, that applying the T_()-func later does not work,
as then the text is not included in the database for translation!
That means, that you can't dynamically create a string within
the T_(string)-func. To accomplish that, you have to use
a sprintf-like-string, for example:
T_("Take $cnt apples") // does NOT work
sprintf( T_('Take %s apples'), $cnt) // use THIS instead
----------------- PHP, Database:
Even if $num is numeric, think to use the quoted syntaxe to set it
in a MySQL query, e.g. $query = ... "Num='$num'," ...;
If $num is not set and if the Num column is numeric, Num='', will set
it to the default while Num=, will return a MySQL syntaxe error.
That's an alternative that must be known.
----------------- PHP:
[Source: http://www.php.net/manual/en/language.references.return.php]
If you try to return a reference from a function with the syntax:
return ($found_var);
this will not work as you are attempting to return the result of an
expression, and not a variable, by reference. You can only return
variables by reference from a function - nothing else.
For example:
Operator "(expr) ? A : B" returns a copy of A|B and not a reference
$this->value{$pos} with var-pos DOES NOT WORK in all cases for
unknown reason! For example, it doesn't work when filter resetted or
not initialized yet !! ok with const so far!
----------------- PHP, Type-system, Comparisons:
Question:
Regarding String-comparison the code sometimes fails, if you compare
a string with '=='. Comparing a string against a constant-String
with '===' is more reliable, but sometimes it also works with '=='.
That really puzzles me.
Do you have any clarification on that effect?
Or is it maybe better to use strcmp()?
Answer (Rod):
That's not always obvious. The problem is because PHP is a "not typed"
language (contrary to C, for instance). So PHP is doing again and again
hidden cast-ings.
There is too a problem, for instance, with the strpos() function.
It may return false if nothing is found, or 0 if something is found at
index 0. So you have to use: "if ( false === strpos(...) )"
to know is something is found because: "if ( false == strpos(...) )"
will also be true when the function returns the 0 index, as
false may be cast-ed to 0, and 0 to false.
Another example with those lines:
$v = "-1.25"; // $v is a string
$v = 0 + $v; // now $v is a float
if ( $v < -1 ) // true if $v is a float
True if $v is a float because PHP will cast (int)-1 to (float)-1.
But if you remove/comment the second line, the test MAY be false.
In fact, to compare two entities, PHP need to cast them to the same type.
Here, if the second line is absent, PHP had to compare an integer (-1)
and a string ("-1.25"). But we can't know if PHP will cast -1 to "-1"
or "-1.25" to -1.25. If it cast -1 to "-1", the test will be:
if ( "-1.25" < "-1" ) and be false because of the lengths difference.
As you can see, the problem is not only around a comparison.
So, as the typage is extremly volatile in PHP, don't hesitate to add
security casts when needed:
if ( ((float)$v) < -1 ) // true even if $v is a string
Effectively, I think that strcmp() may be the solution to compare the
values as strings, because it should do by definition.
In the same spirit, be aware that everything that come from an URI or
a cookie IS a string (e.g. with "game.php?gid=1234", $_GET['gid'] will
always return the string "1234"). It act as an int only when you
implicitly use it as an integer.
So a preventive cast each time you use a $_GET is a good thing too.
----------------- PHP, Type-system, switch:
Always add a type-cast for the expression in a switch-statement like:
switch ( (string)$str )
switch ( (int)$val )
PHP has no type-safety. This measure is to make it clear, what type of value
we are dealing with in the switch-statement and therefore avoid nasty
side-effects.
This behaviour is also outlined in various comments on the PHP-sites:
http://de2.php.net/manual/en/control-structures.switch.php#82351
http://de2.php.net/manual/en/control-structures.switch.php#76440
In PHP it's allowed to use string-consts in the "case"-statement.
In other programming languages (not PHP), values in the case-statements
are to be restricted on scalar-types AND in most (other) languages
"string" is no scalar type.
Therefore using "scalar" types for a switch-statement is preferred.
However in PHP, "string" is a simple-type (scalar type), so it is allowed
to use it as value in a case-statement. But don't use it. The possible
side-effects are too nasty.
If, a day, $var is numeric and contains 0 (zero), it will match any one
of the "case" strings because, during the implicit comparison [let's say,
if ( $var == 'foo' ) ...
'foo' will (may?) be converted to a numeric value, and thus to zero!
Especially for the switch-statement, a "loose comparison" is used,
which can show strange effects. See:
http://de2.php.net/manual/en/types.comparisons.php#types.comparisions-loose
For example: The comparison:
( "PHP" == 0 )
is TRUE. Oerks :(
In which case, "if ( $var === 0 )", it will be converted to "0"
and may fall into the "default" case.
There's also another version of switch-statements, that goes like:
switch ( true )
{
case $x === 'a': ...
}
You can write expression in the case-statement (just like you would
in if-statements) ;) ... but it's rarely used, better write an if-statement
right away.
----------------- PHP:
Depending on the context the following expression does fail to work as
expected because of the same reason as described in the URL-section.
The reason is propably a bad implicit casting used by PHP.
The expression ( $value != '' ) sometimes results in an unexpected
FALSE-value if the $value is '0'. Better use the following expression,
that will work as expected:
( (string)$value != '' )
----------------- PHP: About using 'and/or' vs. '&&/||'
In logical expressions, always use '&&' and '||' instead
of 'and' and 'or'. That's because in combination with
assignments, the '=' has higher precedence than the
logical operator, which often ends in a wrong result.
$res = $a && $b && $c; // USE THIS
$res = $a and $b and $c; // do NOT use this
PHP-Docs:
http://de2.php.net/manual/en/language.operators.php#language.operators.precedence
The 'and' and '&&' does not have the same precedence facing
the assignment operator(s) and the '?:' operator.
$res = true and false; <=> ($res = true) and (false);
so $res results in true (not in false as you might have expected).
$res = true && false; <=> $res = (true && false);
The above shows the correct behaviour and will return $res == false.
The literal 'and/or' are to be used when combined with a command
like in the following cases (depending on the expression result):
$res = $expr and die(); // die() is executed if expression is false
$res = $expr or error(); // error() is executed if expression is true
----------------- PHP: list( ...) = $array
Never reuse variables as source and destination in the same command line.
One example is:
list( $var1, $varAGAIN, $var3 ) = $varAGAIN;
To the nature of the list()-func, this will fail.
See PHP-Docs (Notes with red-boxed Warning):
http://de2.php.net/manual/en/function.list.php
----------------- DGS-convention, SQL-aliases
DGS-convention:
When using field-aliases in SQL-statements applying some formula
on the field use an alias with the prefix 'X_'.
Example:
SELECT Moves, Moves/2 AS X_HalfMoves FROM Games
----------------- PHP: Constant Strings, PHP-Myths
Constant strings can be written as 'string' or "string".
The speed-difference is very minor.
Similar for "using $var in strings", though string-concat is a bit faster
in this case.
see PHP-myths: http://www.tuxradar.com/practicalphp/18/1/23
----------------- PHP: Faster String output with echo
Echo can work faster, if output strings are concattenated
using ',' (arg separator) instead of '.' (string concat).
----------------- PHP: 32-bit integers
Management of Table-column-sets has been replaced with two integer-values,
because DGS needed more than 32 fields (classes BitSet, ConfigTableColumns).
But handling of 32-bit integers requires special handling in PHP:
Take notice, that 0x80000000 is negative in PHP(!)
Database fields storing such values must be signed: INT(11) NOT NULL
Here is a former code-snipplet (from include/table_columns.php#add_or_del_column-func):
http://sourceforge.net/p/dragongoserver/dgs-main/ci/ac1520795de6689f4f3c2a4ee326984ebbb6152d/tree/include/table_columns.php#l626
----------------- PHP: Transactions
DGS uses MyISAM-mysql-table-engine which does not support multi-table transactions.
In the Dragon code there are two "methods" used to "simulate" transactional
behaviour as closely as possible:
* LOCK TABLES - is used to lock a single table when there are multiple changes
on the same table that should be performed within one transaction.
Use with care, because with the current MyISAM-table-engine it will block
ALL operations including SELECTs on the table!!
In the source, make up a block to more clearly mark the scope of the table-lock.
Also try to execute the db-operations as quickly as possible to minimize the
time the table is being locked:
db_lock( "label", "Table READ|WRITE, ..." );
{//LOCK <Table>
...
}
db_unlock();
* ignore_user_abort() - Changes in multiple db-tables should be kept in a block.
The SQL-statements are called HOT-sections (=transaction-like) throughout
the DGS code. Enclose those HOT-sections like the following code-snipplet:
ta_begin();
{//HOT-SECTION to ...
...
}
ta_end();
The ta_begin() and ta_end() functions set/resets the ignore_user_abort()-state.
This prevents that the abortion of the client connection to stop the execution
of the script running, and with it breaking the SQL-statements.
Like this in best-case at least the DB-updates are all executed.
Be aware, that this is no REAL transaction, but the closest we can get
without switching to another table-engine (like InnoDB for example) with
real transactions.
----------------- PHP: unit-tests, phpunit
For preparing, writing and running the unit-tests in the 'scripts/tests/'-directory,
please study the following doc-file:
scripts/tests/README.test
-----------------
//
koh5_pano
Drag mouse to navigate.
Navigation
- Left/Right Mouse drag: Changes camera heading.
- Up/Sown Mouse drag: Changes camera pitch.
- Scroll wheel: Changes camera field of view.
- I-Key: Displays Info panel with canvas size, image size and FPS.
17.Aug.2010, Martin Wengenmayer