65.9K
CodeProject 正在变化。 阅读更多。
Home

Mario5 编辑器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (62投票s)

2012年8月1日

CPOL

22分钟阅读

viewsIcon

115542

downloadIcon

3124

通过提供一个带有社交平台的关卡编辑器,为马里奥游戏增添趣味。

Super Mario for HTML5

引言

这是关于 Mario5 游戏的后续文章。第一篇文章侧重于编码技术和原理设计,而本文将侧重于如何重用代码和集成现有代码。本文有望表明,以原子方式编写的 JavaScript 代码实际上可以很容易地维护和扩展。集成到现有代码库中也是可能的。

关于 Mario5 游戏的第一篇文章介绍了基于类的 JavaScript 方法的概念。本文假设您已经阅读了 CodeProject 上关于 Mario5 游戏的文章。我们不会再次讨论基于类的 JavaScript 方法,也不会再次深入探讨游戏的类图。我们已经为关卡设置了一种(也许不理想,但相对容易编写的)格式。由于手动编写关卡是一项繁琐且非可视化的(因此可能容易出错的)任务,我们希望在浏览器中创建一个合适的关卡编辑器。将关卡保存到文件系统是不(直接)可能的,因此我们还将研究如何将现有代码库集成到 Web 应用程序中。

背景

如果您还没有玩过马里奥游戏,请查看 YouTube 上的视频。在视频中,您将看到第一篇文章的最终成果。我的两名学生,他们启动了马里奥项目并完成了所有美术作品(部分完全由他们自己完成,部分通过在互联网上寻找免费美术作品并将其编译成精灵表),确实提供了一个关卡编辑器。由于我们完全重写了游戏,采用了面向对象的方法,我们现在将尝试重用我们为编辑器编写的类。因此,旧的编辑器——它编写得很好,包含撤销、可视网格等功能——将不会在这里介绍。相反,我们将使用与第一篇文章相同的原则完全重新编写编辑器。

利用游戏的基本设计进行扩展

我们可以重用现有对象来构造新的不同对象。我们通过创建 StaticPlantPipePlant 类看到了这个原理。这些类继承自 Plant 类。因此,这两个类非常相似(它们共享一些属性),但在外观上完全不同。

上面的例子本质上很简单,但是,当修改 Level 类时,可以应用相同的原理。让我们考虑以下代码

var Editor = Level.extend({
    init: function(id) {
        this.world = $('#' + id);
        this.grid = false;
        this.setPosition(0, 0);
        this.reset();
        this.undoList = [];
    }
});

在这里,我们不调用 Level 类的构造函数(通过使用 this._super())。就像 Level 对象期望传递关卡容器的 ID 一样,Editor 也期望传递一个 ID。我们已经看到编辑器特定的属性正在初始化,例如 gridundoList。从这个原理开始,我们现在可以进一步改变和扩展 Level 类的可能性

var Editor = Level.extend({
    init: function(id) {
        var me = this;
        this.world = $('#' + id);
        this.grid = false;
        this.setPosition(0, 0);
        this.reset();
        this.undoList = [];
    },
    reset: function() {
        this._super();
        $('<canvas />').addClass('grid').appendTo(this.world);
        var data = [];
        
        for(var i = 100; i--; ) {
            var t = [];
            
            for(var j = 15; j--; )
                t.push('');
            
            data.push(t);
        }
        
        this.load({
            height: 15,
            width: 100,
            background: 1,
            id: 0,
            data: data
        });
    },
    save: function() {
        return JSON.stringify(this.raw);
    },
    setSize: function(w, h) {
        this._super(w, h);
        this.generateGrid();
    },
    setImage: function(index) {
        if(this.raw)
            this.raw.background = index;
            
        this._super(index);
    },
    generateGrid: function() {
        var c = $('.grid', this.world).get(0).getContext('2d');
        c.canvas.width = this.width;
        c.canvas.height = this.height;
        c.clearRect(0, 0, c.canvas.width, c.canvas.height);
        
        if(this.grid) {
            for(var i = 32; i < this.width; i += 32) {
                c.moveTo(i, 0);
                c.lineTo(i, 480);
            }
            
            for(var i = 32; i < 480; i += 32) {
                c.moveTo(0, i);
                c.lineTo(9600, i);
            }
            
            c.lineWidth = 0.5;
            c.strokeStyle = '#FF00FF';
            c.stroke();
        }
    },
    start: function() {
        //Left blank intentionally...
        //This is just to override (and disable) the parent start();
    },
    pause: function() {
        //Left blank intentionally...
        //This is just to override (and disable) the parent pause();
    },
    gridOn: function() {
        this.grid = true;
        this.generateGrid();
    },
    gridOff: function() {
        this.grid = false;
        this.generateGrid();
    },
    setParallax: function() {
        //Left blank intentionally...
        //This is just to override (and disable) the parent setParallax();
    },
});

这已经是一个非常棒的状态了。如果我们现在启动游戏,我们将生成我们的关卡(甚至可以有一个网格来显示每个关卡组成的 32px x 32px 块)。为了看到这一点,我们还需要一些 HTML。让我们从以下 HTML 开始,为我们的关卡编辑器提供一个合适的容器

