@yyyu-star/arcade-kit 0.10.2 → 0.11.1
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/CHANGELOG.md +26 -0
- package/README.md +84 -3
- package/dist/client.d.ts +41 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +119 -43
- package/dist/client.js.map +1 -1
- package/dist/fun-logic.d.ts +184 -0
- package/dist/fun-logic.d.ts.map +1 -0
- package/dist/fun-logic.js +249 -0
- package/dist/fun-logic.js.map +1 -0
- package/dist/fun.d.ts +1 -13
- package/dist/fun.d.ts.map +1 -1
- package/dist/fun.js +3 -2
- package/dist/fun.js.map +1 -1
- package/dist/room.d.ts +5 -0
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +24 -5
- package/dist/room.js.map +1 -1
- package/dist/table-fun.d.ts +152 -0
- package/dist/table-fun.d.ts.map +1 -0
- package/dist/table-fun.js +652 -0
- package/dist/table-fun.js.map +1 -0
- package/package.json +9 -1
- package/src/client.ts +119 -41
- package/src/fun-logic.ts +351 -0
- package/src/fun.ts +3 -2
- package/src/room.ts +28 -4
- package/src/table-fun.ts +713 -0
package/src/fun-logic.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 牌桌互动的规则(0.11.0):冷却、护盾、连击、回敬、电脑怎么反应、菜单放哪。纯函数和小类,不碰 DOM,时间和随机数由调用方给,
|
|
3
|
+
* 页面(table-fun.ts)和房间(电脑在服务器上回应时)都能用,测试在 workerd 里直接跑。
|
|
4
|
+
*
|
|
5
|
+
* 座位用游戏自己的编号(页面上一般是"我 = 0"的画面座位,房间里是真实座位),这里不关心是哪种,只要同一个对象里一致。
|
|
6
|
+
* 所有数字的出处是仓库根 docs/table-fun.md 第 2–4 节;都能经参数改,默认值就是规范。
|
|
7
|
+
*/
|
|
8
|
+
import { PROPS, STICKERS, type PropId, type StickerId } from './fun-art.js';
|
|
9
|
+
|
|
10
|
+
export { PROPS, STICKERS, type PropId, type StickerId };
|
|
11
|
+
|
|
12
|
+
// ---------- 节奏 ----------
|
|
13
|
+
|
|
14
|
+
export const FUN_TIMING = {
|
|
15
|
+
/** 自己扔道具的冷却 */
|
|
16
|
+
propCooldownMs: 1500,
|
|
17
|
+
/** 表情、短语的冷却 */
|
|
18
|
+
talkCooldownMs: 1000,
|
|
19
|
+
/** 同一个人 shieldWindowMs 里最多被扔 shieldMax 次,多的亮护盾、不播 */
|
|
20
|
+
shieldMax: 6,
|
|
21
|
+
shieldWindowMs: 10_000,
|
|
22
|
+
/** 连击、回敬都按这个算 */
|
|
23
|
+
comboMs: 3000,
|
|
24
|
+
/** 长按多久算"连扔上次那个" */
|
|
25
|
+
longPressMs: 400,
|
|
26
|
+
/** 污渍留多久再淡出 */
|
|
27
|
+
stainMs: 3000,
|
|
28
|
+
/** 首次提示留多久 */
|
|
29
|
+
hintMs: 3000,
|
|
30
|
+
/** 好友房的电脑从房间立刻发出来(房间只有一个闹钟),页面晚这么久再播,像人反应了一下、也等道具落地 */
|
|
31
|
+
botLagMs: 900,
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export type FunKind = 'prop' | 'talk';
|
|
35
|
+
|
|
36
|
+
/** 自己发东西的冷却:道具一个计时,表情和短语共用一个 */
|
|
37
|
+
export class FunCooldown {
|
|
38
|
+
private last: Record<FunKind, number> = { prop: -Infinity, talk: -Infinity };
|
|
39
|
+
constructor(private readonly ms: Record<FunKind, number> = { prop: FUN_TIMING.propCooldownMs, talk: FUN_TIMING.talkCooldownMs }) {}
|
|
40
|
+
|
|
41
|
+
/** 还要等多久(毫秒,0 = 能发) */
|
|
42
|
+
left(kind: FunKind, now: number): number {
|
|
43
|
+
return Math.max(0, this.last[kind] + this.ms[kind] - now);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 能发就记下并返回 true;冷却中返回 false,不记 */
|
|
47
|
+
take(kind: FunKind, now: number): boolean {
|
|
48
|
+
if (this.left(kind, now) > 0) return false;
|
|
49
|
+
this.last[kind] = now;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
total(kind: FunKind): number {
|
|
54
|
+
return this.ms[kind];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------- 护盾、连击、回敬 ----------
|
|
59
|
+
|
|
60
|
+
export interface HitResult {
|
|
61
|
+
/** 被扔太多了:这一下不播,头像上亮护盾 */
|
|
62
|
+
shield: boolean;
|
|
63
|
+
/** 同一个人连着扔同一个人的第几下(1 = 不算连击) */
|
|
64
|
+
combo: number;
|
|
65
|
+
/** 被 to 扔过以后 comboMs 内扔回去 */
|
|
66
|
+
revenge: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 每一下扔中都过这里;真人、电脑、好友房转发来的一视同仁 */
|
|
70
|
+
export class FunTally {
|
|
71
|
+
private readonly hits = new Map<number, number[]>();
|
|
72
|
+
/** 每个人最近一次扔的是谁、第几下:换了目标就断("连续扔同一个人") */
|
|
73
|
+
private readonly chain = new Map<number, { to: number; t: number; n: number }>();
|
|
74
|
+
/** 谁最近扔过这个人 */
|
|
75
|
+
private readonly lastBy = new Map<number, { from: number; t: number }>();
|
|
76
|
+
|
|
77
|
+
constructor(private readonly t: { shieldMax: number; shieldWindowMs: number; comboMs: number } = FUN_TIMING) {}
|
|
78
|
+
|
|
79
|
+
hit(from: number, to: number, now: number): HitResult {
|
|
80
|
+
const recent = (this.hits.get(to) ?? []).filter((x) => now - x < this.t.shieldWindowMs);
|
|
81
|
+
if (recent.length >= this.t.shieldMax) {
|
|
82
|
+
this.hits.set(to, recent);
|
|
83
|
+
return { shield: true, combo: 1, revenge: false };
|
|
84
|
+
}
|
|
85
|
+
recent.push(now);
|
|
86
|
+
this.hits.set(to, recent);
|
|
87
|
+
const prev = this.chain.get(from);
|
|
88
|
+
const combo = prev && prev.to === to && now - prev.t <= this.t.comboMs ? prev.n + 1 : 1;
|
|
89
|
+
this.chain.set(from, { to, t: now, n: combo });
|
|
90
|
+
const by = this.lastBy.get(from);
|
|
91
|
+
const revenge = !!by && by.from === to && now - by.t <= this.t.comboMs;
|
|
92
|
+
this.lastBy.set(to, { from, t: now });
|
|
93
|
+
return { shield: false, combo, revenge };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------- 电脑怎么反应 ----------
|
|
98
|
+
|
|
99
|
+
/** 互动性格:爱闹 / 普通 / 高冷(每个电脑人物一个,写在游戏的人物表里) */
|
|
100
|
+
export type FunTemper = 'rowdy' | 'normal' | 'cool';
|
|
101
|
+
export const TEMPER_MULT: Record<FunTemper, number> = { rowdy: 1.5, normal: 1, cool: 0.3 };
|
|
102
|
+
|
|
103
|
+
/** 反应的情绪:决定冒哪个表情、气泡说哪类话(话由游戏给,kit 有一套不提任何游戏的默认) */
|
|
104
|
+
export type FunMood = 'angry' | 'sweat' | 'love' | 'smug' | 'shock' | 'cry';
|
|
105
|
+
|
|
106
|
+
/** 名场面:游戏把自己的公开事件映射成这几类,没有的不报 */
|
|
107
|
+
export type MomentKind =
|
|
108
|
+
| 'big-win'
|
|
109
|
+
| 'comeback'
|
|
110
|
+
| 'bluff-caught'
|
|
111
|
+
| 'bomb'
|
|
112
|
+
| 'first-out'
|
|
113
|
+
| 'double-down'
|
|
114
|
+
| 'kong'
|
|
115
|
+
| 'self-draw'
|
|
116
|
+
| (string & {});
|
|
117
|
+
|
|
118
|
+
/** 发给 botReact 的事 */
|
|
119
|
+
export type FunEvent =
|
|
120
|
+
/** 有人冲这个电脑扔了道具(prop)或点名短语(没有 prop) */
|
|
121
|
+
| { kind: 'hit'; from: number; to: number; prop?: PropId }
|
|
122
|
+
/** 名场面:赢的、输的各是谁;hand 区分"这一手"(每手电脑主动互动有上限) */
|
|
123
|
+
| { kind: 'moment'; moment: MomentKind; winners: readonly number[]; losers: readonly number[]; hand: string | number };
|
|
124
|
+
|
|
125
|
+
export interface FunBot {
|
|
126
|
+
seat: number;
|
|
127
|
+
temper: FunTemper;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 电脑这一下要做什么:一定有表情和一句话的情绪(看得见),回敬时再带一个道具飞过去 */
|
|
131
|
+
export interface BotAction {
|
|
132
|
+
from: number;
|
|
133
|
+
/** 道具、点名的对象 */
|
|
134
|
+
to?: number;
|
|
135
|
+
/** 过多久做(像人一样反应一下) */
|
|
136
|
+
delay: number;
|
|
137
|
+
sticker: StickerId;
|
|
138
|
+
mood: FunMood;
|
|
139
|
+
prop?: PropId;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface BotReactConfig {
|
|
143
|
+
/** 被扔后回应的概率(再乘性格) */
|
|
144
|
+
respond: number;
|
|
145
|
+
/** 回应里直接回敬道具的比例 */
|
|
146
|
+
counterShare: number;
|
|
147
|
+
/** 同一个电脑 counterWindowMs 里最多回敬几次 */
|
|
148
|
+
counterMax: number;
|
|
149
|
+
counterWindowMs: number;
|
|
150
|
+
/** 同一个电脑两次反应至少隔多久(不能比扔道具的冷却长,否则连扔时回应被吞) */
|
|
151
|
+
minGapMs: number;
|
|
152
|
+
/** 反应延迟区间 */
|
|
153
|
+
delay: readonly [number, number];
|
|
154
|
+
/** 名场面:输的一方冲赢家、赢的一方得意 */
|
|
155
|
+
momentLose: number;
|
|
156
|
+
momentWin: number;
|
|
157
|
+
/** 每一手电脑主动互动最多几次 */
|
|
158
|
+
perHand: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const BOT_REACT: BotReactConfig = {
|
|
162
|
+
respond: 0.6,
|
|
163
|
+
counterShare: 0.25,
|
|
164
|
+
counterMax: 2,
|
|
165
|
+
counterWindowMs: 20_000,
|
|
166
|
+
minGapMs: 1000,
|
|
167
|
+
delay: [800, 2000],
|
|
168
|
+
momentLose: 0.4,
|
|
169
|
+
momentWin: 0.3,
|
|
170
|
+
perHand: 2,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** 电脑反应的账:调用方存着(页面一局一份;房间放内存) */
|
|
174
|
+
export interface BotState {
|
|
175
|
+
lastOut: Record<number, number>;
|
|
176
|
+
counters: Record<number, number[]>;
|
|
177
|
+
hand: string | number | null;
|
|
178
|
+
/** 这一手已经主动互动了几次 */
|
|
179
|
+
n: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function newBotState(): BotState {
|
|
183
|
+
return { lastOut: {}, counters: {}, hand: null, n: 0 };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const DIRTY: readonly PropId[] = ['tomato', 'egg', 'stinkyEgg', 'slipper', 'bomb'];
|
|
187
|
+
|
|
188
|
+
/** 同类回敬:番茄还番茄,鸡蛋还拖鞋(一个比一个狠一点),善意的还善意的 */
|
|
189
|
+
export const COUNTER: Record<PropId, PropId> = {
|
|
190
|
+
tomato: 'tomato',
|
|
191
|
+
egg: 'slipper',
|
|
192
|
+
stinkyEgg: 'stinkyEgg',
|
|
193
|
+
slipper: 'slipper',
|
|
194
|
+
bomb: 'bomb',
|
|
195
|
+
rose: 'rose',
|
|
196
|
+
like: 'like',
|
|
197
|
+
cheers: 'cheers',
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const MOOD_STICKER: Record<FunMood, readonly StickerId[]> = {
|
|
201
|
+
angry: ['angry'],
|
|
202
|
+
sweat: ['sweat'],
|
|
203
|
+
love: ['love'],
|
|
204
|
+
smug: ['smug', 'cool', 'lol'],
|
|
205
|
+
shock: ['shock'],
|
|
206
|
+
cry: ['cry'],
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const pick = <T>(xs: readonly T[], rand: () => number): T => xs[Math.floor(rand() * xs.length) % xs.length]!;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 一个电脑对一件事的反应,没有就是 null。被扔:bot 是被扔的那个;名场面:调用方把每个电脑都问一遍(每手有上限,按问的顺序先到先得)。
|
|
213
|
+
* 被扔时的回应不占"每手主动互动"的次数,只守 minGapMs 和回敬上限。
|
|
214
|
+
*/
|
|
215
|
+
export function botReact(ev: FunEvent, bot: FunBot, st: BotState, now: number, rand: () => number, cfg: BotReactConfig = BOT_REACT): BotAction | null {
|
|
216
|
+
const mult = TEMPER_MULT[bot.temper];
|
|
217
|
+
const p = (x: number): number => Math.min(0.95, x * mult);
|
|
218
|
+
if (now - (st.lastOut[bot.seat] ?? -Infinity) < cfg.minGapMs) return null;
|
|
219
|
+
const delay = Math.round(cfg.delay[0] + rand() * (cfg.delay[1] - cfg.delay[0]));
|
|
220
|
+
const out = (a: Omit<BotAction, 'from' | 'delay'>): BotAction => {
|
|
221
|
+
st.lastOut[bot.seat] = now;
|
|
222
|
+
return { from: bot.seat, delay, ...a };
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
if (ev.kind === 'hit') {
|
|
226
|
+
if (ev.to !== bot.seat || ev.from === bot.seat) return null;
|
|
227
|
+
if (rand() >= p(cfg.respond)) return null;
|
|
228
|
+
const nice = ev.prop !== undefined && !DIRTY.includes(ev.prop);
|
|
229
|
+
const recent = (st.counters[bot.seat] ?? []).filter((t) => now - t < cfg.counterWindowMs);
|
|
230
|
+
st.counters[bot.seat] = recent;
|
|
231
|
+
if (rand() < cfg.counterShare && recent.length < cfg.counterMax) {
|
|
232
|
+
recent.push(now);
|
|
233
|
+
const prop = ev.prop ? COUNTER[ev.prop] : 'tomato';
|
|
234
|
+
return out({ to: ev.from, prop, mood: nice ? 'love' : 'angry', sticker: nice ? 'love' : 'angry' });
|
|
235
|
+
}
|
|
236
|
+
const mood: FunMood = nice ? 'love' : pick(['angry', 'sweat'] as const, rand);
|
|
237
|
+
return out({ to: ev.from, mood, sticker: pick(MOOD_STICKER[mood], rand) });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 名场面:新的一手从头计数
|
|
241
|
+
if (st.hand !== ev.hand) {
|
|
242
|
+
st.hand = ev.hand;
|
|
243
|
+
st.n = 0;
|
|
244
|
+
}
|
|
245
|
+
if (st.n >= cfg.perHand) return null;
|
|
246
|
+
const lost = ev.losers.includes(bot.seat);
|
|
247
|
+
const won = ev.winners.includes(bot.seat);
|
|
248
|
+
if (lost && ev.winners.length) {
|
|
249
|
+
if (rand() >= p(cfg.momentLose)) return null;
|
|
250
|
+
st.n++;
|
|
251
|
+
const to = ev.winners[Math.floor(rand() * ev.winners.length) % ev.winners.length]!;
|
|
252
|
+
// 一半扔个不太狠的道具,一半冒表情加一句
|
|
253
|
+
if (rand() < 0.5) return out({ to, prop: pick(['tomato', 'egg', 'slipper'] as const, rand), mood: 'angry', sticker: 'angry' });
|
|
254
|
+
const mood: FunMood = pick(['shock', 'cry', 'angry', 'sweat'] as const, rand);
|
|
255
|
+
return out({ to, mood, sticker: pick(MOOD_STICKER[mood], rand) });
|
|
256
|
+
}
|
|
257
|
+
if (won) {
|
|
258
|
+
if (rand() >= p(cfg.momentWin)) return null;
|
|
259
|
+
st.n++;
|
|
260
|
+
return out({ mood: 'smug', sticker: pick(MOOD_STICKER.smug, rand) });
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------- 菜单放哪 ----------
|
|
266
|
+
|
|
267
|
+
export interface Box {
|
|
268
|
+
left: number;
|
|
269
|
+
top: number;
|
|
270
|
+
width: number;
|
|
271
|
+
height: number;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const overlap = (a: Box, b: Box): number =>
|
|
275
|
+
Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left)) * Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 菜单(w×h)贴着头像(anchor)放:先试朝屏幕中心那一侧,再试另外三侧;夹在可见区里(留 margin),
|
|
279
|
+
* 和"禁区"(手牌、操作按钮)以及头像本身压得最少的那个位置胜出,一样少时按试的顺序。
|
|
280
|
+
*/
|
|
281
|
+
export function placeMenu(anchor: Box, w: number, h: number, view: { width: number; height: number }, avoid: readonly Box[] = [], margin = 8, gap = 10): { left: number; top: number } {
|
|
282
|
+
const cx = anchor.left + anchor.width / 2;
|
|
283
|
+
const cy = anchor.top + anchor.height / 2;
|
|
284
|
+
const right = { left: anchor.left + anchor.width + gap, top: cy - h / 2 };
|
|
285
|
+
const left = { left: anchor.left - gap - w, top: cy - h / 2 };
|
|
286
|
+
const below = { left: cx - w / 2, top: anchor.top + anchor.height + gap };
|
|
287
|
+
const above = { left: cx - w / 2, top: anchor.top - gap - h };
|
|
288
|
+
// 朝中心:水平离中心远就先左右,否则先上下
|
|
289
|
+
const dx = view.width / 2 - cx;
|
|
290
|
+
const dy = view.height / 2 - cy;
|
|
291
|
+
const order = Math.abs(dx) / view.width >= Math.abs(dy) / view.height ? (dx >= 0 ? [right, below, above, left] : [left, below, above, right]) : dy >= 0 ? [below, right, left, above] : [above, right, left, below];
|
|
292
|
+
const clamp = (p: { left: number; top: number }): Box => ({
|
|
293
|
+
left: Math.max(margin, Math.min(view.width - margin - w, p.left)),
|
|
294
|
+
top: Math.max(margin, Math.min(view.height - margin - h, p.top)),
|
|
295
|
+
width: w,
|
|
296
|
+
height: h,
|
|
297
|
+
});
|
|
298
|
+
let best: Box | null = null;
|
|
299
|
+
let bestCost = Infinity;
|
|
300
|
+
for (const c of order.map(clamp)) {
|
|
301
|
+
const cost = [anchor, ...avoid].reduce((s, b) => s + overlap(c, b), 0);
|
|
302
|
+
if (cost < bestCost) {
|
|
303
|
+
best = c;
|
|
304
|
+
bestCost = cost;
|
|
305
|
+
if (cost === 0) break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { left: best!.left, top: best!.top };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------- 编号 ----------
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 编号怎么排(和房间转发的 emote id 一致):短语 0..P-1,表情 P..P+11,道具 P+12..P+19。P 是游戏短语表的长度。
|
|
315
|
+
* 游戏的 RoomGame.emotes 写 funIdCount(P)。
|
|
316
|
+
*/
|
|
317
|
+
export function funIdCount(phrases: number): number {
|
|
318
|
+
return phrases + 12 + 8;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export type FunItem = { kind: 'phrase'; index: number } | { kind: 'sticker'; index: number } | { kind: 'prop'; index: number };
|
|
322
|
+
|
|
323
|
+
export function funItem(id: number, phrases: number): FunItem | null {
|
|
324
|
+
if (!Number.isInteger(id) || id < 0 || id >= funIdCount(phrases)) return null;
|
|
325
|
+
if (id < phrases) return { kind: 'phrase', index: id };
|
|
326
|
+
if (id < phrases + 12) return { kind: 'sticker', index: id - phrases };
|
|
327
|
+
return { kind: 'prop', index: id - phrases - 12 };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export const stickerId = (phrases: number, index: number): number => phrases + index;
|
|
331
|
+
export const propId = (phrases: number, index: number): number => phrases + 12 + index;
|
|
332
|
+
|
|
333
|
+
/** 电脑的表情对应的情绪(页面收到电脑的表情时配一句话用);不是情绪表情的回 null */
|
|
334
|
+
export function stickerMood(s: StickerId): FunMood | null {
|
|
335
|
+
for (const [mood, list] of Object.entries(MOOD_STICKER) as [FunMood, readonly StickerId[]][]) if (list.includes(s)) return mood;
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 房间用:真人发来的编号换成给 botReact 的"被扔"(冲人来的道具、点名短语才算,表情、不点名的短语不算) */
|
|
340
|
+
export function hitEvent(id: number, phrases: number, from: number, to?: number): Extract<FunEvent, { kind: 'hit' }> | null {
|
|
341
|
+
const it = funItem(id, phrases);
|
|
342
|
+
if (!it || to === undefined || to === from || it.kind === 'sticker') return null;
|
|
343
|
+
return it.kind === 'prop' ? { kind: 'hit', from, to, prop: PROPS[it.index]! } : { kind: 'hit', from, to };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 房间用:电脑的反应换成要经 botEmote 发的编号。先表情(页面会配上一句话),回扔时再加道具 */
|
|
347
|
+
export function botEmoteIds(a: BotAction, phrases: number): { id: number; to?: number }[] {
|
|
348
|
+
const out: { id: number; to?: number }[] = [{ id: stickerId(phrases, STICKERS.indexOf(a.sticker)) }];
|
|
349
|
+
if (a.prop && a.to !== undefined) out.push({ id: propId(phrases, PROPS.indexOf(a.prop)), to: a.to });
|
|
350
|
+
return out;
|
|
351
|
+
}
|
package/src/fun.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* - 被扔的头像抖一下由游戏做(头像在游戏的画布或 DOM 里,kit 碰不到):命中时调 opts.onHit(prop)。
|
|
11
11
|
* 这个文件引用 DOM,但只在调用时才碰:在 workerd、Node 里 import 不会出错(测试只测纯函数和素材)。
|
|
12
12
|
*/
|
|
13
|
+
import { FUN_TIMING } from './fun-logic.js';
|
|
13
14
|
import { BURST, DRIP, PETAL, PROPS, SHELL, SMOKE, SOOT, SPARK, SPLAT_TOMATO, STAR, STICKERS, STINK, dot, propSvg, splatEgg, stickerSvg, type PropId, type StickerId } from './fun-art.js';
|
|
14
15
|
|
|
15
16
|
export { PROPS, STICKERS, propSvg, stickerSvg, type PropId, type StickerId };
|
|
@@ -226,7 +227,7 @@ const SPIN: Record<PropId, number> = { tomato: 540, egg: 600, stinkyEgg: 600, sl
|
|
|
226
227
|
const FLY_MS = 700;
|
|
227
228
|
|
|
228
229
|
/**
|
|
229
|
-
* 从 from(发的人头像的屏幕矩形)扔到 to
|
|
230
|
+
* 从 from(发的人头像的屏幕矩形)扔到 to(对象头像),命中后按道具播效果,脏东西的污渍留 3 秒(FUN_TIMING.stainMs)再淡出,玫瑰停 1.6 秒。
|
|
230
231
|
* 名额满了直接返回 false;播完(不含污渍的停留)返回 true。
|
|
231
232
|
*/
|
|
232
233
|
export async function throwAt(layer: FunLayer, from: RectLike, to: RectLike, prop: PropId, opts: ThrowOptions = {}): Promise<boolean> {
|
|
@@ -312,7 +313,7 @@ async function splat(h: Hit, svg: string, scale: number, extra?: (g: HTMLElement
|
|
|
312
313
|
const sz = h.s * scale;
|
|
313
314
|
const d = sprite(g, svg, sz, h.c, `rotate(${rnd(-20, 20).toFixed(0)}deg)`);
|
|
314
315
|
extra?.(g);
|
|
315
|
-
stain(h.el, h.key, g,
|
|
316
|
+
stain(h.el, h.key, g, FUN_TIMING.stainMs);
|
|
316
317
|
await play(d, [{ transform: pos(h.c, sz, 'scale(.25)') }, { transform: pos(h.c, sz, 'scale(1.18)'), offset: 0.6 }, { transform: pos(h.c, sz, 'scale(1)') }], 180, { easing: 'ease-out' });
|
|
317
318
|
return g;
|
|
318
319
|
}
|
package/src/room.ts
CHANGED
|
@@ -117,6 +117,8 @@ export const DEFAULT_LIMITS: RoomLimits = {
|
|
|
117
117
|
|
|
118
118
|
/** 闹钟里出错时,过这么久再醒来重试(不用"马上",免得每次都出错时连着计费)。 */
|
|
119
119
|
const ALARM_RETRY_MS = 60_000;
|
|
120
|
+
/** 每个座位记住最近几个做成的 moveId(0.11.1):客户端断线时攒着没确认的操作,重连后原样补发 */
|
|
121
|
+
const RECENT_MOVES = 8;
|
|
120
122
|
|
|
121
123
|
/** 计量说的门槛在内存里记多久(不进新人时用;报数、还名额时顺便更新)。 */
|
|
122
124
|
const LEVEL_TTL_MS = 30_000;
|
|
@@ -148,6 +150,11 @@ interface SeatState {
|
|
|
148
150
|
auto: boolean;
|
|
149
151
|
/** 这个座位最后一次做成的操作的 moveId(重发去重) */
|
|
150
152
|
lastMoveId: string | null;
|
|
153
|
+
/**
|
|
154
|
+
* 最近做成的几个 moveId,新的在后(0.11.1,最多 RECENT_MOVES 个;旧房间的状态里没有,读到时当空的)。
|
|
155
|
+
* 只记最后一个不够:连发 A、B,A 的 ack 丢了,重连后补发 A,lastMoveId 已经是 B,不轮流的操作(free)就会被做第二遍
|
|
156
|
+
*/
|
|
157
|
+
recentMoves?: string[];
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
interface Persist<S> {
|
|
@@ -389,13 +396,14 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
389
396
|
this.alarmAt = null;
|
|
390
397
|
await this.entry(() => this.onAlarm());
|
|
391
398
|
// 出错时状态可能没走到"重设闹钟"那一步:房间还在、又没有闹钟,就过一会儿再醒来,不然房间永远没人关
|
|
392
|
-
|
|
399
|
+
// setAlarm 本身失败时 alarmAt 是 undefined(不知道),以存储里读到的为准(0.11.1)
|
|
400
|
+
if (this.lastStepFailed && this.load() && (this.alarmAt === undefined ? await this.ctx.storage.getAlarm() : this.alarmAt) === null) {
|
|
393
401
|
this.alarmAt = Date.now() + ALARM_RETRY_MS;
|
|
394
402
|
this.lastStepWrites++;
|
|
395
403
|
this.totalWrites++;
|
|
396
404
|
this.unmetered++;
|
|
397
405
|
this.maxStepWrites = Math.max(this.maxStepWrites, this.lastStepWrites);
|
|
398
|
-
await this.
|
|
406
|
+
await this.setAlarmSafely(this.alarmAt);
|
|
399
407
|
}
|
|
400
408
|
} catch (e) {
|
|
401
409
|
console.error('room alarm failed', e instanceof Error ? e.message : e);
|
|
@@ -430,11 +438,26 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
430
438
|
this.totalWrites += this.writes;
|
|
431
439
|
this.unmetered += this.writes;
|
|
432
440
|
this.maxStepWrites = Math.max(this.maxStepWrites, this.writes);
|
|
433
|
-
if (at !== null
|
|
441
|
+
if (at !== null && !(await this.setAlarmSafely(at))) this.lastStepFailed = true;
|
|
434
442
|
await this.afterStep();
|
|
435
443
|
return out;
|
|
436
444
|
}
|
|
437
445
|
|
|
446
|
+
/**
|
|
447
|
+
* 设闹钟;失败了不往外抛(0.11.1)。planAlarm 已经把 alarmAt 改成了新时间,失败时它就是错的:
|
|
448
|
+
* 留着的话以后 planAlarm 看"已经设了更早的"一直不重设,房间就没人收场。改回"不知道",下一个入口按 getAlarm() 重新读、照常重排
|
|
449
|
+
*/
|
|
450
|
+
private async setAlarmSafely(at: number): Promise<boolean> {
|
|
451
|
+
try {
|
|
452
|
+
await this.ctx.storage.setAlarm(at);
|
|
453
|
+
return true;
|
|
454
|
+
} catch (e) {
|
|
455
|
+
console.error('room setAlarm failed', e instanceof Error ? e.message : e);
|
|
456
|
+
this.alarmAt = undefined;
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
438
461
|
// ---------- 计量(0.2.0) ----------
|
|
439
462
|
|
|
440
463
|
/**
|
|
@@ -809,7 +832,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
809
832
|
if (seat === null) return this.reject(ws, 'not_seated', moveId);
|
|
810
833
|
const s = st.seats[seat]!;
|
|
811
834
|
// 重发的同一个操作:不再做一遍,只回 ack 和当前状态
|
|
812
|
-
if (s.lastMoveId === moveId) {
|
|
835
|
+
if (s.lastMoveId === moveId || s.recentMoves?.includes(moveId)) {
|
|
813
836
|
const bound = this.boundOf(attachmentOf(ws)!.userId);
|
|
814
837
|
return this.send(ws, this.stateMsg(seat, bound, [{ state: st.game, delay: 0 }], moveId));
|
|
815
838
|
}
|
|
@@ -828,6 +851,7 @@ export abstract class GameRoom<S, A, V, Env = unknown, O = unknown, E extends Ro
|
|
|
828
851
|
const out = this.step(seat, action, now, free);
|
|
829
852
|
if (out) return this.reject(ws, out, moveId);
|
|
830
853
|
s.lastMoveId = moveId;
|
|
854
|
+
s.recentMoves = [...(s.recentMoves ?? []), moveId].slice(-RECENT_MOVES);
|
|
831
855
|
const auto = this.runAuto(now);
|
|
832
856
|
if (seen !== null && auto.length === 0 && this.othersSee(seat) === seen) return this.commitQuiet(ws, seat, moveId);
|
|
833
857
|
this.commit({ frames: [{ state: st.game, delay: 0 }, ...auto], ack: { ws, moveId } });
|