@lizardon386/game-sdk 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/contract/client.d.ts +2 -0
- package/dist/contract/client.d.ts.map +1 -0
- package/dist/contract/client.js +2 -0
- package/dist/contract/client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/kit/client/chat.d.ts +46 -0
- package/dist/kit/client/chat.d.ts.map +1 -0
- package/dist/kit/client/chat.js +183 -0
- package/dist/kit/client/chat.js.map +1 -0
- package/dist/kit/client/feed.d.ts +39 -0
- package/dist/kit/client/feed.d.ts.map +1 -0
- package/dist/kit/client/feed.js +157 -0
- package/dist/kit/client/feed.js.map +1 -0
- package/dist/kit/client/index.d.ts +6 -0
- package/dist/kit/client/index.d.ts.map +1 -0
- package/dist/kit/client/index.js +6 -0
- package/dist/kit/client/index.js.map +1 -0
- package/dist/kit/client/scoreboard.d.ts +68 -0
- package/dist/kit/client/scoreboard.d.ts.map +1 -0
- package/dist/kit/client/scoreboard.js +282 -0
- package/dist/kit/client/scoreboard.js.map +1 -0
- package/dist/kit/palette.d.ts +9 -0
- package/dist/kit/palette.d.ts.map +1 -0
- package/dist/kit/palette.js +38 -0
- package/dist/kit/palette.js.map +1 -0
- package/dist/kit/server/feed.d.ts +41 -0
- package/dist/kit/server/feed.d.ts.map +1 -0
- package/dist/kit/server/feed.js +210 -0
- package/dist/kit/server/feed.js.map +1 -0
- package/dist/kit/server/index.d.ts +3 -0
- package/dist/kit/server/index.d.ts.map +1 -0
- package/dist/kit/server/index.js +3 -0
- package/dist/kit/server/index.js.map +1 -0
- package/dist/kit/shared.d.ts +26 -0
- package/dist/kit/shared.d.ts.map +1 -0
- package/dist/kit/shared.js +8 -0
- package/dist/kit/shared.js.map +1 -0
- package/dist/node/pack.d.ts +2 -0
- package/dist/node/pack.d.ts.map +1 -0
- package/dist/node/pack.js +2 -0
- package/dist/node/pack.js.map +1 -0
- package/package.json +60 -4
- package/src/contract/client.ts +1 -0
- package/src/index.ts +45 -0
- package/src/kit/client/chat.ts +199 -0
- package/src/kit/client/feed.ts +170 -0
- package/src/kit/client/index.ts +33 -0
- package/src/kit/client/scoreboard.ts +333 -0
- package/src/kit/palette.ts +44 -0
- package/src/kit/server/feed.ts +221 -0
- package/src/kit/server/index.ts +2 -0
- package/src/kit/shared.ts +28 -0
- package/src/node/pack.ts +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** 游戏事件流的一条公开记录:玩法文本由游戏填,平台只搬运。 */
|
|
2
|
+
export interface FeedEntry {
|
|
3
|
+
seq: number;
|
|
4
|
+
at: number;
|
|
5
|
+
type: string;
|
|
6
|
+
text: string;
|
|
7
|
+
actor?: string;
|
|
8
|
+
target?: string;
|
|
9
|
+
data?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
/** 事件对谁可见;team 需要 FeedLog 提供 seatTeams。 */
|
|
12
|
+
export type FeedVisibility = 'all' | {
|
|
13
|
+
seatId: string;
|
|
14
|
+
} | {
|
|
15
|
+
team: string | number;
|
|
16
|
+
};
|
|
17
|
+
export declare const FEED_EVENT = "bp.feed";
|
|
18
|
+
export declare class FeedError extends Error {
|
|
19
|
+
constructor(message: string);
|
|
20
|
+
}
|
|
21
|
+
export interface SeatLike {
|
|
22
|
+
seatId: string;
|
|
23
|
+
slot?: number;
|
|
24
|
+
team?: string | number | null;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=shared.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/kit/shared.ts"],"names":[],"mappings":"AAAA,mCAAmC;AACnC,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,2CAA2C;AAC3C,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,CAAC;AAEpF,eAAO,MAAM,UAAU,YAAY,CAAC;AAEpC,qBAAa,SAAU,SAAQ,KAAK;gBACtB,OAAO,EAAE,MAAM;CAI5B;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CAC/B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared.js","sourceRoot":"","sources":["../../src/kit/shared.ts"],"names":[],"mappings":"AAcA,MAAM,CAAC,MAAM,UAAU,GAAG,SAAS,CAAC;AAEpC,MAAM,OAAO,SAAU,SAAQ,KAAK;IAClC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC;IAC1B,CAAC;CACF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pack.d.ts","sourceRoot":"","sources":["../../src/node/pack.ts"],"names":[],"mappings":"AAAA,cAAc,0BAA0B,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pack.js","sourceRoot":"","sources":["../../src/node/pack.ts"],"names":[],"mappings":"AAAA,cAAc,0BAA0B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lizardon386/game-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "3178 游戏接入 SDK:游戏 ABI、客户端契约、单文件包和 game-kit",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "3178-battle-platform contributors",
|
|
7
|
+
"homepage": "https://github.com/Lizardon386/3178-battle-platform#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/Lizardon386/3178-battle-platform/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/Lizardon386/3178-battle-platform.git",
|
|
14
|
+
"directory": "packages/game-sdk"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20.19.0"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"3178",
|
|
21
|
+
"game-platform",
|
|
22
|
+
"game-sdk",
|
|
23
|
+
"game-kit"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "dist/index.js",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./client": {
|
|
34
|
+
"types": "./dist/contract/client.d.ts",
|
|
35
|
+
"default": "./dist/contract/client.js"
|
|
36
|
+
},
|
|
37
|
+
"./node": {
|
|
38
|
+
"types": "./dist/node/pack.d.ts",
|
|
39
|
+
"default": "./dist/node/pack.js"
|
|
40
|
+
},
|
|
41
|
+
"./kit": {
|
|
42
|
+
"types": "./dist/kit/client/index.d.ts",
|
|
43
|
+
"default": "./dist/kit/client/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./kit/server": {
|
|
46
|
+
"types": "./dist/kit/server/index.d.ts",
|
|
47
|
+
"default": "./dist/kit/server/index.js"
|
|
48
|
+
},
|
|
49
|
+
"./package.json": "./package.json"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@lizardon386/core-abi": "^0.3.0",
|
|
53
|
+
"@lizardon386/pack-format": "^0.3.0"
|
|
54
|
+
},
|
|
55
|
+
"files": [
|
|
56
|
+
"dist",
|
|
57
|
+
"src"
|
|
58
|
+
],
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { GameActions, GameClientContract } from '@lizardon386/core-abi';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
Effect,
|
|
3
|
+
GameAction,
|
|
4
|
+
GameAsset,
|
|
5
|
+
GameContext,
|
|
6
|
+
GameInstance,
|
|
7
|
+
GameManifest,
|
|
8
|
+
GameMode,
|
|
9
|
+
GameModule,
|
|
10
|
+
GameReferenceDocument,
|
|
11
|
+
GameReferenceGroup,
|
|
12
|
+
GameReferenceImage,
|
|
13
|
+
GameReferenceItem,
|
|
14
|
+
GameReferenceParseResult,
|
|
15
|
+
GameReferenceSpec,
|
|
16
|
+
GameSeatInfo,
|
|
17
|
+
GameUiOptions,
|
|
18
|
+
NoticeCode,
|
|
19
|
+
Outcome,
|
|
20
|
+
PlatformCode,
|
|
21
|
+
Reject,
|
|
22
|
+
RoomPhase,
|
|
23
|
+
RuleConfigField,
|
|
24
|
+
RuleConfigParseResult,
|
|
25
|
+
RuleConfigSpec,
|
|
26
|
+
SeatRole,
|
|
27
|
+
SeatView,
|
|
28
|
+
ViewerId,
|
|
29
|
+
} from '@lizardon386/core-abi';
|
|
30
|
+
export {
|
|
31
|
+
API_VERSION,
|
|
32
|
+
DEFAULT_MODE,
|
|
33
|
+
GAME_MODES,
|
|
34
|
+
GAME_REFERENCE_LIMITS,
|
|
35
|
+
RULE_CONFIG_LIMITS,
|
|
36
|
+
SUPPORTED_API_VERSIONS,
|
|
37
|
+
SUPPORTED_MODES,
|
|
38
|
+
isReject,
|
|
39
|
+
isValidGameReferenceEntry,
|
|
40
|
+
isValidPackRelativePath,
|
|
41
|
+
parseGameReference,
|
|
42
|
+
parseRuleConfig,
|
|
43
|
+
reject,
|
|
44
|
+
stableDigest,
|
|
45
|
+
} from '@lizardon386/core-abi';
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
export interface ChatLine {
|
|
2
|
+
seatId: string;
|
|
3
|
+
name: string;
|
|
4
|
+
text: string;
|
|
5
|
+
at: number;
|
|
6
|
+
kind: 'say' | 'system';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ChatPanelOptions {
|
|
10
|
+
mount?: HTMLElement;
|
|
11
|
+
max?: number;
|
|
12
|
+
maxText?: number;
|
|
13
|
+
showSystem?: boolean;
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
sendLabel?: string;
|
|
16
|
+
/** 覆盖默认发送方式;不填时在 iframe 里 parent.postMessage。 */
|
|
17
|
+
onSend?: (text: string) => void;
|
|
18
|
+
/** 新到的消息回调;初始历史不会触发。 */
|
|
19
|
+
onLine?: (line: ChatLine) => void;
|
|
20
|
+
/** 是否监听 window 的 bp:chat-history / bp:chat;有 mount 时默认开。 */
|
|
21
|
+
listen?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_CHAT_TEXT = 300;
|
|
25
|
+
|
|
26
|
+
function clamp(value: number | undefined, min: number, max: number, fallback: number): number {
|
|
27
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
28
|
+
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function chatText(value: string): string | null {
|
|
32
|
+
const text = value.trim();
|
|
33
|
+
if (text.length < 1 || text.length > MAX_CHAT_TEXT) return null;
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function chatLineKey(line: ChatLine): string {
|
|
38
|
+
return `${line.at}|${line.seatId}|${line.kind}|${line.text}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeChatLine(value: unknown): ChatLine | null {
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
43
|
+
const raw = value as Record<string, unknown>;
|
|
44
|
+
if (typeof raw.seatId !== 'string' || typeof raw.name !== 'string') return null;
|
|
45
|
+
if (typeof raw.text !== 'string' || typeof raw.at !== 'number' || !Number.isFinite(raw.at)) return null;
|
|
46
|
+
if (raw.kind !== 'say' && raw.kind !== 'system') return null;
|
|
47
|
+
const text = raw.text.trim();
|
|
48
|
+
if (!text || text.length > MAX_CHAT_TEXT) return null;
|
|
49
|
+
return { seatId: raw.seatId, name: raw.name, text, at: raw.at, kind: raw.kind };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function mergeChatLines(current: readonly ChatLine[], incoming: readonly ChatLine[], max: number): ChatLine[] {
|
|
53
|
+
const byKey = new Map<string, ChatLine>();
|
|
54
|
+
for (const line of current) byKey.set(chatLineKey(line), line);
|
|
55
|
+
for (const line of incoming) byKey.set(chatLineKey(line), line);
|
|
56
|
+
return [...byKey.values()].sort((a, b) => a.at - b.at).slice(-Math.max(1, max));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 房间聊天面板:历史以平台推送的 bp:chat-history 为准,发送只发纯文本。 */
|
|
60
|
+
export class ChatPanel {
|
|
61
|
+
private readonly options: ChatPanelOptions;
|
|
62
|
+
private readonly max: number;
|
|
63
|
+
private readonly maxText: number;
|
|
64
|
+
private readonly root: HTMLDivElement | null = null;
|
|
65
|
+
private readonly log: HTMLOListElement | null = null;
|
|
66
|
+
private readonly input: HTMLInputElement | null = null;
|
|
67
|
+
private readonly form: HTMLFormElement | null = null;
|
|
68
|
+
private readonly onChatHistory: ((event: Event) => void) | null = null;
|
|
69
|
+
private readonly onChatLine: ((event: Event) => void) | null = null;
|
|
70
|
+
private lines: ChatLine[] = [];
|
|
71
|
+
|
|
72
|
+
constructor(options: ChatPanelOptions = {}) {
|
|
73
|
+
this.options = options;
|
|
74
|
+
this.max = clamp(options.max, 1, 500, 200);
|
|
75
|
+
this.maxText = clamp(options.maxText, 1, MAX_CHAT_TEXT, MAX_CHAT_TEXT);
|
|
76
|
+
if (options.mount) {
|
|
77
|
+
const doc = options.mount.ownerDocument;
|
|
78
|
+
const root = doc.createElement('div');
|
|
79
|
+
root.className = 'bp-chat';
|
|
80
|
+
root.style.cssText = 'display:flex;flex-direction:column;min-height:0;font:13px/1.5 system-ui, "Microsoft YaHei UI", "Microsoft YaHei", sans-serif';
|
|
81
|
+
const log = doc.createElement('ol');
|
|
82
|
+
log.className = 'bp-chat-log';
|
|
83
|
+
log.setAttribute('role', 'log');
|
|
84
|
+
log.setAttribute('aria-live', 'polite');
|
|
85
|
+
log.style.cssText = 'margin:0;padding:4px 8px;list-style:none;overflow-y:auto;flex:1 1 auto;min-height:120px;max-height:260px';
|
|
86
|
+
const form = doc.createElement('form');
|
|
87
|
+
form.className = 'bp-chat-form';
|
|
88
|
+
form.style.cssText = 'display:flex;gap:6px;padding:6px 0 0';
|
|
89
|
+
const input = doc.createElement('input');
|
|
90
|
+
input.className = 'bp-chat-input';
|
|
91
|
+
input.type = 'text';
|
|
92
|
+
input.maxLength = MAX_CHAT_TEXT;
|
|
93
|
+
input.autocomplete = 'off';
|
|
94
|
+
input.placeholder = options.placeholder ?? '说点什么';
|
|
95
|
+
input.style.cssText = 'flex:1 1 auto;min-width:0;padding:4px 6px';
|
|
96
|
+
const button = doc.createElement('button');
|
|
97
|
+
button.className = 'bp-chat-send';
|
|
98
|
+
button.type = 'submit';
|
|
99
|
+
button.textContent = options.sendLabel ?? '发送';
|
|
100
|
+
button.style.cssText = 'flex:none;padding:4px 10px';
|
|
101
|
+
form.append(input, button);
|
|
102
|
+
root.append(log, form);
|
|
103
|
+
options.mount.replaceChildren(root);
|
|
104
|
+
this.root = root;
|
|
105
|
+
this.log = log;
|
|
106
|
+
this.input = input;
|
|
107
|
+
this.form = form;
|
|
108
|
+
form.addEventListener('submit', (event) => {
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
this.send();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (options.listen ?? Boolean(options.mount)) {
|
|
115
|
+
if (typeof window !== 'undefined') {
|
|
116
|
+
this.onChatHistory = (event: Event) => {
|
|
117
|
+
const lines = (event as CustomEvent).detail;
|
|
118
|
+
if (Array.isArray(lines)) this.setLines(lines as ChatLine[]);
|
|
119
|
+
};
|
|
120
|
+
this.onChatLine = (event: Event) => {
|
|
121
|
+
this.append((event as CustomEvent).detail as ChatLine);
|
|
122
|
+
};
|
|
123
|
+
window.addEventListener('bp:chat-history', this.onChatHistory);
|
|
124
|
+
window.addEventListener('bp:chat', this.onChatLine);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
setLines(lines: readonly ChatLine[]): void {
|
|
130
|
+
const normalized = lines.map(normalizeChatLine).filter((line): line is ChatLine => line !== null);
|
|
131
|
+
this.lines = mergeChatLines([], normalized, this.max);
|
|
132
|
+
this.render();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
append(line: ChatLine): void {
|
|
136
|
+
const normalized = normalizeChatLine(line);
|
|
137
|
+
if (!normalized) return;
|
|
138
|
+
if (this.lines.some((item) => chatLineKey(item) === chatLineKey(normalized))) return;
|
|
139
|
+
this.lines = mergeChatLines(this.lines, [normalized], this.max);
|
|
140
|
+
this.render();
|
|
141
|
+
this.options.onLine?.(normalized);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
send(): void {
|
|
145
|
+
const input = this.input;
|
|
146
|
+
if (!input) return;
|
|
147
|
+
const text = chatText(input.value);
|
|
148
|
+
if (!text) return;
|
|
149
|
+
if (this.options.onSend) {
|
|
150
|
+
this.options.onSend(text);
|
|
151
|
+
} else if (typeof parent !== 'undefined' && parent !== window) {
|
|
152
|
+
parent.postMessage({ type: 'chat', text }, '*');
|
|
153
|
+
}
|
|
154
|
+
input.value = '';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
clear(): void {
|
|
158
|
+
this.lines = [];
|
|
159
|
+
this.render();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
destroy(): void {
|
|
163
|
+
if (typeof window !== 'undefined') {
|
|
164
|
+
if (this.onChatHistory) window.removeEventListener('bp:chat-history', this.onChatHistory);
|
|
165
|
+
if (this.onChatLine) window.removeEventListener('bp:chat', this.onChatLine);
|
|
166
|
+
}
|
|
167
|
+
this.options.mount?.replaceChildren();
|
|
168
|
+
this.lines = [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private render(): void {
|
|
172
|
+
const log = this.log;
|
|
173
|
+
if (!log) return;
|
|
174
|
+
const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 8;
|
|
175
|
+
const allowed = this.options.showSystem === false ? this.lines.filter((line) => line.kind !== 'system') : this.lines;
|
|
176
|
+
log.replaceChildren();
|
|
177
|
+
for (const line of allowed) {
|
|
178
|
+
const item = log.ownerDocument.createElement('li');
|
|
179
|
+
item.className = `bp-chat-line bp-chat-${line.kind}`;
|
|
180
|
+
item.dataset.seatId = line.seatId;
|
|
181
|
+
item.style.cssText = 'padding:1px 0;overflow-wrap:anywhere';
|
|
182
|
+
if (line.kind === 'system') {
|
|
183
|
+
item.style.opacity = '0.65';
|
|
184
|
+
item.textContent = line.text.length > this.maxText ? `${line.text.slice(0, this.maxText)}…` : line.text;
|
|
185
|
+
} else {
|
|
186
|
+
const name = log.ownerDocument.createElement('span');
|
|
187
|
+
name.className = 'bp-chat-name';
|
|
188
|
+
name.textContent = `${line.name}:`;
|
|
189
|
+
name.style.cssText = 'font-weight:600;margin-right:2px';
|
|
190
|
+
const text = log.ownerDocument.createElement('span');
|
|
191
|
+
text.className = 'bp-chat-text';
|
|
192
|
+
text.textContent = line.text.length > this.maxText ? `${line.text.slice(0, this.maxText)}…` : line.text;
|
|
193
|
+
item.append(name, text);
|
|
194
|
+
}
|
|
195
|
+
log.append(item);
|
|
196
|
+
}
|
|
197
|
+
if (nearBottom) log.scrollTop = log.scrollHeight;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { FEED_EVENT, type FeedEntry } from '../shared.js';
|
|
2
|
+
|
|
3
|
+
export interface FeedEvent {
|
|
4
|
+
name: string;
|
|
5
|
+
payload?: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface FeedPanelOptions {
|
|
9
|
+
mount?: HTMLElement;
|
|
10
|
+
max?: number;
|
|
11
|
+
/** 只显示这些 type;不填表示全部。 */
|
|
12
|
+
types?: readonly string[];
|
|
13
|
+
maxText?: number;
|
|
14
|
+
emptyText?: string;
|
|
15
|
+
timeFormat?: (at: number) => string;
|
|
16
|
+
/** 给 actor 上色;不填则用默认文本色。 */
|
|
17
|
+
colorOf?: (seatId: string) => string | null;
|
|
18
|
+
/** 只给新到的事件回调;初始历史不会触发。 */
|
|
19
|
+
onEntry?: (entry: FeedEntry) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function clamp(value: number | undefined, min: number, max: number, fallback: number): number {
|
|
23
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
24
|
+
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatFeedTime(at: number): string {
|
|
28
|
+
const date = new Date(at);
|
|
29
|
+
const pad = (value: number): string => String(value).padStart(2, '0');
|
|
30
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function normalizeFeedEntry(value: unknown): FeedEntry | null {
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
35
|
+
const raw = value as Record<string, unknown>;
|
|
36
|
+
if (!Number.isSafeInteger(raw.seq) || (raw.seq as number) < 1) return null;
|
|
37
|
+
if (typeof raw.at !== 'number' || !Number.isFinite(raw.at)) return null;
|
|
38
|
+
if (typeof raw.type !== 'string' || raw.type.trim().length === 0) return null;
|
|
39
|
+
if (typeof raw.text !== 'string' || raw.text.trim().length === 0) return null;
|
|
40
|
+
if (raw.actor !== undefined && typeof raw.actor !== 'string') return null;
|
|
41
|
+
if (raw.target !== undefined && typeof raw.target !== 'string') return null;
|
|
42
|
+
if (raw.data !== undefined && (typeof raw.data !== 'object' || raw.data === null || Array.isArray(raw.data))) return null;
|
|
43
|
+
const text = raw.text.trim();
|
|
44
|
+
if (text.length === 0) return null;
|
|
45
|
+
const entry: FeedEntry = {
|
|
46
|
+
seq: raw.seq as number,
|
|
47
|
+
at: raw.at,
|
|
48
|
+
type: raw.type.trim(),
|
|
49
|
+
text,
|
|
50
|
+
};
|
|
51
|
+
if (raw.actor !== undefined) entry.actor = raw.actor;
|
|
52
|
+
if (raw.target !== undefined) entry.target = raw.target;
|
|
53
|
+
if (raw.data !== undefined) entry.data = raw.data as Record<string, unknown>;
|
|
54
|
+
return entry;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 按 seq 合并,保留最近的 max 条;重复帧不会重复显示。 */
|
|
58
|
+
export function mergeFeedEntries(
|
|
59
|
+
current: readonly FeedEntry[],
|
|
60
|
+
incoming: readonly FeedEntry[],
|
|
61
|
+
max: number,
|
|
62
|
+
): FeedEntry[] {
|
|
63
|
+
const bySeq = new Map<number, FeedEntry>();
|
|
64
|
+
for (const entry of current) bySeq.set(entry.seq, entry);
|
|
65
|
+
for (const entry of incoming) bySeq.set(entry.seq, entry);
|
|
66
|
+
return [...bySeq.values()].sort((a, b) => a.seq - b.seq).slice(-Math.max(1, max));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 渲染无关的事件流面板;不挂 mount 时只跑回调和状态。 */
|
|
70
|
+
export class FeedPanel {
|
|
71
|
+
private readonly options: FeedPanelOptions;
|
|
72
|
+
private readonly max: number;
|
|
73
|
+
private readonly maxText: number;
|
|
74
|
+
private readonly list: HTMLOListElement | null = null;
|
|
75
|
+
private entries: FeedEntry[] = [];
|
|
76
|
+
|
|
77
|
+
constructor(options: FeedPanelOptions = {}) {
|
|
78
|
+
this.options = options;
|
|
79
|
+
this.max = clamp(options.max, 1, 500, 100);
|
|
80
|
+
this.maxText = clamp(options.maxText, 1, 2_000, 240);
|
|
81
|
+
if (options.mount) {
|
|
82
|
+
const list = options.mount.ownerDocument.createElement('ol');
|
|
83
|
+
list.className = 'bp-feed';
|
|
84
|
+
list.setAttribute('role', 'log');
|
|
85
|
+
list.setAttribute('aria-live', 'polite');
|
|
86
|
+
list.setAttribute('aria-relevant', 'additions');
|
|
87
|
+
list.style.cssText = [
|
|
88
|
+
'margin:0',
|
|
89
|
+
'padding:2px 8px',
|
|
90
|
+
'max-height:240px',
|
|
91
|
+
'overflow-y:auto',
|
|
92
|
+
'list-style:none',
|
|
93
|
+
'font:12px/1.6 system-ui, "Microsoft YaHei UI", "Microsoft YaHei", sans-serif',
|
|
94
|
+
].join(';');
|
|
95
|
+
options.mount.replaceChildren(list);
|
|
96
|
+
this.list = list;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setEntries(entries: readonly FeedEntry[]): void {
|
|
101
|
+
const normalized = entries.map(normalizeFeedEntry).filter((entry): entry is FeedEntry => entry !== null);
|
|
102
|
+
this.entries = mergeFeedEntries([], normalized, this.max);
|
|
103
|
+
this.render();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
append(entry: FeedEntry): void {
|
|
107
|
+
const normalized = normalizeFeedEntry(entry);
|
|
108
|
+
if (!normalized || this.entries.some((item) => item.seq === normalized.seq)) return;
|
|
109
|
+
this.entries = mergeFeedEntries(this.entries, [normalized], this.max);
|
|
110
|
+
this.render();
|
|
111
|
+
this.options.onEntry?.(normalized);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 直接接 GameClient.onEvent;不是 bp.feed 时返回 false。 */
|
|
115
|
+
handleEvent(event: FeedEvent): boolean {
|
|
116
|
+
if (event.name !== FEED_EVENT) return false;
|
|
117
|
+
const payloads = Array.isArray(event.payload) ? event.payload : [event.payload];
|
|
118
|
+
for (const payload of payloads) this.append(payload as FeedEntry);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
clear(): void {
|
|
123
|
+
this.entries = [];
|
|
124
|
+
this.render();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
destroy(): void {
|
|
128
|
+
this.options.mount?.replaceChildren();
|
|
129
|
+
this.entries = [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private render(): void {
|
|
133
|
+
const list = this.list;
|
|
134
|
+
if (!list) return;
|
|
135
|
+
const nearBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 8;
|
|
136
|
+
const allowed = this.options.types ? new Set(this.options.types) : null;
|
|
137
|
+
const visible = this.entries.filter((entry) => !allowed || allowed.has(entry.type));
|
|
138
|
+
list.replaceChildren();
|
|
139
|
+
if (visible.length === 0) {
|
|
140
|
+
const empty = list.ownerDocument.createElement('li');
|
|
141
|
+
empty.className = 'bp-feed-empty';
|
|
142
|
+
empty.textContent = this.options.emptyText ?? '暂无事件';
|
|
143
|
+
empty.style.cssText = 'padding:4px 0;opacity:.55';
|
|
144
|
+
list.append(empty);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for (const entry of visible) {
|
|
148
|
+
const item = list.ownerDocument.createElement('li');
|
|
149
|
+
item.className = 'bp-feed-item';
|
|
150
|
+
item.dataset.type = entry.type;
|
|
151
|
+
item.style.cssText = 'display:flex;gap:8px;padding:1px 0;align-items:baseline';
|
|
152
|
+
const time = list.ownerDocument.createElement('time');
|
|
153
|
+
time.className = 'bp-feed-time';
|
|
154
|
+
time.dateTime = new Date(entry.at).toISOString();
|
|
155
|
+
time.textContent = (this.options.timeFormat ?? formatFeedTime)(entry.at);
|
|
156
|
+
time.style.cssText = 'flex:none;opacity:.55;font-variant-numeric:tabular-nums';
|
|
157
|
+
const text = list.ownerDocument.createElement('span');
|
|
158
|
+
text.className = 'bp-feed-text';
|
|
159
|
+
text.textContent = entry.text.length > this.maxText ? `${entry.text.slice(0, this.maxText)}…` : entry.text;
|
|
160
|
+
text.style.cssText = 'min-width:0;overflow-wrap:anywhere';
|
|
161
|
+
if (entry.actor && this.options.colorOf) {
|
|
162
|
+
const color = this.options.colorOf(entry.actor);
|
|
163
|
+
if (color) text.style.color = color;
|
|
164
|
+
}
|
|
165
|
+
item.append(time, text);
|
|
166
|
+
list.append(item);
|
|
167
|
+
}
|
|
168
|
+
if (nearBottom) list.scrollTop = list.scrollHeight;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export {
|
|
2
|
+
FeedPanel,
|
|
3
|
+
formatFeedTime,
|
|
4
|
+
mergeFeedEntries,
|
|
5
|
+
normalizeFeedEntry,
|
|
6
|
+
type FeedEvent,
|
|
7
|
+
type FeedPanelOptions,
|
|
8
|
+
} from './feed.js';
|
|
9
|
+
export {
|
|
10
|
+
ChatPanel,
|
|
11
|
+
chatLineKey,
|
|
12
|
+
chatText,
|
|
13
|
+
mergeChatLines,
|
|
14
|
+
normalizeChatLine,
|
|
15
|
+
type ChatLine,
|
|
16
|
+
type ChatPanelOptions,
|
|
17
|
+
} from './chat.js';
|
|
18
|
+
export {
|
|
19
|
+
ScoreboardPanel,
|
|
20
|
+
buildScoreboardRows,
|
|
21
|
+
connectionLabel,
|
|
22
|
+
formatColumnValue,
|
|
23
|
+
normalizeScoreboardRows,
|
|
24
|
+
resolveColumnValue,
|
|
25
|
+
sortScoreboardRows,
|
|
26
|
+
type ScoreboardColumn,
|
|
27
|
+
type ScoreboardPanelOptions,
|
|
28
|
+
type ScoreboardRoom,
|
|
29
|
+
type ScoreboardRow,
|
|
30
|
+
type ScoreboardSort,
|
|
31
|
+
} from './scoreboard.js';
|
|
32
|
+
export { seatColor, type SeatColor } from '../palette.js';
|
|
33
|
+
export { FEED_EVENT, FeedError, type FeedEntry, type FeedVisibility, type SeatLike } from '../shared.js';
|