<!doctype html>
<html>
<head>
<meta charset=utf-8 />
<title>Super Mario HTML5 Editor</title>
<link href="Content/style.css" rel="stylesheet" />
</head>
<body>
<div id="edit">
<div id="edit_world"></div>
</div>
<div id="tool">
<div id="tool_world"></div>
</div>
</div>
<script src="Scripts/jquery.js"></script>
<script src="Scripts/testlevels.js"></script>
<script src="Scripts/oop.js"></script>
<script src="Scripts/constants.js"></script>
<script src="Scripts/main.js"></script>
<script src="Scripts/editor.js"></script>
<script>
$(document).ready(function() {
    var edit = new Editor('edit_world');
    edit.load(definedLevels[1]);
    edit.gridOn();
});
</script>
</body>
</html>

在这里,我们重用了第一篇文章中编写的许多脚本。只有 editor.js 文件是新的。该文件将包含我们将在本文中编写的所有 JavaScript(好吧,老实说,更准确地说:大部分)。style.css 文件与第一篇文章中基本相同。我们只需要一些额外的语句

#edit {
	height: 480px; width: 640px; position: absolute; left: 50%; top: 50%;
	margin-left: -321px; margin-top: -241px; border: 1px solid #ccc; overflow: hidden;
}
#tool {
	width: 640px; position: absolute; left: 50%; top: 50%; margin-left: -321px;
	margin-top: 282px; height: 128px; background: #ddd; border: 1px solid #ccc;
}
#tool_world { 
	margin: 0; padding: 0; position: relative; top: 0; left: 0; height: 100%; width: 100%;
}
.tool {
	margin: 0; padding: 0; z-index: 99; position: absolute;
}
.grid {
	margin: 0; padding: 0; z-index: 150; position: absolute; top: 0; left: 0;
}
.block {
	z-index: 100;
}

目前,大多数这些规则都是不必要的,但以后会变得至关重要。现在我们只需要针对带有 #edit 选择器的元素的附加规则。此规则与第一篇文章中针对 #game 选择器的规则相同。

到目前为止,我们所做的只是显示关卡的一部分。在介绍添加和删除项目的可能性之前,我们应该以某种方式引入滚动(水平)的可能性。我们可以通过浏览器提供的滚动条来实现——只需简单地更改 CSS 规则就足够了。然而,大多数浏览器会添加附加滚动条,也就是说,对于水平滚动条,我们必须因此增加一定高度的像素(大约20px)。我们希望使用不同的滚动条系统,这样我们仍然只有480px的总高度。

我们可以自己编写这样一个滚动条控件,但为了节省时间(也许还有金钱),我们选择了一个适合我们的解决方案。由于我们已经在使用 jQuery,并且不想因为使用 Prototype、MooTools 或其他库而增加额外的开销或复杂性,所以该解决方案应该作为 jQuery 插件提供。有几个 jQuery 滚动插件可用,但大多数只适用于垂直滚动(这是最常见的情况)。经过长时间搜索,我们最终找到了 slimScroll,可在作者网站上找到。

这个插件只适用于垂直滚动,因为作者认为水平滚动很愚蠢,不应该被需要(显然他不知道Metro设计原则或我们的马里奥关卡编辑器!)。通过将插件中所有从水平部分到垂直部分的行互换,可以修改水平滚动的限制。在这里展示修改后的代码会浪费空间,因为修改真的非常小。修改后的插件也可在下载包中找到。

最后,我们可以通过在构造函数中添加以下调用来修改 Editor 类的构造函数

var Editor = Level.extend({
    init: function(id) {
        /* as before */
        this.world.slimScroll({ height : 480 })
    },
    /* rest as before */
});

现在我们已经可以看到关卡完全创建了。

关卡编辑器、工具箱和宏元素

此时,我们能够查看并滚动我们决定编辑的关卡。到目前为止,我们尚未包含添加新块或擦除现有块的功能。因此,我们将不得不进一步修改代码。我们的第一个修改将侧重于为我们的应用程序添加更多类。首先,我们创建一个类,它将作为要添加到关卡中的项目的容器发挥重要作用。我们将这个类命名为 ToolBox

var ToolBox = Level.extend({
    init: function(id, edit) {
        this.world = $('#' + id);
        this.edit = edit;
        this.setPosition(0, 0);
        this.reset();
        this.world.slimScroll({height: 128});
    },
    load: function(names) {
        var x = 0;
        this.obstacles = [];
        
        for(var ref in reflection) {
            if(!names || names.indexOf(ref) !== -1) {
                this.obstacles.push([]);
                var t = new (reflection[ref])(x, 0, this);
                t.view.addClass('block').draggable({
                    stack: false,
                    cursor: 'move',
                    cursorAt: { top: t.height / 2, left: t.width / 2 },
                    opacity: 0.8,
                    distance: 0,
                    appendTo: 'body',
                    revert: false,
                    helper: 'clone',
                }).data('name', ref);
                x += t.width + 2 * (t.x - x);
            }
        }
    },
    getGridHeight: function() {
        return 1;
    },
    getGridWidth: function() {
        return this.obstacles.length;
    },
    start: function() {
        //Left blank intentionally...
        //This is just to override (and disable) the parent start();
    },
    pause: function() {
        //Left blank intentionally...
        //This is just to override (and disable) the parent pause();
    },
});

该类也像 Editor 一样继承自 Level 类。我们还用空方法覆盖了一些方法,以防止使用不适当的内容。一个重要的特性在于 load() 方法。在这里,我们允许传递一个可选参数 names。如果传递此参数,则将期望一个包含要添加到此 ToolBox 实例的项目的名称数组。如果我们以后想要单独的工具箱,这将是一个重要的选项。如果未设置该参数,则将添加所有可用项目。

