-
+
+
+
+
+
@@ -421,10 +473,12 @@ watch([gameConfig, strengthConfig], () => {
-
+
未连接
@@ -440,7 +494,7 @@ watch([gameConfig, strengthConfig], () => {
-
@@ -508,8 +562,15 @@ watch([gameConfig, strengthConfig], () => {
+
+
+
diff --git a/frontend/src/stores/ClientsStore.ts b/frontend/src/stores/ClientsStore.ts
new file mode 100644
index 0000000..d644c64
--- /dev/null
+++ b/frontend/src/stores/ClientsStore.ts
@@ -0,0 +1,36 @@
+import { defineStore } from 'pinia'
+
+export interface ClientInfo {
+ id: string;
+ name: string;
+ lastConnectTime: number;
+}
+
+export const useClientsStore = defineStore('clients', {
+ state: () => ({
+ clientList: [] as ClientInfo[]
+ }),
+ actions: {
+ addClient(id: string, name: string) {
+ this.clientList.push({ id, name, lastConnectTime: Date.now() });
+ },
+ getClientInfo(id: string) {
+ return this.clientList.find(c => c.id === id);
+ },
+ updateClientName(id: string, name: string) {
+ const client = this.clientList.find(c => c.id === id);
+ if (client) {
+ client.name = name;
+ }
+ },
+ updateClientConnectTime(id: string) {
+ const client = this.clientList.find(c => c.id === id);
+ if (client) {
+ client.lastConnectTime = Date.now();
+ }
+ },
+ },
+ persist: {
+ key: 'CGH_Clients'
+ }
+});
\ No newline at end of file
diff --git a/frontend/src/stores/RemoteNotificationStore.ts b/frontend/src/stores/RemoteNotificationStore.ts
new file mode 100644
index 0000000..5de5043
--- /dev/null
+++ b/frontend/src/stores/RemoteNotificationStore.ts
@@ -0,0 +1,18 @@
+import { defineStore } from 'pinia'
+
+export const useRemoteNotificationStore = defineStore('remoteNotification', {
+ state: () => ({
+ ignoredIds: [] as string[]
+ }),
+ actions: {
+ isIgnored(id: string) {
+ return this.ignoredIds.includes(id);
+ },
+ ignore(id: string) {
+ this.ignoredIds.push(id);
+ },
+ },
+ persist: {
+ key: 'CGH_RemoteNotification'
+ }
+});
\ No newline at end of file
diff --git a/frontend/src/utils/CoyoteBluetoothController.ts b/frontend/src/utils/CoyoteBluetoothController.ts
index 7e8549e..b5143fd 100644
--- a/frontend/src/utils/CoyoteBluetoothController.ts
+++ b/frontend/src/utils/CoyoteBluetoothController.ts
@@ -2,6 +2,8 @@ import { EventEmitter } from "eventemitter3";
import { CoyoteDeviceVersion } from "../type/common";
import SocketToCoyote3Worker from '../workers/SocketToCoyote3?worker';
+import SocketToCoyote2Worker from '../workers/SocketToCoyote2?worker';
+
import { EventAddListenerFunc, EventRemoveListenerFunc } from "./event";
/** 设备扫描前缀 */
@@ -14,7 +16,7 @@ export const devicePrefixMap = {
export const serviceIdMap = {
[CoyoteDeviceVersion.V2]: {
main: '955a180b-0fe2-f5aa-a094-84b8d4f3e8ad',
- battery: '955a180f-0fe2-f5aa-a094-84b8d4f3e8ad',
+ battery: '955a180a-0fe2-f5aa-a094-84b8d4f3e8ad',
},
[CoyoteDeviceVersion.V3]: {
main: '0000180c-0000-1000-8000-00805f9b34fb',
@@ -43,6 +45,8 @@ export class CoyoteBluetoothController {
public mainService: BluetoothRemoteGATTService | null = null;
public batteryService: BluetoothRemoteGATTService | null = null;
+ private batteryTask: NodeJS.Timeout | null = null;
+
private characteristics: Map
= new Map();
public worker: Worker | null = null;
@@ -101,35 +105,62 @@ export class CoyoteBluetoothController {
const batteryServiceId = serviceIdMap[this.deviceVersion].battery;
this.batteryService = await this.gattServer.getPrimaryService(batteryServiceId);
- if (this.deviceVersion === CoyoteDeviceVersion.V3) {
- // 监听上报消息
- const responseCharacteristic = await this.mainService.getCharacteristic('0000150b-0000-1000-8000-00805f9b34fb');
- this.characteristics.set('0000150b-0000-1000-8000-00805f9b34fb', responseCharacteristic);
- await responseCharacteristic.startNotifications();
- responseCharacteristic.addEventListener('characteristicvaluechanged', this.handleBTResponse);
-
- // 监听电量变化
- const batteryCharacteristic = await this.batteryService.getCharacteristic('00001500-0000-1000-8000-00805f9b34fb');
- this.characteristics.set('00001500-0000-1000-8000-00805f9b34fb', batteryCharacteristic);
- const currentBatteryLevel = await batteryCharacteristic.readValue();
- const currentBatteryLevelValue = currentBatteryLevel.getUint8(0);
- this.events.emit('batteryLevelChange', currentBatteryLevelValue);
-
- // 缓存characteristics
- const writeCharacteristics = await this.mainService.getCharacteristic('0000150a-0000-1000-8000-00805f9b34fb');
- this.characteristics.set('0000150a-0000-1000-8000-00805f9b34fb', writeCharacteristics);
- }
+ await this.addBTLisener();
this.events.emit('connect');
// Start worker
this.startWorker();
+
+ this.batteryTask = setInterval(this.runBatteryTask, 120 * 1000);
} catch (error) {
this.disconnect();
throw error;
}
}
+ private async addBTLisener() {
+ if (this.deviceVersion === CoyoteDeviceVersion.V3) {
+ // 监听上报消息
+ const responseCharacteristic = await this.mainService!.getCharacteristic('0000150b-0000-1000-8000-00805f9b34fb');
+ this.characteristics.set('0000150b-0000-1000-8000-00805f9b34fb', responseCharacteristic);
+ await responseCharacteristic.startNotifications();
+ responseCharacteristic.addEventListener('characteristicvaluechanged', this.handleBTResponse);
+
+ // 监听电量变化
+ const batteryCharacteristic = await this.batteryService!.getCharacteristic('00001500-0000-1000-8000-00805f9b34fb');
+ this.characteristics.set('00001500-0000-1000-8000-00805f9b34fb', batteryCharacteristic);
+ const currentBatteryLevel = await batteryCharacteristic.readValue();
+ const currentBatteryLevelValue = currentBatteryLevel.getUint8(0);
+ this.events.emit('batteryLevelChange', currentBatteryLevelValue);
+
+ // 缓存写入特征
+ const writeCharacteristics = await this.mainService!.getCharacteristic('0000150a-0000-1000-8000-00805f9b34fb');
+ this.characteristics.set('0000150a-0000-1000-8000-00805f9b34fb', writeCharacteristics);
+ } else if (this.deviceVersion === CoyoteDeviceVersion.V2) {
+ console.log('V2');
+ // 监听上报消息
+ const responseCharacteristic = await this.mainService!.getCharacteristic('955a1504-0fe2-f5aa-a094-84b8d4f3e8ad');
+ this.characteristics.set('955a1504-0fe2-f5aa-a094-84b8d4f3e8ad', responseCharacteristic);
+ await responseCharacteristic.startNotifications();
+ responseCharacteristic.addEventListener('characteristicvaluechanged', this.handleBTResponse);
+
+ // 监听电量变化
+ const batteryCharacteristic = await this.batteryService!.getCharacteristic('955a1500-0fe2-f5aa-a094-84b8d4f3e8ad');
+ this.characteristics.set('955a1500-0fe2-f5aa-a094-84b8d4f3e8ad', batteryCharacteristic);
+ const currentBatteryLevel = await batteryCharacteristic.readValue();
+ const currentBatteryLevelValue = currentBatteryLevel.getUint8(0);
+ this.events.emit('batteryLevelChange', currentBatteryLevelValue);
+
+ // 缓存写入特征
+ const aChannelCharacteristic = await this.mainService!.getCharacteristic('955a1505-0fe2-f5aa-a094-84b8d4f3e8ad');
+ this.characteristics.set('955a1505-0fe2-f5aa-a094-84b8d4f3e8ad', aChannelCharacteristic);
+
+ const bChannelCharacteristic = await this.mainService!.getCharacteristic('955a1506-0fe2-f5aa-a094-84b8d4f3e8ad');
+ this.characteristics.set('955a1506-0fe2-f5aa-a094-84b8d4f3e8ad', bChannelCharacteristic);
+ }
+ }
+
public disconnect() {
this.stopping = true;
@@ -142,6 +173,11 @@ export class CoyoteBluetoothController {
this.stopWorker();
}
+ if (this.batteryTask) {
+ clearInterval(this.batteryTask);
+ this.batteryTask = null;
+ }
+
this.device = null;
this.gattServer = null;
this.mainService = null;
@@ -164,6 +200,9 @@ export class CoyoteBluetoothController {
case CoyoteDeviceVersion.V3:
this.worker = new SocketToCoyote3Worker();
break;
+ case CoyoteDeviceVersion.V2:
+ this.worker = new SocketToCoyote2Worker();
+ break;
default:
console.error('Unknown device version');
break;
@@ -215,21 +254,40 @@ export class CoyoteBluetoothController {
return;
}
- const pkgId = value.getUint8(0);
- switch (pkgId) {
- case 0xb1: // 强度上报
- // const resId = value.getUint8(1);
- const strengthA = value.getInt8(2);
- const strengthB = value.getInt8(3);
-
- this.events.emit('strengthChange', strengthA, strengthB);
-
- this.worker?.postMessage({
- type: 'setStrength',
- strengthA,
- strengthB,
- });
- break;
+ if (this.deviceVersion === CoyoteDeviceVersion.V3) {
+ const pkgId = value.getUint8(0);
+ switch (pkgId) {
+ case 0xb1: // 强度上报
+ // const resId = value.getUint8(1);
+ const strengthA = value.getInt8(2);
+ const strengthB = value.getInt8(3);
+
+ this.events.emit('strengthChange', strengthA, strengthB);
+
+ this.worker?.postMessage({
+ type: 'setStrength',
+ strengthA,
+ strengthB,
+ });
+ break;
+ }
+ } else if (this.deviceVersion === CoyoteDeviceVersion.V2) {
+ const buffer = new Uint8Array(value.buffer);
+ buffer.reverse();
+ let strengthA = ((buffer[0] & 0b00111111) << 5) | ((buffer[1] & 0b11111000) >> 3);
+ let strengthB = ((buffer[1] & 0b00000111) << 8) | buffer[2];
+ strengthA = Math.ceil(strengthA / 2047 * 200);
+ strengthB = Math.ceil(strengthB / 2047 * 200);
+
+ console.log('强度上报包: ', buffer);
+
+ this.events.emit('strengthChange', strengthA, strengthB);
+
+ this.worker?.postMessage({
+ type: 'setStrength',
+ strengthA,
+ strengthB,
+ });
}
}
@@ -264,6 +322,8 @@ export class CoyoteBluetoothController {
const dataMap = message.data;
let tasks: Promise[] = [];
+ console.log('发送数据: ', dataMap);
+
// 将数据发送到蓝牙设备
for (let key in dataMap) {
const data = dataMap[key];
@@ -287,6 +347,25 @@ export class CoyoteBluetoothController {
}
}
+ private runBatteryTask = async () => {
+ if (!this.batteryService) {
+ return;
+ }
+
+ try {
+ const batteryCharacteristic = this.characteristics.get('00001500-0000-1000-8000-00805f9b34fb');
+ if (!batteryCharacteristic) {
+ return;
+ }
+
+ const value = await batteryCharacteristic.readValue();
+ const batteryLevel = value.getUint8(0);
+ this.events.emit('batteryLevelChange', batteryLevel);
+ } catch (error) {
+ console.error('获取电量异常: ', error);
+ }
+ }
+
public setStrengthLimit(strengthLimitA: number, strengthLimitB: number) {
this.worker?.postMessage({
type: 'setStrengthLimit',
@@ -295,6 +374,13 @@ export class CoyoteBluetoothController {
});
}
+ public setFreqBalance(freqBalance: number) {
+ this.worker?.postMessage({
+ type: 'setFreqBalance',
+ freqBalance,
+ });
+ }
+
public cleanup() {
this.stopping = true;
this.disconnect();
diff --git a/frontend/src/workers/SocketToCoyote2.ts b/frontend/src/workers/SocketToCoyote2.ts
new file mode 100644
index 0000000..e4c53a0
--- /dev/null
+++ b/frontend/src/workers/SocketToCoyote2.ts
@@ -0,0 +1,260 @@
+// This script should be run in WebWorker.
+
+import { CoyoteChannel, DGLabSocketApi, StrengthChangeMode } from "../apis/dgLabSocketApi";
+import { hexStringToUint8Array } from "../utils/utils";
+
+class SocketToCoyote2 {
+ private socket: DGLabSocketApi | null = null;
+
+ private mainLoopTimer: NodeJS.Timeout | null = null;
+
+ private freqBalance = 150;
+
+ private strengthLimitA = 20;
+ private strengthLimitB = 20;
+
+ private waveStrengthSeek = 0;
+
+ private strengthA = 0;
+ private strengthB = 0;
+
+ private deviceStrengthA = 0;
+ private deviceStrengthB = 0;
+
+ private pulseHexListA: string[] = [];
+ private pulseHexListB: string[] = [];
+
+ private stopping = false;
+
+ constructor() {
+ console.log('SocketToCoyote2 worker started');
+ this.bind();
+ }
+
+ private bind() {
+ self.addEventListener("message", (event) => {
+ if (!event.data?.type) {
+ return;
+ }
+
+ switch (event.data.type) {
+ case 'connect':
+ this.connectToWebSocket(event.data.url);
+ break;
+ case 'disconnect':
+ this.disconnectFromWebSocket();
+ this.stop();
+ break;
+ case 'setStrengthLimit':
+ this.strengthLimitA = event.data.strengthLimitA;
+ this.strengthLimitB = event.data.strengthLimitB;
+ this.sendCurrentStrength();
+ break;
+ case 'setFreqBalance':
+ this.freqBalance = event.data.freqBalance;
+ break;
+ case 'setStrength':
+ this.deviceStrengthA = event.data.strengthA;
+ this.deviceStrengthB = event.data.strengthB;
+ this.sendCurrentStrength();
+ break;
+ }
+ });
+
+ self.postMessage({
+ type: 'onLoad',
+ });
+ }
+
+ private connectToWebSocket(url: string) {
+ console.log('Connecting to WebSocket:', url);
+ this.socket = new DGLabSocketApi(url);
+ this.socket.connect();
+
+ this.socket.on('bind', (_: string) => {
+ this.sendCurrentStrength();
+ this.start();
+ });
+
+ this.socket.on('setStrength', (channel: string, mode: number, value: number) => {
+ if (channel === CoyoteChannel.A) {
+ switch (mode) {
+ case StrengthChangeMode.Sub:
+ this.strengthA = Math.max(0, this.strengthA - value);
+ break;
+ case StrengthChangeMode.Add:
+ this.strengthA = Math.min(this.strengthLimitA, this.strengthA + value);
+ break;
+ case StrengthChangeMode.Set:
+ this.strengthA = Math.max(Math.min(this.strengthLimitA, value), 0);
+ break;
+ }
+ } else if (channel === CoyoteChannel.B) {
+ switch (mode) {
+ case StrengthChangeMode.Sub:
+ this.strengthB = Math.max(0, this.strengthB - value);
+ break;
+ case StrengthChangeMode.Add:
+ this.strengthB = Math.min(this.strengthLimitB, this.strengthB + value);
+ break;
+ case StrengthChangeMode.Set:
+ this.strengthB = Math.max(Math.min(this.strengthLimitB, value), 0);
+ break;
+ }
+ }
+ });
+
+ this.socket.on('pulse', (channel: string, pulseHex: string[]) => {
+ if (channel === CoyoteChannel.A) {
+ this.pulseHexListA.push(...pulseHex);
+ } else if (channel === CoyoteChannel.B) {
+ this.pulseHexListB.push(...pulseHex);
+ }
+ });
+
+ this.socket.on('clearPulse', (channel: string) => {
+ if (channel === CoyoteChannel.A) {
+ this.pulseHexListA = [];
+ } else if (channel === CoyoteChannel.B) {
+ this.pulseHexListB = [];
+ }
+ });
+
+ this.socket.on('breakConnection', () => {
+ this.stop();
+ });
+
+ this.socket.on('close', () => {
+ this.stop();
+ });
+ }
+
+ private disconnectFromWebSocket() {
+ if (this.socket) {
+ this.socket.close();
+ this.socket = null;
+ }
+ }
+
+ public async sendCurrentStrength() {
+ try {
+ await this.socket?.sendStrengthChange(this.deviceStrengthA, this.strengthLimitA, this.deviceStrengthB, this.strengthLimitB);
+ } catch (error) {
+ console.error('Failed to send current strength:', error);
+ }
+ }
+
+ private start() {
+ this.mainLoopTimer = setInterval(() => {
+ this.onTick();
+ }, 100);
+ }
+
+ private stop() {
+ if (this.stopping) {
+ return;
+ }
+
+ this.stopping = true;
+
+ if (this.socket) {
+ this.socket.close();
+ this.socket = null;
+ }
+
+ if (this.mainLoopTimer) {
+ clearInterval(this.mainLoopTimer);
+ this.mainLoopTimer = null;
+ }
+
+ self.postMessage({
+ type: 'stop',
+ });
+ }
+
+ private wave3ToWave2(strength: number, freq: number) {
+ // const msPerWave = 1000 / freq;
+ const X = Math.floor(Math.pow((freq / 1000), 0.5) * (this.freqBalance / 10));
+ const Y = Math.floor(freq - X);
+ const Z = Math.floor(strength / 5);
+
+ return { X, Y, Z };
+ }
+
+ private buildPulsePkg(pulseHex: string) {
+ const pulseData = hexStringToUint8Array(pulseHex);
+
+ const freq = this.uncompressFreq(pulseData[0]); // 脉冲频率,始终取第一个字节
+ const pulseStrength = pulseData[4 + this.waveStrengthSeek]; // 脉冲强度,从第四个字节开始,每4个字节循环播放
+
+ const waveData = this.wave3ToWave2(pulseStrength, freq);
+
+ let tmpBuffer = new Uint32Array([
+ (waveData.Z << 15) + (waveData.Y << 5) + waveData.X
+ ])
+
+ let pulsePkg = new Uint8Array(tmpBuffer.buffer);
+ pulsePkg = pulsePkg.slice(0, 3);
+
+ return pulsePkg;
+ }
+
+ private uncompressFreq(input: number): number {
+ if (input >= 10 && input <= 100) {
+ return input;
+ } else if (input >= 101 && input <= 600) {
+ return (input - 100) / 5 + 100;
+ } else if (input >= 601 && input <= 1000) {
+ return (input - 600) / 10 + 200;
+ } else {
+ return 10;
+ }
+ }
+
+ private onTick() {
+ // 波形帧函数,每100ms调用一次
+ // 用于向蓝牙设备发送数据
+ if (this.pulseHexListA.length + this.pulseHexListB.length === 0) {
+ return; // 波形为空则不发送数据
+ }
+
+ let pkgList: Record = {};
+
+ if (this.deviceStrengthA !== this.strengthA || this.deviceStrengthB !== this.strengthB) {
+ // 组建强度变化数据包
+ let realStrengthA = Math.floor(this.strengthA / 200 * 2047);
+ let realStrengthB = Math.floor(this.strengthB / 200 * 2047);
+ let setStrengthPkg = new Uint8Array([
+ realStrengthA >> 5 & 0xff,
+ ((realStrengthA << 3) & 0xff) | ((realStrengthB >> 8) & 0xff),
+ realStrengthB & 0xff,
+ ]);
+ setStrengthPkg.reverse();
+ pkgList['955a1504-0fe2-f5aa-a094-84b8d4f3e8ad'] = setStrengthPkg;
+ }
+
+ if (this.pulseHexListA.length > 0) {
+ const pulseHex = this.pulseHexListA.shift()!;
+ pkgList['955a1506-0fe2-f5aa-a094-84b8d4f3e8ad'] = this.buildPulsePkg(pulseHex);
+ }
+
+ if (this.pulseHexListB.length > 0) {
+ // 组建脉冲数据包
+ const pulseHex = this.pulseHexListB.shift()!;
+ pkgList['955a1505-0fe2-f5aa-a094-84b8d4f3e8ad'] = this.buildPulsePkg(pulseHex);
+ }
+
+ // 增加波形位置偏移
+ this.waveStrengthSeek ++;
+ if (this.waveStrengthSeek >= 4) {
+ this.waveStrengthSeek = 0;
+ }
+
+ self.postMessage({
+ type: 'sendBluetoothData',
+ data: pkgList,
+ });
+ }
+}
+
+new SocketToCoyote2();
\ No newline at end of file
diff --git a/server/config.example-server.yaml b/server/config.example-server.yaml
index 998114a..086afd5 100644
--- a/server/config.example-server.yaml
+++ b/server/config.example-server.yaml
@@ -6,4 +6,9 @@ webBaseUrl: "https://www.example.com" # 作为服务部署时,配置控
webWsBaseUrl: "wss://ws.example.com" # 网页控制台的WebSocket Base URL,需要包含协议类型
clientWsBaseUrl: "wss://ws.example.com" # 客户端连接的WebSocket Base URL,需要包含协议类型
pulseConfigPath: "pulse.yaml" # 波形配置文件路径
-allowBroadcastToClients: false # 允许向所有已连接的客户端广播消息(搭建公开服务时必须关闭)
\ No newline at end of file
+allowBroadcastToClients: false # 允许向所有已连接的客户端广播消息(搭建公开服务时必须关闭)
+hideWebUpdateNotification: true # 隐藏网页控制台的更新提示
+siteNotifications: # 站点通知
+ - title: "欢迎使用CoyoteGameHub"
+ message: "这是一个示例站点通知,你可以在配置文件中添加自己的通知,或者删除这个通知。"
+ ignoreId: 'site-welcome-1' # 忽略ID,用户可以点击“忽略”按钮来忽略这个通知,忽略的通知将不再显示。建议每次更新通知时更改这个ID。
\ No newline at end of file
diff --git a/server/src/controllers/game/CoyoteGameController.ts b/server/src/controllers/game/CoyoteGameController.ts
index 3db9925..7d94a37 100644
--- a/server/src/controllers/game/CoyoteGameController.ts
+++ b/server/src/controllers/game/CoyoteGameController.ts
@@ -28,8 +28,6 @@ export interface CoyoteGameEvents {
gameStopped: [];
}
-export const FIRE_MAX_DURATION = 30000;
-
export class CoyoteGameController {
/** 在线Socket的ID列表,用于判断是否可以释放Game */
private onlineSockets = new Set();
diff --git a/server/src/controllers/http/GameApi.ts b/server/src/controllers/http/GameApi.ts
index f94e7a3..75cbdf3 100644
--- a/server/src/controllers/http/GameApi.ts
+++ b/server/src/controllers/http/GameApi.ts
@@ -7,7 +7,7 @@ import { MainConfig } from '../../config';
import { DGLabPulseService } from '../../services/DGLabPulse';
import { asleep } from '../../utils/utils';
import { CoyoteGameConfigService, GameConfigType } from '../../services/CoyoteGameConfigService';
-import { GameFireAction } from '../game/actions/GameFireAction';
+import { FIRE_MAX_DURATION, FIRE_MAX_STRENGTH, GameFireAction } from '../game/actions/GameFireAction';
export type SetStrengthConfigRequest = {
strength?: {
@@ -549,19 +549,19 @@ export class GameApiController {
}
let warnings: { code: string, message: string }[] = [];
- if (req.strength > 30) {
+ if (req.strength > FIRE_MAX_STRENGTH) {
warnings.push({
code: 'WARN::INVALID_STRENGTH',
- message: '一键开火强度值不能超过 30',
+ message: `一键开火强度值不能超过 ${FIRE_MAX_STRENGTH}`,
});
}
const fireTime = req.time ?? 5000;
- if (fireTime > 30000) {
+ if (fireTime > FIRE_MAX_DURATION) {
warnings.push({
code: 'WARN::INVALID_TIME',
- message: '一键开火时间不能超过 30000ms',
+ message: `一键开火时间不能超过 ${FIRE_MAX_DURATION}ms`,
});
}
diff --git a/server/src/controllers/ws/WebWS.ts b/server/src/controllers/ws/WebWS.ts
index 1cff894..dc7054a 100644
--- a/server/src/controllers/ws/WebWS.ts
+++ b/server/src/controllers/ws/WebWS.ts
@@ -7,6 +7,7 @@ import { CoyoteGameController } from '../game/CoyoteGameController';
import { validator } from '../../utils/validator';
import { CoyoteGameConfigService, GameConfigType } from '../../services/CoyoteGameConfigService';
import { DGLabPulseService } from '../../services/DGLabPulse';
+import { SiteNotificationService } from '../../services/SiteNotificationService';
export type WebWSPostMessage = {
event: string;
@@ -46,6 +47,15 @@ export class WebWSClient {
data: DGLabPulseService.instance.getPulseInfoList(),
});
+ // 发送站点通知
+ const siteNotifications = SiteNotificationService.instance.getNotifications();
+ for (const notification of siteNotifications) {
+ await this.send({
+ event: 'remoteNotification',
+ data: notification,
+ });
+ }
+
this.heartbeatTask = setInterval(() => this.taskHeartbeat(), 15000);
}
@@ -68,8 +78,8 @@ export class WebWSClient {
public bindEvents() {
const socketEvents = this.eventStore.wrap(this.socket);
- const gameConfigServiceEvents = this.eventStore.wrap(CoyoteGameConfigService.instance);
const pulseServiceEvents = this.eventStore.wrap(DGLabPulseService.instance);
+ const siteNotificationEvents = this.eventStore.wrap(SiteNotificationService.instance);
socketEvents.on("message", async (data, isBinary) => {
if (isBinary) {
@@ -98,6 +108,14 @@ export class WebWSClient {
data: pulseList,
});
});
+
+ // 监听站点通知更新事件
+ siteNotificationEvents.on("newNotification", async (notification) => {
+ await this.send({
+ event: 'remoteNotification',
+ data: notification,
+ });
+ });
}
private async handleMessage(message: any) {
diff --git a/server/src/index.ts b/server/src/index.ts
index 25c9cf5..7e85064 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -17,6 +17,8 @@ import { DGLabPulseService } from './services/DGLabPulse';
import { LocalIPAddress, openBrowser } from './utils/utils';
import { validator } from './utils/validator';
import { CoyoteGameConfigService } from './services/CoyoteGameConfigService';
+import { SiteNotificationService } from './services/SiteNotificationService';
+import { checkUpdate } from './utils/checkUpdate';
async function main() {
// blocked((time, stack) => {
@@ -28,6 +30,7 @@ async function main() {
await DGLabPulseService.instance.initialize();
await CoyoteGameConfigService.instance.initialize();
+ await SiteNotificationService.instance.initialize();
const app = new Koa();
const httpServer = http.createServer(app.callback());
@@ -91,6 +94,24 @@ async function main() {
console.log(` - ${ipAddr}`);
});
});
+
+ // 检测更新
+ checkUpdate().then((updateInfo) => {
+ if (!updateInfo) return;
+
+ if (MainConfig.value.hideWebUpdateNotification) return; // 不在控制台显示更新通知
+
+ SiteNotificationService.instance.addNotification({
+ severity: 'secondary',
+ icon: 'pi pi-download',
+ title: `发现新版本 ${updateInfo.version}`,
+ message: updateInfo.description ?? '请前往GitHub查看更新内容。',
+ url: updateInfo.downloadUrl,
+ urlLabel: '下载',
+ sticky: true,
+ ignoreId: 'update-notification-' + updateInfo.version,
+ });
+ });
}
main().catch((err) => {
diff --git a/server/src/schemas/CoyoteLiveGameConfig.json b/server/src/schemas/CoyoteLiveGameConfig.json
deleted file mode 100644
index b2c1b3e..0000000
--- a/server/src/schemas/CoyoteLiveGameConfig.json
+++ /dev/null
@@ -1,55 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "$ref": "#/definitions/CoyoteLiveGameConfig",
- "definitions": {
- "CoyoteLiveGameConfig": {
- "type": "object",
- "properties": {
- "strength": {
- "$ref": "#/definitions/GameStrengthConfig"
- },
- "pulseId": {
- "type": "string"
- },
- "firePulseId": {
- "type": [
- "string",
- "null"
- ]
- }
- },
- "required": [
- "strength",
- "pulseId"
- ],
- "additionalProperties": false
- },
- "GameStrengthConfig": {
- "type": "object",
- "properties": {
- "strength": {
- "type": "number"
- },
- "randomStrength": {
- "type": "number"
- },
- "minInterval": {
- "type": "number"
- },
- "maxInterval": {
- "type": "number"
- },
- "bChannelMultiplier": {
- "type": "number"
- }
- },
- "required": [
- "strength",
- "randomStrength",
- "minInterval",
- "maxInterval"
- ],
- "additionalProperties": false
- }
- }
-}
\ No newline at end of file
diff --git a/server/src/schemas/MainConfigType.json b/server/src/schemas/MainConfigType.json
index 6c5eb51..4370cd8 100644
--- a/server/src/schemas/MainConfigType.json
+++ b/server/src/schemas/MainConfigType.json
@@ -44,6 +44,17 @@
"allowBroadcastToClients": {
"type": "boolean",
"description": "允许插件API向所有客户端发送指令"
+ },
+ "hideWebUpdateNotification": {
+ "type": "boolean",
+ "description": "网页不显示更新通知"
+ },
+ "siteNotifications": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/RemoteNotificationInfo"
+ },
+ "description": "站点通知"
}
},
"required": [
@@ -51,6 +62,55 @@
"port",
"pulseConfigPath"
]
+ },
+ "RemoteNotificationInfo": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "通知标题"
+ },
+ "message": {
+ "type": "string",
+ "description": "通知内容"
+ },
+ "icon": {
+ "type": "string",
+ "description": "通知图标,需要是PrimeVue图标列表里的className"
+ },
+ "severity": {
+ "type": "string",
+ "enum": [
+ "success",
+ "info",
+ "warn",
+ "error",
+ "secondary",
+ "contrast"
+ ],
+ "description": "通知类型"
+ },
+ "ignoreId": {
+ "type": "string",
+ "description": "通知的ID,如果存在则此通知可以忽略"
+ },
+ "sticky": {
+ "type": "boolean",
+ "description": "阻止通知自动关闭"
+ },
+ "url": {
+ "type": "string",
+ "description": "点击通知后打开的URL"
+ },
+ "urlLabel": {
+ "type": "string",
+ "description": "打开URL的按钮文本"
+ }
+ },
+ "required": [
+ "message"
+ ],
+ "additionalProperties": false
}
}
}
\ No newline at end of file
diff --git a/server/src/schemas/schemas.json b/server/src/schemas/schemas.json
index 2c2ea96..3272904 100644
--- a/server/src/schemas/schemas.json
+++ b/server/src/schemas/schemas.json
@@ -1,13 +1,4 @@
{
- "./src/schemas/RandomStrengthConfig.json": {
- "fileMd5": "631bc4fd8b2340fc59012a7f862ea61e"
- },
- "./src/schemas/CoyoteLiveGameConfig.json": {
- "fileMd5": "2acb2305747cc5debb108157b8592022"
- },
- "./src/schemas/MainConfigType.json": {
- "fileMd5": "246505a20c9c95200b7e94214f15bb1c"
- },
"./src/schemas/GameStrengthConfig.json": {
"fileMd5": "767957c4f5c0bedfadc8b1389d22ba33"
},
@@ -16,5 +7,8 @@
},
"./src/schemas/MainGameConfig.json": {
"fileMd5": "767957c4f5c0bedfadc8b1389d22ba33"
+ },
+ "./src/schemas/MainConfigType.json": {
+ "fileMd5": "58364660a9d932a3170e9437004ace2a"
}
}
\ No newline at end of file
diff --git a/server/src/services/SiteNotificationService.ts b/server/src/services/SiteNotificationService.ts
new file mode 100644
index 0000000..05491da
--- /dev/null
+++ b/server/src/services/SiteNotificationService.ts
@@ -0,0 +1,50 @@
+import { ExEventEmitter } from "../utils/ExEventEmitter";
+import { RemoteNotificationInfo } from "../types/server";
+import { MainConfig } from "../config";
+
+export interface SiteNotificationManagerService {
+ newNotification: [notification: RemoteNotificationInfo];
+}
+
+export class SiteNotificationService {
+ private static _instance: SiteNotificationService;
+
+ private events = new ExEventEmitter();
+
+ private notifications: RemoteNotificationInfo[] = [];
+
+ constructor() {
+
+ }
+
+ public static createInstance() {
+ if (!this._instance) {
+ this._instance = new SiteNotificationService();
+ }
+ }
+
+ public static get instance() {
+ this.createInstance();
+ return this._instance;
+ }
+
+ public async initialize() {
+ if (MainConfig.value.siteNotifications) {
+ this.notifications = MainConfig.value.siteNotifications;
+ }
+ }
+
+ public addNotification(notification: RemoteNotificationInfo) {
+ this.notifications.push(notification);
+ this.events.emit('newNotification', notification);
+ }
+
+ public getNotifications() {
+ return this.notifications;
+ }
+
+ public on = this.events.on.bind(this.events);
+ public once = this.events.once.bind(this.events);
+ public off = this.events.off.bind(this.events);
+ public removeAllListeners = this.events.removeAllListeners.bind(this.events);
+}
\ No newline at end of file
diff --git a/server/src/types/config.ts b/server/src/types/config.ts
index d4d3bf8..8936446 100644
--- a/server/src/types/config.ts
+++ b/server/src/types/config.ts
@@ -1,3 +1,5 @@
+import { RemoteNotificationInfo } from "./server";
+
export type MainConfigType = {
port: number;
host: string;
@@ -15,4 +17,8 @@ export type MainConfigType = {
openBrowser?: boolean;
/** 允许插件API向所有客户端发送指令 */
allowBroadcastToClients?: boolean;
+ /** 网页不显示更新通知 */
+ hideWebUpdateNotification?: boolean;
+ /** 站点通知 */
+ siteNotifications?: RemoteNotificationInfo[];
} & Record;
\ No newline at end of file
diff --git a/server/src/types/server.ts b/server/src/types/server.ts
new file mode 100644
index 0000000..5bab273
--- /dev/null
+++ b/server/src/types/server.ts
@@ -0,0 +1,18 @@
+export type RemoteNotificationInfo = {
+ /** 通知标题 */
+ title?: string;
+ /** 通知内容 */
+ message: string;
+ /** 通知图标,需要是PrimeVue图标列表里的className */
+ icon?: string;
+ /** 通知类型 */
+ severity?: 'success' | 'info' | 'warn' | 'error' | 'secondary' | 'contrast';
+ /** 通知的ID,如果存在则此通知可以忽略 */
+ ignoreId?: string;
+ /** 阻止通知自动关闭 */
+ sticky?: boolean;
+ /** 点击通知后打开的URL */
+ url?: string;
+ /** 打开URL的按钮文本 */
+ urlLabel?: string;
+};
\ No newline at end of file
diff --git a/server/src/utils/checkUpdate.ts b/server/src/utils/checkUpdate.ts
index 468fb66..58c4066 100644
--- a/server/src/utils/checkUpdate.ts
+++ b/server/src/utils/checkUpdate.ts
@@ -7,7 +7,11 @@ export type VersionInfo = {
description?: string;
releaseFile: {
[platform: string]: string;
- }
+ },
+ apiMirrors: {
+ version: string;
+ release: string;
+ }[],
} & Record;
export function compareVersion(current: string, remote: string) {
@@ -39,23 +43,18 @@ export function compareVersion(current: string, remote: string) {
}
}
-export async function checkUpdate() {
- const apis = [
- {
- version: 'https://raw.githubusercontent.com/{repo}/master/version.json',
- release: 'https://github.com/{repo}/releases/download/v{version}/{file}',
- },
- // 镜像地址
- {
- version: 'https://mirror.ghproxy.com/https://raw.githubusercontent.com/{repo}/master/version.json',
- release: 'https://mirror.ghproxy.com/https://github.com/{repo}/releases/download/v{version}/{file}',
- },
- ]
+export type UpdateInfo = {
+ downloadUrl: string;
+} & VersionInfo;
+
+export async function checkUpdate(): Promise {
if (!fs.existsSync('version.json')) return false;
try {
const versionInfo: VersionInfo = JSON.parse(await fs.promises.readFile('version.json', 'utf8'));
if (!versionInfo.repo || !versionInfo.version) return false;
+ let apis = versionInfo.apiMirrors;
+
for (const api of apis) {
try {
const res = await got(api.version.replace('{repo}', versionInfo.repo), {
@@ -74,17 +73,24 @@ export async function checkUpdate() {
console.log(`检测到新版本:${res.version},更新内容:\n${res.description}\n`);
+ let downloadUrl = 'https://github.com/' + res.repo + '/releases/';
if (releaseFile) {
+ downloadUrl = api.release.replace('{repo}', res.repo).replace('{version}', res.version).replace('{file}', releaseFile);
console.log(`下载地址:${api.release.replace('{repo}', res.repo).replace('{version}', res.version).replace('{file}', releaseFile)}`);
}
- return true;
+ return {
+ downloadUrl,
+ ...versionInfo,
+ };
}
} catch (e: any) {
}
}
} catch (e: any) {
-
+ console.error('Failed to check update:', e);
}
+
+ return false;
}
\ No newline at end of file
diff --git a/version.json b/version.json
index 12bab76..d53bb40 100644
--- a/version.json
+++ b/version.json
@@ -1,10 +1,20 @@
{
"repo": "hyperzlib/DG-Lab-Coyote-Game-Hub",
- "version": "1.2.1",
+ "version": "1.0.0",
"description": "1. 降低一键开火延迟",
"releaseFile": {
"windows": "coyote-game-hub-windows-amd64-dist.zip",
"linux": "coyote-game-hub-nodejs-server.zip",
"mac": "coyote-game-hub-nodejs-server.zip"
- }
+ },
+ "apiMirrors": [
+ {
+ "version": "https://raw.githubusercontent.com/{repo}/master/version.json",
+ "release": "https://github.com/{repo}/releases/download/v{version}/{file}"
+ },
+ {
+ "version": "https://mirror.ghproxy.com/https://raw.githubusercontent.com/{repo}/master/version.json",
+ "release": "https://mirror.ghproxy.com/https://github.com/{repo}/releases/download/v{version}/{file}"
+ }
+ ]
}