2010年5月6日星期四

用Qt写GalGame

虽然向PyGame这样东西的东西来写个交互娱乐作品会简单一点,而且也有了像RenPy这样的东西。
不过Qt作为一个跨平台的Qt库兼C++开发框架,用来写一个简单视觉类型的东西还是很方便的。
况且除了其Core和GUI部分,还为我们提供了Paint System,Graphics View,Script Module,Phonon Multimedia Framework这些图形脚本多媒体支持,还有Network Module和OpenGL Module这些会很有用的东西。

以下内容是按照Qt4.5的API来写的,因为这是一个向后兼容LGPL版本。不过实例是在Qt4.6下测试的(Qt4.5下Phonon没编译好的后端支持),并且暂不过考虑在Qt4.7上会有更好的实现方法(对Script有更强大的支持了)。

写此文目的仅仅是为了尝试一种可行的方案罢了。#include部分被我略去了,不要忘记写哦。一般用一个Class就对应写一个,也就是像"#include <QtGui>"这样的东西。这里也没处理空格来着。


QWidget & Events

这部分先考虑显示出基本的界面,以及实现事件处理。
可以通过继承QWidget来实现一个Scene:
class Scene : public QWidget
{
public:
Scene(QWidget *parent);
protected:
void paintEvent(QPaintEvent *event);
void mousePressEvent ( QMouseEvent * event );
void keyPressEvent ( QKeyEvent * event );
void timerEvent(QTimerEvent *event);
};
然后mian.cpp里可以写:
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
QTextCodec::setCodecForCStrings(QTextCodec::codecForLocale());
QWidget window;
window.setWindowFlags(Qt::MSWindowsFixedSizeDialogHint);
window.setWindowTitle("演示程序");
Scene *s=new Scene(&window);
s->resize(800,600);
window.show();
return app.exec();
}
这样就能得到一空白的窗口了,我们事件方面的功能是通过重写QWidget的虚函数来实现的。

首先来看"paintEvent",对QWidget的绘制操作就将在这里进行。需要时可以用"update();"来使这个方法被调用。
方法"timerEvent"是继承自QObject(QWidget的基类),用"int QObject::startTimer(int interval)"来开始一个QTimer。这里interval通常取20的倍数。
一个通常的交互场景的update(逻辑的更新)和draw(图像的绘制)方法,就可以通过重写这项个方法实现。

然后来看对鼠标键盘事件的处理,我以上只是列举了部分可重写的函数,详见的QWidget文档的Events部分。例如:
void MyWidget::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
// handle left mouse button here
} else {
// pass on other buttons to base class
QWidget::mousePressEvent(event);
}
}
也可以只重写(在消息链中添加一环)"event(QEvent *event)"方法,再对其"type()"进行判断后对不同类型的消息处理。不过不调用"setFocusPolicy(Qt::StrongFocus);"的话,会收不到键盘消息的(还有"setMouseTracking(true);"鼠标移动消息)。

QWidget本身还有方法"show()"和"hide()"来改变自身的"visible"属性。
在QWidget上放另一个QWidget也是可行的,不过一般只有一个是活动的吧。


Paint System

QWidget是继承自QPaintDevice,这样就可以在"paintEvent"方法中用"QPainter"对象来实现图形的绘制了:
void SimpleExampleWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setPen(Qt::blue);
painter.setFont(QFont("Arial", 30));
painter.drawText(rect(), Qt::AlignCenter, "Qt");
}
通常还有"painter.setRenderHint(QPainter::Antialiasing);"。

这里绘制图形的效果的设置中线条是"QPen"而填充是"QBrush"决定的字体则是"QFont"。
还有"rotate","scale"和"translate"用于绘制前对图形变换(底层方法是执行矩阵运算)的设置。
具体的绘制操作是一组以"draw"为前缀的方法,有图形,路径或图像。
绘制像素的混合模式参见文档"QPainter"的"Composition Modes"部分。
至于绘制图层的透明度是由被绘制物的材质的透明度决定的,比如"QColor"就可以指定"alpha"值图像也可以"setMask"。