我们构建的下一个类是仅在编辑器中可用的对象的通用基类。我们首先为这些项目提供一个通用基类。为了提供此类类,我们将首先创建一个基类,以减少代码重复。这个新的基类应该被称为 ToolBoxBase

var ToolBoxBase = Base.extend({
    init: function(x, y, level) {
        this.view = $(DIV).addClass(CLS_TOOL).appendTo(level.world);
        this._super(x, y);
        this.level = level;
    },
    addToGrid: function(x, y) {
        this.level.obstacles[x / 32][14 - y / 32] = this;
    },
    onDrop: function(x, y) {
        //Do nothing here by default ...
    },
    setImage: function(img, x, y) {
        this.view.css({
            backgroundImage : img ? c2u(img) : 'none',
            backgroundPosition : '-' + (x || 0) + 'px -' + (y || 0) + 'px',
        });
        this._super(img, x, y);
    },
    setPosition: function(x, y) {
        this.view.css({
            left: x,
            bottom: y
        });
        this._super(x, y);
    },
    setSize: function(w, h) {
        this._super(w, h);
        this.view.css({
            width: w,
            height: h
        });
    },
});

这个类的基本结构遵循 EnemyHero 等类的创建方式。我们这里不需要 move() 方法,因为继承自 ToolBoxBase 的项目将只在关卡编辑器中使用。

第一个可以从 ToolBoxBase 派生的项目是橡皮擦,即一个用于擦除已添加项目的项目。我们将此类别称为 ToolBoxEraser 并覆盖强制性的 onDrop() 方法。在这里,我们使用 xy 的信息从 level 实例(实际上是我们的编辑器)中擦除指定位置的项目。我们返回 true 以向调用函数发出信号,表明不需要进一步的步骤。

var ToolBoxEraser = ToolBoxBase.extend({
    init: function(x, y, level) {
        this._super(x, y, level);
        this.view.css('border', '1px solid #000');
        this.setSize(32, 32);
    },
    onDrop: function(x, y) {
        this.level.setItem('', x, y);
        this.view.remove();
        return true;
    },
}, 'Eraser-1x1');

此外,我们希望能够添加一些宏构建块。这些块将由现有块组成,并应加快关卡创建。我们将所有此类对象的基类命名为 ToolMulti,并再次从 ToolBoxBase 派生。

var ToolMulti = ToolBoxBase.extend({
    init: function(x, y, level, width_blocks, height_blocks, master) {
        this._super(x, y, level);
        this.master = master;
        this.width_blocks = width_blocks;
        this.height_blocks = height_blocks;
        this.setSize(width_blocks * 32, height_blocks * 32);
    },
    onDrop: function(x, y) {
        this.view.remove();
        return false;
    },
});

这里的 onDrop() 为所有将作为宏项目组代表的类提供了功能。基本功能是,一旦项目放到关卡上,其视图就会被移除。我们返回 false 以向调用函数发出信号,表明仍然需要将项目添加到关卡中。

那么,为关卡编辑器创建宏类到底是什么样子的呢?由于我们在适当的面向对象层次结构中所做的努力,我们只需使用适当的参数调用基类构造函数即可。最后,我们给类一个唯一的反射名称——这样它就会被添加到可创建对象的列表中。这也允许我们将它添加到只包含特定元素的工具箱中。

var ToolMultiSoil2 = ToolMulti.extend({
    init: function(x, y, level) {
        this._super(x, y, level, 2, 2, 'soil');
        this.setImage(images.objects, 1071, 3);
    },
}, 'Soil-2x2');

现在我们已经为添加和擦除对象创建了平台,我们只需要在 Editor 类中添加适当的功能。我们已经使用了一些方法,例如 ToolBoxEraser 类的 onDrop() 函数中的 setItem() 方法,这些方法目前还不存在。现在我们必须添加这些方法并进行适当的实现。

由于我们的宏对象将是现有对象的宽度 x 高度数组,因此我们将需要一个更通用的 setItems() 方法。我们还需要围绕这些函数包装一些检查,在实际调用 setItem()setItems() 之前执行一些检查。检查应包括以下场景:添加第二个马里奥(只能有一个玩家!),建议位置是否有效,以及 onDrop() 方法是否返回 true

