If you're new here, you may want to subscribe to my RSS feed or follow me on Twitter. Thanks for visiting!
Sprite Kit是一个在iOS7上制作令人惊喜的2D游戏的新框架,它内置于iOS7 SDK。 它拥有材质精灵(以下将直接引用sprite),支持很酷的特效,比如视频、滤镜、遮罩等,内置了物理引擎库,还有很多其他的东西。
iOS7本来有一个很棒的Sprite Kit范例项目了,叫做冒险(Adventure),你可以马上将其下载下来。但是这个游戏有点复杂,而更多时候你需要的是一个越简单越好的例子来作为入门学习。这就是这篇教程的来由。
在这篇Sprite Kit的初学者教程里,你会从头到尾系统地学到如何为iPhone创建一个简单而有意思的2D游戏。如果你看过我们 Simple Cocos2D game 这篇教程, 这个游戏可能看起来很相似 :)
在开始之前你需要确保自己安装了最新版本的Xcode(5.X),它包含了对Sprite Kit和iOS7的支持。
对了,你可以先看教程,也可以直接跳到教程结尾运行以下完整的样例项目。如果你这样做的话将会看到忍者哦。
在开始之前,我想先指出Sprite Kit 并不是你在iOS平台上制作2D游戏的唯一选择,而且它有一些优缺点是你需要事先注意的。
之后我想再回顾一下iOS上制作2D游戏其他的三种在选择并且与Sprite Kit比较一下各自的优缺点。
Sprite Kit 优点
Sprite Kit 缺点
现在很多人会有疑问:“那么我到底该选择哪个2D游戏引擎呢?”
你需要根据自己的目的做出选择。这是我的观点:
在你看完以上的所有内容后,如果你认为Sprite Kit可能正是你要寻找的东西,请继续你的阅读,我们将正式开始Sprite Kit的教程。
让我们从创建一个简单的Hello World 项目开始,它是用Xcode5内置的Sprite Kit模版创建的。
打开Xcode,选择FileNewProject,接下来选择iOSApplicationSprite Kit Game 模版,然后单击Next:
键入“SpriteKitSimpleGame”做为Product Name,设备选择iPhone,然后单击Next:
把项目保存在你硬盘上的某个位置,然后单击 Create。随后单击运行这个项目。你应该能看到下面的界面:
就像Cocos2D一样,Sprite Kit被组织在scene(场景)之上。scene是一种类似于“层级”或者“屏幕”的概念。举个例子,你可以同时创建两个scene,一个位于游戏的主显示区域,一个可以用作游戏地图展示放在其他区域,两者是并列的关系。
如果你,你会发现Sprite Kit的模版已经默认为你新建了一个scene——MyScene。打开MyScene.m 文件你会看到它包含了一些代码,这些代码实现了两个功能,把一个label放到屏幕上以及在屏幕上随意点按时添加旋转的飞船。
在这篇教程里,你将主要与MyScene打交道。但是在开始之前,你需要做一些小的改动,使得我们的游戏在横评下运行(替代默认的竖屏)。
首先,打开Xcode中target的设定:在项目导航栏中单击SpriteKitSimpleGame项目,选中对应的target。然后在Deployment Info区域内取消Orientation中Portrait(竖屏)的勾选,这样就只有Landscape Left 和 Landscape Right 是被选中的了,如下图所示:
编译运行项目,你会看到刚刚做的改动已经顺利完成并且生效了:
然而,事实并不如此。让我们试着添加忍者到游戏中来看看为什么这样说,到底还有什么问题呢?
首先,下载 这个项目的资源文件 并且把它们拖拽到Xcode项目中。请在拖拽后弹出的对话框中确保勾选了这个选项:“Copy items into destination group’s folder (if needed)(复制所有文件到目标group所在的文件夹)”,同时项目target也要被选中。
下一步,打开MyScene.m并且用下面的代码替换掉它原有的内容:
#import "MyScene.h" // 1 @interface MyScene () @property (nonatomic) SKSpriteNode * player; @end @implementation MyScene -(id)initWithSize:(CGSize)size { if (self = [super initWithSize:size]) { // 2 NSLog(@"Size: %@", NSStringFromCGSize(size)); // 3 self.backgroundColor = [SKColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0]; // 4 self.player = [SKSpriteNode spriteNodeWithImageNamed:@"player"]; self.player.position = CGPointMake(100, 100); [self addChild:self.player]; } return self; } @end |
让我们一步一步解释下上面的代码。
编译运行,然后。。。
不对啊,屏幕白茫茫一片,没有忍者。你可能认为就是这样设计的,但这其实是一个有待解决的问题。如果你观察下刚刚在控制台输出的内容,你会看到下面的输出:
SpriteKitSimpleGame[3139:907] Size: {320, 568} |
因此我们的scene 认为它的宽是320而高是568,但这恰好反了。
为了看看到底发生了什么,我们找到ViewController.m 的viewDidLoad方法:
- (void)viewDidLoad { [super viewDidLoad]; // Configure the view. SKView * skView = (SKView *)self.view; skView.showsFPS = YES; skView.showsNodeCount = YES; // Create and configure the scene. SKScene * scene = [MyScene sceneWithSize:skView.bounds.size]; scene.scaleMode = SKSceneScaleModeAspectFill; // Present the scene. [skView presentScene:scene]; } |
这里从skView的bounds属性获取了size,创建了相应大小的scene。然而,当viewDidLoad方法被调用时,skView还没有被加到view的层级结构上,因而它不能相应方向以及布局的改变。所以skView的bounds属性此时还不是它横屏后的正确值,而是默认竖屏所对应的值,看来这个时候不是初始化scene的好时机。
Note: 有关这个现象的更多细节,请参考Rob Mayoff 的 这个很赞的解释。
解决办法是把初始化代码的运行时机后移。请用下面这个方法替换viewDidLoad:
- (void)viewWillLayoutSubviews { [super viewWillLayoutSubviews]; // Configure the view. SKView * skView = (SKView *)self.view; if (!skView.scene) { skView.showsFPS = YES; skView.showsNodeCount = YES; // Create and configure the scene. SKScene * scene = [MyScene sceneWithSize:skView.bounds.size]; scene.scaleMode = SKSceneScaleModeAspectFill; // Present the scene. [skView presentScene:scene]; } } |
再次编译运行,当当当当,女士们、先生们,忍者终于出现了!
现在游戏的坐标系统已经一切正常,你会把这个忍者放在他应该放的位置,也就是在屏幕左侧面朝中央。为了做这些,切换回MyScene.m并且用下面的代码替换掉已有的那一行设置了忍者位置的代码:
self.player.position = CGPointMake(self.player.size.width/2, self.frame.size.height/2); |
下一步将要把一些怪物添加到scene上,与现有的忍者形成战斗场景。为了使游戏更有意思,怪兽应该是移动的,否则游戏就毫无挑战性可言了!那么让我们在屏幕的右侧一点创建怪兽们,然后为它们设置action使它们能够向左移动。
在MyScene.m中添加如下方法:
- (void)addMonster { // 创建怪物Sprite SKSpriteNode * monster = [SKSpriteNode spriteNodeWithImageNamed:@"monster"]; // 决定怪物在竖直方向上的出现位置 int minY = monster.size.height / 2; int maxY = self.frame.size.height - monster.size.height / 2; int rangeY = maxY - minY; int actualY = (arc4random() % rangeY) + minY; // Create the monster slightly off-screen along the right edge, // and along a random position along the Y axis as calculated above monster.position = CGPointMake(self.frame.size.width + monster.size.width/2, actualY); [self addChild:monster]; // 设置怪物的速度 int minDuration = 2.0; int maxDuration = 4.0; int rangeDuration = maxDuration - minDuration; int actualDuration = (arc4random() % rangeDuration) + minDuration; // Create the actions SKAction * actionMove = [SKAction moveTo:CGPointMake(-monster.size.width/2, actualY) duration:actualDuration]; SKAction * actionMoveDone = [SKAction removeFromParent]; [monster runAction:[SKAction sequence:@[actionMove, actionMoveDone]]]; } |
我会慢一点把代码讲解清楚,让其尽可能容易理解。第一部分正如之前提到过的:我们需要做一些简单的计算来创建怪物对象。为它们设置合适的位置并且用和忍者sprite(player)一样的方式把它们添加到scene上。在相应的位置出现。
接下来轮到添加actions了。像Cocos2D一样,Sprite Kit提供了一些超级实用的内置actions,比如移动、旋转、淡出、动画等等。这里要在怪物身上添加3种aciton:
moveTo:
这个action,让怪物先移动 ,当移动结束时继续运行removeFromParent:
这个action把怪物从scene上移除。别忘了还有件事没做呢,你需要调用addMonster方法来创建怪物!为了让游戏再有趣一点,我们让怪物们持续不断地涌现出来。
Sprite Kit不能像Cocos2D一样设置一个每几秒运行一次的回调方法。它也不能传递一个增量时间参数给update方法。然而我们可以用一小段代码来模仿类似的定时刷新方法。首先把这些属性添加到MyScene.m的私有声明里:
@property (nonatomic) NSTimeInterval lastSpawnTimeInterval; @property (nonatomic) NSTimeInterval lastUpdateTimeInterval; |
我们会使用lastSpawnTimeInterval这个属性来记录上一次生成怪物的时间,
使用lastUpdateTimeInterval这个属性来记录上一次更新的时间。
下一步,你会编写一个每帧都会调用的方法。这个方法的参数是上次更新后的时间增量。由于它不会被默认调用,你需要在下一步编写另一个方法来调用它。
- (void)updateWithTimeSinceLastUpdate:(CFTimeInterval)timeSinceLast { self.lastSpawnTimeInterval += timeSinceLast; if (self.lastSpawnTimeInterval > 1) { self.lastSpawnTimeInterval = 0; [self addMonster]; } } |
在这里你只是简单地把上次更新后的时间增量加给lastSpawnTimeInterval。一旦它的值大于一秒,你就要生成一个怪物然后重置时间。
接下来,添加如下方法来调用上面的updateWithTimeSinceLastUpdate方法 。
- (void)update:(NSTimeInterval)currentTime { // 获取时间增量 // 如果我们运行的每秒帧数低于60,我们依然希望一切和每秒60帧移动的位移相同 CFTimeInterval timeSinceLast = currentTime - self.lastUpdateTimeInterval; self.lastUpdateTimeInterval = currentTime; if (timeSinceLast > 1) { // 如果上次更新后得时间增量大于1秒 timeSinceLast = 1.0 / 60.0; self.lastUpdateTimeInterval = currentTime; } [self updateWithTimeSinceLastUpdate:timeSinceLast]; } |
update: Sprite Kit会在每帧自动调用
这个方法。
这里的代码实际上源自苹果的Adventure范例。它传入当前的时间,我们可以据此来计算出上次更新后的时间增量。值得注意的是这里做了一些必要的检查,如果出现意外致使更新的时间间隔变得超过1秒,这里会把间隔重置为1/60秒来避免奇怪的情况发生。
就是这样,编译运行之,现在你应该看到怪物们在屏幕上欢快地移动着:
到这里,你可以已经迫不及待的为忍者添加一些动作了,那么我们就添加攻击吧。攻击的实现方式有很多种,但在这个游戏里攻击会在玩家点击屏幕时触发,忍者会朝着点按的方向发射一个子弹。
我打算使用moveTo:action动作来实现子弹的前期运行动画,为了实现它需要一些数学运算。这是因为moveTo:需要传入子弹运行轨迹的终点,由于用户点按触发的位置仅仅代表了子弹射出的方向,显然我们不能直接将其当作运行终点。实际上就算子弹超过了触摸点你也应该让子弹保持移动直到子弹超出屏幕为止。
这是一张图片,它标注了这个问题:
就像你看到的,从子弹发射原点到用户触摸点在x轴和y轴上的偏移量会形成一个小三角形。你只要以相同的比例去实现一个顶点在屏幕边缘的大三角形即可。
为了进行这部分的运算,有一些关于向量的基本数学计算方法很有帮助(比如向量间的加减法)。然而,Sprite Kit默认并没有提供,所以你需要自己来实现了。
幸运的是这很容易实现。把下面的方法添加到文件顶部:
static inline CGPoint rwAdd(CGPoint a, CGPoint b) { return CGPointMake(a.x + b.x, a.y + b.y); } static inline CGPoint rwSub(CGPoint a, CGPoint b) { return CGPointMake(a.x - b.x, a.y - b.y); } static inline CGPoint rwMult(CGPoint a, float b) { return CGPointMake(a.x * b, a.y * b); } static inline float rwLength(CGPoint a) { return sqrtf(a.x * a.x + a.y * a.y); } // 让向量的长度(模)等于1 static inline CGPoint rwNormalize(CGPoint a) { float length = rwLength(a); return CGPointMake(a.x / length, a.y / length); } |
这些是向量运算方法的标准实现。如果你对此感到疑惑或者没有学习过向量的数学知识,可以到这里恶补一下 vector math explanation.
下一步,添加一个新方法:
-(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { // 1 - 选择其中的一个touch对象 UITouch * touch = [touches anyObject]; CGPoint location = [touch locationInNode:self]; // 2 - 初始化子弹的位置 SKSpriteNode * projectile = [SKSpriteNode spriteNodeWithImageNamed:@"projectile"]; projectile.position = self.player.position; // 3- 计算子弹移动的偏移量 CGPoint offset = rwSub(location, projectile.position); // 4 - 如果子弹是向后射的那就不做任何操作直接返回 if (offset.x <= 0) return; // 5 - 好了,把子弹添加上吧,我们已经检查了两次位置了 [self addChild:projectile]; // 6 - 获取子弹射出的方向 CGPoint direction = rwNormalize(offset); // 7 - 让子弹射得足够远来确保它到达屏幕边缘 CGPoint shootAmount = rwMult(direction, 1000); // 8 - 把子弹的位移加到它现在的位置上 CGPoint realDest = rwAdd(shootAmount, projectile.position); // 9 - 创建子弹发射的动作 float velocity = 480.0/1.0; float realMoveDuration = self.size.width / velocity; SKAction * actionMove = [SKAction moveTo:realDest duration:realMoveDuration]; SKAction * actionMoveDone = [SKAction removeFromParent]; [projectile runAction:[SKAction sequence:@[actionMove, actionMoveDone]]]; } |
到这里已经做了很多事情,我们来一步一步回顾一下。
moveTo:
和removeFromParent 这两个action。
现在游戏里有了满天飞的手裏剑,但是你的忍者真正要做的是把怪物打下来。所以让我们添加一些代码来监测子弹是否打到了目标。
Sprite Kit一个好处是它已经内置了物理引擎。物理引擎不仅仅非常有助于模拟现实中的移动,同时也对碰撞监测提供了很好的支持。
让我们把Sprite Kit的物理引擎引入到游戏中来监测怪物和子弹的碰撞。大体上讲,下面是你准备要做的:
现在你理解了战斗(指子弹打怪物的过程)的计划,是时候付诸行动了!
让我们添加两个常量开始。将它们添加到MyScene.m中:
static const uint32_t projectileCategory = 0x1 << 0; static const uint32_t monsterCategory = 0x1 << 1; |
这里设置了两个种类,等下就会用到。一个是子弹的,一个是怪物的。
注意:你可能对这种语法感到奇怪。你只要明白在Sprite Kit中category是一个32位的整型然后被用作掩码就好了。这是种用32位整型表示一个category的简单方式(所以你最多能创建32个category)。这里你用首位来表示子弹,用下一位来表示怪物。
下一步,在initWithSize方法中,把忍者加到scene的代码后面再加入如下两行代码:
self.physicsWorld.gravity = CGVectorMake(0,0); self.physicsWorld.contactDelegate = self; |
这里设置了一个没有重力的物理体系,为了收到两个物体碰撞的消息需要把当前的scene设为它的代理。
在addMonster方法中创建完怪物后添加如下代码:
monster.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:monster.size]; // 1 monster.physicsBody.dynamic = YES; // 2 monster.physicsBody.categoryBitMask = monsterCategory; // 3 monster.physicsBody.contactTestBitMask = projectileCategory; // 4 monster.physicsBody.collisionBitMask = 0; // 5 |
让我们逐行看看上面的代码到底做了什么。
monsterCategory
。当发生碰撞时,当前怪物对象会通知它contactTestBitMask
这个属性所代表的category。这里应该把子弹的种类掩码projectileCategory赋给它。collisionBitMask
这个属性表示哪些种类的对象与当前怪物对象相碰撞时物理引擎要让其有所反应(比如回弹效果)。你并不想让怪物和子弹彼此之间发生回弹,设置这个属性为0吧。当然这在其他游戏里是可能的。下一步添加一些相似的代码到touchesEnded:withEvent:方法里,就在设置子弹位置的代码之后:
projectile.physicsBody = [SKPhysicsBody bodyWithCircleOfRadius:projectile.size.width/2]; projectile.physicsBody.dynamic = YES; projectile.physicsBody.categoryBitMask = projectileCategory; projectile.physicsBody.contactTestBitMask = monsterCategory; projectile.physicsBody.collisionBitMask = 0; projectile.physicsBody.usesPreciseCollisionDetection = YES; |
试试看你是否能理解这里的每行代码,如果不能,请参照之前怪物代码的解释。
再试试你是否能发现两者之间细微的区别并回答下面的问题。
Solution Inside: 它们有何区别? | Show |
---|---|
- (void)projectile:(SKSpriteNode *)projectile didCollideWithMonster:(SKSpriteNode *)monster { NSLog(@"Hit"); [projectile removeFromParent]; [monster removeFromParent]; } |
这里做的都是为了在子弹和怪物发生碰撞时把它们从当前的scene上移除。是不是非常简单?
到了实现接触后代理方法的时候了,将下面的代码添加到文件里:
- (void)didBeginContact:(SKPhysicsContact *)contact { // 1 SKPhysicsBody *firstBody, *secondBody; if (contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask) { firstBody = contact.bodyA; secondBody = contact.bodyB; } else { firstBody = contact.bodyB; secondBody = contact.bodyA; } // 2 if ((firstBody.categoryBitMask & projectileCategory) != 0 && (secondBody.categoryBitMask & monsterCategory) != 0) { [self projectile:(SKSpriteNode *) firstBody.node didCollideWithMonster:(SKSpriteNode *) secondBody.node]; } } |
由于你将当前的scene设为了物理体系发生碰撞后的代理( contactDelegate),这个方法会在两个物理外形发生碰撞时被调用(调用的条件还有它们的
contactTestBitMask
s属性也要被正确设置)。
这个方法分成两部分:
最后一步,在MyScene的私有声明上让其实现SKPhysicsContactDelegate这个代理协议,这样才能编译通过。
@interface MyScene () |
编译运行,然后子弹在碰到目标(怪物)时它们就会一起消失了!
你马上就要完成这个简单的游戏了。只要再添加一些音效(哪种游戏也不能没有声音啊!)和一些简单的游戏逻辑即可。
Sprite Kit没有像Cocos2D一样提供声音引擎,但值得庆幸的是它可以通过动作这种简便的方式来实现。并且你可以通过同样很简单的AVFoundation类库来播放背景音乐。
你的项目里已经有一些我做的背景音乐(很酷哦)和一个给力的piu~piu~音效了。它们是从这个教程的资源包里添加的。你只要播放就好了!
为了实现这些,将下面的代码添加到Viewcontroller.m文件里:
@import AVFoundation; |
这里使用了iOS的新特性,通过使用新的@import 关键字,你可以更简单、更高效地引入头文件(还有类库)。想要进一步了解它,请阅读我们在iOS 7 by Tutorials里的第10章——Objective-C 和 iOS基础类库有什么新玩意。
下一步,添加一个新的属性和一个私有声明:
@interface ViewController () @property (nonatomic) AVAudioPlayer * backgroundMusicPlayer; @end |
将如下代码添加到 viewWillLayoutSubviews方法中,添加到 [super viewWillLayoutSubviews]的后面:
NSError *error; NSURL * backgroundMusicURL = [[NSBundle mainBundle] URLForResource:@"background-music-aac" withExtension:@"caf"]; self.backgroundMusicPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:backgroundMusicURL error:&error]; self.backgroundMusicPlayer.numberOfLoops = -1; [self.backgroundMusicPlayer prepareToPlay]; [self.backgroundMusicPlayer play]; |
This is some simple code to start the background music playing with endless loops.
As for the sound effect, switch back to MyScene.m and add this line to the top oftouchesEnded:withEvent::
[self runAction:[SKAction playSoundFileNamed:@"pew-pew-lei.caf" waitForCompletion:NO]]; |
了解?你可以仅仅用一行代码来播放一个声音!
让我们来创建一个新的scene来展现游戏输赢的结果。在新建界面中按 iOSCocoa TouchObjective-C class的方式从模版创建新类,将之命名为GameOverScene,使其继承自 SKScene ,然后单击下一步(Next)而后创建(Create)。
然后用下面的代码替换GameOverScene.h中原有的代码:
#import <SpriteKit/SpriteKit.h> @interface GameOverScene : SKScene -(id)initWithSize:(CGSize)size won:(BOOL)won; @end |
这里你引入了Sprite Kit的头文件并且声明了一个特殊的初始化方法,这个初始化方法除了需要传入size大小外还要传入用户的游戏结果(布尔值,表示输赢)。
然后用以下代码替换 GameOverLayer.m中的原有代码:
#import "GameOverScene.h" #import "MyScene.h" @implementation GameOverScene -(id)initWithSize:(CGSize)size won:(BOOL)won { if (self = [super initWithSize:size]) { // 1 self.backgroundColor = [SKColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0]; // 2 NSString * message; if (won) { message = @"You Won!"; } else { message = @"You Lose :["; } // 3 SKLabelNode *label = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"]; label.text = message; label.fontSize = 40; label.fontColor = [SKColor blackColor]; label.position = CGPointMake(self.size.width/2, self.size.height/2); [self addChild:label]; // 4 [self runAction: [SKAction sequence:@[ [SKAction waitForDuration:3.0], [SKAction runBlock:^{ // 5 SKTransition *reveal = [SKTransition flipHorizontalWithDuration:0.5]; SKScene * myScene = [[MyScene alloc] initWithSize:self.size]; [self.view presentScene:myScene transition: reveal]; }] ]] ]; } return self; } @end |
这是上述代码的解释:
presentScene:transition:
方法进行转场即可。到现在为止一切顺利,你只要在游戏结束时用你的主场景(MyScene)来加载游戏结束的场景(GameOverScene)就好了。
为了实现这个功能,首先要把新的scene引入到MyScene.m文件中:
#import "GameOverScene.h" |
然后在addMonster方法中用下面的action替换最后一行的action:
SKAction * loseAction = [SKAction runBlock:^{ SKTransition *reveal = [SKTransition flipHorizontalWithDuration:0.5]; SKScene * gameOverScene = [[GameOverScene alloc] initWithSize:self.size won:NO]; [self.view presentScene:gameOverScene transition: reveal]; }]; [monster runAction:[SKAction sequence:@[actionMove, loseAction, actionMoveDone]]]; |
这里创建了一个新的“失败action”用来展示游戏结束的场景,当怪物移动到屏幕边缘时游戏就结束了。看看你是否理解了这里的代码,如果没有就翻看之前block的解释吧(指GameOverScene中的系列action)。
还有一个很常见的问题:为什么你要在actionMoveDone 动作之前运行loseAction动作?如果你不知道为什么,那就手动改变两者的顺序试试吧。
Solution Inside: Why is Lose Action First? | Show |
---|---|
@property (nonatomic) int monstersDestroyed; |
然后把下面的代码添加到 projectile:didCollideWithMonster: 方法底部:
self.monstersDestroyed++; if (self.monstersDestroyed > 30) { SKTransition *reveal = [SKTransition flipHorizontalWithDuration:0.5]; SKScene * gameOverScene = [[GameOverScene alloc] initWithSize:self.size won:YES]; [self.view presentScene:gameOverScene transition: reveal]; } |
编译运行之,游戏会在对应的情况下展示输赢场景了!
这个阶段完成了!这里是当前Sprite Kit初学者入门教程的 完整代码 。
我希望你能够沉浸在学习Sprite Kit和制作你自己的游戏之中。
如果你打算学习更多有关于Sprite Kit的知识,你可以购买我们的书iOS Games by Tutorials。我们会从物理特性、瓦片地图(tile maps)、粒子系统等方面让你了解一切你需要了解的东西。甚至专门为你选择合适水平的编辑(来撰写文章)。
如果你对这片教程有问题或者想要吐槽,请加入我们下面的讨论组!