设为首页收藏本站|繁體中文

Project1

 找回密码
 注册会员
搜索
查看: 2371|回复: 1
打印 上一主题 下一主题

[交流讨论] MV源码解析之菜单与选项

[复制链接]

Lv3.寻梦者

梦石
0
星屑
1882
在线时间
1552 小时
注册时间
2013-4-13
帖子
917
跳转到指定楼层
1
发表于 2019-8-31 20:08:55 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式

加入我们,或者,欢迎回来。

您需要 登录 才可以下载或查看,没有帐号?注册会员

x
本帖最后由 沉滞的剑 于 2019-8-31 20:16 编辑

废话两件事:

1. 拖更理由: 过载地牢太好玩了, 明日方舟夏活肝死了, 工作进度完不成又加班了...

2. 真实理由: 虽然预告要讲Scene_Map但是发现一些game object代码实在是太臃肿
要分析就必须粘贴上一大堆代码, 有一种流水帐的感觉, 所以我打算重新开一个新坑(哇哦~

一开始只是凭借的热情来打算分析代码, 后来发现没有目标的话很容易迷失方向
之前也介绍了很多js独特的语言技巧, 所以以后默认大家都有一定的JS代码的理解能力了
我们一般在读源码的时候一般有两个思路
一个是如何理解引擎机制, 另一个是如果我们写插件的话, 应该如何修改源码
所以从这次开始以后每篇文章定一个方向, 带入这两个思路, 不过依然会是源码分析的形式
今天讲一个在旧坑里已经讲到过的Window_Command
这次我们要细讲一些容易被忽视的规则和内容, 增加干货少粘代码
( 惊了! 怕不这是个假的楼主! 你把真的楼主怎么样了!




课题1. 玩家是若何在菜单中进行上下选择操作的

0
游戏菜单算是非常自然和简单的的交互行为了
然而就是这么一个看似简单的操作, 实则实现起来是需要考虑很多细节的
它一共经历了大概3个主要阶段:
捕获输入->修改数据->图像重绘

1.1
在RM的世界里, 一切都以一帧为单位进行, 以下的API其实分别在只代表了其在某一帧的状态
Input.isTriggered
Input.isRepeated
Input.isLongPressed
其中isTriggered只关心按下去的第一帧的状态, 之后不会触发
isRepeated 和 isLongPressed还关心上一帧的状态, 持续了多久, 会持续触发

# Side Note
这里还涉及到一个常见的UI交互问题Debouncing
帧对于玩家来说是个极小的单位, 我们随便按一下键盘好几十帧就过去了, 所以必须要有一种算法对输入频率进行制约
一般分为两种, 一种是输入后不立刻反馈, 而是延迟生效, 如果延迟期间有新的输入则取消前面的输入, 开启新的延迟
另外一种是输入后立刻反馈, 但是一段时间内不接收任何输入
在isRepeated里采取的是第二种方法

1.2
Window_Selectable会在自己的update中的processCursorMove里判断键盘/手柄的输入状态

  1. Window_Selectable.prototype.processCursorMove = function() {
  2.   if (this.isCursorMovable()) {
  3.     var lastIndex = this.index();
  4.     if (Input.isRepeated("down")) {
  5.       this.cursorDown(Input.isTriggered("down"));
  6.     }
  7.     if (Input.isRepeated("up")) {
  8.       this.cursorUp(Input.isTriggered("up"));
  9.     }
  10.     if (Input.isRepeated("right")) {
  11.       this.cursorRight(Input.isTriggered("right"));
  12.     }
  13.     if (Input.isRepeated("left")) {
  14.       this.cursorLeft(Input.isTriggered("left"));
  15.     }
  16.     if (!this.isHandled("pagedown") && Input.isTriggered("pagedown")) {
  17.       this.cursorPagedown();
  18.     }
  19.     if (!this.isHandled("pageup") && Input.isTriggered("pageup")) {
  20.       this.cursorPageup();
  21.     }
  22.     if (this.index() !== lastIndex) {
  23.       SoundManager.playCursor();
  24.     }
  25.   }
  26. };
复制代码

它监听了上下左右和翻页键是否是isRepeated
其中翻页键的逻辑是通过handler来决定的, 这里只是留了一个接口
当然如果我们需要添加更多的按键判断, 我们就可以参考上下左右键的做法

# Side Note
isCursorMovable必须满足:
- 窗口打开并激活
- 光标锁定_cursorFixed为false
- 光标并不是全选状态
- 可选item>0

1.3
cursorDown, cursorUp等本质上都只是修改index的方法

  1. Window_Selectable.prototype.cursorDown = function(wrap) {
  2.   var index = this.index();
  3.   var maxItems = this.maxItems();
  4.   var maxCols = this.maxCols();
  5.   if (index < maxItems - maxCols || (wrap && maxCols === 1)) {
  6.     this.select((index + maxCols) % maxItems);
  7.   }
  8. };
复制代码


# Side Note
不过可能会注意到一点, 为什么这些方法会带一个参数wrap
之前说了isTriggered只会在第一次按下的时候触发一帧
而isRepeated则会周期性执行
所以这个wrap只在用户按下按键的第一帧的时候为true
那这个逻辑有什么用呢?
对于一行或者一列的菜单来说, 如果按住左右或者上下按键不动, 则会一致触发这个函数
但是, 以cursorDown为例, 打开游戏菜单, 在移动到最后一个选项后, 这个时候就不满足index < maxItems - maxCols
因为按下向下键的时候其实就是index+=maxCols, 如果不满足这个条件, 说明已经在最下面的一行了
但是如果你一下一下地按键盘, 而不是一直按住, 你会发现在选择到最后一个选项地时候, 就会直接到上面去了
而这个现象对于一行一列的菜单有效, 而对于类似技能窗口却无效
这个原因就在于这个wrap, 我们看到当wrap为真的时候,
也就是玩家不长按, 而是通过一下一下按触发isTriggered使wrap为true的时候,
如果满足单行单列的条件, 也会继续修改index
而一个取余%操作就会导致出现回到上面的效果
比如你有一个长度为3的菜单, 你在最下面的item时候, 也就是index等于2的时候
那么按下down以后, index+1, 你会得到3 % 3, index就变为0了, 也就是第一个item
反过来, 如果你index=0, 又会发生什么事情呢? 如果index=0, index-1不就是-1了么
RM则用以下方法避免了这种情况: (index - maxCols + maxItems) % maxItems

1.4
至于鼠标的交互方式我之前在旧坑里提到过, 不过这次新坑我打算再简单说明一下
跟选项选择相关的操作
操作1: 点击item, 触发onTouch(true), 如果之前没有被选中则选择这个item, 否则执行'ok'的handler
操作2: 按住并在菜单上拖动, 触发onTouch(false), 只会选中当前位置的item, 并不会执行'ok'的handler
操作3: 按住上下边距的位置, 可以持续触发cursorUp(false)和cursorDown(false)


  1. Window_Selectable.prototype.processTouch = function() {
  2.   if (this.isOpenAndActive()) {
  3.     if (TouchInput.isTriggered() && this.isTouchedInsideFrame()) {
  4.       this._touching = true;
  5.       this.onTouch(true); // 操作1
  6.     } else if (TouchInput.isCancelled()) {
  7.       if (this.isCancelEnabled()) {
  8.         this.processCancel();
  9.       }
  10.     }
  11.     if (this._touching) {
  12.       if (TouchInput.isPressed()) {
  13.         this.onTouch(false);  // 操作2
  14.       } else {
  15.         this._touching = false;
  16.       }
  17.     }
  18.   } else {
  19.     this._touching = false;
  20.   }
  21. };
  22. Window_Selectable.prototype.onTouch = function(triggered) {
  23.   var lastIndex = this.index();
  24.   var x = this.canvasToLocalX(TouchInput.x);
  25.   var y = this.canvasToLocalY(TouchInput.y);
  26.   var hitIndex = this.hitTest(x, y);
  27.   if (hitIndex >= 0) {
  28.     if (hitIndex === this.index()) {
  29.       if (triggered && this.isTouchOkEnabled()) {
  30.         this.processOk(); // 操作1
  31.       }
  32.     } else if (this.isCursorMovable()) {
  33.       this.select(hitIndex); // 操作1 && 操作2
  34.     }
  35.   } else if (this._stayCount >= 10) {
  36.     if (y < this.padding) {
  37.       this.cursorUp();      // 操作3
  38.     } else if (y >= this.height - this.padding) {
  39.       this.cursorDown();   // 操作3
  40.     }
  41.   }
  42.   if (this.index() !== lastIndex) {
  43.     SoundManager.playCursor();
  44.   }
  45. };
复制代码


# Side Note
isTouchedInsideFrame 判断的是是否在整个窗口里, 包括了边距
isContentsArea 判断的则不包括边距

1.5
onTouch中我关心的重点其实是hitTest


  1. Window_Selectable.prototype.hitTest = function(x, y) {
  2.   if (this.isContentsArea(x, y)) {
  3.     var cx = x - this.padding; // content的相对坐标
  4.     var cy = y - this.padding;
  5.     var topIndex = this.topIndex();
  6.     for (var i = 0; i < this.maxPageItems(); i++) {
  7.       var index = topIndex + i;
  8.       if (index < this.maxItems()) {
  9.         var rect = this.itemRect(index);
  10.         var right = rect.x + rect.width;
  11.         var bottom = rect.y + rect.height;
  12.         if (cx >= rect.x && cy >= rect.y && cx < right && cy < bottom) {
  13.           return index;
  14.         }
  15.       }
  16.     }
  17.   }
  18.   return -1;
  19. };
复制代码

注意到不在当前page的item是没有人权的, maxPageItem是通过item的高度计算得到的这也就是滚动出现的条件
而获取的itemRect也是通过index来计算的
这就导致了如果我们要设置不规则的布局, 这种死板的计算方式是无法满足我们的需求的
如果按钮排列不是对齐的, 那么上下左右的关系就不能通过计算index序号的方式来确定
需要每一个item都有自己的数据模型, 需要有id, 有邻居的id, 有自己的位置等等
当我们需要设计一个比较灵活的界面的时候, 这个部分可能需要我们花很多时间去处理

1.6
到这里就完成了捕获输入和修改数据的部分
对于图像的绘制, 本质上的问题是Cursor发生了改变
1. 它改变了位置
2. 它会有选中时候的闪烁动画

改变位置:
调用继承于Window的方法

  1. Window_Selectable.prototype.updateCursor = function() {
  2.   if (this._cursorAll) {
  3.     var allRowsHeight = this.maxRows() * this.itemHeight();
  4.     this.setCursorRect(0, 0, this.contents.width, allRowsHeight);
  5.     this.setTopRow(0);
  6.   } else if (this.isCursorVisible()) {
  7.     var rect = this.itemRect(this.index());
  8.     this.setCursorRect(rect.x, rect.y, rect.width, rect.height); <= 这里
  9.   } else {
  10.     this.setCursorRect(0, 0, 0, 0);
  11.   }
  12. };
  13. Window.prototype.setCursorRect = function(x, y, width, height) {
  14.   var cx = Math.floor(x || 0);
  15.   var cy = Math.floor(y || 0);
  16.   var cw = Math.floor(width || 0);
  17.   var ch = Math.floor(height || 0);
  18.   var rect = this._cursorRect;
  19.   if (
  20.     rect.x !== cx ||
  21.     rect.y !== cy ||
  22.     rect.width !== cw ||
  23.     rect.height !== ch
  24.   ) {
  25.     this._cursorRect.x = cx;
  26.     this._cursorRect.y = cy;
  27.     this._cursorRect.width = cw;
  28.     this._cursorRect.height = ch;
  29.     this._refreshCursor();
  30.   }
  31. };
复制代码


下面是闪烁动画的逻辑

  1. Window.prototype._updateCursor = function() {
  2.   var blinkCount = this._animationCount % 40;
  3.   var cursorOpacity = this.contentsOpacity;
  4.   if (this.active) {
  5.     if (blinkCount < 20) {
  6.       cursorOpacity -= blinkCount * 8;
  7.     } else {
  8.       cursorOpacity -= (40 - blinkCount) * 8;
  9.     }
  10.   }
  11.   this._windowCursorSprite.alpha = cursorOpacity / 255;
  12.   this._windowCursorSprite.visible = this.isOpen();
  13. };
复制代码


_updateCursor 在Window的updateTransform中调用, 这个是覆写了PIXI.Container父类的方法
updateTransform会被render调用
这个的完整一帧(requestAnimationFrame)的调用栈如下
SceneManager.update
SceneManager.updateMain
Graphics.render
WebGLRenderer.prototype.render
Container.prototype.updateTransform
Window.prototype.updateTransform
Window.prototype._updateCursor

总结来说updateTransform是会被Graphics.render触发的loop方法
不像update那么直接明了, 不用debugger查看的话很难找到
对我们有价值的结论就是cursor和item的位置是绑定的
我们如果改写了itemRect, 那么光标也是会跟着发生改变的
当然我们要是写不规则的光标, 理论上也是可行的




那么新坑第一个课题就说这么多
只是一个改变光标位置就在代码实现上就是如此复杂
可能之后还会开一个实战的坑
比如制作一个不规则的菜单
似乎还可以加入参考现有著名插件的写法的部分
做成每天(伪)一个你可能不知道的RM小细节这样的感觉?

以后再说吧, 今天先溜了

评分

参与人数 6+6 收起 理由
lazilime + 1 受益匪浅
白嫩白嫩的 + 1 精品文章
玄羽 + 1
natsukodopa + 1 塞糖
kklt + 1 我很赞同
康姆图帕帕 + 1 塞糖

查看全部评分

夏普的道具店

塞露提亚-道具屋的经营妙方同人作品
发布帖:点击这里

Lv1.梦旅人

梦石
0
星屑
181
在线时间
25 小时
注册时间
2024-1-25
帖子
5
2
发表于 2024-4-5 16:48:21 | 只看该作者
2. 真实理由: 虽然预告要讲Scene_Map但是发现一些game object代码实在是太臃肿
要分析就必须粘贴上一大堆代码, 有一种流水帐的感觉, 所以我打算重新开一个新坑(哇哦~


唉,这个确实让人头大,所以我花了点时间整理了代码,画成了思维导图,用流程图的形式展现代码运行过程,希望对后来人有点用吧。
青山依旧在,几度夕阳红。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册会员

本版积分规则

拿上你的纸笔,建造一个属于你的梦想世界,加入吧。
 注册会员
找回密码

站长信箱:[email protected]|手机版|小黑屋|无图版|Project1游戏制作

GMT+8, 2024-5-2 23:15

Powered by Discuz! X3.1

© 2001-2013 Comsenz Inc.

快速回复 返回顶部 返回列表