var Editor = Level.extend({
    /* Existing methods */    
    setItem: function(value, x, y, noUndo) {
        this.setItems(value, [x], [y], noUndo);
    },
    setItems: function(value, xs, ys, noUndo) {
        var t = [];
        
        for(var i = 0, n = xs.length; i < n; i++) {
            t.push({
                name: this.raw.data[xs[i]][ys[i]],
                x: xs[i],
                y: ys[i]
            });
            
            this.raw.data[xs[i]][ys[i]] = value;
        }
        
        if(!noUndo)
            this.pushUndoList(t);
    },
    addItem: function(name, x, y, noUndo) {
        if(name === 'mario' && this.mario) {
            var oldx = this.mario.i;
            var oldy = this.mario.j;
            this.mario.view.remove();
            this.setItems(['', 'mario'], [oldx, x], [oldy, y]);
            new (reflection[name])(32 * x, 448 - 32 * y, this);
            return;
        }
        
        this.removeView(x, y);
        var t = new (reflection[name])(32 * x, 448 - 32 * y, this);
        
        if(t.onDrop && t.onDrop(x, y))
            return;
            
        var xarr = [];
        var yarr = [];
        
        if(t.width_blocks && t.height_blocks && t.master) {
            var w2 = t.width_blocks / 2;
            var h2 = t.height_blocks / 2;
            name = t.master;
            
            for(var xi = Math.ceil(x - w2); xi < Math.ceil(x + w2); xi++) {
                for(var yi = Math.ceil(y - h2); yi < Math.ceil(y + h2); yi++) {
                    xarr.push(xi);
                    yarr.push(yi);
                    this.removeView(xi, yi);
                    new (reflection[name])(32 * xi, 448 - 32 * yi, this);                
                }
            }
        } else {
            xarr.push(x);
            yarr.push(y);
        }
        
        this.setItems(name, xarr, yarr, noUndo);
    },
    removeItem: function(x, y, noUndo) {
        this.removeView(x, y);
        this.setItem('', x, y, noUndo);    
    },
    removeView: function(x, y) {
        if(this.obstacles[x][y])
            this.obstacles[x][y].view.remove();
        else {
            for(var i = this.figures.length; i--; ) {
                var gp = this.figures[i].getGridPosition();
                
                if(gp.i === x && gp.j === y) {
                    this.figures[i].view.remove();
                    this.figures.splice(i, 1);
                    break;
                }
            }
        }
    },
});

removeItem() 方法处理从关卡数组中实际删除项目的情况。我们还添加了一个帮助方法来删除即将从数组中删除的项目的视图。

最后,我们想添加一个可用的 undoList。我们已经准备好了数组,并在 setItems() 方法中添加了一些方法调用。这里我们不需要太多代码,只需几个函数来组织列表和一个实际调用撤消操作的方法。

var Editor = Level.extend({
    /* Existing methods */    
    pushUndoList: function(action) {
        this.undoList.push(action);
    },
    popUndoList: function() {
        return this.undoList.pop();
    },
    undo: function() {
        if(this.undoList.length) {
            var action = this.popUndoList();
            
            for(var i = 0, n = action.length; i < n; i++) {
                var x = action[i].x;
                var y = action[i].y;
                
                if(action[i].name)
                    this.addItem(action[i].name, x, y, true);
                else
                    this.removeItem(x, y, true);
            }
        }
    },
});

现在我们的编辑器已经完成,可以通过一些脚本使用。下载包中包含了一个编辑器的演示。基本上,这个演示的构建方式与原始游戏演示的构建方式相同。

围绕游戏构建一个平台

维护一个大型 JavaScript 项目与维护其他大型项目一样繁琐。因此,我们需要将代码拆分为更小的项目,这些项目之间(最好)没有任何依赖关系。到目前为止我们构建了什么?

  • 一个抽象层,为我们在 JavaScript 中提供真正的面向对象代码的印象,命名为 (1)
  • 一个声音管理器项目,依赖于 (1),命名为 (2)
  • 一个键盘(或通用输入)项目,依赖于 (1),命名为 (3)
  • 游戏本身(基础、关卡、一些对象等),依赖于 (1),命名为 (4)
  • 带有新对象的关卡编辑器,依赖于 (1) 和 (4)

此外,游戏本身总是需要 (2) 和 (3) 才能插入,形成一个带有声音的可控游戏。我们也没有提到我们的游戏需要 jQuery 作为额外的 JavaScript 层(它简化了跨浏览器编程并提供了一些省时的功能)。编辑器还需要 jQuery slimScroll 插件,该插件需要 jQuery UI 的一部分(以便能够拖动等)。总而言之,我们有以下外部依赖项

  • jQuery
  • jQuery UI(自定义构建,只需一些功能即可 - 不需要主题)
  • jQuery slimScroll

现在,对于任何使用 Mario5 游戏源代码的应用程序,我们至少需要 jQuery 和 OOP 层。由于我们对控制游戏感兴趣,因此我们还需要键盘类的实现。如果我们要有声音,我们还应该包含声音类的适当实现。

如果我们的应用程序应该提供 Mario5 关卡编辑器,我们需要更多的资源。除了 Mario5 游戏的依赖项(以及游戏本身),我们还需要 jQuery UI 和 jQuery slimScroll 插件。提供所有这些脚本构成了任何 Web 应用程序的基础。

我们现在的目标是围绕 Mario5 游戏构建一个具有社区导向的游戏。首先,我们应该对所包含的功能有一个愿景

  • 注册和登录
  • 编辑、保存和加载关卡
  • 玩单人战役以及玩提供的自定义关卡
  • 给其他作者的关卡评分(这非常像 CodeProject!)

这些基本上是需要提供的所有功能。总而言之,我们有一个非常简单的数据库。数据库的代码优先方法可以显示为以下图表

The database concept

特定 DbContext 实现的相关代码如下所示

namespace SuperMario.Models
{
    public class MarioDB : DbContext
    {
        public DbSet<Level> Levels { get; set; }
        public DbSet<User> Users { get; set; }
        public DbSet<Rating> Ratings { get; set; }

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder.Conventions.Remove<IncludeMetadataConvention>();
            modelBuilder.Entity<User>().ToTable("My_AspNet_Users");
        }

        public void Detach(object entity)
        {
            var objectContext = ((IObjectContextAdapter)this).ObjectContext;
            objectContext.Detach(entity);
        }
    }
}

