3.6 创建2D菜单界面

问题

你想创建一个2D菜单界面,让你可以容易地添加新的菜单和指定它们的菜单选项。这个菜单允许用户使用控制器/键盘切换不同的选项和菜单,当用户从一个菜单切换到另一个菜单时还可以定义漂亮的过渡效果。

解决方案

你将创建一个新的类,MenuWindow,这个类保存所有与菜单相关的东西,诸如菜单的当前状态,菜单项,背景图像等。这个类让主程序可以容易地创建多个MenuWindow实例并将菜单项添加到这些实例中。菜单项可以使用你的系统中安装的字体用2D文字的方式显示。

要实现过渡效果,一个窗口将拥有Starting、Ending、Active和Inactive状态。controller/keyboard状态会被传递到Active MenuWindow,可以通过传递选择的菜单让主程序知道用户是否选择了一个菜单项。

让MenuWindow可以存储和显示背景图像可以增强最终效果,这还可以使用后期处理效果加以改进(见教程2-12)。

工作原理

主程序会创建几个MenuWindow对象,每个菜单项都会链接到另一个MenuWindow对象。所以首先定义一个MenuWindow类。 MenuWindow类创建一个叫做MenuWindow的新类。每个菜单能够存储它的菜单项。对每个菜单项,必须存储文字和指向的菜单。所以,在MenuWindow类中定义下述结构:

private struct MenuItem 
{
    public string itemText; 
    public MenuWindow itemLink; 
    public MenuItem(string itemText, MenuWindow itemLink) 
    
    {
        this.itemText = itemText; 
        this.itemLink = itemLink; 
    }
} 

每个菜单总是处于下面四个状态之一:

所以你需要一个枚举表示状态,枚举应放在类的外部:

public enum WindowState 
{
    Starting, 
    Active, 
    Ending, 
    Inactive 
} 

然后是使类正常工作所需的变量:

private TimeSpan changeSpan; 
private WindowState windowState; 
private List<MenuItem> itemList; 
private int selectedItem; 
private SpriteFont spriteFont; 
private double changeProgress;

changeSpan表示淡入淡出持续的时间。然后你需要一些变量保存菜单的当前状态、菜单项的集合和当前选择的菜单项。变量changeProgress保存一个介于0和1之间的值表示在Starting或Ending状态时淡入淡出处在过程的何处。

构造函数只是简单地初始化这些变量:

public MenuWindow(SpriteFont spriteFont) 
{
    itemList = new List<MenuItem>(); 
    changeSpan = TimeSpan.FromMilliseconds(800); 
    selectedItem = 0; 
    changeProgress = 0; 
    windowState = WindowState.Inactive; 
    this.spriteFont = spriteFont; 
} 

你指定了两个菜单间的过渡持续800毫秒的时间,菜单开始时的状态为Inactive。你可以在前一个教程中学到SpriteFont类和如何绘制文字。

然后你需要一个方法添加菜单项:

public void AddMenuItem(string itemText, MenuWindow itemLink) 
{
    MenuItem newItem = new MenuItem(itemText, itemLink); 
    itemList.Add(newItem); 
} 

当用户选择菜单项时,菜单项上的文字和菜单需要被激活,被主程序传递。一个新的菜单项被创建并被添加到itemList中。你还需要一个方法激活一个Inactive菜单:

public void WakeUp() 
{
    windowState = WindowState.Starting; 
} 

像XNA程序中的大多数组件一样。这个类需要被更新:

public void Update(double timePassedSinceLastFrame) 
{
    if ((windowState == WindowState.Starting) || (windowState == WindowState.Ending)) 
        changeProgress += timePassedSinceLastFrame / changeSpan.TotalMilliseconds; 
    if (changeProgress >= 1.0f) 
    {
        changeProgress = 0.0f; 
        if (windowState == WindowState.Starting) 
            windowState = WindowState.Active; 
        else if (windowState == WindowState.Ending) 
            windowState = WindowState.Inactive; 
    }
} 