Graphics View

这部分是个可选的框架,用来实现管理2D图形物件的场景。
不过是实现Sprite精灵对象是最好最直接的方式了。
首先是一个基本的mian.cpp:
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
QGraphicsScene scene;
scene.addText("Hello, world!",QFont("",128));
QGraphicsView view(&scene);
view.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);
view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
view.resize(800,600);
view.show();
return a.exec();
}
这里是把派生自"QGraphicsItem"的对象添加("addItem()"或使用创建加添加的方法)入"QGraphicsScene"(负责管理),然后由"QGraphicsView"来渲染和显示(也可以对Scene使用QPrinter)。
这里我设置滚动条为不显示,并且默认"DragMode"为"NoDrag",所以需要之后控制渲染的场景区域。
还有在View中提供了鼠标选择Item的方法,同时它会把消息传递给Scene。我们对Event的处理就通过重写(依旧是消息链,可调用父类方法)Scene的方法来实现,默认的是选择或把消息提交给当前"focus"的item,而item可以决定它能否被拖拽。

下面来从Scene派生,用于处理事件:
class scene : public QGraphicsScene
{
public:
scene();
protected:
void keyPressEvent ( QKeyEvent * keyEvent );
};
void scene::keyPressEvent ( QKeyEvent * e){
switch(e->key()){
case Qt::Key_Up:
qDebug("Key_Up");
break;
case Qt::Key_Down:
qDebug("Key_Down");
break;
default:
;
}
}
这里截取了消息,没有向父类传递,所以item不会获得消息了。
我是偏向于只保留键盘控制的,就有点像传统NES手柄的感觉。

而Sprite对象则可以从QGraphicsItem派生,然后重写"boundingRect()"和"paint()"。
也可以直接去继承"QGraphicsItem"的子类,可以省些事儿,比如用"QGraphicsPixmapItem"。
class sprite : public QGraphicsItem
{
public:
sprite(QGraphicsItem * parent=0);
//...
};
一组Item可用"QGraphicsItemGroup",不过Item本身就是Qt传统的树状关系了。

每个sprite以new创建,然后交给父Item或Scene管理。:
scene::scene()
{
this->player=new sprite();
this->addItem(player);
}
如果需要控制sprite的属性(比如Pos方位)则使用一个私有的指针成员,然后在处理事件时跟新。
至于sprite的属性和运动角度之类,则是sprite对象的私有属性了,sprite的update(逻辑上的根更新)则通过重写"advance"方法:
void QGraphicsItem::advance (int phase){
if (!phase)return;
//更新自己状态
//进行碰撞检测,这里是复制了Qt自带Demo的代码片段
QList dangerMice = scene()->items(QPolygonF()
<< mapToScene(0, 0)
<< mapToScene(-30, -50)
<< mapToScene(30, -50));
foreach (QGraphicsItem *item, dangerMice) {
if (item == this)
continue;
//在这里判断
}
}
碰撞检测可以用手工根据"QGraphicsItem::contains"判断,或者用"QGraphicsScene::collidingItems","QGraphicsItem::collidesWithItem"。
还有个"QGraphicsItemAnimation"用来实现缩放移动等随时间的变换。

显示区域在View设置,可用View的"setSceneRect"来设施显示区域的范围。
有个"centerOn"用于点居中,不过要注意Scene的"setSceneRect"来设置大小,默认是Scene大小小于View大小时Scene居中于View。

这是个带来方便的可选框架,我这里也没多说清楚。不过
如果需要的功能简单的话也可以手工用"QPainter"来实现Sprite精灵。


Script Module

QtScript模块目前还不能代替C++来使用Qt库,不过由于moc预编译器的存在,QObject的绑定也变成了一件很方便的事:
QScriptEngine engine;
qDebug() << "the magic number is:" << engine.evaluate("1 + 2").toNumber();
比如在Script里写AI脚本,提供一个函数给Host使用。
当然Qt的信号槽机制也很好的支持,这算是最方便的一种用法了。

