“寻找小希”小游戏——我的electron初体验

632 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第22天,点击查看活动详情

前因

前几天在掘金刷到一个老哥的文章:快速入手Electron,拥有一个自己的桌面应用

electron以前接触过一点,不过忘得差不多了,反正知道它是一个可以用JS来构建多平台应用的框架(主要是PC端软件),JS真是无所不能啊,是不是除了生孩子都办得到?

正巧过一阵子喜欢的主播要过生日了,不如拿electron做个小游戏送她吧,创意这就来了。(……为啥不早点想起来,还可以参加创意开发大赛来着)

关于为什么不直接做网页:

  • 网页受制于网络环境可能出现加载慢的情况
  • 浏览器适配性可能存在问题
  • 直播时浏览网页实际上是有风险的(万一制作者不怀好意夹带私货当时替换掉)
  • 使用electron可以调用系统API来丰富更多玩法

动手

关于electron的基础介绍可以去看我开头说的那篇文章,实质上就是加载一个html页面,然后与一些系统API进行交互。

在程序初始化完成后首先加载窗口和初始化菜单:

app.whenReady().then(() => {
  createWindow()
  initMenu();
})
const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    title: "寻找小希",
    webPreferences: {
      preload: path.join(__dirname, "preload.js") // 与html的交互,后面细说
    },
    icon: path.join(__dirname, "./xiaoxi/love.png")
  })

  win.loadFile('./html/index.html')
}
function initMenu() {
  // 菜单栏模板
  const menuBar = [
    {
      label: '这是啥',
      submenu: [
        {
          label: '这是啥', click: () => {
            dialog.showMessageBoxSync({
              "title": "寻找小希",
              "message": "这是一个寻找小希的小游戏,可以跟我一起大喊“小希生日快乐”吗?",
              "icon": path.join(__dirname,'./xiaoxi/love.png')
            })
          }
        },
        { label: '退出', role: 'quit' }
      ]
    },
    {
      label: '我是谁',
      submenu: [
        {
          label: '关于小希', click: () => {
            shell.openExternal("https://zh.moegirl.org.cn/zh/%E5%B0%8F%E5%B8%8C%E5%B0%8F%E6%A1%83")
          }
        },
        {
          label: '虚拟次元计划官网', click: () => {
            shell.openExternal("https://www.vdproject.cn/")
          }
        },
        { type: 'separator' },
        {
          label: '该项目开源地址', click: () => {
            shell.openExternal("https://github.com/zhzhch335/findxiaoxi")
          }
        }
      ]
    }
  ];

  // 构建菜单项
  const menu = Menu.buildFromTemplate(menuBar);
  // 设置一个顶部菜单栏
  Menu.setApplicationMenu(menu);
}

html的代码就不上了,反正就是个普通的网页布局,运行之后长这样:

image.png

一些玩法

这个小游戏很简单,就是通过各种各样的操作来触发卡通头像的显示,点击出现的头像可以前往下一关,总共有10关,有部分关卡在简单玩梗就不多说了,这里挑几个用了系统API的关卡来说说。

监听关卡变化

首先是监听did-navigate-in-page事件来判断关卡的变化,因为我的网页部分用hash来进行关卡的跳转,因此监听到页面重新加载后对hash进行判断。

win.webContents.addListener("did-navigate-in-page", (e, url) => {
    var level = Number(url.split("#")[1]);
    switch (level) {
      case 5:
        //...
        break;
      }
    }
  );

node层与渲染层(html)通信

刚才在初始化代码中,我传入了一个webPreferences.preload选项,这个选项是加载渲染层html文件之前需要加载的脚本,也就是说可以将某些方法注入到渲染层的window对象中去,这个脚本干了这样几件事:

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  // 设置成功回调
  handleSuccess: (callback) => ipcRenderer.on('success', callback),
  // 取消成功回调
  removeSuccess: (callback) => ipcRenderer.off('success', callback),
  // 获取剪贴板内容
  getClipboard: () => ipcRenderer.invoke('getClipBoard'),
  // 调用node层弹窗显示内容
  showTips: (tips) => ipcRenderer.invoke('showTips',tips)
})

node层触发渲染层方法

刚才说到,我使用webPreferences.preload中的脚本向渲染层暴露了全局方法,而这个全局方法就可以传入回调以实现node层对渲染层的控制:

// 预加载脚本暴露给渲染层的全局方法
handleSuccess: (callback) => ipcRenderer.on('success', callback),
// 渲染层
const success = (event, value) => {
  this.levelImg = true
}
window.electronAPI.handleSuccess(success)
// node层
win.webContents.send("success", "ok");

乍一看有些复杂,捋一下整个流程:

  1. 渲染层注册handleSuccess方法监听callback
  2. node层注册ipcRenderer的监听,参数为handleSuccess传入的callback
  3. node层调用send方法,根据相同的channelsuccess,触发ipcRenderer的回调callback
  4. ipcRenderer的回调触发渲染层的回调callback

渲染层触发node层方法

反过来的操作依然是先从渲染层发起的,毕竟所有方法都暴露在渲染层window对象上,我们可以看到,刚才的预加载脚本里使用一个invoke方法传入channel和来自于渲染层的参数:

// 预加载脚本暴露给渲染层的全局方法
showTips: (tips) => ipcRenderer.invoke('showTips',tips)

而渲染层就这样直接调用:

window.electronAPI.showTips(this.LEVEL_TIPS[this.level])

最后由node层接收这个channel

// 弹出提示
ipcMain.handle("showTips", (_,tips) => {
  dialog.showMessageBox(win,{
    title: "你是笨蛋吗?",
    message: tips,
    icon: path.join(__dirname,'./xiaoxi/confusion.png')
  })
})

这里我们可以有一个小小的总结,node层与渲染层的通信主要靠同名的channel

双向通信

二者的双向通信实际上就是渲染层触发node层方法的扩展,node层接收channel的回调函数中传入一个返回值,这个返回值会在渲染层异步加载进来,渲染层即可接收:

// 预加载脚本暴露给渲染层的全局方法
getClipboard: () => ipcRenderer.invoke('getClipBoard'),
// 渲染层
async checkLevel6Clipboard() {
  const currentClipboard = await window.electronAPI.getClipboard()
  //...
}
// node层
ipcMain.handle("getClipBoard", () => clipboard.readText())

监听窗口事件

通过win.webContents.addListener方法可以监听窗口的各种事件,比如第五关监听了失去焦点

这关的解法是使窗口失去焦点十秒钟,我用的方法是监听BrowserWindow对象的blur事件并进行计时:

let timeoutId = null;
const blur5seconds = () => {
  timeoutId = setTimeout(() => {
    win.webContents.send("success", "ok");
    win.webContents.off("blur", blur5seconds);
    timeoutId = null;
  }, 10000)
}
win.webContents.on("blur", blur5seconds);
win.webContents.on("focus", () => {
  if (timeoutId) clearTimeout(timeoutId);
})

image.png

右下角的系统托盘

第八关的解法是点击系统托盘的图标。

没啥说的,就是调用electron的托盘APITray,设置好图标和悬浮文字就好了:

tray = new Tray(path.join(__dirname, "./xiaoxi/love.png"))
        tray.setToolTip('小希,找到你了!')
        tray.on('click', () => {
          win.webContents.send("success", "ok");
          tray.destroy();
        })

image.png

image.png

读取剪贴板内容

第六关是读取剪贴板内容进行判断,使用的是刚才提到的双向通信的方式,由渲染层触发node层的方法并异步接受数据:

async checkLevel6Clipboard() {
  const currentClipboard = await window.electronAPI.getClipboard()
  if (currentClipboard == "我爱小希") {
    this.levelImg = true;
  }
}

image.png

阻止窗口关闭

第九关的解法是直接关闭窗口,程序会对窗口的关闭行为进行阻止,这个用到的是对BrowserWindow对象的close事件的监听,并且在回调函数中调用event.preventDefault()方法阻止关闭行为(不能真的结束游戏嘛):

const closeCallback = (event) => {
  dialog.showMessageBoxSync({
    "title": "错了错了",
    "message": "小希出现了,不要关闭游戏好不好~",
    icon: path.join(__dirname, "./xiaoxi/love.png")
  })
  win.webContents.send("success", "ok");
  win.off('close',closeCallback);
  event.preventDefault();
}
win.on('close', closeCallback);

image.png