dsh-game-center 1.0.0 → 1.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/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  [English](#english) | [中文](#中文)
4
4
 
5
- ![version](https://img.shields.io/badge/version-1.0.0-blue.svg)
5
+ ![version](https://img.shields.io/badge/version-1.2.0-blue.svg)
6
+ ![npm](https://img.shields.io/npm/v/dsh-game-center?label=npm&color=cb3837)
6
7
  ![DSH](https://img.shields.io/badge/DSH-0.2.1--rc.5-blueviolet.svg)
7
8
  ![license](https://img.shields.io/badge/license-MIT-green.svg)
8
9
 
@@ -20,17 +21,26 @@ A DeepSeek Harness panel plugin that keeps the mini-games built for this harness
20
21
 
21
22
  | | |
22
23
  | --- | --- |
23
- | DeepSeek Harness | `0.2.1-rc.5` (Web UI) |
24
+ | DeepSeek Harness | `0.2.1-rc.5` or newer (Web UI; verified on `0.2.2-rc.1`) |
24
25
  | Node.js | `>= 22` |
25
26
  | npm dependencies | none at runtime — the panel injects `@deepseek-ai/dsh-client-locale` and `@deepseek-ai/dsh-client-ui-slots`, which the harness provides |
26
27
 
27
28
  ### Install
28
29
 
29
- ```sh
30
- dsh plugin add github:stallenlu/dsh-game-center
31
- ```
30
+ The plugin is listed in the [awesome-dsh-plugin catalog](https://awesome-dsh-plugin.com/p/stallenlu/dsh-game-center/), so it appears in **dshmarket** and in DSH's **Plugins → Discover**: search for *Game Center*, *游戏中心* or *Tetris* and click **Install**. That is the easiest path.
31
+
32
+ It is also published through three channels you can install from directly:
32
33
 
33
- Restart DSH (or refresh the Web UI) afterwards, then open **Game Center** from the sidebar.
34
+ | Channel | Command | Notes |
35
+ | --- | --- | --- |
36
+ | dshmarket / Discover | search **Game Center** | one-click; the catalog entry lives in awesome-dsh-plugin |
37
+ | npm | `dsh plugin --profile web add dsh-game-center` | prebuilt `dsh-game-center@1.2.0`; no build scripts, so no `allowBuilds` prompt |
38
+ | GitHub Release tarball | `dsh plugin --profile web add "https://github.com/stallenlu/dsh-game-center/releases/latest/download/dsh-game-center.tgz"` | prebuilt; the asset name carries no version, so the URL keeps working after upgrades |
39
+ | Git source | `dsh plugin --profile web add github:stallenlu/dsh-game-center` | always the current `main`; a code push needs no PR |
40
+
41
+ With a single profile, `dsh plugin add …` works the same without `--profile web`.
42
+
43
+ Restart DSH afterwards — a full restart, not just a Web UI refresh, for a first install — then open **Game Center** from the sidebar.
34
44
 
35
45
  ### What it does
36
46
 
@@ -69,7 +79,8 @@ index.js host-side entry (no host behaviour)
69
79
  cordis.patch.yml the insert row that mounts the bundle
70
80
  locale/*.json the plugin-list title and description
71
81
  icon.svg the in-app icon
72
- assets/ storefront images
82
+ assets/ storefront images (screenshot-1.png, screenshot-2.png, icon-180.png)
83
+ screenshots.json the image list the catalog collects
73
84
  verify.mjs runnable check (node verify.mjs) — wiring + both games' cores
74
85
  ```
75
86
 
@@ -87,6 +98,25 @@ verify.mjs runnable check (node verify.mjs) — wiring + both games' cor
87
98
  node verify.mjs
88
99
  ```
89
100
 
101
+ ### Distribution and updates
102
+
103
+ The catalog entry lives in [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) (`data/plugins/stallenlu__dsh-game-center.yml`); the code lives in this repository. The catalog stores no copy of the code and no version number — it derives the install target at request time. Three channels are published:
104
+
105
+ | Channel | Where | How an update reaches users |
106
+ | --- | --- | --- |
107
+ | npm | [`dsh-game-center@1.2.0`](https://www.npmjs.com/package/dsh-game-center) on registry.npmjs.org | `npm version patch && npm publish`; the catalog's nightly `probe-npm.mjs` checks that `repository` points back here and picks up the new version automatically — no PR, and update detection switches to version numbers |
108
+ | GitHub Release | [`v1.2.0`](https://github.com/stallenlu/dsh-game-center/releases/tag/v1.2.0) with asset `dsh-game-center.tgz` | publish a new Release keeping the **same** asset name, so `releases/latest/download/dsh-game-center.tgz` keeps resolving. Never put the version in the asset name — the permanent URL would 404 on the next release |
109
+ | Git source | `main` | just `git push`; installs pinned to a commit see it on the next update check — no PR, no wait |
110
+
111
+ `installTargetFor()` prefers **npm → release tarball → git source**. Once the catalog auto-collects the npm package, npm becomes the default install command and the tarball stays as a fallback (and as the site's alternate command). A `tarball:` line in the catalog YAML (added through a catalog PR) is what makes the prebuilt archive visible there; `npm:` is deliberately not a valid YAML key — that mapping is auto-collected, never hand-written.
112
+
113
+ Rules worth keeping in mind when updating:
114
+
115
+ - `screenshots.json` and `assets/` live in **this** repo — push here and they show up after the next nightly build, no catalog PR.
116
+ - The catalog's `icon`, `name`, `category` and zh/en descriptions live in the catalog YAML — changing those needs a PR to awesome-dsh-plugin (edit only your own entry; never hand-edit the generated READMEs).
117
+ - Bump `version` in `package.json` when you change code, and keep `cordis.patch.yml`'s `name:` equal to the installed package name — a mismatch makes `install_bundle` fail with `ERR_MODULE_NOT_FOUND`.
118
+ - The catalog rebuilds automatically after a merge (usually within a day); users on the China mirror read the `dsh-plugin-catalog` npm package, one build later.
119
+
90
120
  ### License
91
121
 
92
122
  MIT
@@ -105,17 +135,26 @@ MIT
105
135
 
106
136
  | | |
107
137
  | --- | --- |
108
- | DeepSeek Harness | `0.2.1-rc.5`(Web UI) |
138
+ | DeepSeek Harness | `0.2.1-rc.5` 或更新(Web UI;已在 `0.2.2-rc.1` 验证) |
109
139
  | Node.js | `>= 22` |
110
140
  | npm 依赖 | 运行时无依赖——面板只注入 `@deepseek-ai/dsh-client-locale` 和 `@deepseek-ai/dsh-client-ui-slots`,由 harness 提供 |
111
141
 
112
142
  ### 安装
113
143
 
114
- ```sh
115
- dsh plugin add github:stallenlu/dsh-game-center
116
- ```
144
+ 本插件已收录进 [awesome-dsh-plugin 精选列表](https://awesome-dsh-plugin.com/p/stallenlu/dsh-game-center/),因此会出现在 **dshmarket** 以及 DSH 的**插件 → 发现**里:搜索 *Game Center*、*游戏中心* 或 *Tetris*,点「安装」即可,这是最省事的方式。
145
+
146
+ 也可以从以下三个已发布渠道直接安装:
117
147
 
118
- 安装后重启 DSH(或刷新 Web UI),从侧边栏打开**游戏中心**。
148
+ | 渠道 | 命令 | 说明 |
149
+ | --- | --- | --- |
150
+ | dshmarket / 发现 | 搜索**游戏中心** | 一键安装;目录条目维护在 awesome-dsh-plugin |
151
+ | npm | `dsh plugin --profile web add dsh-game-center` | 预构建包 `dsh-game-center@1.2.0`;无构建脚本,不会弹 `allowBuilds` 授权 |
152
+ | GitHub Release tarball | `dsh plugin --profile web add "https://github.com/stallenlu/dsh-game-center/releases/latest/download/dsh-game-center.tgz"` | 预构建;资产名不含版本号,升级后链接仍有效 |
153
+ | Git 源码 | `dsh plugin --profile web add github:stallenlu/dsh-game-center` | 始终是 `main` 最新代码;推送代码后无需提 PR |
154
+
155
+ 单 profile 环境下 `dsh plugin add …` 不带 `--profile web` 效果相同。
156
+
157
+ 安装后请**完整重启** DSH(首次安装仅刷新页面不够),再从侧边栏打开**游戏中心**。
119
158
 
120
159
  ### 功能
121
160
 
@@ -154,7 +193,8 @@ index.js 宿主侧入口(无宿主行为)
154
193
  cordis.patch.yml 挂载 bundle 的 insert 行
155
194
  locale/*.json 插件列表的标题与描述
156
195
  icon.svg 应用内图标
157
- assets/ 商店图片
196
+ assets/ 商店图片(screenshot-1.png、screenshot-2.png、icon-180.png)
197
+ screenshots.json 目录采集的图片清单
158
198
  verify.mjs 可运行自检(node verify.mjs):接线 + 两款游戏的核心逻辑
159
199
  ```
160
200
 
@@ -172,6 +212,25 @@ verify.mjs 可运行自检(node verify.mjs):接线 + 两款游戏
172
212
  node verify.mjs
173
213
  ```
174
214
 
215
+ ### 分发与更新
216
+
217
+ 目录条目在 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)(`data/plugins/stallenlu__dsh-game-center.yml`),代码在本仓库。目录不保存代码副本、也不记录版本号——它在每次请求时派生安装目标。目前发布了三个渠道:
218
+
219
+ | 渠道 | 发布位置 | 更新如何到达用户 |
220
+ | --- | --- | --- |
221
+ | npm | registry.npmjs.org 上的 [`dsh-game-center@1.2.0`](https://www.npmjs.com/package/dsh-game-center) | `npm version patch && npm publish`;目录的 nightly `probe-npm.mjs` 校验 `repository` 指回本仓库后自动采集新版本——无需提 PR,更新检测改按版本号 |
222
+ | GitHub Release | [`v1.2.0`](https://github.com/stallenlu/dsh-game-center/releases/tag/v1.2.0),附资产 `dsh-game-center.tgz` | 发布新 Release 时保持**同名**资产,`releases/latest/download/dsh-game-center.tgz` 会一直可用;资产名不要带版本号,否则下次发版永久链 404 |
223
+ | Git 源码 | `main` | 直接 `git push`;按 commit 钉住的安装会在下次检查更新时看到——无需 PR、无需等待 |
224
+
225
+ `installTargetFor()` 的优先级是 **npm → Release tarball → Git 源码**:一旦目录自动采集到 npm 包,npm 就成为默认安装命令,tarball 退为兜底(以及站点上的备选命令)。目录 YAML 里的 `tarball:` 行(通过给目录提 PR 添加)让预构建包在站点可见;`npm:` 不是合法 YAML 键,该映射由脚本自动采集,不能手写。
226
+
227
+ 更新时还要记住:
228
+
229
+ - `screenshots.json` 与 `assets/` 在**本仓库**,这里 push 后下一次 nightly 构建即生效,不用给目录提 PR。
230
+ - 目录里的 `icon`、`name`、`category` 和中英描述在 YAML 里——改这些才需要向 awesome-dsh-plugin 提 PR(只改自己那一条;生成的 README 不要手改)。
231
+ - 改代码时顺手 bump `package.json` 的 `version`,并保证 `cordis.patch.yml` 的 `name:` 等于已安装包名——不一致会让 `install_bundle` 报 `ERR_MODULE_NOT_FOUND`。
232
+ - PR 合并后目录自动重建(通常一天内);中国区用户读 `dsh-plugin-catalog` npm 包,再晚一个构建周期。
233
+
175
234
  ### 许可
176
235
 
177
236
  MIT
package/client.js CHANGED
@@ -7,6 +7,8 @@ window.__ModuleLoader__.load({
7
7
 
8
8
  const NS = 'gameCenter';
9
9
  const PANEL_ID = 'game-center';
10
+ // Single source for the version shown on game cards. Bump together with package.json.
11
+ const VERSION = 'v1.2.0';
10
12
 
11
13
  const zh = {
12
14
  open: '游戏中心',
@@ -48,11 +50,13 @@ window.__ModuleLoader__.load({
48
50
  categoryAction: '动作',
49
51
  lives: '命数',
50
52
  loot: '剩余赃物',
51
- version: 'v1.0',
53
+ combo: '连击',
54
+ tspins: 'T 旋',
55
+ stun: '眩晕',
56
+ stunHint: '捡到闪光弹可以短暂定住全部警察。',
52
57
  status: '可游玩',
53
58
  empty: '后续开发的小游戏会自动出现在这里。',
54
59
  best: '最高分',
55
- howTo: '玩法',
56
60
  settings: '插件设置',
57
61
  settingsTitle: '设置',
58
62
  sidebarShow: '在侧边栏显示入口',
@@ -99,11 +103,13 @@ window.__ModuleLoader__.load({
99
103
  categoryAction: 'Action',
100
104
  lives: 'Lives',
101
105
  loot: 'Loot left',
102
- version: 'v1.0',
106
+ combo: 'Combo',
107
+ tspins: 'T-spins',
108
+ stun: 'Stun',
109
+ stunHint: 'Pick up a flashbang to freeze every cop for a moment.',
103
110
  status: 'Ready',
104
111
  empty: 'More mini-games will show up here as they are added.',
105
112
  best: 'Best',
106
- howTo: 'How to play',
107
113
  settings: 'Plugin Settings',
108
114
  settingsTitle: 'Settings',
109
115
  sidebarShow: 'Show entry in sidebar',
@@ -150,6 +156,7 @@ window.__ModuleLoader__.load({
150
156
  .gc-thumb { height: 104px; border-radius: 10px; background: var(--dsw-alias-bg-layer-2); display: grid; place-content: center; overflow: hidden; }
151
157
  .gc-thumb-cell { border-radius: 2px; opacity: .95; }
152
158
  .gc-thumb-cell.off { background: transparent; }
159
+ .gc-thumb-empty { height: 104px; border-radius: 10px; background: var(--dsw-alias-bg-layer-2); }
153
160
  .gc-card-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
154
161
  .gc-name { font-size: 16px; font-weight: 600; line-height: 22px; }
155
162
  .gc-badge { font-size: 11px; line-height: 16px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); }
@@ -190,6 +197,7 @@ window.__ModuleLoader__.load({
190
197
  .gc-chase-board { display: grid; grid-template-columns: repeat(15, 28px); grid-auto-rows: 28px; gap: 1px; padding: 6px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; }
191
198
  .gc-chase-cell { display: flex; align-items: center; justify-content: center; border-radius: 3px; background: var(--dsw-alias-bg-layer-1); font-size: 17px; line-height: 1; user-select: none; }
192
199
  .gc-chase-cell.wall { background: var(--dsw-alias-border-l3); }
200
+ .gc-chase-cell.frozen { opacity: .45; }
193
201
  `;
194
202
 
195
203
  function insertStyles(css) {
@@ -310,6 +318,13 @@ window.__ModuleLoader__.load({
310
318
  const COLORS = { I: '#38bdf8', J: '#3b82f6', L: '#f59e0b', O: '#facc15', S: '#22c55e', T: '#a855f7', Z: '#ef4444' };
311
319
  const TYPES = Object.keys(SHAPES);
312
320
  const LINE_SCORE = [0, 100, 300, 500, 800];
321
+ // A T-spin is scored from the same clear count but at a higher rate: the
322
+ // rotation that squeezes the piece into a gap is the hard part, so it pays
323
+ // more than a plain clear. Index 0 is a T-spin that cleared nothing.
324
+ const TSPIN_SCORE = [400, 800, 1200, 1600];
325
+ // Each clear in an unbroken run pays a little more; the run is worth 0 for
326
+ // its first clear and grows from there.
327
+ const COMBO_STEP = 50;
313
328
  const BLOCK_SHADE = 'inset 0 -3px 0 rgba(0,0,0,.22), inset 0 2px 0 rgba(255,255,255,.28)';
314
329
 
315
330
  function rotateShape(m) {
@@ -321,8 +336,36 @@ window.__ModuleLoader__.load({
321
336
  function emptyBoard() {
322
337
  return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
323
338
  }
324
- function randomType() {
325
- return TYPES[Math.floor(Math.random() * TYPES.length)];
339
+ // 7-bag: every bag holds each of the seven pieces once, so the longest wait
340
+ // for a piece is bounded and the board cannot be flooded by one shape.
341
+ // A fresh bag never opens with the piece that just landed, which removes the
342
+ // only repeat a plain bag still allows.
343
+ function shuffledBag(avoid) {
344
+ const bag = TYPES.slice();
345
+ for (let i = bag.length - 1; i > 0; i--) {
346
+ const j = Math.floor(Math.random() * (i + 1));
347
+ const t = bag[i]; bag[i] = bag[j]; bag[j] = t;
348
+ }
349
+ if (bag[0] === avoid) {
350
+ const k = 1 + Math.floor(Math.random() * (bag.length - 1));
351
+ const t = bag[0]; bag[0] = bag[k]; bag[k] = t;
352
+ }
353
+ return bag;
354
+ }
355
+ // Draw the next piece, refilling the bag when it runs out.
356
+ function drawType(bag, avoid) {
357
+ const pool = Array.isArray(bag) && bag.length ? bag : shuffledBag(avoid);
358
+ return { type: pool[0], bag: pool.slice(1) };
359
+ }
360
+ // A piece that has landed may still be slid or turned for this long…
361
+ const LOCK_DELAY = 500;
362
+ // …and every such nudge re-arms the timer, up to this many times.
363
+ const LOCK_RESETS = 15;
364
+ // Any successful nudge on the ground restarts the lock timer, a bounded
365
+ // number of times so a piece cannot be hovered forever.
366
+ function rearm(s) {
367
+ if (!s.groundedAt || s.lockResets >= LOCK_RESETS) return s;
368
+ return { ...s, groundedAt: Date.now(), lockResets: s.lockResets + 1 };
326
369
  }
327
370
  function makePiece(type) {
328
371
  const shape = SHAPES[type].map((row) => row.slice());
@@ -361,46 +404,101 @@ window.__ModuleLoader__.load({
361
404
  while (kept.length < ROWS) kept.unshift(Array(COLS).fill(null));
362
405
  return { board: kept, cleared };
363
406
  }
407
+ // A T-spin is a T piece that was turned into its slot and then locked
408
+ // without being slid: three of the four corners around the piece's centre
409
+ // are blocked. It is checked at lock time, because only then is the final
410
+ // position of the piece known.
411
+ function isTSpin(board, piece, rotated) {
412
+ if (!rotated || piece.type !== 'T') return false;
413
+ const cx = piece.x + 1;
414
+ const cy = piece.y + 1;
415
+ const blocked = (x, y) => x < 0 || x >= COLS || y < 0 || y >= ROWS || Boolean(board[y][x]);
416
+ let corners = 0;
417
+ if (blocked(cx - 1, cy - 1)) corners += 1;
418
+ if (blocked(cx + 1, cy - 1)) corners += 1;
419
+ if (blocked(cx - 1, cy + 1)) corners += 1;
420
+ if (blocked(cx + 1, cy + 1)) corners += 1;
421
+ return corners >= 3;
422
+ }
364
423
  function initialState(startLevel) {
424
+ const first = drawType([], null);
425
+ const second = drawType(first.bag, first.type);
426
+ const level = startLevel || 1;
365
427
  return {
366
- board: emptyBoard(), piece: makePiece(randomType()), next: makePiece(randomType()),
367
- score: 0, lines: 0, level: startLevel || 1, over: false, paused: false, started: false,
428
+ board: emptyBoard(), piece: makePiece(first.type), next: makePiece(second.type),
429
+ bag: second.bag, startLevel: level,
430
+ score: 0, lines: 0, level, over: false, paused: false, started: false,
431
+ groundedAt: 0, lockResets: 0,
432
+ rotated: false, combo: 0, tspins: 0,
368
433
  };
369
434
  }
435
+ // The piece has landed: hold it for the lock delay, then commit it. A hard
436
+ // drop skips the wait and commits at once.
437
+ function lockOrWait(s) {
438
+ if (!s.groundedAt) return { ...s, groundedAt: Date.now(), lockResets: 0 };
439
+ if (Date.now() - s.groundedAt < LOCK_DELAY) return s;
440
+ return lockAndSpawn(s, merge(s.board, s.piece));
441
+ }
370
442
  function lockAndSpawn(s, boardWithPiece) {
371
443
  const { board, cleared } = clearLines(boardWithPiece);
372
444
  const lines = s.lines + cleared;
373
- const level = Math.floor(lines / 10) + 1;
374
- const score = s.score + LINE_SCORE[cleared] * s.level;
445
+ // The chosen difficulty sets the *starting* level; clearing lines raises it
446
+ // from there. Recomputing from lines alone used to throw the start away.
447
+ const level = (s.startLevel || 1) + Math.floor(lines / 10);
448
+ // A T-spin pays the T-spin table instead of the plain one; a clear in an
449
+ // unbroken run of clears adds a combo bonus on top of either.
450
+ const tspin = isTSpin(s.board, s.piece, s.rotated);
451
+ const base = tspin
452
+ ? TSPIN_SCORE[Math.min(cleared, TSPIN_SCORE.length - 1)]
453
+ : LINE_SCORE[cleared];
454
+ const combo = cleared ? (s.combo || 0) + 1 : 0;
455
+ const bonus = combo > 1 ? COMBO_STEP * (combo - 1) : 0;
456
+ const score = s.score + (base + bonus) * s.level;
375
457
  const piece = s.next;
458
+ const draw = drawType(s.bag, piece.type);
376
459
  const over = collides(board, piece.shape, piece.x, piece.y);
377
- return { ...s, board, piece, next: makePiece(randomType()), score, lines, level, over };
460
+ return {
461
+ ...s, board, piece, next: makePiece(draw.type), bag: draw.bag,
462
+ score, lines, level, over, groundedAt: 0, lockResets: 0,
463
+ rotated: false, combo, tspins: (s.tspins || 0) + (tspin ? 1 : 0),
464
+ };
378
465
  }
379
466
  function step(s) {
380
467
  if (!s.started || s.over || s.paused) return s;
381
468
  if (!collides(s.board, s.piece.shape, s.piece.x, s.piece.y + 1)) {
382
- return { ...s, piece: { ...s.piece, y: s.piece.y + 1 } };
469
+ const piece = { ...s.piece, y: s.piece.y + 1 };
470
+ const landed = collides(s.board, piece.shape, piece.x, piece.y + 1);
471
+ // Falling or sliding after a turn means the piece was not turned into
472
+ // its slot, so the T-spin flag only survives a piece that stays put.
473
+ return { ...s, piece, groundedAt: landed ? Date.now() : 0, lockResets: 0, rotated: false };
383
474
  }
384
- return lockAndSpawn(s, merge(s.board, s.piece));
475
+ return lockOrWait(s);
385
476
  }
386
477
  function move(s, dx) {
387
478
  if (!s.started || s.over || s.paused) return s;
388
479
  if (collides(s.board, s.piece.shape, s.piece.x + dx, s.piece.y)) return s;
389
- return { ...s, piece: { ...s.piece, x: s.piece.x + dx } };
480
+ return rearm({ ...s, piece: { ...s.piece, x: s.piece.x + dx }, rotated: false });
390
481
  }
391
482
  function softDrop(s) {
392
483
  if (!s.started || s.over || s.paused) return s;
393
484
  if (!collides(s.board, s.piece.shape, s.piece.x, s.piece.y + 1)) {
394
- return { ...s, piece: { ...s.piece, y: s.piece.y + 1 }, score: s.score + 1 };
485
+ return { ...s, piece: { ...s.piece, y: s.piece.y + 1 }, score: s.score + 1, rotated: false };
395
486
  }
396
- return lockAndSpawn(s, merge(s.board, s.piece));
487
+ return lockOrWait(s);
397
488
  }
489
+ // Wall kicks, tried in order: in place, sideways off a wall, then one row up
490
+ // off the floor. The upward ones are what let a vertical bar turn once it is
491
+ // resting — with side kicks alone that rotation is simply refused. They are
492
+ // capped at a single row so a piece cannot be rotated upward for free.
493
+ const ROTATE_KICKS = [[0, 0], [-1, 0], [1, 0], [-2, 0], [2, 0], [0, -1], [-1, -1], [1, -1]];
398
494
  function rotate(s) {
399
495
  if (!s.started || s.over || s.paused) return s;
400
496
  const shape = rotateShape(s.piece.shape);
401
- for (const dx of [0, -1, 1, -2, 2]) {
402
- if (!collides(s.board, shape, s.piece.x + dx, s.piece.y)) {
403
- return { ...s, piece: { ...s.piece, shape, x: s.piece.x + dx } };
497
+ for (const [dx, dy] of ROTATE_KICKS) {
498
+ if (!collides(s.board, shape, s.piece.x + dx, s.piece.y + dy)) {
499
+ // `rotated` is what makes a T-spin possible: the piece must be turned
500
+ // and then locked without being moved again.
501
+ return rearm({ ...s, piece: { ...s.piece, shape, x: s.piece.x + dx, y: s.piece.y + dy }, rotated: true });
404
502
  }
405
503
  }
406
504
  return s;
@@ -411,7 +509,12 @@ window.__ModuleLoader__.load({
411
509
  let dropped = 0;
412
510
  while (!collides(s.board, s.piece.shape, s.piece.x, y + 1)) { y++; dropped++; }
413
511
  const piece = { ...s.piece, y };
414
- return lockAndSpawn({ ...s, score: s.score + dropped * 2 }, merge(s.board, piece));
512
+ // A hard drop that actually travels is a move, so it cancels the turn the
513
+ // same way gravity does; dropping an already resting piece keeps it.
514
+ return lockAndSpawn(
515
+ { ...s, score: s.score + dropped * 2, rotated: dropped === 0 ? s.rotated : false },
516
+ merge(s.board, piece),
517
+ );
415
518
  }
416
519
  function ghostY(s) {
417
520
  let y = s.piece.y;
@@ -544,6 +647,7 @@ window.__ModuleLoader__.load({
544
647
  });
545
648
  const sRef = useRef(s);
546
649
  sRef.current = s;
650
+ const rootRef = useRef(null);
547
651
 
548
652
  useEffect(() => {
549
653
  if (s.score > best) {
@@ -552,7 +656,10 @@ window.__ModuleLoader__.load({
552
656
  }
553
657
  }, [s.score, best]);
554
658
 
555
- const speed = Math.max(90, 800 - (s.level - 1) * 70);
659
+ // Gravity keeps tightening with the level instead of flattening out at a
660
+ // floor after ~11 levels: a fixed 10% cut per level still reaches the
661
+ // 60ms floor, but only around level 25, so late levels stay distinguishable.
662
+ const speed = Math.max(60, Math.round(800 * Math.pow(0.9, s.level - 1)));
556
663
  useEffect(() => {
557
664
  if (!s.started || s.over || s.paused) return undefined;
558
665
  const id = setInterval(() => setS(step), speed);
@@ -561,6 +668,8 @@ window.__ModuleLoader__.load({
561
668
 
562
669
  useEffect(() => {
563
670
  const onKey = (e) => {
671
+ const root = rootRef.current;
672
+ if (!root || !root.contains(e.target)) return;
564
673
  const el = e.target;
565
674
  if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || ''))) return;
566
675
  const cur = sRef.current;
@@ -580,7 +689,10 @@ window.__ModuleLoader__.load({
580
689
  return () => window.removeEventListener('keydown', onKey);
581
690
  }, []);
582
691
 
583
- const start = () => setS({ ...initialState(levelForDifficulty(difficulty)), started: true });
692
+ const start = () => {
693
+ setS({ ...initialState(levelForDifficulty(difficulty)), started: true });
694
+ if (rootRef.current) rootRef.current.focus();
695
+ };
584
696
  // Back to the start screen, so the difficulty is always reachable.
585
697
  const reset = () => setS(initialState());
586
698
  let overlay = null;
@@ -602,7 +714,7 @@ window.__ModuleLoader__.load({
602
714
  h('button', { className: 'gc-btn', type: 'button', onClick: () => setS((v) => ({ ...v, paused: false })) }, t('resume')));
603
715
  }
604
716
 
605
- return h('div', { className: 'gc-play' },
717
+ return h('div', { className: 'gc-play', ref: rootRef, tabIndex: -1 },
606
718
  h('div', { className: 'gc-board-wrap' }, h(Board, { s }), overlay),
607
719
  h('div', { className: 'gc-side' },
608
720
  h('div', { className: 'gc-panel' },
@@ -610,7 +722,9 @@ window.__ModuleLoader__.load({
610
722
  h('div', { className: 'gc-stat' }, h('span', { className: 'gc-stat-label' }, t('score')), h('span', { className: 'gc-stat-value' }, s.score)),
611
723
  h('div', { className: 'gc-stat' }, h('span', { className: 'gc-stat-label' }, t('best')), h('span', { className: 'gc-stat-value' }, best)),
612
724
  h('div', { className: 'gc-stat' }, h('span', { className: 'gc-stat-label' }, t('lines')), h('span', { className: 'gc-stat-value' }, s.lines)),
613
- h('div', { className: 'gc-stat' }, h('span', { className: 'gc-stat-label' }, t('level')), h('span', { className: 'gc-stat-value' }, s.level))),
725
+ h('div', { className: 'gc-stat' }, h('span', { className: 'gc-stat-label' }, t('level')), h('span', { className: 'gc-stat-value' }, s.level)),
726
+ h('div', { className: 'gc-stat' }, h('span', { className: 'gc-stat-label' }, t('combo')), h('span', { className: 'gc-stat-value' }, s.combo || 0)),
727
+ h('div', { className: 'gc-stat' }, h('span', { className: 'gc-stat-label' }, t('tspins')), h('span', { className: 'gc-stat-value' }, s.tspins || 0))),
614
728
  h('div', { className: 'gc-panel' },
615
729
  h('div', { className: 'gc-panel-title' }, t('next')),
616
730
  h(NextPreview, { piece: s.next })),
@@ -654,6 +768,11 @@ window.__ModuleLoader__.load({
654
768
  const CHASE_ROWS = CHASE_MAP.length;
655
769
  const CHASE_WALL = CHASE_MAP.map((row) => Array.from(row, (ch) => ch === '#'));
656
770
  const CHASE_LOOT = [[3, 3], [7, 3], [11, 3], [3, 9], [7, 6], [11, 9]];
771
+ // One flashbang per level, on a floor cell no loot and no spawn uses. It is
772
+ // the only way to break a tightening encirclement, so it is worth a detour.
773
+ const CHASE_STUN_CELL = { x: 7, y: 5 };
774
+ // How many player ticks the police stay frozen after a flashbang.
775
+ const CHASE_STUN_TICKS = 12;
657
776
  const CHASE_THIEF_START = { x: 7, y: 9 };
658
777
  const CHASE_COP_STARTS = [
659
778
  { x: 1, y: 1 }, { x: 13, y: 1 }, { x: 7, y: 1 },
@@ -671,12 +790,23 @@ window.__ModuleLoader__.load({
671
790
  }
672
791
 
673
792
  // Level N of the chase: how many police, how fast they step, how often.
793
+ // The clock decays by a fixed ratio instead of subtracting a flat 10ms, so
794
+ // it keeps tightening all the way to its floor (with the old straight-line
795
+ // drop the clock and the cop count both bottomed out around level 7 and
796
+ // every later level played identically).
797
+ //
798
+ // copEvery is floored at 2 on purpose: at 1 the police step on every single
799
+ // player tick, so a thief can never outrun them and the round ends in about
800
+ // half the ticks no matter how well it is played (measured: 27 vs 54 ticks
801
+ // of survival at level 10). Police may gain on the thief, never match it.
802
+ const CHASE_TICK_FLOOR = 90;
803
+ const CHASE_COP_EVERY_FLOOR = 2;
674
804
  function chaseLevel(difficulty, level) {
675
805
  const d = DIFFICULTIES.find((x) => x.id === difficulty) || DIFFICULTIES[1];
676
806
  return {
677
807
  cops: Math.min(CHASE_COP_STARTS.length, d.police + Math.floor(level / 2)),
678
- tick: Math.max(90, d.tick - (level - 1) * 10),
679
- copEvery: d.copEvery,
808
+ tick: Math.max(CHASE_TICK_FLOOR, Math.round(d.tick * Math.pow(0.94, level - 1))),
809
+ copEvery: Math.max(CHASE_COP_EVERY_FLOOR, d.copEvery - Math.floor((level - 1) / 4)),
680
810
  };
681
811
  }
682
812
 
@@ -719,11 +849,16 @@ window.__ModuleLoader__.load({
719
849
  dir: null,
720
850
  want: null,
721
851
  ticks: 0,
852
+ stun: 0,
722
853
  cops: CHASE_COP_STARTS.slice(0, cfg.cops).map((c) => ({ x: c.x, y: c.y })),
723
854
  };
724
855
  }
725
856
  function chaseRespawn(s) {
726
- return { ...chaseReset(s), loot: CHASE_LOOT.map((p) => ({ x: p[0], y: p[1] })) };
857
+ return {
858
+ ...chaseReset(s),
859
+ loot: CHASE_LOOT.map((p) => ({ x: p[0], y: p[1] })),
860
+ item: { x: CHASE_STUN_CELL.x, y: CHASE_STUN_CELL.y },
861
+ };
727
862
  }
728
863
  function chaseInitial(difficulty) {
729
864
  return chaseRespawn({
@@ -761,8 +896,16 @@ window.__ModuleLoader__.load({
761
896
  return chaseRespawn(next);
762
897
  }
763
898
  }
899
+ // A flashbang freezes every cop for a few ticks — a way out of a corner
900
+ // once the net has closed, but it does not touch the loot or the clock.
901
+ if (next.item && next.item.x === thief.x && next.item.y === thief.y) {
902
+ next.item = null;
903
+ next.stun = CHASE_STUN_TICKS;
904
+ next.score = next.score + 50;
905
+ }
764
906
 
765
907
  if (next.cops.some((c) => c.x === thief.x && c.y === thief.y)) return chaseCaught(next);
908
+ if (next.stun > 0) return { ...next, stun: next.stun - 1 };
766
909
  if (next.ticks % cfg.copEvery !== 0) return next;
767
910
  next.cops = next.cops.map((cop) => chaseStep(cop, thief) || cop);
768
911
  if (next.cops.some((c) => c.x === thief.x && c.y === thief.y)) return chaseCaught(next);
@@ -774,18 +917,24 @@ window.__ModuleLoader__.load({
774
917
  const copAt = new Set(s.cops.map(key));
775
918
  const lootAt = new Set(s.loot.map(key));
776
919
  const thiefAt = key(s.thief);
920
+ const itemAt = s.item ? key(s.item) : -1;
921
+ // While stunned the police still hold their cells; the class dims them so
922
+ // the freeze is visible instead of looking like a bug.
923
+ const stunned = s.stun > 0;
777
924
  const cells = [];
778
925
  for (let r = 0; r < CHASE_ROWS; r++) {
779
926
  for (let c = 0; c < CHASE_COLS; c++) {
780
927
  const wall = CHASE_WALL[r][c];
781
928
  const k = r * CHASE_COLS + c;
782
929
  let glyph = null;
930
+ let extra = '';
783
931
  if (!wall) {
784
- if (copAt.has(k)) glyph = '👮';
932
+ if (copAt.has(k)) { glyph = '👮'; if (stunned) extra = ' frozen'; }
785
933
  else if (k === thiefAt) glyph = '🥷';
786
934
  else if (lootAt.has(k)) glyph = '💰';
935
+ else if (k === itemAt) glyph = '💥';
787
936
  }
788
- cells.push(h('div', { key: k, className: 'gc-chase-cell' + (wall ? ' wall' : '') }, glyph));
937
+ cells.push(h('div', { key: k, className: 'gc-chase-cell' + (wall ? ' wall' : '') + extra }, glyph));
789
938
  }
790
939
  }
791
940
  return h('div', { className: 'gc-chase-board' }, cells);
@@ -805,6 +954,7 @@ window.__ModuleLoader__.load({
805
954
  });
806
955
  const sRef = useRef(s);
807
956
  sRef.current = s;
957
+ const rootRef = useRef(null);
808
958
 
809
959
  useEffect(() => {
810
960
  if (s.score > best) {
@@ -822,6 +972,8 @@ window.__ModuleLoader__.load({
822
972
 
823
973
  useEffect(() => {
824
974
  const onKey = (e) => {
975
+ const root = rootRef.current;
976
+ if (!root || !root.contains(e.target)) return;
825
977
  const el = e.target;
826
978
  if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || ''))) return;
827
979
  const cur = sRef.current;
@@ -836,7 +988,10 @@ window.__ModuleLoader__.load({
836
988
  return () => window.removeEventListener('keydown', onKey);
837
989
  }, []);
838
990
 
839
- const start = () => setS({ ...chaseInitial(difficulty), started: true });
991
+ const start = () => {
992
+ setS({ ...chaseInitial(difficulty), started: true });
993
+ if (rootRef.current) rootRef.current.focus();
994
+ };
840
995
  // Back to the start screen, so the difficulty is always reachable.
841
996
  const reset = () => setS(chaseInitial(difficulty));
842
997
  let overlay = null;
@@ -844,6 +999,7 @@ window.__ModuleLoader__.load({
844
999
  overlay = h('div', { className: 'gc-overlay' },
845
1000
  h('div', { className: 'gc-overlay-title' }, t('chase')),
846
1001
  h(Controls, { t, rows: chaseControlRows(t) }),
1002
+ h('div', { className: 'gc-overlay-sub' }, t('stunHint')),
847
1003
  h(DifficultyPicker, { t, value: difficulty, onChange: setDifficultyValue }),
848
1004
  h('button', { className: 'gc-btn', type: 'button', onClick: start }, t('start')));
849
1005
  } else if (s.over) {
@@ -861,7 +1017,7 @@ window.__ModuleLoader__.load({
861
1017
  }, t('resume')));
862
1018
  }
863
1019
 
864
- return h('div', { className: 'gc-play' },
1020
+ return h('div', { className: 'gc-play', ref: rootRef, tabIndex: -1 },
865
1021
  h('div', { className: 'gc-board-wrap' }, h(ChaseBoard, { s }), overlay),
866
1022
  h('div', { className: 'gc-side' },
867
1023
  h('div', { className: 'gc-panel' },
@@ -880,7 +1036,10 @@ window.__ModuleLoader__.load({
880
1036
  h('span', { className: 'gc-stat-value' }, s.lives)),
881
1037
  h('div', { className: 'gc-stat' },
882
1038
  h('span', { className: 'gc-stat-label' }, t('loot')),
883
- h('span', { className: 'gc-stat-value' }, s.loot.length))),
1039
+ h('span', { className: 'gc-stat-value' }, s.loot.length)),
1040
+ h('div', { className: 'gc-stat' },
1041
+ h('span', { className: 'gc-stat-label' }, t('stun')),
1042
+ h('span', { className: 'gc-stat-value' }, s.stun > 0 ? s.stun : '—'))),
884
1043
  h('div', { className: 'gc-panel' },
885
1044
  h('div', { className: 'gc-panel-title' }, t('controls')),
886
1045
  h(Controls, { t, rows: chaseControlRows(t) })),
@@ -894,9 +1053,9 @@ window.__ModuleLoader__.load({
894
1053
  }
895
1054
 
896
1055
  /* ------------------------------------------------------------------ *
897
- * Registry — one entry per mini-game. Adding a game = appending here.
898
- * ponytail: static in-bundle list; upgrade path: load from host storage
899
- * if games ever become installable without shipping a new bundle.
1056
+ * Registry — one entry per mini-game. The built-ins are listed here; any
1057
+ * other bundle loaded beside this one can append its own through the
1058
+ * exported `registerGame` and this panel picks it up without a reload.
900
1059
  * ------------------------------------------------------------------ */
901
1060
  // Card artwork is built from each game's own data, so the picture on the
902
1061
  // card cannot drift from what the game actually is.
@@ -932,7 +1091,7 @@ window.__ModuleLoader__.load({
932
1091
  nameKey: 'tetris',
933
1092
  descKey: 'tetrisDesc',
934
1093
  categoryKey: 'category',
935
- version: 'v1.0',
1094
+ version: VERSION,
936
1095
  statusKey: 'status',
937
1096
  thumb: tetrisThumb(),
938
1097
  thumbCell: 11,
@@ -944,7 +1103,7 @@ window.__ModuleLoader__.load({
944
1103
  nameKey: 'chase',
945
1104
  descKey: 'chaseDesc',
946
1105
  categoryKey: 'categoryAction',
947
- version: 'v1.0',
1106
+ version: VERSION,
948
1107
  statusKey: 'status',
949
1108
  thumb: chaseThumb(),
950
1109
  thumbCell: 6,
@@ -953,10 +1112,48 @@ window.__ModuleLoader__.load({
953
1112
  },
954
1113
  ];
955
1114
 
1115
+ // Games registered by other bundles. They are kept apart from the built-in
1116
+ // list so a bad entry can never take the two shipped games down with it, and
1117
+ // so the screens read from one array (`allGames()`).
1118
+ const extraGames = [];
1119
+ const gameListeners = new Set();
1120
+ // A game entry needs, at minimum, a unique id and a render function; the
1121
+ // thumb is optional and a plain badge is drawn when it is missing.
1122
+ function registerGame(entry) {
1123
+ if (!entry || typeof entry !== 'object') return () => {};
1124
+ if (typeof entry.id !== 'string' || !entry.id) return () => {};
1125
+ if (typeof entry.render !== 'function') return () => {};
1126
+ if (GAMES.some((g) => g.id === entry.id) || extraGames.some((g) => g.id === entry.id)) return () => {};
1127
+ extraGames.push(entry);
1128
+ for (const fn of Array.from(gameListeners)) { try { fn(); } catch (e) { /* ignore listener errors */ } }
1129
+ return () => {
1130
+ const i = extraGames.indexOf(entry);
1131
+ if (i < 0) return;
1132
+ extraGames.splice(i, 1);
1133
+ for (const fn of Array.from(gameListeners)) { try { fn(); } catch (e) { /* ignore */ } }
1134
+ };
1135
+ }
1136
+ function allGames() {
1137
+ return GAMES.concat(extraGames);
1138
+ }
1139
+ function subscribeGames(fn) {
1140
+ gameListeners.add(fn);
1141
+ return () => gameListeners.delete(fn);
1142
+ }
1143
+ function useGames() {
1144
+ const [games, setGames] = useState(allGames);
1145
+ useEffect(() => subscribeGames(() => setGames(allGames())), []);
1146
+ return games;
1147
+ }
1148
+
956
1149
  // Draws a game.thumb matrix (falsy = empty, else a colour) as a grid, sized
957
1150
  // per game so a 15x13 maze and a 10x8 tetris board both fit the card.
958
1151
  function GameThumb({ game }) {
959
1152
  const rows = game.thumb;
1153
+ // A game registered from outside may ship no artwork; the card still works.
1154
+ if (!Array.isArray(rows) || !rows.length || !Array.isArray(rows[0])) {
1155
+ return h('div', { className: 'gc-thumb gc-thumb-empty' });
1156
+ }
960
1157
  const cols = rows[0].length;
961
1158
  const cell = game.thumbCell || 19;
962
1159
  const gap = game.thumbGap === undefined ? 3 : game.thumbGap;
@@ -982,15 +1179,20 @@ window.__ModuleLoader__.load({
982
1179
  }
983
1180
 
984
1181
  function GameCard({ game, t, onPlay }) {
1182
+ // nameKey/descKey let a bundled game reuse the panel's own translations;
1183
+ // a game registered from outside can carry plain `name`/`desc` strings.
1184
+ const name = game.nameKey ? t(game.nameKey) : String(game.name || game.id);
1185
+ const desc = game.descKey ? t(game.descKey) : String(game.desc || '');
1186
+ const category = game.categoryKey ? t(game.categoryKey) : String(game.category || '');
985
1187
  return h('div', { className: 'gc-card' },
986
1188
  h(GameThumb, { game }),
987
1189
  h('div', { className: 'gc-card-top' },
988
- h('span', { className: 'gc-name' }, t(game.nameKey)),
989
- h('span', { className: 'gc-badge' }, t(game.categoryKey)),
990
- h('span', { className: 'gc-badge ready' }, t(game.statusKey))),
991
- h('div', { className: 'gc-desc' }, t(game.descKey)),
1190
+ h('span', { className: 'gc-name' }, name),
1191
+ category ? h('span', { className: 'gc-badge' }, category) : null,
1192
+ h('span', { className: 'gc-badge ready' }, game.statusKey ? t(game.statusKey) : t('status'))),
1193
+ h('div', { className: 'gc-desc' }, desc),
992
1194
  h('div', { className: 'gc-card-foot' },
993
- h('span', { className: 'gc-meta' }, game.version),
1195
+ h('span', { className: 'gc-meta' }, String(game.version || VERSION)),
994
1196
  h('button', { className: 'gc-btn', type: 'button', onClick: onPlay }, t('play'))));
995
1197
  }
996
1198
 
@@ -998,13 +1200,14 @@ window.__ModuleLoader__.load({
998
1200
  const t = useTranslate(locale);
999
1201
  const [selected, setSelected] = useState(null);
1000
1202
  const [showSettings, setShowSettings] = useState(false);
1001
- const game = selected ? GAMES.find((g) => g.id === selected) : null;
1203
+ const games = useGames();
1204
+ const game = selected ? games.find((g) => g.id === selected) : null;
1002
1205
 
1003
1206
  if (game) {
1004
1207
  return h('div', { className: 'gc-root gc-play-root' },
1005
1208
  h('div', { className: 'gc-crumbs' },
1006
1209
  h('button', { className: 'gc-btn ghost small', type: 'button', onClick: () => setSelected(null) }, '← ' + t('backToGames')),
1007
- h('div', { className: 'gc-playing' }, t('nowPlaying'), h('b', null, t(game.nameKey)))),
1210
+ h('div', { className: 'gc-playing' }, t('nowPlaying'), h('b', null, game.nameKey ? t(game.nameKey) : String(game.name || game.id)))),
1008
1211
  game.render(t));
1009
1212
  }
1010
1213
 
@@ -1021,7 +1224,7 @@ window.__ModuleLoader__.load({
1021
1224
  h('div', { className: 'gc-title' }, t('title')),
1022
1225
  h('div', { className: 'gc-sub' }, t('subtitle'))),
1023
1226
  h('div', { className: 'gc-head-actions' },
1024
- h('div', { className: 'gc-count' }, t('gameCount', { n: GAMES.length })),
1227
+ h('div', { className: 'gc-count' }, t('gameCount', { n: games.length })),
1025
1228
  h('div', { className: 'gc-settings-wrap' },
1026
1229
  h('button', {
1027
1230
  className: 'gc-settings-btn', type: 'button', 'aria-expanded': showSettings,
@@ -1030,7 +1233,7 @@ window.__ModuleLoader__.load({
1030
1233
  settingsPop))),
1031
1234
  h('div', { className: 'gc-section-title' }, t('games')),
1032
1235
  h('div', { className: 'gc-grid' },
1033
- GAMES.map((g) => h(GameCard, { key: g.id, game: g, t, onPlay: () => setSelected(g.id) }))),
1236
+ games.map((g) => h(GameCard, { key: g.id, game: g, t, onPlay: () => setSelected(g.id) }))),
1034
1237
  h('div', { className: 'gc-empty' }, t('empty')));
1035
1238
  }
1036
1239
 
@@ -1054,6 +1257,11 @@ window.__ModuleLoader__.load({
1054
1257
 
1055
1258
  return {
1056
1259
  inject: ['slots', 'locale'],
1260
+ // Games shipped by other bundles attach through this. It is deliberately a
1261
+ // plain function on the module, not a slot: a game is data + a render
1262
+ // function, and the panel owns the frame around it.
1263
+ registerGame,
1264
+ listGames: allGames,
1057
1265
  apply(ctx) {
1058
1266
  try {
1059
1267
  const locale = ctx.get('locale');
package/locale/en.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Game Center",
4
- "description": "Manage every mini-game built in DSH and play them from one panel. Ships with Tetris."
4
+ "description": "Manage every mini-game built in DSH and play them from one panel. Ships with Tetris and Cops & Robbers."
5
5
  },
6
6
  "open": "Game Center",
7
7
  "title": "Game Center",
@@ -24,13 +24,34 @@
24
24
  "resume": "Resume",
25
25
  "restart": "Restart",
26
26
  "start": "Start",
27
- "hint": "← → move · ↑ / X rotate · ↓ soft drop · Space hard drop · P pause",
27
+ "ctrlMove": "Move",
28
+ "ctrlRotate": "Rotate",
29
+ "ctrlSoftDrop": "Soft drop",
30
+ "ctrlHardDrop": "Hard drop",
31
+ "ctrlPause": "Pause",
32
+ "keySpace": "Space",
33
+ "difficulty": "Difficulty",
34
+ "diffEasy": "Easy",
35
+ "diffNormal": "Normal",
36
+ "diffHard": "Hard",
28
37
  "tetris": "Tetris",
29
38
  "tetrisDesc": "The classic falling-block puzzle. Clear lines, level up, chase a high score.",
39
+ "chase": "Cops & Robbers",
40
+ "chaseDesc": "Be the thief: grab every piece of loot to clear the level before the police catch you. Each level sends more of them, and they get faster.",
30
41
  "category": "Puzzle",
31
- "version": "v1.0",
42
+ "categoryAction": "Action",
43
+ "lives": "Lives",
44
+ "loot": "Loot left",
45
+ "combo": "Combo",
46
+ "tspins": "T-spins",
47
+ "stun": "Stun",
48
+ "stunHint": "Pick up a flashbang to freeze every cop for a moment.",
32
49
  "status": "Ready",
33
50
  "empty": "More mini-games will show up here as they are added.",
34
51
  "best": "Best",
35
- "howTo": "How to play"
52
+ "settings": "Plugin Settings",
53
+ "settingsTitle": "Settings",
54
+ "sidebarShow": "Show entry in sidebar",
55
+ "sidebarShowHint": "When off, the Game Center entry is hidden from the sidebar. You can re-enable it here anytime.",
56
+ "reset": "Reset"
36
57
  }
package/locale/zh.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "游戏中心",
4
- "description": "统一管理在 DSH 里开发的所有小游戏,并可直接游玩。首发游戏:俄罗斯方块。"
4
+ "description": "统一管理在 DSH 里开发的所有小游戏,并可直接游玩。自带俄罗斯方块和警察抓小偷。"
5
5
  },
6
6
  "open": "游戏中心",
7
7
  "title": "游戏中心",
@@ -23,14 +23,35 @@
23
23
  "pause": "暂停",
24
24
  "resume": "继续",
25
25
  "restart": "重新开始",
26
- "start": "开始",
27
- "hint": "← → 移动 · ↑ / X 旋转 · ↓ 软降 · 空格 硬降 · P 暂停",
26
+ "start": "开始游戏",
27
+ "ctrlMove": "移动",
28
+ "ctrlRotate": "旋转",
29
+ "ctrlSoftDrop": "软降",
30
+ "ctrlHardDrop": "硬降",
31
+ "ctrlPause": "暂停",
32
+ "keySpace": "空格",
33
+ "difficulty": "难度",
34
+ "diffEasy": "简单",
35
+ "diffNormal": "普通",
36
+ "diffHard": "困难",
28
37
  "tetris": "俄罗斯方块",
29
38
  "tetrisDesc": "经典落块益智游戏。消除整行、提升等级、刷新最高分。",
39
+ "chase": "警察抓小偷",
40
+ "chaseDesc": "扮演小偷,把赃物全部捡走就能过关,同时别让警察抓住。每过一关警察都会更多、更快。",
30
41
  "category": "益智",
31
- "version": "v1.0",
42
+ "categoryAction": "动作",
43
+ "lives": "命数",
44
+ "loot": "剩余赃物",
45
+ "combo": "连击",
46
+ "tspins": "T 旋",
47
+ "stun": "眩晕",
48
+ "stunHint": "捡到闪光弹可以短暂定住全部警察。",
32
49
  "status": "可游玩",
33
50
  "empty": "后续开发的小游戏会自动出现在这里。",
34
51
  "best": "最高分",
35
- "howTo": "玩法"
52
+ "settings": "插件设置",
53
+ "settingsTitle": "设置",
54
+ "sidebarShow": "在侧边栏显示入口",
55
+ "sidebarShowHint": "关闭后,游戏中心入口将从侧边栏隐藏。可在此面板内随时重新开启。",
56
+ "reset": "恢复默认"
36
57
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-game-center",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
- "description": "Game Center for DSH: a panel that manages every mini-game built here and lets you play them. Ships with Tetris.",
5
+ "description": "Game Center for DSH: a panel that manages every mini-game built here and lets you play them. Ships with Tetris and Cops & Robbers.",
6
6
  "exports": {
7
7
  ".": "./index.js",
8
8
  "./client": "./client.js",