由于我们使用的是 Entity Framework,因此只需在 web.config 文件中设置相应的数据库提供程序即可与任何数据库提供程序一起使用。

我们还有一些动作需要编写。我们不想在这里详细介绍每个实现,所以我们只在这里展示加载和保存关卡的实现(因为这是一个直接的(和依赖的)JavaScript / 我们的 Mario5 游戏模块)。首先,我们实际保存关卡需要什么?

  • 触发事件的按钮
  • 一个 JavaScript 事件处理程序
  • 调用 jQuery ajax() 方法,或更专业的包装器
  • 作为目标 URL 的适当操作

现在我们已经把所有东西都准备好了,我们只需要实现这个动作。

namespace SuperMario.Controllers
{ 
    public class LevelController : Controller
    {
        /* ... */

        //
        // GET: /Level/Save

        [Authorize]
        public ActionResult Save()
        {
            return PartialView();
        }

        //
        // POST: /Level/Save

        [HttpPost]
        [Authorize]
        public ActionResult Save(Level level)
        {
            if(level == null)
                return PartialView();

            var content = H.Json.Decode<LevelData>(level.Content);
            level.UserId = (int)Membership.GetUser().ProviderUserKey;
            level.Played = 0;
            level.Created = DateTime.Now;
            level.Updated = DateTime.Now;
            level.Background = content.background;

            if (ModelState.IsValid)
            {
                db.Levels.Add(level);
                db.SaveChanges();
                content.id = level.Id;
                level.Content = H.Json.Encode(content);
                db.Entry(level).State = EntityState.Modified;
                db.SaveChanges();
                return Json(new { id = level.Id });
            }

            return PartialView(level);
        }
    }
}

实际上,我们编写了两个方法,一个(表单)在用户点击按钮时触发(通过 AJAX 请求),另一个在用户提交表单时触发。在此方法中,我们正在进行一些基本的模型创建和一些模型更新。由于关卡数组将作为包含所用 ID 的 JSON 字符串存储,因此我们需要修改 ID。这是一个问题,因为在实际插入之前我们不知道 ID。我们可以通过两个步骤来解决这个问题

  1. 首先,我们将实体插入数据库(这里 SaveChanges() 调用很重要)
  2. 然后,我们通过反序列化 JSON 字符串、更新属性、再次序列化并更新实体来更新 JSON 字符串

此外,我们还保存了从 JSON 字符串中提取的一些信息。这样做是为了在以后请求现有关卡列表时节省一些计算能力。我们应该注意,这里我们有直接的 DbContext 访问,这在大型 Web 应用程序中应该避免。对于我们围绕 Mario5 游戏的小型 Web 应用程序来说,这仍然是可以接受的。

Web 应用程序的 JavaScript 需要将此操作与负责保存当前关卡的适当按钮连接起来。这里我们必须区分保存新关卡和保存现有关卡,即编辑关卡。

$('#saveEdit').click(function () {
    var url = edit.id ? ('/Level/Edit/' + edit.id) : '/Level/Save';
    webapp.performAjax(url, function () {
        $('#Content').val(edit.save());
    });
});

此代码片段将 ID 为 saveEdit 的按钮与适当的 AJAX 调用连接起来。如果编辑器分配了有效的 ID,我们使用编辑操作的 URL;否则,我们使用上面显示的操作。

社交整合和移动设备考量

我们需要思考不同的方式(这些也许是当前的趋势,但 IT 始终与当前趋势相关)来使我们的应用程序可用和知名

  • 使用分享按钮,让用户轻松传播信息
  • 使用 OpenAuth,让用户能够使用他们的(主要)在线账户
  • 集成触摸友好按钮(并将其连接到触摸控制)来控制游戏
  • 让游戏在智能手机等移动设备上可玩

分享按钮将取自一个名为 Shareaholic.com 的页面。在这个页面上,我们可以编译自己的社交书签集。完成之后,该页面会给我们一个片段,必须将其包含在我们页面中所需的位置。当然,我们将包含三大巨头(Facebook、Twitter 和 Google+),还有 Orkut、LinkedIn,以及更传统的服务,例如通过电子邮件分享。

有些页面内容不需要用户完全关注,应被视为常规内容的补充。此类内容可以通过使用无模式(即非阻塞)对话框来提供。此类对话框的原则是它们可以填充任何内容,并且不依赖于当前页面。最终,此类对话框将如下所示

The modeless dialog in action

集成 OpenAuth 提供商可能很棘手,但幸运的是,大部分工作可以通过 DotNetOpenAuth 库(托管在 dotnetopenauth.net)来完成。我们仍然需要编写一些操作,设置一些视图并将所有内容连接起来。如果您想更详细地了解 OpenId 和 DotNetOpenAuth,那么您应该阅读类似 这篇关于 ASP.NET MVC 中 OpenId 快速设置的博客文章。基本概念如下

  • 我们提供一个带有输入字段和提交按钮的 <form>
  • 输入字段应包含有效的 OpenId 提供程序
  • 提交应由适当的控制器操作处理(我们的部分)
  • 然后控制器将向指定的 URL(即指定的 OpenId 提供程序)发送请求
  • 将检查此请求的答案,结果将影响我们的响应
  • 通常答案将是重定向到 OpenId 提供程序(重定向以登录用户)
  • 随着该重定向,我们必须向提供程序提供一个有效的回调 URL
  • 提供程序的答案将与一些参数一起发送到回调 URL,然后我们再次检查答案
  • 最后,我们根据整个过程显示结果

