【原创】使用JS封装的一个小型游戏引擎及源码分享



1
/** 2 * @description: 引擎的设计与实现 3 * @user: xiugang 4 * @time: 2018/10/01 5 */ 6 7 /* 8 * V1.0: 引擎实现的基本模块思路 9 * 1.创建一个游戏引擎对象及精灵对象 10 * 2.将精灵对象添加到引擎中去,并实现播放动画效果以及需要用到的回调方法 11 * 3.启动引擎 12 * */ 13 14 /* 15 * V2.0: 实现游戏循环模块 16 * 1.如果游戏暂停了,就跳过以下各步骤,并在100毫秒后再次执行游戏循环 17 * 2.更新帧速率 18 * 3.设置游戏时间 19 * 4.清除屏幕内容 20 * 5.在播放动画前,调用startAnimate的方法(可以进行碰撞检测) 21 * 6.绘制精灵背后的内容(绘制背景) 22 * 7.更新精灵 23 * 8.绘制精灵 24 * 9.绘制精灵前方的内容 25 * 10.动画播放完毕之后,调用endAnimate方法 26 * 11.请求浏览器播放下一帧动画 27 * 28 * */ 29 30 31 /** 32 * V3.0: 实现在暂停状态与运行状态之间的切换togglePaused 33 */ 34 35 /** 36 * V4.0:实现基于时间的运动效果 :pixelPerFrame 37 * 计算公式:(pixels / second) * (second / frame) = pixeld / second【单位:每一秒移动的像素数】 38 */ 39 40 /** 41 * V5.0: 实现加载图像的功能: 42 * queueImage(imageUrl): 将图像放入到加载队列中去 43 * loadImages(): 开发者需要持续调用该方法,知道返回100位置(方法的返回值表示图像加载完成的百分比) 44 * getImage(imageUrl):返回图像对象, 只有咋loadImages()返回100之后,才可以调用该方法 45 */ 46 47 /** 48 * V6.0:实现同时播放多个声音的功能 49 * canPlay(): 用于查询浏览器是否能够播放某种特定格式的声音文件 50 * playSound():用于播放声音 51 */ 52 53 54 /** 55 * V7.0: 键盘事件的处理 56 * addKeyListener(): 用于向游戏注册按键监听器 57 */ 58 59 60 /** 61 * V8.0: 高分榜的维护:游戏的高分榜数组以json格式存档在本地 62 */ 63 64 /** 65 * V9.0: 实现了一个比较完整的游戏引擎,开始使用这个简单的游戏引擎去制作一个小游戏 66 * 需求分析: 67 * 1.资源加载的画面 68 * 2.游戏资源的管理 69 * 3.声音的播放 70 * 4.具有视差动画的滚动背景 71 * 5.生命数量的显示 72 * 6.高分榜的维护 73 * 7.按键的监听与处理 74 * 8.暂停功能与自动暂停机制实现 75 * 9.游戏结束的流程处理 76 */ 77 78 79 /** 80 * 游戏类 81 * @param gameName 游戏名称 82 * @param canvasId 画布ID 83 * @returns {Game} 游戏实例 84 * @constructor 85 */ 86 var Game = function (gameName, canvasId) { 87 // 获取canvas画布 88 var canvas = document.getElementById(canvasId); 89 console.log(canvas); 90 var self = this; 91 92 93 //----------------------------------------基本属性 94 this.context = canvas.getContext('2d'); // 定义游戏中的基本需要的属性 95 this.sprites = []; // 游戏中的精灵对象 96 this.gameName = gameName; // 游戏的名字 97 98 99 //----------------------------------------时间管理 100 this.startTime = 0; // 游戏开始时间 101 this.lastTime = 0; // 游戏上一次的时间 102 this.gameTime = 0; // 游戏总时间 103 this.fps = 0; // 游戏帧速率(实时更新的) 104 this.STARTING_FPS = 60; // 默认启动的时候的帧速率 105 106 this.paused = false; // 游戏是否暂停 107 this.startedPauseAt = 0; 108 this.PAUSE_TIMEOUT = 100; // 游戏暂停的持续时间 109 110 111 //---------------------------------------图像加载 112 this.imageLoadingProgressCallback; // 图像加载过程的回调函数 113 this.images = {}; // 图像对象 114 this.imageUrls = []; // 图像的Urls 115 this.imagesLoaded = 0; // 已加载完成的图像个数 116 this.imagesFailedToLoad = 0; // 加载失败的图像个数 117 this.imagesIndex = 0; // 图像数组的下标, 从0开始的 118 119 120 121 //-----------------------------------------声音加载播放 122 this.soundOn = true; 123 this.soundChannels = []; // 初始化一个播放信道数组 124 this.audio = new Audio(); // 这里的Audio实际上是JavaScript内置的DOM对象, 不需要自己手动去创建一个Audio对象 125 this.NUM_SOUND_CHANNELS = 10; // 设置初始信道的数量 126 127 128 129 //----------------------------------------键盘事件的监听 130 this.keyListeners = []; // 用于存放keyandListener的键值对 131 132 window.onkeypress = function (ev) { // 这里的对象处理的是DOM Window这个窗体对象,添加了一个监听事件 133 self.keyPressed(ev); 134 } 135 window.onkeydown = function (ev) { 136 self.keyPressed(ev); 137 } 138 139 140 //-----------------------------------------高分榜的维护 141 this.HIGH_SCORES_SUFFIX = '_highscores'; // 后缀名字 142 this.highScores = []; // 用于存储游戏分数的数组 143 144 145 146 // 构造10个Audio对象,将其加入到数组中去, 当调用playSound()方法,游戏引擎会找出第一个未被占用的声道,并用它来播放指定的声音文件 147 for (var i = 0; i < this.NUM_SOUND_CHANNELS; i++){ 148 var audio = new Audio(); 149 this.soundChannels.push(audio); 150 } 151 152 return this; // 把当前的游戏对象返回 153 } 154 155 156 157 158 /** 159 * 游戏的成员方法 160 * @type {{start: Game.start, animate: Game.animate, tick: Game.tick, updateFrameRate: Game.updateFrameRate, clearScreen: Game.clearScreen, startAnimate: Game.startAnimate, paintUnderSprites: Game.paintUnderSprites, updateSprites: Game.updateSprites, paintSprites: Game.paintSprites, paintOverSprites: Game.paintOverSprites, endAnimate: Game.endAnimate}} 161 */ 162 Game.prototype = { 163 // 游戏加载图像的模块------------------------------------------------------- 164 /** 165 * 通过图像的Url地址,获取这个图像(json格式对象取出值的方法)对象 166 * @param imageUrl 167 */ 168 getImage : function (imageUrl) { 169 return this.images[imageUrl]; 170 }, 171 172 173 174 /** 175 * 图像加载完成的回调函数 176 */ 177 imageLoadedCallback : function (e) { 178 // 每次加载完成一个图像,次数加一 179 this.imagesLoaded++; 180 }, 181 182 183 184 /** 185 * 当一个图像加载失败的回调函数 186 */ 187 imageLoadErrorCallback : function (e) { 188 this.imagesFailedToLoad++; 189 }, 190 191 192 /** 193 * 正式加载一张图像 194 * @param imageUrl 195 */ 196 loadImage : function (imageUrl) { 197 var self = this; 198 var image = new Image(); 199 200 image.src = imageUrl; 201 202 // 图像加载完成的回调函数 203 204 image.addEventListener("load", function (e) { 205 self.imageLoadedCallback(e); 206 207 // 显示出来, 测试成功 208 //self.context.drawImage(image, 0, 0); 209 }); 210 211 212 // 图像加载失败的回调函数 213 image.addEventListener("error", function (e) { 214 self.imageLoadErrorCallback(e); 215 }); 216 217 218 // 把所有的加载的Images存起来 219 this.images[imageUrl] = image; 220 }, 221 222 /** 223 * 加载图像的过程中反复调用这个函数, 这个函数返回已经处理完成的图像百分比 224 * 当图像返回100%的时候, 表示所有的图像已经全部加载完毕 225 * @returns {number} 226 */ 227 loadImages : function () { 228 // 如果还有图像没有加载【图像的url个数多余已经加载完成的图像下标】 229 if (this.imagesIndex < this.imageUrls.length){ 230 // 再次把当前这个图像去加载(把没有加载的全部加载进来) 231 this.loadImage(this.imageUrls[this.imagesIndex]); 232 this.imagesIndex ++; 233 } 234 235 236 // 返回已经加载完成的图像百分比(加载成功的个数+加载失败的个数 占整个事先提供的所有URL个数的百分比) 237 var percentage = (this.imagesLoaded + this.imagesFailedToLoad) / this.imageUrls.length * 100; 238 console.log(percentage); 239 return (this.imagesLoaded + this.imagesFailedToLoad) / this.imageUrls.length * 100; 240 }, 241 242 /** 243 * 用于把所有的图像URL放在一个队列里面【数组】 244 * @param imageUrl 245 */ 246 queueImage : function (imageUrl) { 247 this.imageUrls.push(imageUrl); 248 }, 249 250 251 252 253 254 // 游戏循环模块--------------------------------------------------------------- 255 start: function () { 256 var self = this; 257 258 this.startTime = +new Date(); // 获取游戏当前的时间 259 console.log("游戏启动成功, 当前时间:", this.startTime); 260 261 262 // 开始游戏循环(这是一个系统实现的帧速率方法) 263 window.requestNextAnimationFrame( 264 function (time) { 265 // self is the game, and this is the window 266 console.log(self, this); 267 // 每次把游戏实例对象的引用和当前的时间传过去 268 self.animate.call(self, time); // self is the game 269 } 270 ); 271 }, 272 273 animate: function (time) { 274 // 这里的this 指向的是Game对象 275 var self = this; 276 277 if (this.paused) { 278 // 如果用户暂停了游戏,然后每隔100ms的时间检查一次去看一下有没有开始循环 279 // (由于游戏暂停的情况不会频繁出现,因此使用setTimeout()方法就可以满足我们的需求, 每隔100ms去看一次) 280 281 setTimeout(function () { 282 self.animate.call(self, time); 283 }, this.PAUSE_TIMEOUT); 284 } 285 // 没有暂停的话 286 else { 287 this.tick(time); // 1.更新帧速率, 设置游戏时间 288 this.clearScreen(); // 2.清空屏幕内容 289 290 // 碰撞检测代码 291 292 this.startAnimate(time); // 3.开始游戏动画 293 this.paintUnderSprites(); // 4.绘制精灵后面的内容---背景 294 295 this.updateSprites(time); // 5.更新精灵的位置 296 this.paintSprites(time); // 6.绘制精灵 297 298 this.paintOverSprites(); // 7.绘制精灵前方的内容 299 this.endAnimate(); // 8.动画结束 300 301 302 // 回调这个方法, 开始进入到下一帧动画 303 window.requestNextAnimationFrame( 304 function (time) { 305 console.log(self, this); 306 // 注意这里不能直接传过去哈, 如果直接传过去的话,第一个参数就是就会把time 的指向修改为Game这个类 307 // self.animate(self, time); 308 // 第一个参数是用来校正animate函数内部的this的指向, 第二个参数是用来传递animate()函数执行需要的参数 309 self.animate.call(self, time); 310 } 311 ); 312 } 313 }, 314 togglePaused : function () { 315 // 这是一个游戏暂停的方法 316 var now = +new Date(); // 获取游戏暂停的那个时间点 317 318 this.paused = !this.paused; // 每次在暂停与不暂停之间来回切换 319 320 if (this.paused){ 321 // 如果游戏暂停了(暂停的那个时间点就是当前的时间) 322 this.startedPauseAt = now; 323 }else{ 324 // 没有暂停的话:调整开始的时间, 使得游戏开始是从点击开始游戏之后就开始计时的 325 // this.startTime 记录的是:开始时间 + 当前时间 - 游戏上一次暂停的时间点 326 // now - this.startedPauseAt = 游戏暂停的时长, 然后再加上游戏开始的时候的时间,就能从原来的暂停位置处继续执行下去 327 this.startTime = this.startTime + now - this.startedPauseAt; 328 this.lastTime = now; 329 } 330 }, 331 // 实现动画中需要实现的功能 332 /** 333 * // 1.更新帧速率(实现基于时间的运动效果) 334 * @param time 335 */ 336 tick: function (time) { 337 // 1. 更新帧帧速率 338 this.updateFrameRate(time); 339 340 // 2. 设置游戏时间(每一帧间隔的时间) 341 this.gameTime = (+new Date()) - this.startTime; 342 console.log("设置游戏的时间:" + this.gameTime); 343 this.lastTime = time; 344 345 }, 346 updateFrameRate: function (time) { 347 // 启动时候的帧速率 348 if (this.lastTime === 0) { 349 this.fps = this.STARTING_FPS; 350 } 351 else { 352 // 计算当前的帧速率(每秒执行的帧数) 353 this.fps = 1000 / (time - this.lastTime); 354 console.log("实时更新游戏当前的帧速率", this.fps); 355 } 356 357 }, 358 /** 359 * 实现基于时间的运动效果 360 * @param time 361 * @param velocity 362 */ 363 pixelsPerFrame : function (time, velocity) { 364 // 是动画平滑地运动起来 365 // 计算公式:(pixels / second) * (second / frame) = pixeld / second【单位:每一秒移动的像素数】 366 return velocity / this.fps; 367 }, 368 369 /** 370 * // 2.清空屏幕内容 371 */ 372 clearScreen: function () { 373 // 注意this.context.canvas.width, this.context.canvas.height 用于获取画布的宽度和高度 374 // this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); 375 this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); 376 console.log("画布屏幕清空成功!"); 377 }, 378 379 380 /** 381 * // 3.开始游戏动画 382 * @param time 383 */ 384 startAnimate: function (time) { 385 console.log(time, "开始游戏动画………………"); 386 }, 387 /** 388 * // 4.绘制精灵后面的内容 389 */ 390 paintUnderSprites: function () { 391 console.log("绘制精灵后面的内容!"); 392 }, 393 /** 394 * // 5. 更新精灵的位置 395 * @param time 396 */ 397 updateSprites: function (time) { 398 console.log("更新所有精灵的位置!"); 399 for (var i = 0; i < this.sprites.length; i++) { 400 var sprite = this.sprites[i]; 401 // 重新绘制精灵(调用每一个精灵自己的方法去绘制显示) 402 sprite.update(this.context, time); 403 } 404 }, 405 406 // 6.绘制所有可见的精灵对象 407 paintSprites: function (time) { 408 console.log("绘制所有可见的精灵对象"); 409 for (var i = 0; i < this.sprites.length; i++) { 410 var sprite = this.sprites[i]; 411 // 绘制之前需要先判断一下 412 if (sprite.visible) { 413 sprite.paint(this.context); //绘制精灵的时候需要拿到绘制精灵的绘图句柄 414 } 415 } 416 }, 417 418 // 7. 绘制精灵前方的内容 419 paintOverSprites: function () { 420 console.log("绘制精灵前面的内容!"); 421 }, 422 // 8. 绘制动画结束 423 endAnimate: function () { 424 console.log("绘制动画结束!"); 425 }, 426 427 428 429 430 // 声音文件加载播放的模块---------------------------------------------------------- 431 /** 432 * 浏览器是否支持ogg格式的文件 433 * @returns {boolean} 434 */ 435 canPlayOggVorbis : function () { 436 // 只要返回的有内容,就说明浏览器支持这个文件格式 437 return "" != this.audio.canPlayType('audio/ogg; codecs="vorbis"'); 438 }, 439 440 /** 441 * 浏览器是否支持MP3格式的音乐播放 442 * @returns {boolean} 443 */ 444 canPlayMp3 : function () { 445 // 返回的内容不为空,说明支持 446 return "" != this.audio.canPlayType('audio/mpeg'); 447 }, 448 449 /** 450 * 用于返回当前系统中可以使用的信道 451 * @returns {*} 452 */ 453 getAvailableSoundChannel : function () { 454 var audio; 455 456 // 遍历初始化中的所有信道 457 for (var i = 0; i < this.NUM_SOUND_CHANNELS; i++){ 458 audio = this.soundChannels[i]; 459 // 如果当前的audio信道已经开始播放了(而且已经播放的信道数量不为空) 460 if (audio.played && audio.played.length > 0){ 461 // 如果当前的信道已经播放完毕了音乐 462 if (audio.ended){ 463 return audio; 464 } 465 } else{ 466 // 如果当前的信道已经播放完毕音乐了, 就返回当前的这个audio对象 467 if (!audio.ended) 468 return audio; 469 } 470 } 471 472 // 如果所有的信道都在使用的话,就返回undifined 473 return undefined; 474 }, 475 /** 476 * 用于播放指定ID的音乐 477 * @param id 478 */ 479 playSound : function (id) { 480 // 获取当前可以使用的一个信道 481 var track = this.getAvailableSoundChannel(), 482 element = document.getElementById(id); 483 484 485 // 如果不为空(undefined) 486 if (track && element){ 487 // 获取当前选中的媒体资源的URL地址 488 track.src = element.src === '' ? element.currentSrc : element.src; 489 490 // 加载并播放音乐 491 track.load(); 492 track.play(); 493 494 } 495 }, 496 497 498 499 // 键盘事件的监听与处理操作--------------------------------------------- 500 /** 501 * 把一个键值对添加到监听数组中去 502 * @param keyAndListener 503 */ 504 addKeyListener : function (keyAndListener) { 505 this.keyListeners.push(keyAndListener); 506 }, 507 508 /** 509 * 通过key来查找相应的listener对象 510 * @param key 511 * @returns {undefined} 512 */ 513 findKeyListener : function (key) { 514 var listener = undefined; 515 516 // 遍历所有的keyListeners数组 517 for (var i = 0; i < this.keyListeners.length; i++){ 518 // 拿到当前的键值监听对象及按键的key值 519 var keyAndListener = this.keyListeners[i], 520 currentKey = keyAndListener.key; 521 522 // 如果按下的按键是在我今天按下的所有keyAndListener中,就得到了这个listener 523 if (currentKey === key){ 524 listener = keyAndListener.listener; 525 } 526 } 527 528 return listener; 529 }, 530 531 /** 532 * 键盘按下的回调事件 533 * @param e 534 */ 535 keyPressed : function (e) { 536 var listener = undefined, 537 key = undefined; 538 539 switch (e.keyCode){ 540 // 添加一些常用的按键处理键值对 541 case 32: 542 key = 'space'; 543 break; 544 case 65: 545 key = 'a'; 546 break; 547 case 83: 548 key = 's'; 549 break; 550 case 80: 551 key = 'p'; 552 break; 553 case 87: 554 key = 'w'; 555 break; 556 // 记忆:左上右下的顺序,依次为:37 38 39 40 557 case 37: 558 key = 'left arrow'; 559 break; 560 case 39: 561 key = 'right arrow'; 562 break; 563 case 38: 564 key = 'up arrow'; 565 break; 566 case 40: 567 key = 'down arrow'; 568 break; 569 } 570 571 // 获取当前按下的按键的监听事件 572 listener = this.findKeyListener(key); 573 if (listener){ 574 listener(); // 这里的listener是一个监听函数,如果按下的按键有监听事件的处理,就去处理这个监听事件 575 } 576 577 }, 578 579 580 581 // 高分榜的维护管理模块---------------------------------------------------- 582 /** 583 * 从本地存储中获取存储的数据(返回的是一个本地存储的高分列表) 584 * @returns {any} 585 */ 586 getHighScores : function () { 587 // 把key的值存储起来 588 var key = this.gameName + this.HIGH_SCORES_SUFFIX, 589 highScoresString = localStorage[key]; 590 591 592 // 如果为空的话,返回一个空的Json数据 593 if (highScoresString == undefined){ 594 localStorage[key] = JSON.stringify([]); 595 } 596 597 // 使用JSON解析字符串内容(返回的是一个JSon与key相对应的数值内容) 598 return JSON.parse(localStorage[key]); 599 }, 600 /** 601 * 存储内容到本地存储 602 * @param highScore 603 */ 604 setHighScore : function (highScore) { 605 // unshift() 方法不创建新的创建,而是直接修改原有的数组【会在数组的头部插入新的元素】 606 var key = this.gameName + this.HIGH_SCORES_SUFFIX, 607 highScoresString = localStorage[key]; 608 609 610 611 // 主要目的是把每一次最高分放在数组的第一位置,方便查看和管理 612 // 这里的highScores数组,是一个用户初始化的数组(全局变量)【数组的第一个元素始终是最高分】 613 //this.highScores.unshift(highScore);(每次都在原理的基础上添加数据) 614 if (this.highScores.length === 0){ 615 this.highScores = this.getHighScores(); 616 } 617 this.highScores.unshift(highScore); 618 619 620 // 游戏的key始终是惟一的,每一次都将会修改为最新的状态 621 localStorage[key] = JSON.stringify(this.highScores); 622 }, 623 624 /** 625 * 清空高分榜(清空浏览器的本地存储) 626 */ 627 clearHighScores : function () { 628 // 直接把相应的键对应的值设置为空即可 629 localStorage[this.name + this.HIGH_SCORES_SUFFIX] = JSON.stringify([]); 630 } 631 632 }

下面的是一个测试案例:

【原创】使用JS封装的一个小型游戏引擎及源码分享_第1张图片

 

功能实现的比较简单,但是也就是对上面的这个引擎的基本使用吧。

 




    
    Game 1.0
    
    




    Canvas not supported in your browser!




Engine Development

主要功能:

  • 1.资源加载的画面
  • 2.游戏资源的管理
  • 3.声音的播放
  • 4.具有视差动画的滚动背景
  • 5.生命数量的显示
  • 6.高分榜的维护
  • 7.按键的监听与处理
  • 8.暂停功能与自动暂停机制实现
  • 9.游戏结束的流程处理

Loading……
000
生命值:100
暂停
点击此区域任意位置继续
140
High Score!
What's your name?
Previous High Scores

  

 

实现的功能基本介绍:(目前是开发期间的9个版本及功能介绍)

/*
* V1.0: 引擎实现的基本模块思路
* 1.创建一个游戏引擎对象及精灵对象
* 2.将精灵对象添加到引擎中去,并实现播放动画效果以及需要用到的回调方法
* 3.启动引擎
* */

/*
* V2.0: 实现游戏循环模块
* 1.如果游戏暂停了,就跳过以下各步骤,并在100毫秒后再次执行游戏循环
* 2.更新帧速率
* 3.设置游戏时间
* 4.清除屏幕内容
* 5.在播放动画前,调用startAnimate的方法(可以进行碰撞检测)
* 6.绘制精灵背后的内容(绘制背景)
* 7.更新精灵
* 8.绘制精灵
* 9.绘制精灵前方的内容
* 10.动画播放完毕之后,调用endAnimate方法
* 11.请求浏览器播放下一帧动画
*
* */


/**
* V3.0: 实现在暂停状态与运行状态之间的切换togglePaused
*/

/**
* V4.0:实现基于时间的运动效果 :pixelPerFrame
* 计算公式:(pixels / second) * (second / frame) = pixeld / second【单位:每一秒移动的像素数】
*/

/**
* V5.0: 实现加载图像的功能:
* queueImage(imageUrl): 将图像放入到加载队列中去
* loadImages(): 开发者需要持续调用该方法,知道返回100位置(方法的返回值表示图像加载完成的百分比)
* getImage(imageUrl):返回图像对象, 只有咋loadImages()返回100之后,才可以调用该方法
*/

/**
* V6.0:实现同时播放多个声音的功能
* canPlay(): 用于查询浏览器是否能够播放某种特定格式的声音文件
* playSound():用于播放声音
*/


/**
* V7.0: 键盘事件的处理
* addKeyListener(): 用于向游戏注册按键监听器
*/


/**
* V8.0: 高分榜的维护:游戏的高分榜数组以json格式存档在本地
*/

/**
* V9.0: 实现了一个比较完整的游戏引擎,开始使用这个简单的游戏引擎去制作一个小游戏
* 需求分析:
* 1.资源加载的画面
* 2.游戏资源的管理
* 3.声音的播放
* 4.具有视差动画的滚动背景
* 5.生命数量的显示
* 6.高分榜的维护
* 7.按键的监听与处理
* 8.暂停功能与自动暂停机制实现
* 9.游戏结束的流程处理
*/

其他的文件已经全部上传到Github,感兴趣的朋友可以下载查看学习交流,如果觉得不错,欢迎给个star支持一下:

 

https://github.com/xiugangzhang/GameEngine

 

其他的几个小游戏也已经分享:

中国象棋:https://github.com/xiugangzhang/ChineseChess

超级马里奥小游戏:https://github.com/xiugangzhang/SuperMarioGame

苏拉卡尔塔小游戏:https://github.com/xiugangzhang/SularaGame

 

 

转载于:https://www.cnblogs.com/52tech/p/9782704.html

你可能感兴趣的:(游戏,json,javascript,ViewUI)