用"QScriptEngine::newQObject"的话是绑定QObject的 Signals and slots, properties and children:
QScriptEngine engine;
QObject *someObject = new MyObject;
QScriptValue objectValue = engine.newQObject(someObject);
engine.globalObject().setProperty("myObject", objectValue);
这里是通过个QObject设计一组API(包括绘图操作,事件绑定)给脚本,然后把整个程序的逻辑写在.js文件里。

下面来看Qt示例里的这段代码:
QApplication app(argc, argv);
QScriptEngine engine;

QScriptValue Qt = engine.newQMetaObject(QtMetaObject::get());
Qt.setProperty("App", engine.newQObject(&app));
engine.globalObject().setProperty("Qt", Qt);

evaluateFile(engine, ":/tetrixpiece.js");
//...
QScriptValue ctor = engine.evaluate("TetrixWindow");
QScriptValue scriptUi = engine.newQObject(ui, QScriptEngine::ScriptOwnership);
QScriptValue tetrix = ctor.construct(QScriptValueList() << scriptUi);
它把Qt命名空间传给脚本,并用脚本提供的构造函数创建了一个对象。

不过最主要还涉及各种connect,以及C++和脚本中函数的互相调用(比如脚本中函数对象的call方法)啦。
这里问题在于怎么划分出脚本的职责,因为绑定方法是都可以直接使用文档上的示例代码的。
或QScriptEngine::fromScriptValue()在C++与Script间传递。


Phonon Multimedia

最简单的用法是:
Phonon::MediaObject *music =
Phonon::createPlayer(Phonon::GameCategory,
Phonon::MediaSource("/path/mysong.wav"));
music->play();
这里MediaObject是可以存放一个序列的。
然后使用"setCurrentSource",音乐播放是不需要全部载入内存的。
循环播放的话,用finish信号。

也有视频支持来着。


GUI

按钮啦文本框啦什么的也是会用到的,这些就按Qt本来的方式处理吧。
然后也可以渲染到View里(QGraphicsScene::addWidget),再就指定一下"Style Sheets"显示样式。

不过Qt毕竟是GUI库,其显示界面是基于模式和消息循环的。也就是说对于一个画面显示的状态,有一个个对应的Widget的mode,要处理界面的重绘(比如窗口覆盖)并重写处理事件的消息。
而不是像SDL那样有个全局的Surface供绘制并可随时在脚本中间进行一个消息循环。虽然说像STG那种也通常就一个主模式和主循环。当然Qt中也可用一个QImage作Surface用。
QPainter一般是让它在析构的时候才自动执行实际的绘制工作。


Resource System

Qt提供嵌入Resource,也就是qrc资源文件。资源文件编译有可以绑如可执行文件中,或者单独的.rcc文件。
QResource::registerResource("/path/to/myresource.rcc");
cutAct = new QAction(QIcon(":/images/cut.png"), tr("Cu&t"), this);
如果用"RESOURCES = application.qrc"捆绑的话,是会自动初始化资源文件的。


其他

qDebug() 用于输出调试文本
qsrand(QTime(0,0,0).secsTo(QTime::currentTime())); 初始化随机数
OpenGl可以用来2D加速,或3D支持。
Qt4.6有些状态机模式的示例,原理可用到。

额,就是暂且列了些可用的东西而已,没出现什么实际的代码。
最关键一点,详见Qt文档。
然后,PyQt貌似也能用来着。


----
荐歌环节:中岛美雪《骑在银龙背上》

====
Part.2
现在到文本的补充环节了,上面的说的内容,因该是算Qt的相关API部分了。
然后我也确实那它来写了一个简单的GalGame,最初的样子就是单个QWidget然后一个执行脚本序列(最初只是简单解析下文本而已,)。
不过这种方案有两个问题:
一是这样只用一个模式,像俄罗斯方块那样的确实一个模式就可以了,不过对于一个GalGame来说单模式还是很单一的。
另一个是消息循环在Host程序中,脚本序列只是一串孤立命令,无法实现丰富的脚本功能,引入QtScript的好处被限制了。