这个方法接受上一个update调用以来经历的毫秒数为参数(通常这个参数为1000/60 毫秒,见教程1-5)。如果菜单正在过渡模式中,变量changeProgress进行更新,导致在经过了存储在changeSpan (800,前面你已经定义了)中的毫秒后,这个值达到1。

当这个值达到1,过渡结束,状态要么从Starting变换到Active,要么从Ending变换为Inactive。

最后,你需要一些代码绘制菜单。当菜单处于Active状态时,必须显示菜单项,例如从位置(300,300)开始,每个菜单项位于前一个之下30个像素。

当菜单在Starting状态时,菜单项应该淡入(它们的alpha值应该从0增加到1)并且从屏幕的左边移动至最终的位置。如果处于Ending状态,菜单项应该淡出 (它们的alpha值应该减少)并且移动到右方。

public void Draw(SpriteBatch spriteBatch) 
{
    if (windowState == WindowState.Inactive) 
        return; 
    
    float smoothedProgress = MathHelper.SmoothStep(0,1,(float)changeProgress); 
    int verPosition = 300; 
    float horPosition = 300; 
    float alphaValue; 
    
    switch (windowState) 
    {
        case WindowState.Starting: 
            horPosition -= 200 * (1.0f - (float)smoothedProgress); 
            alphaValue = smoothedProgress; 
            break; 
        case WindowState.Ending: 
            horPosition += 200 * (float)smoothedProgress; 
            alphaValue = 1.0f - smoothedProgress; 
            break;
        default: 
            alphaValue = 1; 
            break; 
    }
    
    for (int itemID = 0; itemID < itemList.Count; itemID++) 
    {
        Vector2 itemPostition = new Vector2(horPosition, verPosition); 
        Color itemColor = Color.White; 
        
        if (itemID == selectedItem) 
            itemColor = new Color(new Vector4(1,0,0,alphaValue)); 
        else 
            itemColor = new Color(new Vector4(1,1,1,alphaValue)); 
        spriteBatch.DrawString(spriteFont, itemList[itemID].itemText, itemPostition, itemColor, 0, Vector2.Zero, 1, SpriteEffects.None, 0); 
        verPosition += 30; 
    }
} 

当处于Starting或Ending状态时,changeProgress值会线性地从0增加到1,它工作正常但无法在开始或结束时产生平滑的效果。MathHelper. SmoothStep方法平滑曲线,让开始和结束时都能平滑过渡。当菜单处于Starting或Ending状态时,case结构中调整菜单项的水平位置和alpha值。然后,对每个菜单项,其上的文字被正确地绘制到屏幕上。绘制文字更多的信息可见前一个教程。如果菜单项没有被选择,文字是白色的,当选择时变为红色。

以上就是MenuWindow类的基础!

在主程序中,你只需一个集合存储所有的菜单:

List<MenuWindow> menuList; 

在LoadContent方法中,你可以创建菜单并将它们添加到menuList中。然后,你可以将菜单项添加到菜单中,让你可以在用户选择菜单项时指定激活哪个菜单。

MenuWindow menuMain = new MenuWindow(menuFont, "Main Menu", backgroundImage); 
MenuWindow menuNewGame = new MenuWindow(menuFont, "Start a New Game", bg); 
menuList.Add(menuMain); 
menuList.Add(menuNewGame); 
menuMain.AddMenuItem("New Game", menuNewGame); 
menuNewGame.AddMenuItem("Back to Main menu", menuMain); 
menuMain.WakeUp();

以上操作创建了两个菜单,每个菜单包含一个链接到另一个菜单的菜单项。初始化菜单结构后,激活mainMenu,使它处于Starting状态。现在,你需要在程序更新循环中更新所有菜单:

foreach (MenuWindow currentMenu in menuList) 
            currentMenu.Update(gameTime.ElapsedGameTime.TotalMilliseconds); 

并在Draw方法中绘制菜单:

spriteBatch.Begin(); 

