@lily-cooperative/custom-farm 0.35.0 → 0.37.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 +25 -5
- package/describe.json +3 -3
- package/dist/webui.js +145 -13
- package/overlay-electron/main.js +38 -12
- package/package.json +1 -1
- package/src/catalog.js +36 -14
- package/src/farm.js +18 -2
- package/src/index.js +5 -3
- package/src/weather.js +1 -1
- package/src/world.js +122 -4
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
| **农场自定义 → 杂草** | 增删杂草、阶段与天数、出现权重、上下图层、收获物名字/价值/贴图 |
|
|
26
26
|
| **农场自定义 → 浇水特效 / 枯萎** | 浇水帧序列、水花帧序列、帧间隔、枯萎贴图 |
|
|
27
27
|
| **生态自定义** | 一个游戏日多长、离线最多补几天、刷新间隔、每天是否推事件、浇水蒸发/补水/枯萎天数、天气 |
|
|
28
|
-
| **天气** |
|
|
28
|
+
| **天气** | 增删天气(无风不可删)、持续时间、权重、作物/装饰摇摆方向与幅度、压扁幅度、速度倍率(0~20)、心情变化 |
|
|
29
29
|
| **状态自定义** | 数值状态、开关状态、条件规则、消耗行为(例如饥饿时自动吃背包里的东西) |
|
|
30
30
|
| **道具自定义** | 种子/农作物/消耗品/装饰的买价卖价、能不能吃、吃掉后的状态效果、图标、摆放贴图、图层、缩放 |
|
|
31
31
|
| **合成表** | 1~4 个材料合成 1 个产物,桌宠可以用 `farm_craft` 合成 |
|
|
@@ -71,7 +71,9 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\install.ps1
|
|
|
71
71
|
Invoke-RestMethod -Method Post http://127.0.0.1:17788/api/run/restart
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
##
|
|
74
|
+
## 游戏配置
|
|
75
|
+
|
|
76
|
+
### 安装路径
|
|
75
77
|
|
|
76
78
|
在 Coopanion 的 **游戏配置 → 安装** 里可以直接看到和修改安装路径:
|
|
77
79
|
|
|
@@ -80,6 +82,23 @@ Invoke-RestMethod -Method Post http://127.0.0.1:17788/api/run/restart
|
|
|
80
82
|
- 存档和自定义配置仍在 `data\home\companion\data\farm\`,不会被搬走
|
|
81
83
|
- 安装目录里的 `overlay\runtime\` 是运行痕迹,可以清;`assets\sprites\cust_*.png` 是你上传的贴图
|
|
82
84
|
|
|
85
|
+
### 显示器
|
|
86
|
+
|
|
87
|
+
**游戏配置 → 显示器** 可以选择图层放在哪块屏幕:
|
|
88
|
+
|
|
89
|
+
- 「自动」使用主屏
|
|
90
|
+
- 下拉里会列出当前所有显示器,改完约 1 秒内移动到目标屏幕
|
|
91
|
+
- 这个设置作用于 Electron 图层;WPF 回退仍跟随桌宠所在屏幕
|
|
92
|
+
|
|
93
|
+
### 备份与重置
|
|
94
|
+
|
|
95
|
+
**游戏配置** 页底部有「存档与重置」:
|
|
96
|
+
|
|
97
|
+
- **备份存档**:把 `state.json` 和 `custom.json` 复制到 `data\farm\backups\backup-时间\`
|
|
98
|
+
- **重置一切**:先自动备份,再清空 `state.json` 和 `custom.json`,World 自动重启并生成默认目录;安装路径和已上传的 PNG 文件不会被删除
|
|
99
|
+
|
|
100
|
+
也可以从「存档与重置」页签操作同样的按钮。
|
|
101
|
+
|
|
83
102
|
## 桌面图层
|
|
84
103
|
|
|
85
104
|
- **Windows 10/11**:默认 Electron/HTML 图层;Electron 不可用时回退 WPF。
|
|
@@ -91,7 +110,7 @@ Invoke-RestMethod -Method Post http://127.0.0.1:17788/api/run/restart
|
|
|
91
110
|
|
|
92
111
|
| 工具 | 用途 |
|
|
93
112
|
|---|---|
|
|
94
|
-
| `farm_status` |
|
|
113
|
+
| `farm_status` | 看每棵作物的位置、等级、进度、水分、是否可收/枯萎,背包、掉落物、摆放的装饰物位置和最近田里的变化 |
|
|
95
114
|
| `farm_stand` | 站到某个位置,`at` 必填 |
|
|
96
115
|
| `farm_plant` | 在脚下种一棵,消耗 1 包种子 |
|
|
97
116
|
| `farm_water` | 浇脚下一片 |
|
|
@@ -117,8 +136,8 @@ Invoke-RestMethod -Method Post http://127.0.0.1:17788/api/run/restart
|
|
|
117
136
|
| `cropScale` / `dropScale` | 1.5 / 0.6 | 作物 / 掉落物大小 |
|
|
118
137
|
| `groundHeight` / `stripHeight` | 5 / 320 | 地表高度 / 图层纵向覆盖 |
|
|
119
138
|
| `realMinutesPerDay` | 60 | 1 游戏日 = 多少真实分钟 |
|
|
120
|
-
| `maxStepDays` | 1 |
|
|
121
|
-
| `tickIntervalMs` | 5000 |
|
|
139
|
+
| `maxStepDays` | 1 | 离线回来最多补几个游戏日(安全上限,防止关机一夜跳几十天) |
|
|
140
|
+
| `tickIntervalMs` | 5000 | 刷新间隔;只影响状态/事件更新频率,不改变生长速度 |
|
|
122
141
|
| `startCoins` | 120 | 新开局金币 |
|
|
123
142
|
| `moodIntervalMin` | 10 | 心情按真实分钟结算的间隔 |
|
|
124
143
|
| 浇水 | 0.15 / 1 / 2.2 | 每天蒸发 / 一次补水 / 干透后枯萎天数 |
|
|
@@ -151,6 +170,7 @@ Invoke-RestMethod -Method Post http://127.0.0.1:17788/api/run/restart
|
|
|
151
170
|
- Coopanion `>= 0.2.13`,World API `6`
|
|
152
171
|
- Node.js `>= 22`(Coopanion 自带)
|
|
153
172
|
- Electron `^45.1.0`(桌面图层依赖)
|
|
173
|
+
- Windows 10/11 已实机测试;macOS / Linux 未实机测试
|
|
154
174
|
|
|
155
175
|
## 许可
|
|
156
176
|
|
package/describe.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"tools": [
|
|
3
3
|
{
|
|
4
4
|
"name": "farm_status",
|
|
5
|
-
"description": "查看田地:每棵作物的位置 xPct
|
|
5
|
+
"description": "查看田地:每棵作物的位置 xPct(屏幕底部百分比)、等级、成熟进度、水分、是否熟了/枯萎了、还能收几次,以及背包里有什么、地上掉了什么、野草有几丛/哪些能收、摆放的装饰物在哪个位置、主人自定义的状态(数值 + 开关)当前是多少。最后一行「最近田里的变化」会列出最近少掉的棵和原因——田空了先看这行,别当成存档被清。想知道情况就查这个,田地不会主动推送生长进度。",
|
|
6
6
|
"parameters": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"properties": {}
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"snapshot"
|
|
13
13
|
],
|
|
14
14
|
"summary": {
|
|
15
|
-
"zh": "
|
|
16
|
-
"en": "Show every plant (position, level, progress, moisture, ripe/wilted) plus the bag, the drops, and recent field changes (which plant is gone and why)."
|
|
15
|
+
"zh": "看田:每棵作物的位置、等级、进度、水分、是否可收/枯萎,外加背包、地上的掉落物、摆放的装饰物位置和最近田里的变化(少了哪棵、为什么)。",
|
|
16
|
+
"en": "Show every plant (position, level, progress, moisture, ripe/wilted) plus the bag, the drops, placed decorations and recent field changes (which plant is gone and why)."
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
{
|
package/dist/webui.js
CHANGED
|
@@ -22,14 +22,99 @@ function renameFarmConfigTab() {
|
|
|
22
22
|
if (typeof document !== 'undefined') {
|
|
23
23
|
const start = () => {
|
|
24
24
|
renameFarmConfigTab();
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
injectFarmConfigActions();
|
|
26
|
+
new MutationObserver(() => { renameFarmConfigTab(); injectFarmConfigActions(); }).observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
27
|
+
window.addEventListener('hashchange', () => { renameFarmConfigTab(); injectFarmConfigActions(); });
|
|
28
|
+
setInterval(() => { renameFarmConfigTab(); injectFarmConfigActions(); }, 1000);
|
|
28
29
|
};
|
|
29
30
|
if (document.body) start();
|
|
30
31
|
else document.addEventListener('DOMContentLoaded', start, { once: true });
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** 调 World 的 manage 面板:备份 / 重置。 */
|
|
35
|
+
async function farmManageCall(method) {
|
|
36
|
+
const res = await fetch('/api/webui/providers/' + encodeURIComponent('world:farm') + '/panels/manage/' + method, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'content-type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({ args: [] }),
|
|
40
|
+
});
|
|
41
|
+
const data = await res.json().catch(() => ({}));
|
|
42
|
+
if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 往宿主的「游戏配置」页里塞「存档与重置」按钮。 */
|
|
47
|
+
function injectFarmConfigActions() {
|
|
48
|
+
try {
|
|
49
|
+
const hash = String(location.hash || '');
|
|
50
|
+
if (!hash.includes('world%3Afarm') && !hash.includes('world:farm')) return;
|
|
51
|
+
const view = document.querySelector('.configview');
|
|
52
|
+
if (!view || view.querySelector('[data-farm-manage]')) return;
|
|
53
|
+
|
|
54
|
+
const section = document.createElement('div');
|
|
55
|
+
section.className = 'tsection';
|
|
56
|
+
section.dataset.farmManage = '1';
|
|
57
|
+
section.textContent = '存档与重置';
|
|
58
|
+
|
|
59
|
+
const desc = document.createElement('div');
|
|
60
|
+
desc.className = 'tsecdesc';
|
|
61
|
+
desc.textContent = '备份会复制 state.json 和 custom.json 到 data/farm/backups;重置会先备份,再清空存档和全部自定义设置,World 随后重启。';
|
|
62
|
+
|
|
63
|
+
const row = document.createElement('div');
|
|
64
|
+
row.className = 'trow';
|
|
65
|
+
const label = document.createElement('span');
|
|
66
|
+
label.className = 'tlabel';
|
|
67
|
+
label.textContent = '操作';
|
|
68
|
+
const field = document.createElement('div');
|
|
69
|
+
field.className = 'tfield';
|
|
70
|
+
field.style.display = 'flex';
|
|
71
|
+
field.style.gap = '8px';
|
|
72
|
+
field.style.flexWrap = 'wrap';
|
|
73
|
+
const status = document.createElement('span');
|
|
74
|
+
status.className = 'tdesc';
|
|
75
|
+
status.setAttribute('role', 'status');
|
|
76
|
+
|
|
77
|
+
const button = (text, primary) => {
|
|
78
|
+
const b = document.createElement('button');
|
|
79
|
+
b.type = 'button';
|
|
80
|
+
b.className = 'btn sm' + (primary ? ' primary' : '');
|
|
81
|
+
b.textContent = text;
|
|
82
|
+
return b;
|
|
83
|
+
};
|
|
84
|
+
const backup = button('备份存档', true);
|
|
85
|
+
const reset = button('重置一切', false);
|
|
86
|
+
|
|
87
|
+
backup.addEventListener('click', async () => {
|
|
88
|
+
backup.disabled = true;
|
|
89
|
+
status.textContent = '正在备份…';
|
|
90
|
+
try {
|
|
91
|
+
const r = await farmManageCall('backup');
|
|
92
|
+
status.textContent = r.text || '已备份';
|
|
93
|
+
} catch (err) {
|
|
94
|
+
status.textContent = '备份失败:' + ((err && err.message) || err);
|
|
95
|
+
} finally { backup.disabled = false; }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
reset.addEventListener('click', async () => {
|
|
99
|
+
if (!window.confirm('确定重置一切?会清空存档和全部自定义设置(会先自动备份)。')) return;
|
|
100
|
+
reset.disabled = true;
|
|
101
|
+
status.textContent = '正在重置…';
|
|
102
|
+
try {
|
|
103
|
+
const r = await farmManageCall('reset');
|
|
104
|
+
status.textContent = r.text || '已重置,World 正在重启…';
|
|
105
|
+
setTimeout(() => { try { location.reload(); } catch { /* ignore */ } }, 3000);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
status.textContent = '重置失败:' + ((err && err.message) || err);
|
|
108
|
+
reset.disabled = false;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
field.append(backup, reset, status);
|
|
113
|
+
row.append(label, field);
|
|
114
|
+
view.append(section, desc, row);
|
|
115
|
+
} catch { /* 不是宿主的配置页就算了 */ }
|
|
116
|
+
}
|
|
117
|
+
|
|
33
118
|
const TEXT = {
|
|
34
119
|
zh: {
|
|
35
120
|
title: '农场',
|
|
@@ -56,8 +141,8 @@ const TEXT = {
|
|
|
56
141
|
ecologyTime: '时间与生长',
|
|
57
142
|
ecologyWater: '浇水',
|
|
58
143
|
timePerDay: '一个游戏日 = 真实分钟',
|
|
59
|
-
maxStepDays: '
|
|
60
|
-
tickMs: '刷新间隔(ms
|
|
144
|
+
maxStepDays: '离线最多补几天(安全上限)',
|
|
145
|
+
tickMs: '刷新间隔(ms,不影响生长)',
|
|
61
146
|
dailyEvent: '每天推「新的一天」事件',
|
|
62
147
|
saveEcology: '保存生态设置',
|
|
63
148
|
addGround: '添加自定义贴图',
|
|
@@ -160,8 +245,8 @@ const TEXT = {
|
|
|
160
245
|
ecologyTime: 'Time & growth',
|
|
161
246
|
ecologyWater: 'Watering',
|
|
162
247
|
timePerDay: 'Real minutes per game day',
|
|
163
|
-
maxStepDays: 'Max
|
|
164
|
-
tickMs: 'Refresh interval (ms)',
|
|
248
|
+
maxStepDays: 'Max offline days (safety cap)',
|
|
249
|
+
tickMs: 'Refresh interval (ms, does not affect growth)',
|
|
165
250
|
dailyEvent: 'Push a "new day" event daily',
|
|
166
251
|
saveEcology: 'Save ecology settings',
|
|
167
252
|
addGround: 'Add custom tile',
|
|
@@ -2490,7 +2575,7 @@ function ecologyPanel(ctx) {
|
|
|
2490
2575
|
decorAmp: Math.max(0, Math.min(40, Number(w.decorAmp ?? w.swayAmp) || 0)),
|
|
2491
2576
|
squash: Math.max(0, Math.min(0.6, Number(w.squash) || 0)),
|
|
2492
2577
|
mood: Math.max(-100, Math.min(100, Number(w.mood) || 0)),
|
|
2493
|
-
speed: Math.max(0
|
|
2578
|
+
speed: Math.max(0, Math.min(20, Number(w.speed) || 1)),
|
|
2494
2579
|
}));
|
|
2495
2580
|
if (!out.some((w) => w.id === 'calm')) out.unshift({ ...DEFAULT_WEATHERS[0] });
|
|
2496
2581
|
if (!out.some((w) => w.id !== 'calm')) out.push(...DEFAULT_WEATHERS.slice(1).map((w) => ({ ...w })));
|
|
@@ -2519,9 +2604,10 @@ function ecologyPanel(ctx) {
|
|
|
2519
2604
|
if (!draft) draft = cloneSettings(snapshot?.settings);
|
|
2520
2605
|
return draft;
|
|
2521
2606
|
};
|
|
2522
|
-
const num = (value, onChange, step = '1', min = '0') => {
|
|
2607
|
+
const num = (value, onChange, step = '1', min = '0', max = '') => {
|
|
2523
2608
|
const input = ui.input({ type: 'number', value: String(value ?? '') });
|
|
2524
2609
|
input.step = step; input.min = min;
|
|
2610
|
+
if (max !== '') input.max = max;
|
|
2525
2611
|
input.addEventListener('input', () => { const n = Number(input.value); if (Number.isFinite(n)) onChange(n); }, { signal: ctx.signal });
|
|
2526
2612
|
return input;
|
|
2527
2613
|
};
|
|
@@ -2596,7 +2682,7 @@ function ecologyPanel(ctx) {
|
|
|
2596
2682
|
row1.append(
|
|
2597
2683
|
ui.field(zh ? '持续(秒)' : 'Duration (s)', num(w.duration, (v) => { w.duration = v; }, '1', '1')),
|
|
2598
2684
|
ui.field(zh ? '触发概率(权重)' : 'Probability (weight)', num(w.weight, (v) => { w.weight = v; }, '1', '0')),
|
|
2599
|
-
ui.field(zh ? '速度(倍率)' : 'Speed (x)', num(w.speed, (v) => { w.speed = v; }, '0.1', '0
|
|
2685
|
+
ui.field(zh ? '速度(倍率)' : 'Speed (x)', num(w.speed, (v) => { w.speed = v; }, '0.1', '0', '20')),
|
|
2600
2686
|
ui.field(zh ? '心情变化/结算' : 'Mood per settlement', num(w.mood, (v) => { w.mood = v; }, '1', '-100')),
|
|
2601
2687
|
);
|
|
2602
2688
|
const crop = ui.h('div', 'cf-row');
|
|
@@ -2633,13 +2719,15 @@ function ecologyPanel(ctx) {
|
|
|
2633
2719
|
const d = getDraft();
|
|
2634
2720
|
const sheet = ui.sheet({
|
|
2635
2721
|
title: zh ? '生态自定义' : 'Ecology',
|
|
2636
|
-
desc: zh
|
|
2722
|
+
desc: zh
|
|
2723
|
+
? '1 游戏日 = 现实分钟决定生长速度;离线最多补几天是防止关机回来一次跳太多天;刷新间隔只影响状态/事件更新,不影响生长速度。天气按持续时间切换,触发概率按权重计算。'
|
|
2724
|
+
: 'Real minutes per game day sets growth speed; max offline days caps how many game days a long shutdown can catch up; the refresh interval only affects state/event updates, not growth speed. Weather changes by duration and weighted probability.',
|
|
2637
2725
|
});
|
|
2638
2726
|
const time = ui.h('div', 'cf-row');
|
|
2639
2727
|
time.append(
|
|
2640
2728
|
ui.field(zh ? '1 游戏日 = 现实分钟' : 'Real minutes per game day', num(d.realMinutesPerDay, (v) => { d.realMinutesPerDay = v; }, '0.5', '0.5')),
|
|
2641
|
-
ui.field(zh ? '
|
|
2642
|
-
ui.field(zh ? '
|
|
2729
|
+
ui.field(zh ? '离线最多补几天(安全上限)' : 'Max offline days (safety cap)', num(d.maxStepDays, (v) => { d.maxStepDays = v; }, '0.1', '0.01')),
|
|
2730
|
+
ui.field(zh ? '刷新间隔(毫秒,不影响生长)' : 'Refresh interval (ms, does not affect growth)', num(d.tickIntervalMs, (v) => { d.tickIntervalMs = v; }, '100', '1000')),
|
|
2643
2731
|
);
|
|
2644
2732
|
const daily = ui.checkbox(zh ? '每天推送 farm.day' : 'Emit farm.day', {
|
|
2645
2733
|
checked: d.dailyEvent,
|
|
@@ -2697,8 +2785,52 @@ function ecologyPanel(ctx) {
|
|
|
2697
2785
|
reload();
|
|
2698
2786
|
}
|
|
2699
2787
|
|
|
2788
|
+
function managePanel(ctx) {
|
|
2789
|
+
const { ui } = ctx;
|
|
2790
|
+
const root = ui.h('div', 'cf-root');
|
|
2791
|
+
ctx.root.appendChild(root);
|
|
2792
|
+
const sheet = ui.sheet({
|
|
2793
|
+
title: '存档与重置',
|
|
2794
|
+
desc: '备份会复制 state.json 和 custom.json 到 data/farm/backups;重置会先备份,再清空存档和全部自定义设置,World 随后重启。',
|
|
2795
|
+
});
|
|
2796
|
+
const bar = ui.rowbar();
|
|
2797
|
+
const status = ui.h('p', 'cf-note', '');
|
|
2798
|
+
const backup = ui.button('备份存档', {
|
|
2799
|
+
variant: 'primary',
|
|
2800
|
+
onClick: async () => {
|
|
2801
|
+
backup.disabled = true;
|
|
2802
|
+
status.textContent = '正在备份…';
|
|
2803
|
+
try {
|
|
2804
|
+
const r = await ctx.invoke('backup');
|
|
2805
|
+
status.textContent = r.text || '已备份';
|
|
2806
|
+
} catch (err) {
|
|
2807
|
+
status.textContent = '备份失败:' + ((err && err.message) || err);
|
|
2808
|
+
} finally { backup.disabled = false; }
|
|
2809
|
+
},
|
|
2810
|
+
});
|
|
2811
|
+
const reset = ui.button('重置一切', {
|
|
2812
|
+
onClick: async () => {
|
|
2813
|
+
if (!window.confirm('确定重置一切?会清空存档和全部自定义设置(会先自动备份)。')) return;
|
|
2814
|
+
reset.disabled = true;
|
|
2815
|
+
status.textContent = '正在重置…';
|
|
2816
|
+
try {
|
|
2817
|
+
const r = await ctx.invoke('reset');
|
|
2818
|
+
status.textContent = r.text || '已重置,World 正在重启…';
|
|
2819
|
+
setTimeout(() => { try { location.reload(); } catch { /* ignore */ } }, 3000);
|
|
2820
|
+
} catch (err) {
|
|
2821
|
+
status.textContent = '重置失败:' + ((err && err.message) || err);
|
|
2822
|
+
reset.disabled = false;
|
|
2823
|
+
}
|
|
2824
|
+
},
|
|
2825
|
+
});
|
|
2826
|
+
bar.append(backup, reset);
|
|
2827
|
+
sheet.body.append(bar, status);
|
|
2828
|
+
root.appendChild(sheet.el);
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2700
2831
|
export default {
|
|
2701
2832
|
panels: {
|
|
2833
|
+
manage: { mount: managePanel },
|
|
2702
2834
|
bag: { mount: bagPanel },
|
|
2703
2835
|
shop: { mount: shopPanel },
|
|
2704
2836
|
customize: { mount: panel },
|
package/overlay-electron/main.js
CHANGED
|
@@ -9,6 +9,7 @@ function arg(name, fallback = '') {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
const statePath = path.resolve(arg('state-path', path.join(__dirname, '..', 'overlay', 'runtime', 'state.json')));
|
|
12
|
+
const screensPath = path.join(path.dirname(statePath), 'screens.json');
|
|
12
13
|
const assetsDir = path.resolve(arg('assets-dir', path.join(__dirname, '..', 'assets', 'sprites')));
|
|
13
14
|
const logPath = arg('log', '');
|
|
14
15
|
const pidPath = arg('pid', '');
|
|
@@ -46,10 +47,34 @@ function checkLease() {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
function listScreens() {
|
|
51
|
+
const primary = screen.getPrimaryDisplay();
|
|
52
|
+
return screen.getAllDisplays().map((d, i) => ({
|
|
53
|
+
id: String(d.id),
|
|
54
|
+
label: d.label || ('Display ' + (i + 1)),
|
|
55
|
+
primary: d.id === primary.id,
|
|
56
|
+
width: Math.round(d.bounds.width),
|
|
57
|
+
height: Math.round(d.bounds.height),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeScreens() {
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(path.dirname(screensPath), { recursive: true });
|
|
64
|
+
fs.writeFileSync(screensPath, JSON.stringify(listScreens(), null, 2), 'utf8');
|
|
65
|
+
} catch (e) { log('screens.json failed: ' + e.message); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function displayFor(st) {
|
|
69
|
+
const all = screen.getAllDisplays();
|
|
70
|
+
const id = String(st?.screen ?? '');
|
|
71
|
+
return all.find((d) => String(d.id) === id) || screen.getPrimaryDisplay();
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
function bounds() {
|
|
50
75
|
const st = readState() || {};
|
|
51
76
|
const h = Math.min(800, Math.max(120, Number(st.stripHeight) || 320));
|
|
52
|
-
const wa =
|
|
77
|
+
const wa = displayFor(st).workArea;
|
|
53
78
|
return { x: Math.round(wa.x), y: Math.round(wa.y + wa.height - h), width: Math.round(wa.width), height: h };
|
|
54
79
|
}
|
|
55
80
|
|
|
@@ -103,21 +128,22 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
103
128
|
app.on('second-instance', () => { if (win) win.showInactive(); });
|
|
104
129
|
app.whenReady().then(() => {
|
|
105
130
|
writePid();
|
|
131
|
+
writeScreens();
|
|
106
132
|
createWindow();
|
|
107
|
-
|
|
133
|
+
const fit = () => {
|
|
108
134
|
if (!win) return;
|
|
109
135
|
const b = bounds();
|
|
110
|
-
win.
|
|
111
|
-
|
|
112
|
-
|
|
136
|
+
const cur = win.getBounds();
|
|
137
|
+
if (cur.x !== b.x || cur.y !== b.y || cur.width !== b.width || cur.height !== b.height) {
|
|
138
|
+
win.setBounds(b);
|
|
139
|
+
win.webContents.send('resize');
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
for (const event of ['display-added', 'display-removed', 'display-metrics-changed']) {
|
|
143
|
+
screen.on(event, fit);
|
|
144
|
+
}
|
|
113
145
|
setInterval(checkLease, 5000);
|
|
114
|
-
setInterval(
|
|
115
|
-
if (!win) return;
|
|
116
|
-
const st = readState() || {};
|
|
117
|
-
const h = Math.min(800, Math.max(120, Number(st.stripHeight) || 320));
|
|
118
|
-
const b = bounds();
|
|
119
|
-
if (win.getBounds().height !== h) { win.setBounds(b); win.webContents.send('resize'); }
|
|
120
|
-
}, 1000);
|
|
146
|
+
setInterval(fit, 1000);
|
|
121
147
|
});
|
|
122
148
|
}
|
|
123
149
|
|
package/package.json
CHANGED
package/src/catalog.js
CHANGED
|
@@ -387,6 +387,8 @@ export class FarmCatalog {
|
|
|
387
387
|
recipes: [],
|
|
388
388
|
settings: {},
|
|
389
389
|
seededDecor: false,
|
|
390
|
+
seededCabin: false,
|
|
391
|
+
seededDecorSet: false,
|
|
390
392
|
seededFreeCrops: false,
|
|
391
393
|
seededFood: false,
|
|
392
394
|
seededMood: false,
|
|
@@ -472,18 +474,31 @@ export class FarmCatalog {
|
|
|
472
474
|
this.persist();
|
|
473
475
|
}
|
|
474
476
|
|
|
475
|
-
/**
|
|
477
|
+
/** 旧存档切到免费素材作物集:补全新作物,删掉没有替代的小麦/卷心菜/葡萄。只迁移一次,之后保留用户改动。 */
|
|
476
478
|
ensureFreeCrops() {
|
|
477
479
|
if (!this.store) return;
|
|
480
|
+
const removed = new Set(['wheat', 'chinese_cabbage', 'grape']);
|
|
481
|
+
const filterConsume = () => {
|
|
482
|
+
let changed = false;
|
|
483
|
+
for (const action of this.store.consume ?? []) {
|
|
484
|
+
const before = (action.items ?? []).length;
|
|
485
|
+
action.items = (action.items ?? []).filter((id) => !removed.has(id) && !removed.has(String(id).replace(/^seed_/, '')));
|
|
486
|
+
if ((action.items ?? []).length !== before) changed = true;
|
|
487
|
+
}
|
|
488
|
+
return changed;
|
|
489
|
+
};
|
|
490
|
+
if (this.store.seededFreeCrops) {
|
|
491
|
+
const before = this.store.crops.length;
|
|
492
|
+
this.store.crops = this.store.crops.filter((c) => !removed.has(c.key));
|
|
493
|
+
if (filterConsume() || this.store.crops.length !== before) this.persist();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
478
496
|
const defs = new Map(this.builtinCrops().map((c) => [c.key, c]));
|
|
479
|
-
const removed = new Set(["wheat", "chinese_cabbage", "grape"]);
|
|
480
497
|
const order = Object.keys(CROPS);
|
|
481
498
|
// 旧档没有 builtin 标记,但 wheat/chinese_cabbage/grape 一定是旧内置作物;删掉。
|
|
482
499
|
const custom = this.store.crops.filter((c) => !c.builtin && !removed.has(c.key));
|
|
483
500
|
this.store.crops = order.map((key) => defs.get(key)).filter(Boolean).concat(custom);
|
|
484
|
-
|
|
485
|
-
action.items = (action.items ?? []).filter((id) => !removed.has(id) && !removed.has(String(id).replace(/^seed_/, "")));
|
|
486
|
-
}
|
|
501
|
+
filterConsume();
|
|
487
502
|
this.store.seededFreeCrops = true;
|
|
488
503
|
this.persist();
|
|
489
504
|
}
|
|
@@ -519,20 +534,25 @@ export class FarmCatalog {
|
|
|
519
534
|
!(r.inputs ?? []).some((x) => removed.has(x.item)));
|
|
520
535
|
if ((this.store.items?.length ?? 0) !== beforeItems || (this.store.recipes?.length ?? 0) !== beforeRecipes) this.persist();
|
|
521
536
|
}
|
|
522
|
-
/**
|
|
523
|
-
/** 小木屋是后加的内置装饰;旧存档也补上。 */
|
|
537
|
+
/** 小木屋是后加的内置装饰;只在迁移时补一次,用户删掉后不再回来。 */
|
|
524
538
|
ensureCabin() {
|
|
525
|
-
if (!this.store) return;
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
539
|
+
if (!this.store || this.store.seededCabin) return;
|
|
540
|
+
const list = this.store.items ?? (this.store.items = []);
|
|
541
|
+
if (!list.some((it) => it.id === 'deco_cabin')) {
|
|
542
|
+
const def = this.builtinDecorItems().find((it) => it.id === 'deco_cabin');
|
|
543
|
+
if (def) {
|
|
544
|
+
const item = this.normalizeItem(def, list.length, this.store.states ?? []);
|
|
545
|
+
if (item) list.push(item);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
this.store.seededCabin = true;
|
|
549
|
+
this.persist();
|
|
531
550
|
}
|
|
532
551
|
|
|
533
552
|
ensureDecorSet() {
|
|
534
|
-
if (!this.store) return;
|
|
553
|
+
if (!this.store || this.store.seededDecorSet) return;
|
|
535
554
|
this.store.items = (this.store.items ?? []).filter((it) => it.id !== 'deco_haystack');
|
|
555
|
+
this.store.seededDecorSet = true;
|
|
536
556
|
this.persist();
|
|
537
557
|
}
|
|
538
558
|
/** 老存档补一次内置装饰;只补一次,之后用户删了也不会自动回来。 */
|
|
@@ -620,6 +640,8 @@ export class FarmCatalog {
|
|
|
620
640
|
recipes,
|
|
621
641
|
settings: this.normalizeSettings(raw.settings),
|
|
622
642
|
seededDecor: Boolean(raw.seededDecor),
|
|
643
|
+
seededCabin: Boolean(raw.seededCabin),
|
|
644
|
+
seededDecorSet: Boolean(raw.seededDecorSet),
|
|
623
645
|
seededFreeCrops: Boolean(raw.seededFreeCrops),
|
|
624
646
|
seededFood: Boolean(raw.seededFood),
|
|
625
647
|
seededMood: Boolean(raw.seededMood),
|
package/src/farm.js
CHANGED
|
@@ -49,6 +49,8 @@ export const REACH = 0.05;
|
|
|
49
49
|
export const DEFAULTS = {
|
|
50
50
|
/** 安装目录:overlay / overlay-electron / assets 放在这里;空 = <dataDir>/custom-farm */
|
|
51
51
|
installPath: '',
|
|
52
|
+
/** 选中的显示器 id;空 = 自动(主屏)。 */
|
|
53
|
+
screen: '',
|
|
52
54
|
/** 最多同时种几棵(屏幕上不重叠地放得下的上限) */
|
|
53
55
|
maxPlants: 12,
|
|
54
56
|
/** 屏幕逻辑宽度,用来把"像素宽"换算成 x 间距 */
|
|
@@ -1074,6 +1076,7 @@ export function overlayView(state) {
|
|
|
1074
1076
|
weatherFx: state.weatherFx ?? weatherFxOf(normalizeWeathers(DEFAULT_WEATHERS)[0]),
|
|
1075
1077
|
current: state.current ?? null,
|
|
1076
1078
|
currentAt: Number.isFinite(state.standingAt) ? Number(state.standingAt) : null,
|
|
1079
|
+
screen: state.screen ?? '',
|
|
1077
1080
|
farm: { min: fb.min, max: fb.max, cells: fb.cells, cropScale: fb.cropScale, cropZoom: Number(state.cropScale) || 1 },
|
|
1078
1081
|
ground: { tile: groundTile },
|
|
1079
1082
|
waterFx: live.status ? {
|
|
@@ -1162,7 +1165,8 @@ export function overlayView(state) {
|
|
|
1162
1165
|
|
|
1163
1166
|
/** 给工具看的摘要。 */
|
|
1164
1167
|
export function summary(state, cfg = DEFAULTS) {
|
|
1165
|
-
const
|
|
1168
|
+
const inventory = Object.fromEntries(Object.entries(state.inventory ?? {}).filter(([id]) => Boolean(live.items?.[id])));
|
|
1169
|
+
const total = Object.values(inventory).reduce((a, b) => a + b, 0);
|
|
1166
1170
|
const weedList = (state.weeds ?? []).map((w) => {
|
|
1167
1171
|
const t = weedTypeOf(w);
|
|
1168
1172
|
const lv = Number(w.level ?? 0);
|
|
@@ -1180,6 +1184,17 @@ export function summary(state, cfg = DEFAULTS) {
|
|
|
1180
1184
|
};
|
|
1181
1185
|
});
|
|
1182
1186
|
const ripeWeeds = weedList.filter((w) => w.ready);
|
|
1187
|
+
const decorations = (state.furniture ?? [])
|
|
1188
|
+
.filter((f) => f.x !== null && f.x !== undefined)
|
|
1189
|
+
.map((f) => ({
|
|
1190
|
+
uid: f.uid,
|
|
1191
|
+
id: f.id,
|
|
1192
|
+
name: live.names?.[f.id] ?? f.id,
|
|
1193
|
+
x: round2(Number(f.x)),
|
|
1194
|
+
xPct: Math.round(Number(f.x) * 100),
|
|
1195
|
+
layer: live.items?.[f.id]?.layer ?? f.layer ?? 'bottom',
|
|
1196
|
+
}))
|
|
1197
|
+
.sort((a, b) => a.x - b.x);
|
|
1183
1198
|
return {
|
|
1184
1199
|
day: state.day,
|
|
1185
1200
|
clock: clockText(state.minute),
|
|
@@ -1190,7 +1205,7 @@ export function summary(state, cfg = DEFAULTS) {
|
|
|
1190
1205
|
standingAt: Number.isFinite(state.standingAt) ? Number(state.standingAt) : null,
|
|
1191
1206
|
realMinutesPerDay: Number(cfg.realMinutesPerDay),
|
|
1192
1207
|
bagTotal: total,
|
|
1193
|
-
bag:
|
|
1208
|
+
bag: inventory,
|
|
1194
1209
|
drops: state.drops.length,
|
|
1195
1210
|
planted: state.plots.length,
|
|
1196
1211
|
max: cfg.maxPlants,
|
|
@@ -1199,6 +1214,7 @@ export function summary(state, cfg = DEFAULTS) {
|
|
|
1199
1214
|
weeds: weedList.length,
|
|
1200
1215
|
weedRipe: ripeWeeds.length,
|
|
1201
1216
|
weedList: ripeWeeds,
|
|
1217
|
+
decorations,
|
|
1202
1218
|
states: (Array.isArray(cfg.stateDefs) ? cfg.stateDefs : []).map((d) => {
|
|
1203
1219
|
const value = state.states?.[d.id] ?? {};
|
|
1204
1220
|
const flag = d.kind === 'flag';
|
package/src/index.js
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { CROPS, ROTATION } from './crops.js';
|
|
5
5
|
import { DEFAULTS, overlayView, summary } from './farm.js';
|
|
6
|
-
import { FARM_TOOL_DECLS, FarmWorld } from './world.js';
|
|
6
|
+
import { FARM_TOOL_DECLS, FarmWorld, screenOptions } from './world.js';
|
|
7
7
|
|
|
8
8
|
const TOOL_SUMMARIES = {
|
|
9
9
|
farm_status: {
|
|
10
|
-
zh: '
|
|
11
|
-
en: 'Show every plant (position, level, progress, moisture, ripe/wilted) plus the bag, the drops, and recent field changes (which plant is gone and why).',
|
|
10
|
+
zh: '看田:每棵作物的位置、等级、进度、水分、是否可收/枯萎,外加背包、地上的掉落物、摆放的装饰物位置和最近田里的变化(少了哪棵、为什么)。',
|
|
11
|
+
en: 'Show every plant (position, level, progress, moisture, ripe/wilted) plus the bag, the drops, placed decorations and recent field changes (which plant is gone and why).',
|
|
12
12
|
},
|
|
13
13
|
farm_stand: {
|
|
14
14
|
zh: '站到某个位置(at 必填),播种/收割/捡东西都要先站过去。',
|
|
@@ -64,7 +64,9 @@ export const farmDefinition = {
|
|
|
64
64
|
botDir: ctx.botDir,
|
|
65
65
|
packageDir: ctx.packageDir,
|
|
66
66
|
persist: (patch) => ctx.persist(patch),
|
|
67
|
+
restart: () => ctx.restart(),
|
|
67
68
|
}),
|
|
69
|
+
configOptions: (kind, language) => (kind === 'farm-screens' ? screenOptions(language) : []),
|
|
68
70
|
describe: {
|
|
69
71
|
tools: describeTools(FARM_TOOL_DECLS),
|
|
70
72
|
events: [
|
package/src/weather.js
CHANGED
|
@@ -36,7 +36,7 @@ export function normalizeWeathers(input) {
|
|
|
36
36
|
decorAmp: clamp(row.decorAmp ?? row.swayAmp, 0, 40, 3),
|
|
37
37
|
squash: clamp(row.squash, 0, 0.6, 0),
|
|
38
38
|
mood: clamp(row.mood ?? { calm: 2, breeze: 1, gale: -6, egg: 4 }[id], -100, 100, 0),
|
|
39
|
-
speed: clamp(row.speed, 0
|
|
39
|
+
speed: clamp(row.speed, 0, 20, 1),
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
42
|
if (!out.some((w) => w.id === 'calm')) out.unshift({ ...DEFAULT_WEATHERS[0] });
|
package/src/world.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { spawn } from 'node:child_process';
|
|
19
19
|
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { execFileSync } from 'node:child_process';
|
|
21
|
-
import { mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
|
|
21
|
+
import { mkdir, copyFile, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
|
|
22
22
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
|
|
@@ -73,6 +73,17 @@ import {
|
|
|
73
73
|
} from './farm.js';
|
|
74
74
|
|
|
75
75
|
const ENV_PROMPT = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
|
|
76
|
+
/** Electron 图层写出的显示器列表;配置页的「显示器」下拉用它。 */
|
|
77
|
+
let screenChoices = [];
|
|
78
|
+
|
|
79
|
+
export function screenOptions(language = 'zh') {
|
|
80
|
+
const zh = String(language).toLowerCase().startsWith('zh');
|
|
81
|
+
const out = [{ value: '', label: zh ? '自动(主屏)' : 'Auto (primary)' }];
|
|
82
|
+
for (const [i, d] of screenChoices.entries()) {
|
|
83
|
+
out.push({ value: String(d.id), label: String(d.label || (zh ? '显示器 ' : 'Display ') + (i + 1)) + (d.primary ? (zh ? '(主屏)' : ' (primary)') : '') });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
76
87
|
const msg = (err) => (err instanceof Error ? err.message : String(err));
|
|
77
88
|
|
|
78
89
|
const pct = (x) => (x === null || x === undefined ? '?' : Math.round(Number(x) * 100));
|
|
@@ -83,7 +94,7 @@ export const FARM_TOOL_DECLS = [
|
|
|
83
94
|
{
|
|
84
95
|
name: 'farm_status',
|
|
85
96
|
tags: ['read', 'snapshot'],
|
|
86
|
-
description: '查看田地:每棵作物的位置 xPct
|
|
97
|
+
description: '查看田地:每棵作物的位置 xPct(屏幕底部百分比)、等级、成熟进度、水分、是否熟了/枯萎了、还能收几次,以及背包里有什么、地上掉了什么、野草有几丛/哪些能收、摆放的装饰物在哪个位置、主人自定义的状态(数值 + 开关)当前是多少。最后一行「最近田里的变化」会列出最近少掉的棵和原因——田空了先看这行,别当成存档被清。想知道情况就查这个,田地不会主动推送生长进度。',
|
|
87
98
|
parameters: { type: 'object', properties: {} },
|
|
88
99
|
},
|
|
89
100
|
{
|
|
@@ -297,6 +308,13 @@ function farmConfigGroups(language = 'zh', defaultHomePath = '') {
|
|
|
297
308
|
description: t('影响之后的操作;startCoins 主要影响新开局。', 'Affects subsequent actions; startCoins mainly affects a fresh game.'),
|
|
298
309
|
properties: {
|
|
299
310
|
'worlds.farm.startCoins': int(t('新开局初始金币', 'Starting coins'), t('默认 120,只影响新开档。', 'Default 120; only affects a new save.'), 0, 100000),
|
|
311
|
+
'worlds.farm.screen': {
|
|
312
|
+
type: 'string',
|
|
313
|
+
title: t('显示器', 'Display'),
|
|
314
|
+
description: t('图层放在哪块屏幕;「自动」跟随主屏。', 'Which display hosts the overlay; Auto uses the primary display.'),
|
|
315
|
+
'x-options': 'farm-screens',
|
|
316
|
+
'x-hot': true,
|
|
317
|
+
},
|
|
300
318
|
'worlds.farm.overlayEnabled': bool(t('写桌面图层的 state.json', 'Write the overlay state.json'), t('默认开。关掉后桌面农场停止更新。', 'On by default. Off stops the desktop farm from updating.')),
|
|
301
319
|
'worlds.farm.hideFarm': bool(t('隐藏农场', 'Hide farm'), t('勾选后停止桌面图层;农场仍在后台生长,取消勾选恢复。', 'Stop the desktop overlay; the farm keeps growing. Uncheck to show it again.')),
|
|
302
320
|
},
|
|
@@ -331,6 +349,7 @@ export class FarmWorld {
|
|
|
331
349
|
this.botDir = opts.botDir ?? process.cwd();
|
|
332
350
|
this.packageDir = opts.packageDir ?? process.cwd();
|
|
333
351
|
this.persistConfig = typeof opts.persist === 'function' ? opts.persist : null;
|
|
352
|
+
this.restartWorld = typeof opts.restart === 'function' ? opts.restart : null;
|
|
334
353
|
// 是否由这个 World 管桌面图层的启停(单元测试里传 false,避免碰到真实图层)
|
|
335
354
|
this.manageLayers = opts.manageLayers !== false;
|
|
336
355
|
this.language = 'zh';
|
|
@@ -372,6 +391,7 @@ export class FarmWorld {
|
|
|
372
391
|
const water = this.#catalog?.status?.water ?? null;
|
|
373
392
|
return {
|
|
374
393
|
...DEFAULTS,
|
|
394
|
+
screen: String(this.cfg?.screen ?? ''),
|
|
375
395
|
maxPlants: this.#num('maxPlants', 1, 24),
|
|
376
396
|
screenWidth,
|
|
377
397
|
zoom: this.#num('zoom', 1, 8),
|
|
@@ -543,6 +563,67 @@ export class FarmWorld {
|
|
|
543
563
|
this.#ensureLayers('安装路径变更');
|
|
544
564
|
}
|
|
545
565
|
|
|
566
|
+
/** 备份存档:把 state.json 和 custom.json 复制到 data/farm/backups/backup-时间/。 */
|
|
567
|
+
async #backupNow() {
|
|
568
|
+
const d = new Date();
|
|
569
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
570
|
+
const stamp = d.getFullYear() + p2(d.getMonth() + 1) + p2(d.getDate()) + '-' + p2(d.getHours()) + p2(d.getMinutes()) + p2(d.getSeconds());
|
|
571
|
+
const dir = join(this.dataDir, 'farm', 'backups', 'backup-' + stamp);
|
|
572
|
+
await mkdir(dir, { recursive: true });
|
|
573
|
+
const files = [];
|
|
574
|
+
for (const [name, src] of [
|
|
575
|
+
['state.json', this.stateFile()],
|
|
576
|
+
['custom.json', join(this.dataDir, 'farm', 'custom.json')],
|
|
577
|
+
]) {
|
|
578
|
+
try {
|
|
579
|
+
if (!existsSync(src)) continue;
|
|
580
|
+
await copyFile(src, join(dir, name));
|
|
581
|
+
files.push(name);
|
|
582
|
+
} catch (err) {
|
|
583
|
+
this.#host?.log?.warn?.('自定义农场:备份文件失败', { file: src, err: msg(err) });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
this.#host?.log?.info?.('自定义农场:已备份存档', { dir, files });
|
|
587
|
+
return {
|
|
588
|
+
ok: true,
|
|
589
|
+
path: dir,
|
|
590
|
+
files,
|
|
591
|
+
text: files.length ? '已备份到 ' + dir : '没有可备份的存档文件;已创建空备份目录 ' + dir,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** 重置一切:先备份,再删 state.json / custom.json,最后重启 World 重新生成默认目录。 */
|
|
596
|
+
async #resetAll() {
|
|
597
|
+
const backup = await this.#backupNow().catch(() => null);
|
|
598
|
+
// 先断开内存里的 state,World 重启/停止时就不会把旧状态写回去。
|
|
599
|
+
this.#state = null;
|
|
600
|
+
for (const file of [this.stateFile(), join(this.dataDir, 'farm', 'custom.json')]) {
|
|
601
|
+
try { await unlink(file); } catch { /* 文件本来就不存在 */ }
|
|
602
|
+
}
|
|
603
|
+
this.#host?.log?.warn?.('自定义农场:已重置全部存档与设置', { backup: backup?.path ?? null });
|
|
604
|
+
if (this.restartWorld) this.setTimeoutImpl(() => { void this.restartWorld(); }, 300);
|
|
605
|
+
return {
|
|
606
|
+
ok: true,
|
|
607
|
+
backup: backup?.path ?? null,
|
|
608
|
+
text: '已重置存档和自定义设置' + (backup?.path ? ';备份在 ' + backup.path : '') + '。World 正在重启。',
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** 读取 Electron 图层写出的显示器列表,供游戏配置的「显示器」下拉使用。 */
|
|
613
|
+
#refreshScreens() {
|
|
614
|
+
try {
|
|
615
|
+
const file = join(dirname(this.overlayFile()), 'screens.json');
|
|
616
|
+
const list = JSON.parse(readFileSync(file, 'utf8'));
|
|
617
|
+
if (Array.isArray(list) && list.length) {
|
|
618
|
+
screenChoices = list.map((d) => ({
|
|
619
|
+
id: String(d.id ?? ''),
|
|
620
|
+
label: String(d.label ?? ''),
|
|
621
|
+
primary: Boolean(d.primary),
|
|
622
|
+
})).filter((d) => d.id);
|
|
623
|
+
}
|
|
624
|
+
} catch { /* 图层还没起来,保留上次的列表 */ }
|
|
625
|
+
}
|
|
626
|
+
|
|
546
627
|
/** 自定义目录:custom.json + 安装目录下的 assets\sprites。 */
|
|
547
628
|
#createCatalog() {
|
|
548
629
|
const home = this.#homePath();
|
|
@@ -625,10 +706,30 @@ export class FarmWorld {
|
|
|
625
706
|
delete this.#state.inventory?.['seed_' + key];
|
|
626
707
|
}
|
|
627
708
|
delete this.#state.inventory?.seed_wheat;
|
|
628
|
-
for (const key of ['wheat', 'flour'
|
|
709
|
+
for (const key of ['wheat', 'flour']) delete this.#state.inventory?.[key];
|
|
629
710
|
this.#state.furniture = (this.#state.furniture ?? []).filter((f) => f.id !== 'deco_haystack');
|
|
630
711
|
delete this.#state.inventory?.deco_haystack;
|
|
631
|
-
this.#catalog?.removeRecipesUsing(['chinese_cabbage', 'seed_chinese_cabbage', 'grape', 'seed_grape', 'wheat', 'flour'
|
|
712
|
+
this.#catalog?.removeRecipesUsing(['chinese_cabbage', 'seed_chinese_cabbage', 'grape', 'seed_grape', 'wheat', 'flour']);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** 清掉旧版本留在背包/田里的孤儿 id(比如旧家具 deco_16):当前目录里没有这个道具就删。 */
|
|
716
|
+
#pruneOrphanItems() {
|
|
717
|
+
if (!this.#state) return;
|
|
718
|
+
const known = new Set(Object.keys(live.items ?? {}));
|
|
719
|
+
if (!known.size) return;
|
|
720
|
+
let changed = false;
|
|
721
|
+
for (const id of Object.keys(this.#state.inventory ?? {})) {
|
|
722
|
+
if (known.has(id)) continue;
|
|
723
|
+
delete this.#state.inventory[id];
|
|
724
|
+
changed = true;
|
|
725
|
+
}
|
|
726
|
+
const beforeFurniture = (this.#state.furniture ?? []).length;
|
|
727
|
+
this.#state.furniture = (this.#state.furniture ?? []).filter((f) => known.has(f.id));
|
|
728
|
+
if (this.#state.furniture.length !== beforeFurniture) changed = true;
|
|
729
|
+
const beforeDrops = (this.#state.drops ?? []).length;
|
|
730
|
+
this.#state.drops = (this.#state.drops ?? []).filter((d) => known.has(d.item));
|
|
731
|
+
if (this.#state.drops.length !== beforeDrops) changed = true;
|
|
732
|
+
if (changed) this.#host?.log?.info?.('自定义农场:清掉旧存档里已经不存在的道具');
|
|
632
733
|
}
|
|
633
734
|
#ensureStarted() {
|
|
634
735
|
if (!this.#started || !this.#state) throw new Error('自定义农场 World 还没启动;等它启用后再试。');
|
|
@@ -681,6 +782,9 @@ export class FarmWorld {
|
|
|
681
782
|
lines.push('野草 ' + s.weeds + ' 丛(都还没长成;种东西时会把脚下的铲掉)。');
|
|
682
783
|
}
|
|
683
784
|
}
|
|
785
|
+
if (s.decorations.length) {
|
|
786
|
+
lines.push('摆放的装饰:' + s.decorations.map((d) => d.name + '(' + d.xPct + '%,' + (d.layer === 'top' ? '上层' : '下层') + ')').join('、') + '。');
|
|
787
|
+
}
|
|
684
788
|
if (s.states.length) {
|
|
685
789
|
lines.push('状态:' + s.states.map((st) => (st.kind === 'flag'
|
|
686
790
|
? st.name + (st.active ? '(生效)' : '(未生效)')
|
|
@@ -1259,7 +1363,9 @@ export class FarmWorld {
|
|
|
1259
1363
|
this.#state.dropScale = cfg.dropScale;
|
|
1260
1364
|
this.#state.groundHeight = cfg.groundHeight;
|
|
1261
1365
|
this.#state.stripHeight = cfg.stripHeight;
|
|
1366
|
+
this.#state.screen = cfg.screen;
|
|
1262
1367
|
}
|
|
1368
|
+
this.#refreshScreens();
|
|
1263
1369
|
await writeJsonAtomic(this.overlayFile(), overlayView(this.#state));
|
|
1264
1370
|
} catch (err) {
|
|
1265
1371
|
this.#host?.log?.debug?.('自定义农场:图层状态没写出去', { file: this.overlayFile(), err: msg(err) });
|
|
@@ -1459,6 +1565,11 @@ export class FarmWorld {
|
|
|
1459
1565
|
{ label: '天数', value: started ? '第 ' + s.day + ' 天' : '—', tone: 'plain' },
|
|
1460
1566
|
],
|
|
1461
1567
|
panels: [{
|
|
1568
|
+
id: 'manage',
|
|
1569
|
+
title: '存档与重置',
|
|
1570
|
+
description: '备份存档,或把存档和全部自定义设置重置为默认。',
|
|
1571
|
+
getMethods: ['state'],
|
|
1572
|
+
}, {
|
|
1462
1573
|
id: 'bag',
|
|
1463
1574
|
title: '背包',
|
|
1464
1575
|
description: '背包、农场预览、摆放道具与手动合成;装饰在这里输入坐标放置。',
|
|
@@ -1505,6 +1616,12 @@ export class FarmWorld {
|
|
|
1505
1616
|
async invoke(panel, method, args = []) {
|
|
1506
1617
|
return this.#serial(async () => {
|
|
1507
1618
|
const cfg = this.#cfgAll();
|
|
1619
|
+
if (panel === 'manage') {
|
|
1620
|
+
if (method === 'backup') return this.#backupNow();
|
|
1621
|
+
if (method === 'reset') return this.#resetAll();
|
|
1622
|
+
if (method === 'state') return { ok: true };
|
|
1623
|
+
throw new Error('不认识的存档操作:' + method);
|
|
1624
|
+
}
|
|
1508
1625
|
if (panel === 'status') return this.#statusInvoke(method, args, cfg);
|
|
1509
1626
|
if (panel === 'bag') return this.#bagInvoke(method, args);
|
|
1510
1627
|
if (panel === 'shop') return this.#shopInvoke(method, args);
|
|
@@ -2490,6 +2607,7 @@ export class FarmWorld {
|
|
|
2490
2607
|
}
|
|
2491
2608
|
this.#state = await this.#loadOrInit();
|
|
2492
2609
|
this.#pruneRemovedCrops();
|
|
2610
|
+
this.#pruneOrphanItems();
|
|
2493
2611
|
this.#state.current = null;
|
|
2494
2612
|
this.#state.standingAt = null;
|
|
2495
2613
|
// 老存档的 drySince 是按"游戏分钟"存的,换成按"累计游戏日"之后要清一次,免得永远不枯萎
|