一般的GalGame专用引擎都会实现自己的脚本,以行为单位依次执行命令,控制流程则通过古典的GOTO跳转语句。这样的好处是没有的局部作用域,行的存在就是脚本执行的一个状态。这样GalGame的程序的save&load来保存状态就只需要保存行号和所有变量的值。
脚本文件的存在,即是数据也是程序。不过由于GalGame功能上的东西往往都是有引擎实现好的,或者可由插件支持的。更多的是把脚本作为数据看待,作为引擎解析的文本而不是一组命令。
在古董(因为简单嘛)级别BASIC小游戏中,数组是唯一的数据结构,然后程序和游戏逻辑的代码就全混合在一起了。
在C语言中,虽然也是有goto语句的,不过这样就无法获得程序执行的状态。此时可以转用switch来实现:
enum Map{ROOM,TREE,OTHER};
switch(MapId){
case ROOM:
say("欢迎回家");
say("1.去森林");
n=key();
if(n==1)return TREE;
break;
case TREE:
say("欢迎森林");
say("1.回家");
n=key();
if(n==1)return ROOM;
break;
case OTHER:
say("欢迎回家");
break;
default:
;
}
这只是一种重写在中间状态的代码,每个key()的存在都是单独的状态。
这在传统的Goto式脚本中是可行而且简便的,不过在有统一消息循环的程序中,将会带来频繁的模式mode切换。
所以这里可以再修改一下,建立一个跳转表,把switch放在while内,并每次循环只调用一次key()。不过坏处就是,我们有时确实是需要频繁的模式切换的。
从编译器的角度说switch就会被优化为一个跳转表的,于是对于一些轻量的脚本语言(例如Javascript和lua)就有了另一种解决方案(它们要们switch有问题,或干脆就没定义这条语句),用字典类型的数据结构(json和table)。
并且由于函数的first class value的存在,语句块是可以作为一个key对应的值,于是语句也就天然地作为了数据的一部分了:
#lua#
map={

home={
name="家",
goto={"tree"},
has={"she"},
run=function(self)

end
},
tree={
name="森林",
goto={"home"},
has={},
run=function(self)
local tree={}
self.goto={"home","shop"}
self.has={}
if not table.has(save.bag,"card") then
self.has={"card"}
end
end
},
shop={
name="商店",
goto={"home","tree"},
has={},
run=function(self)

end
}

}
obj={

she={
name="女友",
type="human",
run=function(self)
say("你好啊")
end,
catch=function(self,o)
say("什么东西?")
if o==obj.card then
say("好啦,我早就认识你了")
return true
end
return false
end
},
card={
name="身份证",
type="thing",
run=function(self)
return false
end,
use=function(self,obj)
say("你出示了"..self.name)
return true
end,
take=function(self)
table.add(save.bag,"card")
return true
end

}

}
这只是实验性质代码的DATA部分(半成品,分离的程度还不够),然后可以根据需要去实现若干View视图。
从做法上来说,感觉这里的代码分离是很具有个人喜好的美感的,而且此时去实现View部分的代码也是非常具有效率的。