总而言之,我们最多需要三个视图和最多两个操作。我们还需要了解 OpenId API,即名称和接受的值。这听起来像是一些工作,但幸运的是,我们可以使用上面提到的 DotNetOpenAuth 库来完成大部分工作。最后,我们只需要一个操作

/* ... */
using DotNetOpenAuth.Messaging;
using DotNetOpenAuth.OpenId;
using DotNetOpenAuth.OpenId.RelyingParty;
using DotNetOpenAuth.OpenId.Extensions.SimpleRegistration;
using DotNetOpenAuth.OpenId.Extensions.AttributeExchange;

namespace SuperMario.Controllers
{
    public class AccountController : Controller
    {
        static OpenIdRelyingParty openid;

        public AccountController()
        {
            openid = new OpenIdRelyingParty();
        }

        [ValidateInput(false)]
        public ActionResult Authenticate(string openid_identifier)
        {
            var response = openid.GetResponse();

            //Distinguish between: Redirect FROM OpenId provider and TO OpenId
            if (response == null) // this case: TO OpenId
            {
                Identifier id;

                if (Identifier.TryParse(openid_identifier, out id))
                {
                    try
                    {
                        var request = openid.CreateRequest(id);
                        var fetch = new FetchRequest();
                        fetch.Attributes.AddRequired(WellKnownAttributes.Contact.Email);
                        request.AddExtension(fetch);
                        return request.RedirectingResponse.AsActionResult();
                    }
                    catch (ProtocolException ex)
                    {
                        TempData.Add("StatusMessage", ex.Message);
                        return RedirectToAction("Index", "Home");
                    }
                }
                else
                {
                    TempData.Add("StatusMessage", "Invalid identifier");
                    return RedirectToAction("Index", "Home");
                }
            }
            else // this case: FROM OpenId
            {
                switch (response.Status)
                {
                    case AuthenticationStatus.Authenticated:
                        // Create account if not already existing
                        // Login user
                        /* ... */
                        return RedirectToAction("Index", "Home");

                    case AuthenticationStatus.Canceled:
                        TempData.Add("StatusMessage", "Canceled at provider");
                        return RedirectToAction("Index", "Home");

                    case AuthenticationStatus.Failed:
                        TempData.Add("StatusMessage", response.Exception.Message);
                        return RedirectToAction("Index", "Home");
                }
            }

            return new EmptyResult();
        }
    }
}

由于只提供一个文本字段(带有神秘的 URL 供输入)和一个提交字段有点(至少可以说)不友好,我们应该使用一个(一些)可用 OpenId 提供商的列表。同样,考虑到已经有一些非常好的免费解决方案,这可能会导致太多的工作。最好的解决方案之一是 OpenId 选择器。这基本上是一个 JavaScript 解决方案(适用于 jQuery 或其他流行的库),它将我们的普通表单字段转换为一个丰富多彩、按钮丰富的选择。现在用户只需简单点击一下即可选择他们最喜欢的 OpenId 提供商。我们所要做的就是提供精灵表(或我们自己的编译)用于图形并设置 JavaScript。

一旦我们更改了 openid-jquery.js 代码中的 img_path 变量,我们就调整了登录页面。最终的登录页面现在还包含以下可能性

Login with OpenAuth

集成触摸友好按钮非常简单直接(适用于任何 Web 应用程序)。我们所需要做的就是将任何链接(或通常是可点击元素)做得足够大。我们不必遵循 Metro 设计原则指南——但我们可以使用其中描述的一些技巧。这些原则已在 Metro 设计语言文档中汇编。

我们将创建带有彩色背景的大型矩形按钮。实际的(文本)超链接只会偶尔使用。主菜单,即用户在 Web 应用程序加载后将直接看到的视图,如下所示

The main menu after the game has loaded

这个概念必须移植到每个对话框。这对整个应用程序的设计有一些影响。一个重要方面在于对话框的可视化。我们使用完整的视口向用户显示问题。视口下方的按钮行则用于可能的答案。让我们快速看一个例子

The modal dialog on opening the editor

为了让游戏在移动设备上可玩,我们设置了众所周知的 <meta name="viewport"> 指令。完整的解释可在 Mozilla Developer Network (MDN) 上找到。我们的规则如下

<meta name="viewport" content="width=640, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0">

移动设备的另一项调整是设备(宽度)特定的 CSS 规则。使用此 CSS3 功能,我们可以为分辨率较低的设备制作特殊外观。一个重要的功能是游戏按钮将始终在游戏区域内可见。通常游戏按钮位于视口下方的按钮行中。这看起来像下图所示

The game including the touch buttons

在移动设备上,我们可以使用以下 CSS 规则

@media only screen and (max-width: 900px) 
{
    #editGame { display: none; }
    #sections { top: 50%; margin-top: -240px; }
    #toppanel { z-index: 0; }
    #topnav { position: relative; z-index: 10; }
    #bottompanel { top: auto; bottom: 0; z-index: 10; opacity: 0.8; }
}

最重要的是,顶部和底部面板(负责显示菜单按钮;顶部导航非常通用,而底部导航始终显示当前内容的动作)已更改。现在内容屏幕始终位于显示器的中间,这可能会与顶部和底部导航重叠。因此,顶部和底部导航必须放置在内容屏幕前面。这是通过将 z-index 规则更改为 10 来完成的。

