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 +72 -13
- package/client.js +254 -46
- package/locale/en.json +25 -4
- package/locale/zh.json +26 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[English](#english) | [中文](#中文)
|
|
4
4
|
|
|
5
|
-

|
|
6
|
+

|
|
6
7
|

|
|
7
8
|

|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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(
|
|
367
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
402
|
-
if (!collides(s.board, shape, s.piece.x + dx, s.piece.y)) {
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
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(
|
|
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 {
|
|
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 = () =>
|
|
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.
|
|
898
|
-
*
|
|
899
|
-
*
|
|
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:
|
|
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:
|
|
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' },
|
|
989
|
-
h('span', { className: 'gc-badge' },
|
|
990
|
-
h('span', { className: 'gc-badge ready' }, t(game.statusKey))),
|
|
991
|
-
h('div', { className: 'gc-desc' },
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
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",
|