foreach (MenuWindow currentMenu in menuList) 
    currentMenu.Draw(spriteBatch); 
spriteBatch.End(); 

当运行代码时,主菜单会从左侧淡入,因为你还没有处理用户输入,所以无法切换到其他菜单。

允许用户通过菜单项导航

你将在MenuWindow类中添加一个方法处理用户输入。注意这个方法只能被当前激活的菜单调用:

public MenuWindow ProcessInput(KeyboardState lastKeybState, KeyboardState currentKeybState) 
{
    if (lastKeybState.IsKeyUp(Keys.Down) && currentKeybState.IsKeyDown(Keys.Down)) 
        selectedItem++; 
    if (lastKeybState.IsKeyUp(Keys.Up) && currentKeybState.IsKeyDown(Keys.Up)) 
        selectedItem--; 
    if (selectedItem < 0) 
        selectedItem = 0; 
    if (selectedItem >= itemList.Count) 
        selectedItem = itemList.Count-1; 
    if (lastKeybState.IsKeyUp(Keys.Enter) && currentKeybState.IsKeyDown(Keys.Enter)) 
    { 
        windowState = WindowState.Ending; 
        return itemList[selectedItem].itemLink; 
    }
    else if (lastKeybState.IsKeyDown(Keys.Escape)) 
        return null; 
    else
        return this; 
} 

这里有许多有趣的东西。首先,你检查up或down键是否被按下。当用户按下一个键时,只要这个键一直被按着,那么这个键的IsKeyUp为true!因此,你需要检查上一帧这个键是否已经被按下。

如果up或down键被按下,你需要对应地改变selectedItem变量。如果超出边界,需要将它返回到一个合理的位置。

接下去的代码包含整个导航逻辑。你应该注意到这个方法需要返回一个MenuWindow对象到主程序中。因为这个方法只会被当前激活的菜单调用,这允许当前菜单将新选择的菜单返回到主程序。如果用户没有选择任何菜单项,当前菜单会保持激活并返回自己,这就是最后一行代码的操作。通过这种方式,主菜单在处理输入后知道哪个菜单是激活菜单。

当用户按下Enter键后,当前激活菜单会从Active转到Ending状态,被选择菜单项链接的菜单被返回到主程序。如果用户按下Escape键,返回null,这回被后面的退出程序捕捉到。如果什么都没选,返回当前菜单自身,告知主程序当前菜单仍保持激活。

这个方法需要从主程序调用,主程序需要两个变量:

MenuWindow activeMenu; 
KeyboardState lastKeybState; 

第一个变量保存当前激活的菜单,在LoadContent 方法中进行初始化。lastKeybState 变量在Initialize方法中进行初始化。

private void MenuInput(KeyboardState currentKeybState) 
{
    MenuWindow newActive = activeMenu.ProcessInput(lastKeybState, currentKeybState); 
    
    if (newActive == null) 
        this.Exit(); 
    else if (newActive != activeMenu) 
        newActive.WakeUp(); 
    activeMenu = newActive; 
} 

这个方法调用当前激活菜单的ProcessInput方法,并传递前一个和当前的键盘状态。如前所述,如果用户按下Escape键这个方法会返回null,所以在这种情况中,应用程序会退出。否则,如果这个方法返回一个不同于当前菜单的菜单,说明用户做出了一个选择。在这种情况中,新选择的菜单会通过调用它的WakeUp方法从Inactive变化到Starting状态。如果两种情况都不是,则返回当前菜单,存储在activeMenu变量中。

请确保在Update方法内部调用这个方法。运行这个代码让你可以在两个菜单间自由切换。

在菜单中添加标题和背景图像

菜单现在已经可以正常工作了,但没有背景图片菜单还不够完美。在MenuWindow类中添加两个变量:

private string menuTitle; 
private Texture2D backgroundImage; 

这两个变量需要在Initialize方法中赋值:

public MenuWindow(SpriteFont spriteFont, string menuTitle, Texture2D backgroundImage) 
{
    //... 
    this.menuTitle = menuTitle; 
    this.backgroundImage = backgroundImage; 
}

显示标题很简单。但是,如果菜单使用不同的背景图片,那么在绘制背景图片时会遇到些麻烦。你想要的是在Active和Ending状态下显示图片。当处于Starting状态时,新的背景会混合在前一个的上面。当在第一张图片上混合第二张图片时,你想要保证第一张图像首先被绘制!这并不容易,因为这涉及到改变菜单的绘制顺序。

一个简单的方法是使用SpriteBatch . Draw方法的layerDepth参数(见教程3-3)。当处于Active或Ending状态时,背景图像会在距离1绘制,即“最深的”层。在Starting模式中,图像会在距离0.5绘制,所有文字会在距离0绘制。当使用SpriteSortMode. BackToFront时,首先在深度1的Active或Ending菜单会被绘制。然后,Starting菜单被绘制(混合在已经存在的图像上),最后绘制所有文字。

在MenuWindow的Draw方法中,创建两个变量:

float bgLayerDepth; 
Color bgColor;

这两个变量保存背景图像的layerDepth和透明颜色值,在switch中设置这两个变量:

switch (windowState) 
{
    case WindowState.Starting: 
        horPosition -= 200 * (1.0f - (float)smoothedProgress); 
        alphaValue = smoothedProgress; 
        bgLayerDepth = 0.5f; 
        bgColor = new Color(new Vector4(1, 1, 1, alphaValue)); 
        break; 
    case WindowState.Ending: 
        horPosition += 200 * (float)smoothedProgress; 
        alphaValue = 1.0f - smoothedProgress; 
        bgLayerDepth = 1; 
        bgColor = Color.White; 
        break; 
    default: alphaValue = 1; 
        bgLayerDepth = 1; 
        bgColor = Color.White; 
        break; 
} 

Color. White与Color(new Vector4(1, 1, 1, 1))相同,表示完全alpha值。如果一个菜单处于Starting或Ending状态,会计算alphaValue。然后,使用透明颜色值绘制标题和背景图像。

Color titleColor = new Color(new Vector4(1, 1, 1, alphaValue)); 
        
spriteBatch.Draw(backgroundImage, new Vector2(), null, bgColor, 0, Vector2.Zero, 1, SpriteEffects.None, bgLayerDepth); 
spriteBatch.DrawString(spriteFont, menuTitle, new Vector2(horPosition, 200), titleColor,0,Vector2.Zero, 1.5f, SpriteEffects.None, 0); 

标题文字被缩放到1.5f,比菜单项的文字大。

最后,你需要确保在主程序的Draw方法中将SpriteSortMode设置为BackToFront:

spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None); 

从菜单移动到游戏

至此你创建了一些漂亮的菜单,但如何创建一些菜单项开始游戏?这可以使用dummy 菜单,这个菜单应该存储在主程序中。例如,如果你想在Start New Game菜单中包含start an Easy,a Normal或a Hard game的菜单项,应该将下列代码添加到菜单中:

MenuWindow startGameEasy; 
MenuWindow startGameNormal; 
MenuWindow startGameHard; 
bool menusRunning; 

在LoadContent方法中,你可以使用null参数实例化这些变量并将它们链接到menuNewGame中的菜单项上:

startGameEasy = new MenuWindow(null, null, null); 
startGameNormal = new MenuWindow(null, null, null);
startGameHard = new MenuWindow(null, null, null); 
menuNewGame.AddMenuItem("Easy", startGameEasy); 
menuNewGame.AddMenuItem("Normal", startGameNormal); 
menuNewGame.AddMenuItem("Hard", startGameHard); 
menuNewGame.AddMenuItem("Back to Main menu", menuMain);

这会在New Game菜单中添加四个菜单项。接下去你要做的就是检测哪一个dummy菜单被选择。因此,需要扩展一下MenuInput方法:

private void MenuInput(KeyboardState currentKeybState) 
{
    MenuWindow newActive = activeMenu.ProcessInput(lastKeybState, currentKeybState); 
    
    if (newActive == startGameEasy) 
    {
        //set level to easy 
        menusRunning = false; 
    }
    else if (newActive == startGameNormal) 
    {
        //set level to normal 
        menusRunning = false; 
    }
    else if (newActive == startGameHard) 
    {
        //set level to hard 
        menusRunning = false; 
    }
    else if (newActive == null) 
        this.Exit(); 
    else if (newActive != activeMenu) 
        newActive.WakeUp(); 
    activeMenu = newActive; 
} 

你可以使用menusRunning变量保证当用户在游戏中时不更新/绘制菜单:

if (menusRunning) 
{
    spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None); 
    
    foreach (MenuWindow currentMenu in menuList) 
        currentMenu.Draw(spriteBatch); 
    spriteBatch.End(); 
    Window.Title = "Menu running ..."; 
}
else 
{
    Window.Title = "Game running..."; 
}

代码

MenuWindow类

下面是简单、必须的方法:

public MenuWindow(SpriteFont spriteFont, string menuTitle, Texture2D backgroundImage) 
{
    itemList = new List<MenuItem>(); 
    changeSpan = TimeSpan.FromMilliseconds(800); 
    selectedItem = 0; 
    changeProgress = 0; 
    windowState = WindowState.Inactive; 
    this.spriteFont = spriteFont; 
    this.menuTitle = menuTitle; 
    this.backgroundImage = backgroundImage; 
}

public void AddMenuItem(string itemText, MenuWindow itemLink) 
{
    MenuItem newItem = new MenuItem(itemText, itemLink); 
    itemList.Add(newItem); 
}

public void WakeUp() 
{
    windowState = WindowState.Starting; 
}

然后是更新菜单的方法。注意只有当前激活的菜单才会调用ProcessInput方法。

public void Update(double timePassedSinceLastFrame) 
{
    if ((windowState == WindowState.Starting) || (windowState == WindowState.Ending)) 
        changeProgress += timePassedSinceLastFrame / changeSpan.TotalMilliseconds; 
    if (changeProgress >= 1.0f) 
    {
        changeProgress = 0.0f; 
        if (windowState == WindowState.Starting) 
            windowState = WindowState.Active; 
        else if (windowState == WindowState.Ending) 
            windowState = WindowState.Inactive; 
     }
}

public MenuWindow ProcessInput(KeyboardState lastKeybState, KeyboardState currentKeybState) 
{
    if (lastKeybState.IsKeyUp(Keys.Down) && currentKeybState.IsKeyDown(Keys.Down)) 
        selectedItem++; 
    if (lastKeybState.IsKeyUp(Keys.Up) && currentKeybState.IsKeyDown(Keys.Up)) 
        selectedItem--; 
    if (selectedItem < 0) 
        selectedItem = 0; 
    if (selectedItem >= itemList.Count) 
        selectedItem = itemList.Count-1; 
    if (lastKeybState.IsKeyUp(Keys.Enter) && currentKeybState.IsKeyDown(Keys.Enter)) 
    {
        windowState = WindowState.Ending; 
        return itemList[selectedItem].itemLink; 
    }
    else if (lastKeybState.IsKeyDown(Keys.Escape)) 
        return null; 
    else 
        return this; 
} 

最后是绘制菜单的方法:

public void Draw(SpriteBatch spriteBatch) 
{
    if (windowState == WindowState.Inactive) 
        return; 
    float smoothedProgress = MathHelper.SmoothStep(0,1,(float)changeProgress); 
    int verPosition = 300; 
    float horPosition = 300; 
    float alphaValue; 
    float bgLayerDepth; 
    Color bgColor; 
    
    switch (windowState) 
    {
        case WindowState.Starting: 
            horPosition -= 200 * (1.0f - (float)smoothedProgress); 
            alphaValue = smoothedProgress; 
            bgLayerDepth = 0.5f; 
            bgColor = new Color(new Vector4(1, 1, 1, alphaValue)); 
            break; 
        case WindowState.Ending: 
            horPosition += 200 * (float)smoothedProgress; 
            alphaValue = 1.0f - smoothedProgress; 
            bgLayerDepth = 1; 
            bgColor = Color.White; 
            break; 
        default: 
            alphaValue = 1; 
            bgLayerDepth = 1; 
            bgColor = Color.White; 
            break; 
     }
     
     Color titleColor = new Color(new Vector4(1, 1, 1, alphaValue));
     spriteBatch.Draw(backgroundImage, new Vector2(), null, bgColor, 0, Vector2.Zero, 1, SpriteEffects.None, bgLayerDepth); 
     spriteBatch.DrawString(spriteFont, menuTitle, new Vector2(horPosition, 200), titleColor,0,Vector2.Zero, 1.5f, SpriteEffects.None, 0); 
     
     for (int itemID = 0; itemID < itemList.Count; itemID++) 
     {
         Vector2 itemPostition = new Vector2(horPosition, verPosition); 
         Color itemColor = Color.White; 
         
         if (itemID == selectedItem) 
             itemColor = new Color(new Vector4(1,0,0,alphaValue)); 
         else 
             itemColor = new Color(new Vector4(1,1,1,alphaValue)); 
         spriteBatch.DrawString(spriteFont, itemList[itemID].itemText, itemPostition, itemColor, 0, Vector2.Zero, 1, SpriteEffects.None, 0); 
         verPosition += 30; 
     }
} 

主程序

你可以在LoadContent方法中创建菜单结构。更新方法必须调用每个菜单的更新方法和MenuInput方法:

protected override void Update(GameTime gameTime) 
{
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) 
        this.Exit(); 
    KeyboardState keybState = Keyboard.GetState(); 
    if (menusRunning) 
    {
        foreach (MenuWindow currentMenu in menuList) 
            currentMenu.Update(gameTime.ElapsedGameTime.TotalMilliseconds); 
        MenuInput(keybState); 
    }
    else
    {
    }
    
    lastKeybState = keybState; 
    base.Update(gameTime); 
} 

MenuInput方法将用户输入传递到当前激活的菜单,当输入处理后接受返回的激活菜单:

private void MenuInput(KeyboardState currentKeybState) 
{
    MenuWindow newActive = activeMenu.ProcessInput(lastKeybState, currentKeybState); 
    if (newActive == startGameEasy) 
    {
        //set level to easy 
        menusRunning = false; 
    }
    else if (newActive == startGameNormal) 
    {
        //set level to normal 
        menusRunning = false; 
    }
    else if (newActive == startGameHard) 
    {
        //set level to hard 
        menusRunning = false; 
    }
    else if (newActive == null) 
        this.Exit(); 
    else if (newActive != activeMenu) 
        newActive.WakeUp(); 
    activeMenu = newActive; 
} 

扩展阅读

虽然对一个基础菜单系统这个方法已经足够了,但对一个完整的游戏来说,MenuWindow类应该是一个抽象类,这样菜单不能作为这个类的实例。作为替代,你应该为菜单和游戏各创建一个新类,这两个类都从MenuWindow类继承。通过这种方式,键盘处理和绘制完全被这个方法处理,无需丑陋的menuRunning变量了。这也是http ://creators . xna . com网站上菜单示例的基础(译者注:这应该是指示例Game State Management(http://creators.xna.com/en-US/samples/gamestatemanagement),源代码GameStateManagementSample.rar下载)。

程序截图


发布时间:2009/10/9 下午3:04:09  阅读次数:8964

2006 - 2024,推荐分辨率 1024*768 以上,推荐浏览器 Chrome、Edge 等现代浏览器,截止 2021 年 12 月 5 日的访问次数:1872 万 9823 站长邮箱

沪 ICP 备 18037240 号-1

沪公网安备 31011002002865 号