底部行也显示在游戏中(带有可用作触摸输入面板的按钮),这可能(并且很可能会)与游戏重叠。为了避免游戏流程中的限制,例如看不到角色或关卡的一部分,按钮已设置为透明。这里我们只需将 opacity 规则设置为小于 1.0(不透明)的值。在这种情况下,0.8 的值应该足够了。

编辑器在移动设备上被淘汰了,因为编辑器的界面可能是(Mario5 Web 应用程序中所有界面中)最难移植到长边分辨率低于 900px 的移动设备上的。通过查看编辑器的屏幕截图就可以看出这一点

The editor on a 13 inch MacBook Air in fullscreen mode

还有一件事:动画!

我们还需要这样一个平台用于游戏内序列。目前我们只有一个用途:作为在单人战役中获胜的奖励。

通常,我们希望在这样的动画中出现大马里奥。我们还需要(对于那个特定的结局场景)马里奥的心上人 Peach。这两个特殊的角色可以快速、直接地创建出来

var Peach = Hero.extend({
	init: function(x, y, level) {
		this.width = 80;
		this._super(x, y, level);
		this.setSize(46, 64);
		this.direction = directions.right;
		this.setImage(images.peach, 0, 80);
	},
	setVelocity: function(vx, vy) {
		this._super(vx, vy);
		
        if(vx !== 0) {
			if(!this.setupFrames(6, 4, false, 'Walk'))
				this.setImage(images.peach, 138, 80);
		} else if(this.frameTick) {
            this.clearFrames();
			this.setImage(images.peach, 0, 80);
		}
	},
}, 'peach');

var BigMario = Hero.extend({
	init: function(x, y, level) {
		this._super(x, y, level);
		this.direction = directions.right;
		this.setSize(32, 62);
		this.setImage(images.sprites, 0, 88);
	},
	setVelocity: function(vx, vy) {
		this._super(vx, vy);
		
        if(vx !== 0) {
			if(!this.setupFrames(9, 2, false, 'WalkRightBig'))
				this.setImage(images.sprites, 32, 88);
		} else if(this.frameTick) {
            this.clearFrames();
			this.setImage(images.sprites, 0, 88);
		}
	},
}, 'bigmario');

请注意,BigMario 类本来可以避免,但这些少量的代码行实际上比调整常规类 (Mario) 更短。此外,新角色会自动添加到关卡编辑器中——这为关卡创建者提供了新的可能性。另外一点说明:这两个类只包含向一个特定方向(左或右)奔跑的动画。如果考虑到更复杂的动画,则应进行扩展。

现在我们有了游戏中对应的角色,我们就可以开始着手实际的 Animation 类了。不多说代码,我们先看一眼

var Animation = Level.extend({
    init: function (id) {
        this.world = $('#' + id);
		this.setPosition(0, 0);
        this.input = [];
        this.speeches = [];
        this.currentSpeeches = [];
        this.animations = [];
        this.currentAnimations = [];
        this.cycles = 0;
        this.maxCycles = 0;
		this.reset();
    },
    load: function(level) {
        this._super(level);
        this.onend = level.onend || function() {};
        this.maxCycles = Math.ceil(level.duration / constants.interval);

        for(var i = 0; i < level.characters.length; i++) {
            var character = level.characters[i];
            var figure = new (reflection[character.name])(character.x, character.y, this);

            for(var j = 0; j < character.speeches.length; j++) {
                var speech = character.speeches[j];
                this.speeches.push({
                    figure: figure,
                    start: Math.floor(speech.start / constants.interval),
                    end: Math.floor(speech.end / constants.interval),
                    text: speech.text
                });
            }

            for(var j = 0; j < character.animations.length; j++) {
                var animation = character.animations[j];
                var obj = {
                    figure: figure,
                    start: Math.floor(animation.start / constants.interval),
                    end: Math.floor(animation.end / constants.interval)
                };

                for(var key in animation) {
                    if(obj[key] === undefined)
                        obj[key] = animation[key];
                }

                this.animations.push(obj);
            }
        }

        this.speeches.sort(function(a, b) {
            return b.start - a.start;
        });

        this.animations.sort(function(a, b) {
            return b.start - a.start;
        });
    },
    createSpeech: function(s) {
		var pos = s.figure.view.position();
        s.element = $(DIV).addClass('speech-bubble').appendTo(this.world).text(s.text).css({
			left: pos.left - 90,
			top: pos.top - s.figure.view.height() - 40
		});
    },
    removeSpeech: function(index) {
        var s = this.currentSpeeches[index];
        s.element.remove();
        this.currentSpeeches.splice(index, 1);
    },
    createAnimation: function(a) {
        if(a.x !== undefined) {
            var dx = (a.x - a.figure.x) / (a.end - a.start);
            var dy = a.figure.vy;
            a.figure.setVelocity(dx, dy);
        }

        if(a.background !== undefined) {
            a.figure.setImage(a.background.image, a.background.x, a.background.y);
        }
    },
    removeAnimation: function(index) {
        var a = this.currentAnimations[index];

        if(a.x !== undefined) {
            a.figure.setVelocity(0, a.figure.vy)
        }

        this.currentAnimations.splice(index, 1);
    },
    tick: function () {
        var i = 0, figure;

        if(this.cycles === this.maxCycles) {
            this.onend();
            this.pause();
            return;
        }

        for(i = this.currentSpeeches.length; i--; ) {
            if(this.currentSpeeches[i].end === this.cycles)
                this.removeSpeech(i);
			else if(this.currentSpeeches[i].figure.vx !== 0) {
				this.currentSpeeches[i].element.css({
					left: '+=' + this.currentSpeeches[i].figure.vx
				});
			}
        }

        for(i = this.currentAnimations.length; i--; ) {
            if(this.currentAnimations[i].end === this.cycles)
                this.removeAnimation(i);
        }

        while(this.speeches.length && this.speeches[this.speeches.length - 1].start === this.cycles) {
            var speech = this.speeches.pop();
            this.createSpeech(speech);
            this.currentSpeeches.push(speech);
        }

        while(this.animations.length && this.animations[this.animations.length - 1].start === this.cycles) {
            var animation = this.animations.pop();
            this.createAnimation(animation);
            this.currentAnimations.push(animation);
        }
		
		for(i = this.figures.length; i--; ) {
			figure = this.figures[i];
			figure.move();
			figure.playFrame();
		}
		
		for(i = this.items.length; i--; ) {
			this.items[i].playFrame();
        }

        this.cycles = this.cycles + 1;
    },
});

所以这基本上是 Level 类的另一个实现。这次我们重写了 tick() 等方法,只是为了完全按照我们的方式处理动画(这排除了任何碰撞检测和其他目前我们不需要的东西)。如果我们仔细查看代码,我们会看到 createSpeech()createAnimation() 等方法弹出。这些方法是为了确保我们的动画序列中可能有两个动作。

  1. 其中一个角色正在演讲/说话
  2. 其中一个角色正在做一些事情,比如向一个方向行走

这个类的关卡到底是什么样的?嗯,与真实的关卡没有什么不同,也就是说,这里我们也有一个关卡数组和一些属性,比如 widthbackground。此外,我们必须设置角色并为他们分配动画和对话。这是一个例子

var endingLevel = {
    /* The start is the same as in ordinary levels */
    onend: function() { }, //This one is new - a callback if the animation has ended
    duration: 16000, //The total duration of the animation - this is when the callback is executed
    characters: [ //Our array of characters
        { // First character
            name: 'bigmario', // what is the name of the character in the reflection array ?
            x: -30, // the starting position
            y: 96,  // x and y coordinates in px
            speeches: [
                {
                    start: 7500, // start at 7.5 s
                    end: 10500, // end at 10.5 s
                    text: 'Oh Daisy!' // this text will be displayed
                }
            ],
            animations: [
                {
                    start: 2500, // start at 2.5 s
                    end: 4100, // end at 4.1 s (duration = 1.6 s)
                    x: 100 // this will be the position in the end: 100px
                },
                /* and more animations */
            ]
        },
        /* and more characters */
    ],
};

关于这些语音气泡的最后一点说明。我们为语音对象使用了 CSS 类 speech-bubble。此类别背后的 CSS 代码如下

.speech-bubble {
    position: absolute; padding: 20px; margin: 1em 0 3em; color: #000; background: #fdfdfd; text-align: center;
    border-radius: 100px; width: 160px; height: 25px; z-index: 100; font-size: 1.3em; border: 1px solid #ccc;
}
.speech-bubble:after {
    content: ""; display: block; position: absolute; bottom: -14px; left: 92px; width: 0;
    border-width: 15px 15px 0; border-style: solid; border-color: #fdfdfd transparent;
}

这个太狡猾了,太狡猾了,太狡猾了。CSS 大师们很久以前就发现边框规则的一个属性实际上非常有用:它们直接连接。这意味着什么?嗯,考虑一个简单的正方形(比如说 10px x 10px)。我们现在在每边设置一个 1px 的简单边框。现在我们的正方形实际上是 12px x 12px(我们现在使用标准的 CSS 盒模型,而不是 IE 的盒模型,即更直观的盒模型,可以通过 box-sizing: border-box; 使用)。这很简单。如果我们把 border-top 设置为 0px 呢?嗯,我们有一个 12px x 11px 的盒子,左上角和右上角的边框看起来有点平滑。现在我们增加边框的宽度(但顶部的边框保持 0px)。我们看到左上角和右上角出现了三角形。让我们做一些奇怪的事情,减小盒子的大小(从 102 变为简单的 1)。我们看到这将变成一个三角形!好的:长话短说,我们甚至可以将面积设置为零(宽度为 0 且高度为 0),从而获得一个真正的三角形。使用这个技巧(可能还有其他各种技巧),我们可以创建许多可能的形状。一个很棒的页面在线于 CSS-Tricks.com

关注点

OpenAuth 集成实际上非常重要,因为它减少了注册过程的摩擦。有些人只是为每个页面注册,但大多数人试图通过只在必要时才注册帐户来最小化他们的在线帐户数量。一个例子是,如果需要 Twitter API 密钥,则必须注册 Twitter。即使是非常简单的注册表单(例如用于 Mario5 平台的表单)对大多数人来说显然也很痛苦。因此,OpenAuth 在鼓励用户尝试或使用 Web 应用程序方面大有帮助。

您可以在线玩完整版:mario5.florian-rappl.de

历史

  • v1.0.0 | 首次发布 | 2012年8月1日。
  • v1.1.0 | 包含动画 | 2012年8月2日
  • v1.1.1 | 修复了一些拼写错误 | 2012年8月3日
© . All rights reserved.