携手创作,共同成长!这是我参与「掘金日新计划 · 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的代码就不上了,反正就是个普通的网页布局,运行之后长这样:
一些玩法
这个小游戏很简单,就是通过各种各样的操作来触发卡通头像的显示,点击出现的头像可以前往下一关,总共有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");
乍一看有些复杂,捋一下整个流程:
- 渲染层注册
handleSuccess方法监听callback - node层注册
ipcRenderer的监听,参数为handleSuccess传入的callback - node层调用
send方法,根据相同的channel名success,触发ipcRenderer的回调callback 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);
})
右下角的系统托盘
第八关的解法是点击系统托盘的图标。
没啥说的,就是调用electron的托盘APITray,设置好图标和悬浮文字就好了:
tray = new Tray(path.join(__dirname, "./xiaoxi/love.png"))
tray.setToolTip('小希,找到你了!')
tray.on('click', () => {
win.webContents.send("success", "ok");
tray.destroy();
})
读取剪贴板内容
第六关是读取剪贴板内容进行判断,使用的是刚才提到的双向通信的方式,由渲染层触发node层的方法并异步接受数据:
async checkLevel6Clipboard() {
const currentClipboard = await window.electronAPI.getClipboard()
if (currentClipboard == "我爱小希") {
this.levelImg = true;
}
}
阻止窗口关闭
第九关的解法是直接关闭窗口,程序会对窗口的关闭行为进行阻止,这个用到的是对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);