啦,可以回到之前的关于Qt的消息循环与模式切换的问题上,一个还算可行的方案是用设计模式中的状态模式以及模拟Stack。
因为我之前说到的那个所写的Qt代码是单模式的只使用的一个QWidget,所以来发扬一下重构精神。用消息托管的方法分离一个state的界面处理QWidget来:
class world;
class Scene :public QObject
{
protected:
world* w;
public:
bool pop;
Scene(world* parent);
virtual ~Scene(){};
virtual void paint (QPaintEvent *event) =0;
virtual bool event ( QEvent * event );
//virtual void timer ( QTimerEvent *event){};
//virtual void update();
//virtual void mousePressEvent ( QMouseEvent * event ){};
//virtual void keyPressEvent ( QKeyEvent * event ){};
};
这里的class world就是我先前的那个单一QWidegt,不过显然职责分配的过重了。它本身承担了QWidet本身要处理的消息和绘制操作,而mode间交换的全局环境(先叫env)以及script的处理代码居然还混在里面。不过暂且目标是实现多mode,这里是重构了一半的代码,不过工作良好(往后的细节问题还没考虑好来着)啊。
然后,模式间的切换就交给单实例类(实现单实例类,那么env就是全局的了,所以现在的传指针的方案是有好处的。)env的成员stack或map来。
如果使用stack那么每创建一个mode就是把旧mode给push了,然后消息托管给栈顶的mode处理。
如果mode设置退出消息,那么就把它pop掉。
用map的话,就是同时存在多个mode,但在一个时刻里,只有一个mode是活动的。mode切换的方法和上面stack的类似。例如用map,这里string和enum都行。

这样的话,我们的脚本部分就终于又有一种用法了,而且OO到这种别扭的地步下还能算是优雅吧:用来实现mode。
对于QtScript可以用"QScriptValue::construct"由javascript脚本中的构造函数来创建对象。那么接下来,只要在js对象的Prototype中添加mode子类需要重写的若然方法就行了。
这样的话消息循环依旧由QWidget去做,但是已经给脚本部分很大的自由度了。
Qt自带有个Demo是去实现一个canvas供Script使用,它在界面重绘时不是去调用脚本。而是脚本在绘制时把图像绘制在一个QImage上,然后再需要时把QImage的内容绘制到显示用的QWidegt上。
这里我们脚本的职责是定义对象(就是相当于用new语句创建对象到环境里一样啦)。

再然后嘛,这里和前面写到json部分结合起来有点不够协和的感觉,那就有必要去再扯一扯关于对象实现方面的事情了。
Javascript(QtScript里用到的)的继承是基于原型,用new的方法来创建对象,用函数的形式来写对象的构造器。于是可以写出:
function Person(name)
{
this.name = name;
}
var p1 = new Person("John Doe");
Person.prototype.toString = function() { return "Person(name: " + this.name + ")"; }
print(p1 instanceof Person); // true
function Employee(name, salary)
{
Person.call(this, name); // call base constructor

this.salary = salary;
}
Employee.prototype = new Person();
var e = new Employee("Johnny Bravo", 5000000);
print(e instanceof Employee); // true
print(e instanceof Person); // true
print(e instanceof Object); // true
(示例取自Qt文档)
这里隐含实现了对象的方法查找链,以及对象派生关系的判断。如果要写成类似json的形式的话,就要自己来实现一些机制了。
比如在还很淳朴的Lua中(这两种语言的优雅支持就在于废话写多了不觉得累啊)用table和metatable机制(有机制但没自带oo实现):
比如学Python(令人印象深刻的"__init__"双下划线写法)把table的metatable都设成(在Lua中也就额外遍历一下)自己?
这样就实现了居于原型的查找,然后还要实现一个类似instanceof的函数吧。
额。。。我也只是想象而已。相比写成一序列new构造(貌似可以玩出复杂的制造构造的构造来着),个人还是觉得写成这种嵌套字典的形式好看一点(况且可用)。
个人评价标准嘛,依旧是以前一篇里提过的"声明>命令",以及Scheme的语言最小话的原则。

恩,这部分补充的东西乱了一点。大体上说是关于脚本和程序混合的问题。
当然如果纯粹像插件类型的或批处理类型的,实现会简单很多的感觉来着。
还有一种更简单的分割原则就是,以脚本为主体,制作二进制库来扩展。使开发偏重脚本,必要时以C语言等重写或扩展组件。
本篇的第二部分就暂且不独立成篇了。
#100513
----
其实上边的脚本最初是写成这种样子的:

on choose 床 水池
case 1
player.hp+=1
case 2
player.mp+=12
纯手写风格,好处是可以作为思维的开始。
不然就总觉得在哪里被框住了。

没有评论: