@lizardon386/game-sdk 0.0.0-stage → 0.2.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/contract/module-api.d.ts +104 -0
- package/dist/contract/module-api.d.ts.map +1 -0
- package/dist/contract/module-api.js +22 -0
- package/dist/contract/module-api.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 +30 -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,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';
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import type { RoomPhase, SeatView } from '@lizardon386/core-abi';
|
|
2
|
+
import { seatColor } from '../palette.js';
|
|
3
|
+
|
|
4
|
+
export interface ScoreboardRow {
|
|
5
|
+
seatId: string;
|
|
6
|
+
values: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ScoreboardColumn {
|
|
10
|
+
key: string;
|
|
11
|
+
label: string;
|
|
12
|
+
value?: string | ((row: ScoreboardRow) => unknown);
|
|
13
|
+
format?: (value: unknown, row: ScoreboardRow) => string;
|
|
14
|
+
/** 自定义单元格节点;返回 null 时退回 format。 */
|
|
15
|
+
render?: (value: unknown, row: ScoreboardRow) => Node | null;
|
|
16
|
+
sortValue?: (row: ScoreboardRow) => number | string;
|
|
17
|
+
sortable?: boolean;
|
|
18
|
+
align?: 'left' | 'center' | 'right';
|
|
19
|
+
width?: string | number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ScoreboardRoom {
|
|
23
|
+
code: string;
|
|
24
|
+
phase: RoomPhase | 'none';
|
|
25
|
+
seats: SeatView[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ScoreboardSort {
|
|
29
|
+
key: string;
|
|
30
|
+
dir: 'asc' | 'desc';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ScoreboardPanelOptions {
|
|
34
|
+
mount?: HTMLElement;
|
|
35
|
+
columns: readonly ScoreboardColumn[];
|
|
36
|
+
rows?: readonly ScoreboardRow[];
|
|
37
|
+
seats?: readonly SeatView[];
|
|
38
|
+
showTeam?: boolean;
|
|
39
|
+
showConnection?: boolean;
|
|
40
|
+
showHost?: boolean;
|
|
41
|
+
emptyText?: string;
|
|
42
|
+
sort?: ScoreboardSort;
|
|
43
|
+
/** 是否监听 window 的 bp:room;有 mount 时默认开。 */
|
|
44
|
+
listen?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeScoreboardRows(rows: readonly unknown[]): ScoreboardRow[] {
|
|
48
|
+
const out: ScoreboardRow[] = [];
|
|
49
|
+
for (const value of rows) {
|
|
50
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
51
|
+
const raw = value as Record<string, unknown>;
|
|
52
|
+
if (typeof raw.seatId !== 'string' || raw.seatId.length === 0) continue;
|
|
53
|
+
const values = raw.values;
|
|
54
|
+
if (values !== undefined && (typeof values !== 'object' || values === null || Array.isArray(values))) continue;
|
|
55
|
+
out.push({ seatId: raw.seatId, values: (values as Record<string, unknown> | undefined) ?? {} });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveColumnValue(column: ScoreboardColumn, row: ScoreboardRow): unknown {
|
|
61
|
+
if (typeof column.value === 'function') return column.value(row);
|
|
62
|
+
if (typeof column.value === 'string') return row.values[column.value];
|
|
63
|
+
return row.values[column.key];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatColumnValue(column: ScoreboardColumn, row: ScoreboardRow): string {
|
|
67
|
+
const value = resolveColumnValue(column, row);
|
|
68
|
+
if (column.format) return column.format(value, row);
|
|
69
|
+
return value === null || value === undefined ? '—' : String(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function compareValues(a: unknown, b: unknown): number {
|
|
73
|
+
if (a === b) return 0;
|
|
74
|
+
if (a === null || a === undefined) return 1;
|
|
75
|
+
if (b === null || b === undefined) return -1;
|
|
76
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
77
|
+
return String(a).localeCompare(String(b), 'zh-CN');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function sortScoreboardRows(
|
|
81
|
+
rows: readonly ScoreboardRow[],
|
|
82
|
+
columns: readonly ScoreboardColumn[],
|
|
83
|
+
sort: ScoreboardSort | undefined,
|
|
84
|
+
): ScoreboardRow[] {
|
|
85
|
+
if (!sort) return [...rows];
|
|
86
|
+
const column = columns.find((item) => item.key === sort.key);
|
|
87
|
+
if (!column) return [...rows];
|
|
88
|
+
const direction = sort.dir === 'desc' ? -1 : 1;
|
|
89
|
+
return [...rows].sort((a, b) => {
|
|
90
|
+
const left = column.sortValue ? column.sortValue(a) : resolveColumnValue(column, a);
|
|
91
|
+
const right = column.sortValue ? column.sortValue(b) : resolveColumnValue(column, b);
|
|
92
|
+
const leftMissing = left === null || left === undefined;
|
|
93
|
+
const rightMissing = right === null || right === undefined;
|
|
94
|
+
if (leftMissing || rightMissing) return leftMissing === rightMissing ? 0 : leftMissing ? 1 : -1;
|
|
95
|
+
return compareValues(left, right) * direction;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 座位表是主列表:没有游戏行的玩家也显示;有行但没座位的排在后面。 */
|
|
100
|
+
export function buildScoreboardRows(
|
|
101
|
+
rows: readonly ScoreboardRow[],
|
|
102
|
+
seats: readonly SeatView[],
|
|
103
|
+
): ScoreboardRow[] {
|
|
104
|
+
const bySeat = new Map(rows.map((row) => [row.seatId, row]));
|
|
105
|
+
const out: ScoreboardRow[] = [];
|
|
106
|
+
for (const seat of [...seats].sort((a, b) => a.slot - b.slot)) {
|
|
107
|
+
out.push(bySeat.get(seat.seatId) ?? { seatId: seat.seatId, values: {} });
|
|
108
|
+
bySeat.delete(seat.seatId);
|
|
109
|
+
}
|
|
110
|
+
for (const row of rows) {
|
|
111
|
+
if (bySeat.has(row.seatId)) out.push(row);
|
|
112
|
+
bySeat.delete(row.seatId);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function teamText(team: string | number | null): string {
|
|
118
|
+
return team === null || team === undefined ? '—' : String(team);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function connectionLabel(seat: SeatView | undefined): string {
|
|
122
|
+
if (!seat) return '—';
|
|
123
|
+
if (seat.role === 'ai') return '电脑';
|
|
124
|
+
if (seat.role === 'spectator') return '观战';
|
|
125
|
+
return seat.connected ? '在线' : '离线';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function styleCell(cell: HTMLTableCellElement, align: 'left' | 'center' | 'right' | undefined, width: string | number | undefined): void {
|
|
129
|
+
cell.style.padding = '6px 8px';
|
|
130
|
+
cell.style.borderTop = '1px solid rgba(127,127,127,.22)';
|
|
131
|
+
cell.style.textAlign = align ?? 'left';
|
|
132
|
+
if (width !== undefined) cell.style.width = typeof width === 'number' ? `${width}px` : width;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 记分板只提供壳:列和值完全由游戏定义,平台只补座位元数据。 */
|
|
136
|
+
export class ScoreboardPanel {
|
|
137
|
+
private readonly options: ScoreboardPanelOptions;
|
|
138
|
+
private readonly columns: ScoreboardColumn[];
|
|
139
|
+
private readonly root: HTMLDivElement | null = null;
|
|
140
|
+
private readonly head: HTMLTableSectionElement | null = null;
|
|
141
|
+
private readonly body: HTMLTableSectionElement | null = null;
|
|
142
|
+
private readonly onRoom: ((event: Event) => void) | null = null;
|
|
143
|
+
private rows: ScoreboardRow[] = [];
|
|
144
|
+
private seats: SeatView[] = [];
|
|
145
|
+
private sort: ScoreboardSort | undefined;
|
|
146
|
+
|
|
147
|
+
constructor(options: ScoreboardPanelOptions) {
|
|
148
|
+
this.options = options;
|
|
149
|
+
this.columns = options.columns.filter((column) => typeof column.key === 'string' && column.key.length > 0);
|
|
150
|
+
this.rows = normalizeScoreboardRows(options.rows ?? []);
|
|
151
|
+
this.seats = [...(options.seats ?? [])];
|
|
152
|
+
this.sort = options.sort;
|
|
153
|
+
if (options.mount) {
|
|
154
|
+
const doc = options.mount.ownerDocument;
|
|
155
|
+
const root = doc.createElement('div');
|
|
156
|
+
root.className = 'bp-scoreboard';
|
|
157
|
+
root.style.cssText = 'overflow-x:auto;font:13px/1.5 system-ui, "Microsoft YaHei UI", "Microsoft YaHei", sans-serif';
|
|
158
|
+
const table = doc.createElement('table');
|
|
159
|
+
table.className = 'bp-scoreboard-table';
|
|
160
|
+
table.setAttribute('aria-label', '记分板');
|
|
161
|
+
table.style.cssText = 'width:100%;border-collapse:collapse;min-width:360px';
|
|
162
|
+
const head = doc.createElement('thead');
|
|
163
|
+
const body = doc.createElement('tbody');
|
|
164
|
+
table.append(head, body);
|
|
165
|
+
root.append(table);
|
|
166
|
+
options.mount.replaceChildren(root);
|
|
167
|
+
this.root = root;
|
|
168
|
+
this.head = head;
|
|
169
|
+
this.body = body;
|
|
170
|
+
}
|
|
171
|
+
if (options.listen ?? Boolean(options.mount)) {
|
|
172
|
+
if (typeof window !== 'undefined') {
|
|
173
|
+
this.onRoom = (event: Event) => {
|
|
174
|
+
const room = (event as CustomEvent).detail as ScoreboardRoom | null;
|
|
175
|
+
this.seats = room && Array.isArray(room.seats) ? [...room.seats] : [];
|
|
176
|
+
this.render();
|
|
177
|
+
};
|
|
178
|
+
window.addEventListener('bp:room', this.onRoom);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.render();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setRows(rows: readonly ScoreboardRow[]): void {
|
|
185
|
+
this.rows = normalizeScoreboardRows(rows);
|
|
186
|
+
this.render();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
setSeats(seats: readonly SeatView[]): void {
|
|
190
|
+
this.seats = [...seats];
|
|
191
|
+
this.render();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setRoom(room: ScoreboardRoom | null): void {
|
|
195
|
+
this.setSeats(room?.seats ?? []);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
setSort(sort: ScoreboardSort | undefined): void {
|
|
199
|
+
this.sort = sort;
|
|
200
|
+
this.render();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
clear(): void {
|
|
204
|
+
this.rows = [];
|
|
205
|
+
this.seats = [];
|
|
206
|
+
this.render();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
destroy(): void {
|
|
210
|
+
if (typeof window !== 'undefined' && this.onRoom) window.removeEventListener('bp:room', this.onRoom);
|
|
211
|
+
this.options.mount?.replaceChildren();
|
|
212
|
+
this.rows = [];
|
|
213
|
+
this.seats = [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private toggleSort(key: string): void {
|
|
217
|
+
const current = this.sort;
|
|
218
|
+
this.sort = current?.key === key
|
|
219
|
+
? { key, dir: current.dir === 'asc' ? 'desc' : 'asc' }
|
|
220
|
+
: { key, dir: 'asc' };
|
|
221
|
+
this.render();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private render(): void {
|
|
225
|
+
const head = this.head;
|
|
226
|
+
const body = this.body;
|
|
227
|
+
if (!head || !body || !this.root) return;
|
|
228
|
+
const doc = this.root.ownerDocument;
|
|
229
|
+
const seats = new Map(this.seats.map((seat) => [seat.seatId, seat]));
|
|
230
|
+
const rows = sortScoreboardRows(buildScoreboardRows(this.rows, this.seats), this.columns, this.sort);
|
|
231
|
+
head.replaceChildren();
|
|
232
|
+
body.replaceChildren();
|
|
233
|
+
|
|
234
|
+
const headerRow = doc.createElement('tr');
|
|
235
|
+
const playerTh = doc.createElement('th');
|
|
236
|
+
playerTh.scope = 'col';
|
|
237
|
+
playerTh.textContent = '玩家';
|
|
238
|
+
styleCell(playerTh, 'left', undefined);
|
|
239
|
+
playerTh.style.textAlign = 'left';
|
|
240
|
+
headerRow.append(playerTh);
|
|
241
|
+
if (this.options.showTeam ?? true) {
|
|
242
|
+
const th = doc.createElement('th');
|
|
243
|
+
th.scope = 'col';
|
|
244
|
+
th.textContent = '队伍';
|
|
245
|
+
styleCell(th, 'left', undefined);
|
|
246
|
+
headerRow.append(th);
|
|
247
|
+
}
|
|
248
|
+
if (this.options.showConnection ?? true) {
|
|
249
|
+
const th = doc.createElement('th');
|
|
250
|
+
th.scope = 'col';
|
|
251
|
+
th.textContent = '连接';
|
|
252
|
+
styleCell(th, 'left', undefined);
|
|
253
|
+
headerRow.append(th);
|
|
254
|
+
}
|
|
255
|
+
for (const column of this.columns) {
|
|
256
|
+
const th = doc.createElement('th');
|
|
257
|
+
th.scope = 'col';
|
|
258
|
+
if (column.sortable !== false) {
|
|
259
|
+
const button = doc.createElement('button');
|
|
260
|
+
button.type = 'button';
|
|
261
|
+
button.className = 'bp-scoreboard-sort';
|
|
262
|
+
button.style.cssText = 'all:unset;cursor:pointer;font-weight:600';
|
|
263
|
+
const active = this.sort?.key === column.key;
|
|
264
|
+
button.textContent = `${column.label}${active ? (this.sort?.dir === 'asc' ? ' ↑' : ' ↓') : ''}`;
|
|
265
|
+
button.addEventListener('click', () => this.toggleSort(column.key));
|
|
266
|
+
th.append(button);
|
|
267
|
+
} else {
|
|
268
|
+
th.textContent = column.label;
|
|
269
|
+
}
|
|
270
|
+
th.style.fontWeight = '600';
|
|
271
|
+
styleCell(th, column.align, column.width);
|
|
272
|
+
headerRow.append(th);
|
|
273
|
+
}
|
|
274
|
+
head.append(headerRow);
|
|
275
|
+
|
|
276
|
+
if (rows.length === 0) {
|
|
277
|
+
const row = doc.createElement('tr');
|
|
278
|
+
const cell = doc.createElement('td');
|
|
279
|
+
cell.colSpan = 1 + (this.options.showTeam ?? true ? 1 : 0) + (this.options.showConnection ?? true ? 1 : 0) + this.columns.length;
|
|
280
|
+
cell.textContent = this.options.emptyText ?? '暂无数据';
|
|
281
|
+
cell.style.cssText = 'padding:12px 8px;opacity:.6;text-align:center';
|
|
282
|
+
row.append(cell);
|
|
283
|
+
body.append(row);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const row of rows) {
|
|
288
|
+
const seat = seats.get(row.seatId);
|
|
289
|
+
const tr = doc.createElement('tr');
|
|
290
|
+
tr.dataset.seatId = row.seatId;
|
|
291
|
+
|
|
292
|
+
const player = doc.createElement('td');
|
|
293
|
+
const dot = doc.createElement('span');
|
|
294
|
+
dot.className = 'bp-scoreboard-color';
|
|
295
|
+
dot.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;background:${seatColor({ seatId: row.seatId, slot: seat?.slot, team: seat?.team }).value}`;
|
|
296
|
+
const name = doc.createElement('span');
|
|
297
|
+
name.textContent = seat?.name ?? row.seatId;
|
|
298
|
+
player.append(dot, name);
|
|
299
|
+
if (seat?.isHost && (this.options.showHost ?? true)) {
|
|
300
|
+
const host = doc.createElement('span');
|
|
301
|
+
host.className = 'bp-scoreboard-host';
|
|
302
|
+
host.textContent = '房主';
|
|
303
|
+
host.style.cssText = 'margin-left:6px;padding:0 4px;border:1px solid currentColor;border-radius:4px;font-size:11px;opacity:.75';
|
|
304
|
+
player.append(host);
|
|
305
|
+
}
|
|
306
|
+
styleCell(player, 'left', undefined);
|
|
307
|
+
tr.append(player);
|
|
308
|
+
|
|
309
|
+
if (this.options.showTeam ?? true) {
|
|
310
|
+
const team = doc.createElement('td');
|
|
311
|
+
team.textContent = teamText(seat?.team ?? null);
|
|
312
|
+
styleCell(team, 'left', undefined);
|
|
313
|
+
tr.append(team);
|
|
314
|
+
}
|
|
315
|
+
if (this.options.showConnection ?? true) {
|
|
316
|
+
const connection = doc.createElement('td');
|
|
317
|
+
connection.textContent = connectionLabel(seat);
|
|
318
|
+
connection.style.opacity = seat && (seat.role === 'ai' || seat.connected) ? '1' : '0.6';
|
|
319
|
+
styleCell(connection, 'left', undefined);
|
|
320
|
+
tr.append(connection);
|
|
321
|
+
}
|
|
322
|
+
for (const column of this.columns) {
|
|
323
|
+
const cell = doc.createElement('td');
|
|
324
|
+
const rendered = column.render?.(resolveColumnValue(column, row), row) ?? null;
|
|
325
|
+
if (rendered && typeof rendered.nodeType === 'number') cell.append(rendered);
|
|
326
|
+
else cell.textContent = formatColumnValue(column, row);
|
|
327
|
+
styleCell(cell, column.align, column.width);
|
|
328
|
+
tr.append(cell);
|
|
329
|
+
}
|
|
330
|
+
body.append(tr);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { SeatLike } from './shared.js';
|
|
2
|
+
|
|
3
|
+
export interface SeatColor {
|
|
4
|
+
index: number;
|
|
5
|
+
name: string;
|
|
6
|
+
value: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const PALETTE: ReadonlyArray<{ name: string; value: string }> = [
|
|
10
|
+
{ name: 'red', value: '#e05d5d' },
|
|
11
|
+
{ name: 'blue', value: '#5d9ce0' },
|
|
12
|
+
{ name: 'green', value: '#5dbe7a' },
|
|
13
|
+
{ name: 'yellow', value: '#d6b85d' },
|
|
14
|
+
{ name: 'purple', value: '#a98ae0' },
|
|
15
|
+
{ name: 'teal', value: '#5dc9c9' },
|
|
16
|
+
{ name: 'orange', value: '#e09a5d' },
|
|
17
|
+
{ name: 'pink', value: '#d98ab8' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function mod(value: number): number {
|
|
21
|
+
return ((value % PALETTE.length) + PALETTE.length) % PALETTE.length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hashTeam(team: string): number {
|
|
25
|
+
let hash = 0;
|
|
26
|
+
for (let i = 0; i < team.length; i += 1) hash = (hash * 31 + team.charCodeAt(i)) | 0;
|
|
27
|
+
return hash;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 队伍优先,其次座位号;同一输入永远得到同一种颜色。 */
|
|
31
|
+
export function seatColor(seat: SeatLike): SeatColor {
|
|
32
|
+
let index: number;
|
|
33
|
+
if (typeof seat.team === 'number' && Number.isFinite(seat.team)) {
|
|
34
|
+
index = mod(Math.trunc(seat.team) - 1);
|
|
35
|
+
} else if (typeof seat.team === 'string' && seat.team.length > 0) {
|
|
36
|
+
index = mod(hashTeam(seat.team));
|
|
37
|
+
} else if (typeof seat.slot === 'number' && Number.isFinite(seat.slot)) {
|
|
38
|
+
index = mod(Math.trunc(seat.slot) - 1);
|
|
39
|
+
} else {
|
|
40
|
+
index = 0;
|
|
41
|
+
}
|
|
42
|
+
const color = PALETTE[index]!;
|
|
43
|
+
return { index, ...color };
|
|
44
|
+
}
|