@sdd330dev/jy-skill 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/README.md +54 -10
- package/SKILL.md +1 -1
- package/assets/templates.json +2 -2
- package/package.json +1 -1
- package/references/agent-handbook.md +2 -1
- package/references/player-guide.md +5 -4
- package/save/.gitkeep +0 -0
- package/scripts/config-loader.ts +50 -2
- package/scripts/game-engine.ts +253 -27
- package/scripts/game-logic.ts +5 -0
- package/scripts/game-types.ts +7 -0
- package/scripts/persistence.ts +40 -5
package/AGENTS.md
CHANGED
package/README.md
CHANGED
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
|
|
35
35
|
## 安装
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
包已发布至 npm:**[@sdd330dev/jy-skill](https://www.npmjs.com/package/@sdd330dev/jy-skill)**
|
|
38
|
+
|
|
39
|
+
### 项目内安装(推荐)
|
|
38
40
|
|
|
39
41
|
在 Cursor 项目根目录执行:
|
|
40
42
|
|
|
@@ -43,32 +45,74 @@ npm install @sdd330dev/jy-skill --save-dev
|
|
|
43
45
|
npx jy-skill install
|
|
44
46
|
```
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
使用 pnpm 或 yarn:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pnpm add -D @sdd330dev/jy-skill && pnpm exec jy-skill install
|
|
52
|
+
# 或
|
|
53
|
+
yarn add -D @sdd330dev/jy-skill && yarn jy-skill install
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
安装完成后,目录结构如下:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
你的项目/
|
|
60
|
+
└── .cursor/skills/jy/ ← SKILL.md、assets/、scripts/ 等
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
在 Cursor 中打开该项目,对话里说 **`jy`** 或 **「开始游戏」** 即可。
|
|
64
|
+
|
|
65
|
+
### 全局安装(所有项目可用)
|
|
47
66
|
|
|
48
67
|
```bash
|
|
49
68
|
npm install -g @sdd330dev/jy-skill
|
|
50
69
|
jy-skill install --global
|
|
51
70
|
```
|
|
52
71
|
|
|
53
|
-
|
|
72
|
+
skill 将安装到 `~/.cursor/skills/jy`,任意 Cursor 项目均可加载。
|
|
73
|
+
|
|
74
|
+
### CLI 选项
|
|
75
|
+
|
|
76
|
+
| 选项 | 说明 |
|
|
77
|
+
|------|------|
|
|
78
|
+
| `--global` | 安装到 `~/.cursor/skills/jy` 而非当前项目 |
|
|
79
|
+
| `--force` | 覆盖已存在的 skill 目录 |
|
|
80
|
+
| `--copy` | 强制复制文件(默认优先 symlink;Windows 无权限时自动 fallback) |
|
|
81
|
+
|
|
82
|
+
示例:`npx jy-skill install --force --copy`
|
|
83
|
+
|
|
84
|
+
> **注意**:`npm install` 只把包放入 `node_modules`,不会自动写入 `.cursor/`。必须再执行 **`jy-skill install`**(无 postinstall,避免 monorepo/CI 误写配置)。
|
|
85
|
+
|
|
86
|
+
### 其他安装方式
|
|
87
|
+
|
|
88
|
+
**GitHub Release(离线 / 无 npm)**
|
|
89
|
+
|
|
90
|
+
从 [Releases](https://github.com/sdd330/jy-skill/releases) 下载 `jy-skill.zip`,解压后:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
mkdir -p .cursor/skills
|
|
94
|
+
unzip jy-skill.zip -d .cursor/skills/jy
|
|
95
|
+
```
|
|
54
96
|
|
|
55
|
-
|
|
97
|
+
**从源码开发**
|
|
56
98
|
|
|
57
|
-
|
|
99
|
+
克隆本仓库,在本目录用 pnpm 开发与测试(见下方「开发与测试」)。本地调试也可手动链接:
|
|
58
100
|
|
|
59
101
|
```bash
|
|
60
|
-
mkdir -p .cursor/skills
|
|
102
|
+
mkdir -p .cursor/skills
|
|
103
|
+
ln -s "$(pwd)" .cursor/skills/jy # macOS / Linux
|
|
104
|
+
# Windows 或 symlink 失败时:cp -R . .cursor/skills/jy
|
|
61
105
|
```
|
|
62
106
|
|
|
63
|
-
###
|
|
107
|
+
### 发布与版本
|
|
64
108
|
|
|
65
|
-
Git tag(`v*`)触发 [Release 工作流](.github/workflows/release.yml),生成 `jy-skill.zip` 并同步发布到 [npm @sdd330dev/jy-skill](https://www.npmjs.com/package/@sdd330dev/jy-skill)
|
|
109
|
+
Git tag(`v*`)触发 [Release 工作流](.github/workflows/release.yml),生成 `jy-skill.zip` 并同步发布到 [npm @sdd330dev/jy-skill](https://www.npmjs.com/package/@sdd330dev/jy-skill)(GitHub Secrets 需配置 `NPM_TOKEN`)。
|
|
66
110
|
|
|
67
|
-
|
|
111
|
+
**维护者:本机发布到 npm**
|
|
68
112
|
|
|
69
113
|
```bash
|
|
70
114
|
pnpm run ci
|
|
71
|
-
npm login
|
|
115
|
+
# 使用 Granular Access Token(Publish + Bypass 2FA),或 npm login 后带 OTP:
|
|
72
116
|
npm publish --access public # 或 pnpm run publish:npm
|
|
73
117
|
```
|
|
74
118
|
|
package/SKILL.md
CHANGED
package/assets/templates.json
CHANGED
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
"山贼": { "characterId": 200 },
|
|
53
53
|
"强盗": { "characterId": 201 },
|
|
54
54
|
"武林高手": { "characterId": 202 },
|
|
55
|
-
"老虎": { "hp": 150, "attack": 35, "defence": 20, "solo": true },
|
|
56
|
-
"毒蛇": { "hp": 60, "attack": 10, "defence": 5, "solo": true }
|
|
55
|
+
"老虎": { "hp": 150, "attack": 35, "defence": 20, "solo": true, "onHitHurt": 10 },
|
|
56
|
+
"毒蛇": { "hp": 60, "attack": 10, "defence": 5, "solo": true, "onHitPoison": 15 }
|
|
57
57
|
},
|
|
58
58
|
"defaultInventory": {
|
|
59
59
|
"silver": 100,
|
package/package.json
CHANGED
|
@@ -113,7 +113,8 @@ getStatus(state) 生成状态栏,附在回复末尾
|
|
|
113
113
|
- 新角色默认:**基本拳法**(不耗内力)。
|
|
114
114
|
- **`learnSkill`**:武功须存在于 `assets/skills.json`;已学会则失败。
|
|
115
115
|
- **`useSkillInBattle`**:须已学会且内力 ≥ 消耗;消耗公式见 `game-logic.ts`。
|
|
116
|
-
-
|
|
116
|
+
- 武功 `skillLevels` 0–9 对应 Lv.1–10;每次战斗使用 +1~3 熟练度,满 100 升一级
|
|
117
|
+
- 死亡后(hp≤0)不可休息、移动、战斗;须 `restartGame()`
|
|
117
118
|
|
|
118
119
|
---
|
|
119
120
|
|
|
@@ -118,10 +118,11 @@
|
|
|
118
118
|
|
|
119
119
|
## 生存技巧
|
|
120
120
|
|
|
121
|
-
-
|
|
122
|
-
-
|
|
123
|
-
-
|
|
124
|
-
-
|
|
121
|
+
- **移动**消耗体力(轻功越高消耗越低,约 3–7 点),并推进 1 周;体力不足时无法移动。
|
|
122
|
+
- **干粮**恢复体力,**金创药**恢复生命,**小还丹**恢复内力;**解毒丸**可解除中毒。**大力丸**/**疾风丸**临时提升攻击/轻功(移动或休息后失效)。
|
|
123
|
+
- **武功秘籍**(如九阴真经、独孤九剑剑谱)研读后可学会对应武功,需满足资质与经验要求。
|
|
124
|
+
- **休息**一次满血满内力满体力,并清除中毒、受伤与临时增益(在任意地点可用);**昏迷时无法休息**。
|
|
125
|
+
- 中毒、受伤状态下每移动一周会持续掉血;**毒蛇**等敌人攻击可能使你中毒。
|
|
125
126
|
- 死亡后当前存档清除,需从头开始;重要进度请多探索、少鲁莽涉险。
|
|
126
127
|
|
|
127
128
|
---
|
package/save/.gitkeep
ADDED
|
File without changes
|
package/scripts/config-loader.ts
CHANGED
|
@@ -35,6 +35,13 @@ export interface ItemConfig {
|
|
|
35
35
|
useAddMp: number;
|
|
36
36
|
useAddStamina: number;
|
|
37
37
|
useDePoison: number;
|
|
38
|
+
useAddAttack?: number;
|
|
39
|
+
useAddAgility?: number;
|
|
40
|
+
useAddDefence?: number;
|
|
41
|
+
useAddPoison?: number;
|
|
42
|
+
skillId?: number;
|
|
43
|
+
needIQ?: number;
|
|
44
|
+
needExp?: number;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
export interface SkillConfig {
|
|
@@ -43,6 +50,7 @@ export interface SkillConfig {
|
|
|
43
50
|
desc: string;
|
|
44
51
|
mpCost: number;
|
|
45
52
|
damageType: number;
|
|
53
|
+
poison?: number;
|
|
46
54
|
levels: number[][];
|
|
47
55
|
}
|
|
48
56
|
|
|
@@ -67,6 +75,8 @@ export interface EnemyTemplate {
|
|
|
67
75
|
attack: number;
|
|
68
76
|
defence: number;
|
|
69
77
|
solo?: boolean;
|
|
78
|
+
onHitPoison?: number;
|
|
79
|
+
onHitHurt?: number;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
export interface GameTemplates {
|
|
@@ -114,6 +124,7 @@ const mapsByName = new Map<string, MapInfo>();
|
|
|
114
124
|
const itemsByName = new Map<string, ItemConfig>();
|
|
115
125
|
const itemsById = new Map<number, ItemConfig>();
|
|
116
126
|
const skillsByName = new Map<string, SkillConfig>();
|
|
127
|
+
const skillsById = new Map<number, SkillConfig>();
|
|
117
128
|
const charactersById = new Map<number, CharacterConfig>();
|
|
118
129
|
const charactersByName = new Map<string, CharacterConfig>();
|
|
119
130
|
const dialogsById = new Map<string, DialogConfig>();
|
|
@@ -185,7 +196,7 @@ function buildMapsFromConfig(): void {
|
|
|
185
196
|
.filter((name): name is string => Boolean(name));
|
|
186
197
|
|
|
187
198
|
const shops =
|
|
188
|
-
templateMap && templateMap.shops
|
|
199
|
+
templateMap && templateMap.shops?.length > 0 ? [...templateMap.shops] : [...shopNames];
|
|
189
200
|
|
|
190
201
|
const encounterConfig = parseEncounters(templateMap?.encounters);
|
|
191
202
|
|
|
@@ -241,6 +252,7 @@ function loadSkills(): void {
|
|
|
241
252
|
const data = loadJson<{ skills: SkillConfig[] }>('skills.json');
|
|
242
253
|
for (const skill of data.skills) {
|
|
243
254
|
skillsByName.set(skill.name, skill);
|
|
255
|
+
skillsById.set(skill.id, skill);
|
|
244
256
|
}
|
|
245
257
|
}
|
|
246
258
|
|
|
@@ -277,6 +289,7 @@ export function resetConfigsForTest(): void {
|
|
|
277
289
|
itemsByName.clear();
|
|
278
290
|
itemsById.clear();
|
|
279
291
|
skillsByName.clear();
|
|
292
|
+
skillsById.clear();
|
|
280
293
|
charactersById.clear();
|
|
281
294
|
charactersByName.clear();
|
|
282
295
|
dialogsById.clear();
|
|
@@ -311,6 +324,27 @@ export function getSkill(name: string): SkillConfig | undefined {
|
|
|
311
324
|
return skillsByName.get(name);
|
|
312
325
|
}
|
|
313
326
|
|
|
327
|
+
export function getSkillById(id: number): SkillConfig | undefined {
|
|
328
|
+
initConfigs();
|
|
329
|
+
return skillsById.get(id);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** damageType 数字 → calculateStaminaCost 字符串键 */
|
|
333
|
+
export function mapDamageTypeToStaminaCost(damageType: number): string {
|
|
334
|
+
switch (damageType) {
|
|
335
|
+
case 1:
|
|
336
|
+
return 'absorbMp';
|
|
337
|
+
case 2:
|
|
338
|
+
return 'poison';
|
|
339
|
+
case 3:
|
|
340
|
+
return 'depoison';
|
|
341
|
+
case 4:
|
|
342
|
+
return 'heal';
|
|
343
|
+
default:
|
|
344
|
+
return 'normal';
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
314
348
|
export function getSkillAttackAtLevel(skillName: string, levelIndex = 0): number {
|
|
315
349
|
const skill = getSkill(skillName);
|
|
316
350
|
if (!skill || !skill.levels.length) return 0;
|
|
@@ -344,7 +378,21 @@ export function isArmor(item: ItemConfig): boolean {
|
|
|
344
378
|
}
|
|
345
379
|
|
|
346
380
|
export function isConsumable(item: ItemConfig): boolean {
|
|
347
|
-
return
|
|
381
|
+
return (
|
|
382
|
+
item.useAddHp > 0 ||
|
|
383
|
+
item.useAddMp > 0 ||
|
|
384
|
+
item.useAddStamina > 0 ||
|
|
385
|
+
item.useDePoison > 0 ||
|
|
386
|
+
(item.useAddAttack ?? 0) > 0 ||
|
|
387
|
+
(item.useAddAgility ?? 0) > 0 ||
|
|
388
|
+
(item.useAddDefence ?? 0) > 0 ||
|
|
389
|
+
(item.useAddPoison ?? 0) > 0 ||
|
|
390
|
+
isSkillBook(item)
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function isSkillBook(item: ItemConfig): boolean {
|
|
395
|
+
return item.type === 2 && (item.skillId ?? 0) > 0;
|
|
348
396
|
}
|
|
349
397
|
|
|
350
398
|
/** 校验 assets 完整性(CLI / CI 用) */
|
package/scripts/game-engine.ts
CHANGED
|
@@ -8,11 +8,15 @@ import {
|
|
|
8
8
|
calculateDamage as calcDamage,
|
|
9
9
|
getExpForLevel,
|
|
10
10
|
calculateMpCost,
|
|
11
|
+
calculateStaminaCost,
|
|
11
12
|
calculatePoisonDamage,
|
|
12
13
|
calculateHurtDamage,
|
|
14
|
+
calculateMoveStaminaCost,
|
|
13
15
|
DEFAULT_ATTRIBUTES,
|
|
14
16
|
MAX_STAMINA,
|
|
15
17
|
MAX_LEVEL,
|
|
18
|
+
MAX_SKILL_LEVEL,
|
|
19
|
+
MAX_INVENTORY_SIZE,
|
|
16
20
|
} from './game-logic';
|
|
17
21
|
import {
|
|
18
22
|
initConfigs,
|
|
@@ -20,12 +24,15 @@ import {
|
|
|
20
24
|
getMap,
|
|
21
25
|
getItem,
|
|
22
26
|
getSkill,
|
|
27
|
+
getSkillById,
|
|
23
28
|
getSkillAttackAtLevel,
|
|
24
29
|
getDialog,
|
|
25
30
|
getEnemyTemplate,
|
|
26
31
|
isWeapon,
|
|
27
32
|
isArmor,
|
|
28
33
|
isConsumable,
|
|
34
|
+
isSkillBook,
|
|
35
|
+
mapDamageTypeToStaminaCost,
|
|
29
36
|
} from './config-loader';
|
|
30
37
|
import type { GameState, BattleEnemy, Character } from './game-types';
|
|
31
38
|
import {
|
|
@@ -46,6 +53,42 @@ function autoSave(state: GameState): void {
|
|
|
46
53
|
saveGameState(state);
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
function assertAlive(state: GameState): { ok: true } | { ok: false; message: string } {
|
|
57
|
+
if (state.character.hp <= 0) {
|
|
58
|
+
return { ok: false, message: '你已昏迷,无法行动' };
|
|
59
|
+
}
|
|
60
|
+
return { ok: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clearBuffs(state: GameState): void {
|
|
64
|
+
state.character.buffs = {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ensureSkillExp(state: GameState): void {
|
|
68
|
+
if (!state.character.skillExp) {
|
|
69
|
+
state.character.skillExp = {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @internal 供单元测试覆盖防御性分支 */
|
|
74
|
+
export function consumeItemStack(state: GameState, itemName: string): void {
|
|
75
|
+
const inv = state.inventory.items.find((i) => i.name === itemName);
|
|
76
|
+
if (!inv) return;
|
|
77
|
+
inv.count--;
|
|
78
|
+
if (inv.count <= 0) {
|
|
79
|
+
state.inventory.items = state.inventory.items.filter((i) => i.name !== itemName);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveEnemyTemplateName(enemyDisplayName: string): string {
|
|
84
|
+
const templates = getTemplates().enemies;
|
|
85
|
+
if (templates[enemyDisplayName]) return enemyDisplayName;
|
|
86
|
+
for (const key of Object.keys(templates)) {
|
|
87
|
+
if (enemyDisplayName.startsWith(key)) return key;
|
|
88
|
+
}
|
|
89
|
+
return enemyDisplayName;
|
|
90
|
+
}
|
|
91
|
+
|
|
49
92
|
// ============================================================================
|
|
50
93
|
// 初始化
|
|
51
94
|
// ============================================================================
|
|
@@ -53,6 +96,7 @@ function autoSave(state: GameState): void {
|
|
|
53
96
|
function buildCharacter(name: string): Character {
|
|
54
97
|
const tpl = getTemplates();
|
|
55
98
|
const attrs = { ...DEFAULT_ATTRIBUTES };
|
|
99
|
+
const defaultSkills = tpl.defaultCharacter.skills ?? ['基本拳法'];
|
|
56
100
|
return {
|
|
57
101
|
name,
|
|
58
102
|
level: 1,
|
|
@@ -66,10 +110,10 @@ function buildCharacter(name: string): Character {
|
|
|
66
110
|
hurt: 0,
|
|
67
111
|
attributes: attrs,
|
|
68
112
|
equipment: { weapon: null, armor: null },
|
|
69
|
-
skills: [...
|
|
70
|
-
skillLevels: Object.fromEntries(
|
|
71
|
-
|
|
72
|
-
|
|
113
|
+
skills: [...defaultSkills],
|
|
114
|
+
skillLevels: Object.fromEntries(defaultSkills.map((s) => [s, 0])),
|
|
115
|
+
skillExp: Object.fromEntries(defaultSkills.map((s) => [s, 0])),
|
|
116
|
+
buffs: {},
|
|
73
117
|
};
|
|
74
118
|
}
|
|
75
119
|
|
|
@@ -123,6 +167,8 @@ export function getStatus(state: GameState): string {
|
|
|
123
167
|
`❤️ ${c.hp}/${c.maxHp} | 💠 ${c.mp}/${c.maxMp} | ⚡ ${c.stamina}/${MAX_STAMINA}`,
|
|
124
168
|
c.poison > 0 ? `🧪 中毒: ${c.poison}` : null,
|
|
125
169
|
c.hurt > 0 ? `💊 受伤: ${c.hurt}` : null,
|
|
170
|
+
(c.buffs?.attack ?? 0) > 0 ? `⚔️ 攻加成: +${c.buffs!.attack}` : null,
|
|
171
|
+
(c.buffs?.agility ?? 0) > 0 ? `💨 轻功加成: +${c.buffs!.agility}` : null,
|
|
126
172
|
`💰 ${state.inventory.silver} | 📍 ${state.location} | 📅 第${state.week}周`,
|
|
127
173
|
]
|
|
128
174
|
.filter(Boolean)
|
|
@@ -160,7 +206,14 @@ export function getInventory(state: GameState): string {
|
|
|
160
206
|
export function getSkills(state: GameState): string {
|
|
161
207
|
const skills = state.character.skills;
|
|
162
208
|
if (skills.length === 0) return '🥋 还没有学会任何武功';
|
|
163
|
-
|
|
209
|
+
ensureSkillExp(state);
|
|
210
|
+
return `🥋 武功:\n${skills
|
|
211
|
+
.map((s) => {
|
|
212
|
+
const lv = (state.character.skillLevels[s] ?? 0) + 1;
|
|
213
|
+
const exp = state.character.skillExp![s] ?? 0;
|
|
214
|
+
return `- ${s} Lv.${lv}(熟练 ${exp}/100)`;
|
|
215
|
+
})
|
|
216
|
+
.join('\n')}`;
|
|
164
217
|
}
|
|
165
218
|
|
|
166
219
|
// ============================================================================
|
|
@@ -171,6 +224,9 @@ export function moveTo(
|
|
|
171
224
|
state: GameState,
|
|
172
225
|
destination: string,
|
|
173
226
|
): { success: boolean; message: string; encounter?: string } {
|
|
227
|
+
const alive = assertAlive(state);
|
|
228
|
+
if (!alive.ok) return { success: false, message: alive.message };
|
|
229
|
+
|
|
174
230
|
const map = getMap(state.location);
|
|
175
231
|
if (!map) return { success: false, message: '当前位置未知' };
|
|
176
232
|
|
|
@@ -185,9 +241,15 @@ export function moveTo(
|
|
|
185
241
|
return { success: false, message: `从${state.location}无法直达${destination}` };
|
|
186
242
|
}
|
|
187
243
|
|
|
244
|
+
const moveCost = calculateMoveStaminaCost(getEffectiveAgility(state));
|
|
245
|
+
if (state.character.stamina < moveCost) {
|
|
246
|
+
return { success: false, message: `体力不足,需要${moveCost}点体力才能前往${destination}` };
|
|
247
|
+
}
|
|
248
|
+
|
|
188
249
|
state.location = destination;
|
|
189
|
-
state.character.stamina = Math.max(0, state.character.stamina -
|
|
250
|
+
state.character.stamina = Math.max(0, state.character.stamina - moveCost);
|
|
190
251
|
state.week++;
|
|
252
|
+
clearBuffs(state);
|
|
191
253
|
advanceWeekEffects(state);
|
|
192
254
|
|
|
193
255
|
if (!state.visitedMaps.includes(destination)) {
|
|
@@ -249,6 +311,9 @@ export function talkTo(state: GameState, npcName: string): { success: boolean; m
|
|
|
249
311
|
// ============================================================================
|
|
250
312
|
|
|
251
313
|
export function buyItem(state: GameState, itemName: string): { success: boolean; message: string } {
|
|
314
|
+
const alive = assertAlive(state);
|
|
315
|
+
if (!alive.ok) return { success: false, message: alive.message };
|
|
316
|
+
|
|
252
317
|
const item = getItem(itemName);
|
|
253
318
|
if (!item) return { success: false, message: `没有${itemName}出售` };
|
|
254
319
|
|
|
@@ -264,8 +329,13 @@ export function buyItem(state: GameState, itemName: string): { success: boolean;
|
|
|
264
329
|
};
|
|
265
330
|
}
|
|
266
331
|
|
|
267
|
-
state.inventory.
|
|
332
|
+
const totalItems = state.inventory.items.reduce((sum, i) => sum + i.count, 0);
|
|
268
333
|
const existing = state.inventory.items.find((i) => i.name === itemName);
|
|
334
|
+
if (!existing && totalItems >= MAX_INVENTORY_SIZE) {
|
|
335
|
+
return { success: false, message: `背包已满(最多${MAX_INVENTORY_SIZE}种物品)` };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
state.inventory.silver -= item.price;
|
|
269
339
|
if (existing) {
|
|
270
340
|
existing.count++;
|
|
271
341
|
} else {
|
|
@@ -285,6 +355,9 @@ export function buyItem(state: GameState, itemName: string): { success: boolean;
|
|
|
285
355
|
// ============================================================================
|
|
286
356
|
|
|
287
357
|
export function useItem(state: GameState, itemName: string): { success: boolean; message: string } {
|
|
358
|
+
const alive = assertAlive(state);
|
|
359
|
+
if (!alive.ok) return { success: false, message: alive.message };
|
|
360
|
+
|
|
288
361
|
const inv = state.inventory.items.find((i) => i.name === itemName);
|
|
289
362
|
if (!inv || inv.count <= 0) {
|
|
290
363
|
return { success: false, message: `没有${itemName}` };
|
|
@@ -295,12 +368,40 @@ export function useItem(state: GameState, itemName: string): { success: boolean;
|
|
|
295
368
|
return { success: false, message: `${itemName}无法使用` };
|
|
296
369
|
}
|
|
297
370
|
|
|
371
|
+
// 武功秘籍
|
|
372
|
+
if (isSkillBook(item)) {
|
|
373
|
+
const skill = getSkillById(item.skillId!);
|
|
374
|
+
if (!skill) return { success: false, message: `${itemName}内容残缺,无法修习` };
|
|
375
|
+
|
|
376
|
+
const c = state.character;
|
|
377
|
+
if ((item.needIQ ?? 0) > c.attributes.iq) {
|
|
378
|
+
return { success: false, message: `资质不足,需要${item.needIQ}点资质才能研读${itemName}` };
|
|
379
|
+
}
|
|
380
|
+
if ((item.needExp ?? 0) > c.exp) {
|
|
381
|
+
return { success: false, message: `经验不足,需要${item.needExp}点经验才能研读${itemName}` };
|
|
382
|
+
}
|
|
383
|
+
if (c.skills.includes(skill.name)) {
|
|
384
|
+
return { success: false, message: `已经学会了${skill.name}` };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
c.skills.push(skill.name);
|
|
388
|
+
c.skillLevels[skill.name] = 0;
|
|
389
|
+
ensureSkillExp(state);
|
|
390
|
+
state.character.skillExp![skill.name] = 0;
|
|
391
|
+
consumeItemStack(state, itemName);
|
|
392
|
+
autoSave(state);
|
|
393
|
+
return { success: true, message: `研读${itemName},学会了${skill.name}` };
|
|
394
|
+
}
|
|
395
|
+
|
|
298
396
|
const c = state.character;
|
|
397
|
+
if (!c.buffs) c.buffs = {};
|
|
299
398
|
const parts: string[] = [];
|
|
300
399
|
let hpGain = 0;
|
|
301
400
|
let mpGain = 0;
|
|
302
401
|
let staminaGain = 0;
|
|
303
402
|
let poisonReduced = 0;
|
|
403
|
+
let buffAttack = 0;
|
|
404
|
+
let buffAgility = 0;
|
|
304
405
|
|
|
305
406
|
if (item.useAddHp > 0) {
|
|
306
407
|
hpGain = Math.min(item.useAddHp, c.maxHp - c.hp);
|
|
@@ -318,8 +419,23 @@ export function useItem(state: GameState, itemName: string): { success: boolean;
|
|
|
318
419
|
poisonReduced = Math.min(item.useDePoison, c.poison);
|
|
319
420
|
parts.push('解除中毒');
|
|
320
421
|
}
|
|
422
|
+
if ((item.useAddAttack ?? 0) > 0) {
|
|
423
|
+
if ((c.buffs.attack ?? 0) > 0) {
|
|
424
|
+
return { success: false, message: `${itemName}效果仍在,无需重复使用` };
|
|
425
|
+
}
|
|
426
|
+
buffAttack = item.useAddAttack!;
|
|
427
|
+
parts.push(`攻击力临时+${buffAttack}`);
|
|
428
|
+
}
|
|
429
|
+
if ((item.useAddAgility ?? 0) > 0) {
|
|
430
|
+
if ((c.buffs.agility ?? 0) > 0) {
|
|
431
|
+
return { success: false, message: `${itemName}效果仍在,无需重复使用` };
|
|
432
|
+
}
|
|
433
|
+
buffAgility = item.useAddAgility!;
|
|
434
|
+
parts.push(`轻功临时+${buffAgility}`);
|
|
435
|
+
}
|
|
321
436
|
|
|
322
|
-
|
|
437
|
+
const totalGain = hpGain + mpGain + staminaGain + poisonReduced + buffAttack + buffAgility;
|
|
438
|
+
if (totalGain === 0) {
|
|
323
439
|
if (item.useDePoison > 0 && c.poison <= 0) {
|
|
324
440
|
return { success: false, message: '你没有中毒,无需使用解毒丸' };
|
|
325
441
|
}
|
|
@@ -330,12 +446,10 @@ export function useItem(state: GameState, itemName: string): { success: boolean;
|
|
|
330
446
|
c.mp += mpGain;
|
|
331
447
|
c.stamina += staminaGain;
|
|
332
448
|
c.poison -= poisonReduced;
|
|
449
|
+
if (buffAttack > 0) c.buffs.attack = buffAttack;
|
|
450
|
+
if (buffAgility > 0) c.buffs.agility = buffAgility;
|
|
333
451
|
|
|
334
|
-
|
|
335
|
-
if (inv.count <= 0) {
|
|
336
|
-
state.inventory.items = state.inventory.items.filter((i) => i.name !== itemName);
|
|
337
|
-
}
|
|
338
|
-
|
|
452
|
+
consumeItemStack(state, itemName);
|
|
339
453
|
autoSave(state);
|
|
340
454
|
return { success: true, message: `使用${itemName},${parts.join(',')}` };
|
|
341
455
|
}
|
|
@@ -348,6 +462,9 @@ export function equipItem(
|
|
|
348
462
|
state: GameState,
|
|
349
463
|
itemName: string,
|
|
350
464
|
): { success: boolean; message: string } {
|
|
465
|
+
const alive = assertAlive(state);
|
|
466
|
+
if (!alive.ok) return { success: false, message: alive.message };
|
|
467
|
+
|
|
351
468
|
const inv = state.inventory.items.find((i) => i.name === itemName);
|
|
352
469
|
if (!inv || inv.count <= 0) {
|
|
353
470
|
return { success: false, message: `没有${itemName}` };
|
|
@@ -379,6 +496,9 @@ export function learnSkill(
|
|
|
379
496
|
state: GameState,
|
|
380
497
|
skillName: string,
|
|
381
498
|
): { success: boolean; message: string } {
|
|
499
|
+
const alive = assertAlive(state);
|
|
500
|
+
if (!alive.ok) return { success: false, message: alive.message };
|
|
501
|
+
|
|
382
502
|
if (!getSkill(skillName)) {
|
|
383
503
|
return { success: false, message: `江湖上没有${skillName}这门武功` };
|
|
384
504
|
}
|
|
@@ -388,6 +508,8 @@ export function learnSkill(
|
|
|
388
508
|
|
|
389
509
|
state.character.skills.push(skillName);
|
|
390
510
|
state.character.skillLevels[skillName] = 0;
|
|
511
|
+
ensureSkillExp(state);
|
|
512
|
+
state.character.skillExp![skillName] = 0;
|
|
391
513
|
autoSave(state);
|
|
392
514
|
return { success: true, message: `学会了${skillName}` };
|
|
393
515
|
}
|
|
@@ -397,11 +519,15 @@ export function learnSkill(
|
|
|
397
519
|
// ============================================================================
|
|
398
520
|
|
|
399
521
|
export function rest(state: GameState): { success: boolean; message: string } {
|
|
522
|
+
const alive = assertAlive(state);
|
|
523
|
+
if (!alive.ok) return { success: false, message: alive.message };
|
|
524
|
+
|
|
400
525
|
state.character.hp = state.character.maxHp;
|
|
401
526
|
state.character.mp = state.character.maxMp;
|
|
402
527
|
state.character.stamina = MAX_STAMINA;
|
|
403
528
|
state.character.poison = 0;
|
|
404
529
|
state.character.hurt = 0;
|
|
530
|
+
clearBuffs(state);
|
|
405
531
|
|
|
406
532
|
autoSave(state);
|
|
407
533
|
return { success: true, message: '休息完毕,状态全满' };
|
|
@@ -415,6 +541,9 @@ export function startBattle(
|
|
|
415
541
|
state: GameState,
|
|
416
542
|
enemyName: string,
|
|
417
543
|
): { success: boolean; message: string; enemies?: BattleEnemy[] } {
|
|
544
|
+
const alive = assertAlive(state);
|
|
545
|
+
if (!alive.ok) return { success: false, message: alive.message };
|
|
546
|
+
|
|
418
547
|
const entry = getTemplates().enemies[enemyName];
|
|
419
548
|
const template = getEnemyTemplate(enemyName);
|
|
420
549
|
if (!template) {
|
|
@@ -442,11 +571,19 @@ export function attackEnemy(
|
|
|
442
571
|
enemies: BattleEnemy[],
|
|
443
572
|
targetIndex: number,
|
|
444
573
|
): { message: string; enemyDefeated: boolean; playerDamage: number } {
|
|
574
|
+
const alive = assertAlive(state);
|
|
575
|
+
if (!alive.ok) return { message: alive.message, enemyDefeated: false, playerDamage: 0 };
|
|
576
|
+
|
|
445
577
|
const target = enemies[targetIndex];
|
|
446
578
|
if (!target || target.hp <= 0) {
|
|
447
579
|
return { message: '目标无效', enemyDefeated: false, playerDamage: 0 };
|
|
448
580
|
}
|
|
449
581
|
|
|
582
|
+
const staminaCost = calculateStaminaCost('normal');
|
|
583
|
+
if (state.character.stamina < staminaCost) {
|
|
584
|
+
return { message: '体力不足,无法攻击', enemyDefeated: false, playerDamage: 0 };
|
|
585
|
+
}
|
|
586
|
+
|
|
450
587
|
const damage = calcDamage(
|
|
451
588
|
getEffectiveAttack(state),
|
|
452
589
|
0,
|
|
@@ -456,7 +593,7 @@ export function attackEnemy(
|
|
|
456
593
|
);
|
|
457
594
|
|
|
458
595
|
target.hp = Math.max(0, target.hp - damage);
|
|
459
|
-
state.character.stamina = Math.max(0, state.character.stamina -
|
|
596
|
+
state.character.stamina = Math.max(0, state.character.stamina - staminaCost);
|
|
460
597
|
|
|
461
598
|
const defeated = target.hp <= 0;
|
|
462
599
|
if (defeated) {
|
|
@@ -477,6 +614,9 @@ export function useSkillInBattle(
|
|
|
477
614
|
skillName: string,
|
|
478
615
|
targetIndex: number,
|
|
479
616
|
): { success: boolean; message: string } {
|
|
617
|
+
const alive = assertAlive(state);
|
|
618
|
+
if (!alive.ok) return { success: false, message: alive.message };
|
|
619
|
+
|
|
480
620
|
if (!state.character.skills.includes(skillName)) {
|
|
481
621
|
return { success: false, message: `没有学会${skillName}` };
|
|
482
622
|
}
|
|
@@ -484,17 +624,52 @@ export function useSkillInBattle(
|
|
|
484
624
|
const skill = getSkill(skillName);
|
|
485
625
|
if (!skill) return { success: false, message: `未知武功${skillName}` };
|
|
486
626
|
|
|
487
|
-
const target = enemies[targetIndex];
|
|
488
|
-
if (!target || target.hp <= 0) {
|
|
489
|
-
return { success: false, message: '目标无效' };
|
|
490
|
-
}
|
|
491
|
-
|
|
492
627
|
const levelIndex = state.character.skillLevels[skillName] ?? 0;
|
|
493
628
|
const mpCost = calculateMpCost(skill.mpCost, levelIndex);
|
|
494
629
|
if (state.character.mp < mpCost) {
|
|
495
630
|
return { success: false, message: `内力不足,需要${mpCost}点内力` };
|
|
496
631
|
}
|
|
497
632
|
|
|
633
|
+
const staminaCost = calculateStaminaCost(mapDamageTypeToStaminaCost(skill.damageType));
|
|
634
|
+
if (state.character.stamina < staminaCost) {
|
|
635
|
+
return { success: false, message: `体力不足,无法施展${skillName}` };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
state.character.mp = Math.max(0, state.character.mp - mpCost);
|
|
639
|
+
state.character.stamina = Math.max(0, state.character.stamina - staminaCost);
|
|
640
|
+
|
|
641
|
+
// 自身目标类武功
|
|
642
|
+
if (skill.damageType === 3) {
|
|
643
|
+
const reduced = Math.min(state.character.poison, 50);
|
|
644
|
+
state.character.poison = Math.max(0, state.character.poison - reduced);
|
|
645
|
+
grantSkillExp(state, skillName);
|
|
646
|
+
autoSave(state);
|
|
647
|
+
return {
|
|
648
|
+
success: true,
|
|
649
|
+
message: reduced > 0 ? `使用${skillName},解除了部分毒素` : `使用${skillName},你并未中毒`,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (skill.damageType === 4) {
|
|
654
|
+
const skillAttack = getSkillAttackAtLevel(skillName, levelIndex);
|
|
655
|
+
const healAmount = Math.min(skillAttack, state.character.maxHp - state.character.hp);
|
|
656
|
+
state.character.hp += healAmount;
|
|
657
|
+
grantSkillExp(state, skillName);
|
|
658
|
+
autoSave(state);
|
|
659
|
+
return {
|
|
660
|
+
success: true,
|
|
661
|
+
message:
|
|
662
|
+
healAmount > 0
|
|
663
|
+
? `使用${skillName},恢复${healAmount}点生命`
|
|
664
|
+
: `使用${skillName},气血已足,无需治疗`,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const target = enemies[targetIndex];
|
|
669
|
+
if (!target || target.hp <= 0) {
|
|
670
|
+
return { success: false, message: '目标无效' };
|
|
671
|
+
}
|
|
672
|
+
|
|
498
673
|
const skillAttack = getSkillAttackAtLevel(skillName, levelIndex);
|
|
499
674
|
const damage = calcDamage(
|
|
500
675
|
getEffectiveAttack(state),
|
|
@@ -505,18 +680,27 @@ export function useSkillInBattle(
|
|
|
505
680
|
);
|
|
506
681
|
|
|
507
682
|
target.hp = Math.max(0, target.hp - damage);
|
|
508
|
-
|
|
509
|
-
|
|
683
|
+
|
|
684
|
+
let extra = '';
|
|
685
|
+
if (skill.damageType === 1) {
|
|
686
|
+
const absorbed = Math.min(Math.floor(damage / 2), state.character.maxMp - state.character.mp);
|
|
687
|
+
state.character.mp += absorbed;
|
|
688
|
+
if (absorbed > 0) extra = `,吸取${absorbed}点内力`;
|
|
689
|
+
}
|
|
690
|
+
if (skill.damageType === 2) {
|
|
691
|
+
extra = ',敌人身中剧毒';
|
|
692
|
+
}
|
|
510
693
|
|
|
511
694
|
const defeated = target.hp <= 0;
|
|
512
695
|
if (defeated) {
|
|
513
696
|
grantBattleExp(state, target.maxHp);
|
|
514
697
|
}
|
|
698
|
+
grantSkillExp(state, skillName);
|
|
515
699
|
|
|
516
700
|
autoSave(state);
|
|
517
701
|
return {
|
|
518
702
|
success: true,
|
|
519
|
-
message: `使用${skillName}攻击${target.name},造成${damage}点伤害${defeated ? ',击败!' : ''}`,
|
|
703
|
+
message: `使用${skillName}攻击${target.name},造成${damage}点伤害${extra}${defeated ? ',击败!' : ''}`,
|
|
520
704
|
};
|
|
521
705
|
}
|
|
522
706
|
|
|
@@ -524,6 +708,10 @@ export function enemyAttack(
|
|
|
524
708
|
state: GameState,
|
|
525
709
|
enemies: BattleEnemy[],
|
|
526
710
|
): { message: string; playerDefeated: boolean } {
|
|
711
|
+
if (state.character.hp <= 0) {
|
|
712
|
+
return { message: '', playerDefeated: true };
|
|
713
|
+
}
|
|
714
|
+
|
|
527
715
|
const aliveEnemies = enemies.filter((e) => e.hp > 0);
|
|
528
716
|
if (aliveEnemies.length === 0) return { message: '', playerDefeated: false };
|
|
529
717
|
|
|
@@ -532,19 +720,33 @@ export function enemyAttack(
|
|
|
532
720
|
|
|
533
721
|
state.character.hp = Math.max(0, state.character.hp - damage);
|
|
534
722
|
|
|
723
|
+
const templateName = resolveEnemyTemplateName(enemy.name);
|
|
724
|
+
const template = getEnemyTemplate(templateName);
|
|
725
|
+
if (template?.onHitPoison) {
|
|
726
|
+
state.character.poison += template.onHitPoison;
|
|
727
|
+
}
|
|
728
|
+
if (template?.onHitHurt) {
|
|
729
|
+
state.character.hurt += template.onHitHurt;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let debuffMsg = '';
|
|
733
|
+
if (template?.onHitPoison) debuffMsg += ',你感到一阵麻痹';
|
|
734
|
+
if (template?.onHitHurt) debuffMsg += ',你受了内伤';
|
|
735
|
+
|
|
535
736
|
autoSave(state);
|
|
536
737
|
return {
|
|
537
|
-
message: `${enemy.name}攻击你,造成${damage}
|
|
738
|
+
message: `${enemy.name}攻击你,造成${damage}点伤害${debuffMsg}`,
|
|
538
739
|
playerDefeated: state.character.hp <= 0,
|
|
539
740
|
};
|
|
540
741
|
}
|
|
541
742
|
|
|
542
743
|
// ============================================================================
|
|
543
|
-
//
|
|
744
|
+
// 状态辅助
|
|
544
745
|
// ============================================================================
|
|
545
746
|
|
|
546
747
|
export function advanceWeek(state: GameState): void {
|
|
547
748
|
state.week++;
|
|
749
|
+
clearBuffs(state);
|
|
548
750
|
advanceWeekEffects(state);
|
|
549
751
|
autoSave(state);
|
|
550
752
|
}
|
|
@@ -568,8 +770,12 @@ export function isDead(state: GameState): boolean {
|
|
|
568
770
|
// 内部
|
|
569
771
|
// ============================================================================
|
|
570
772
|
|
|
773
|
+
function getEffectiveAgility(state: GameState): number {
|
|
774
|
+
return state.character.attributes.agility + (state.character.buffs?.agility ?? 0);
|
|
775
|
+
}
|
|
776
|
+
|
|
571
777
|
function getEffectiveAttack(state: GameState): number {
|
|
572
|
-
let attack = state.character.attributes.attack;
|
|
778
|
+
let attack = state.character.attributes.attack + (state.character.buffs?.attack ?? 0);
|
|
573
779
|
const weapon = state.character.equipment.weapon;
|
|
574
780
|
if (weapon) {
|
|
575
781
|
const item = getItem(weapon);
|
|
@@ -589,10 +795,28 @@ function getEffectiveDefence(state: GameState): number {
|
|
|
589
795
|
}
|
|
590
796
|
|
|
591
797
|
function grantBattleExp(state: GameState, enemyMaxHp: number): void {
|
|
592
|
-
state.character.exp += 10 + enemyMaxHp / 10;
|
|
798
|
+
state.character.exp += Math.floor(10 + enemyMaxHp / 10);
|
|
593
799
|
checkLevelUp(state);
|
|
594
800
|
}
|
|
595
801
|
|
|
802
|
+
function grantSkillExp(state: GameState, skillName: string): void {
|
|
803
|
+
ensureSkillExp(state);
|
|
804
|
+
const levels = state.character.skillLevels;
|
|
805
|
+
const levelIndex = levels[skillName] ?? 0;
|
|
806
|
+
if (levelIndex >= MAX_SKILL_LEVEL - 1) return;
|
|
807
|
+
|
|
808
|
+
const gain = Math.floor(Math.random() * 3) + 1;
|
|
809
|
+
state.character.skillExp![skillName] = (state.character.skillExp![skillName] ?? 0) + gain;
|
|
810
|
+
|
|
811
|
+
while (
|
|
812
|
+
state.character.skillExp![skillName] >= 100 &&
|
|
813
|
+
(levels[skillName] ?? 0) < MAX_SKILL_LEVEL - 1
|
|
814
|
+
) {
|
|
815
|
+
state.character.skillExp![skillName] -= 100;
|
|
816
|
+
levels[skillName] = (levels[skillName] ?? 0) + 1;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
596
820
|
function checkLevelUp(state: GameState): void {
|
|
597
821
|
const c = state.character;
|
|
598
822
|
let needed = getExpForLevel(c.level + 1);
|
|
@@ -605,11 +829,13 @@ function checkLevelUp(state: GameState): void {
|
|
|
605
829
|
const attrGain = Math.floor(Math.random() * (Math.floor((iq - 10) / 20) + 2)) + 1;
|
|
606
830
|
|
|
607
831
|
c.maxHp += (c.attributes.hpInc + Math.floor(Math.random() * 4)) * 3;
|
|
608
|
-
c.maxMp += (9 - attrGain) * 4;
|
|
832
|
+
c.maxMp += Math.max(0, (9 - attrGain) * 4);
|
|
609
833
|
c.attributes.attack += attrGain;
|
|
610
834
|
c.attributes.agility += attrGain;
|
|
611
835
|
c.attributes.defence += attrGain;
|
|
612
836
|
|
|
837
|
+
c.attributes.level = c.level;
|
|
838
|
+
c.attributes.exp = c.exp;
|
|
613
839
|
c.hp = c.maxHp;
|
|
614
840
|
c.mp = c.maxMp;
|
|
615
841
|
needed = getExpForLevel(c.level + 1);
|
package/scripts/game-logic.ts
CHANGED
|
@@ -105,6 +105,11 @@ export function calculateMovePoints(agility: number): number {
|
|
|
105
105
|
return Math.floor(agility / 15) + 3;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/** 地点移动体力消耗:轻功越高消耗越低,最低 3 */
|
|
109
|
+
export function calculateMoveStaminaCost(agility: number): number {
|
|
110
|
+
return Math.max(3, 8 - Math.floor(agility / 15));
|
|
111
|
+
}
|
|
112
|
+
|
|
108
113
|
// ============================================================================
|
|
109
114
|
// 默认属性
|
|
110
115
|
// ============================================================================
|
package/scripts/game-types.ts
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import type { DEFAULT_ATTRIBUTES } from './game-logic';
|
|
6
6
|
|
|
7
|
+
export interface CharacterBuffs {
|
|
8
|
+
attack?: number;
|
|
9
|
+
agility?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
7
12
|
export interface Character {
|
|
8
13
|
name: string;
|
|
9
14
|
level: number;
|
|
@@ -19,6 +24,8 @@ export interface Character {
|
|
|
19
24
|
equipment: { weapon: string | null; armor: string | null };
|
|
20
25
|
skills: string[];
|
|
21
26
|
skillLevels: Record<string, number>;
|
|
27
|
+
skillExp?: Record<string, number>;
|
|
28
|
+
buffs?: CharacterBuffs;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
export interface Inventory {
|
package/scripts/persistence.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import type { GameState } from './game-types';
|
|
16
|
-
import { DEFAULT_ATTRIBUTES, MAX_STAMINA } from './game-logic';
|
|
16
|
+
import { DEFAULT_ATTRIBUTES, MAX_STAMINA, MAX_LEVEL, MAX_EXP } from './game-logic';
|
|
17
17
|
import { getMap, getTemplates, initConfigs } from './config-loader';
|
|
18
18
|
|
|
19
19
|
export interface LoadGameResult {
|
|
@@ -30,13 +30,20 @@ export function getSavePath(): string {
|
|
|
30
30
|
return SAVE_FILE;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function isFiniteNumber(n: unknown): n is number {
|
|
34
|
+
return typeof n === 'number' && Number.isFinite(n);
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
function isValidGameState(raw: unknown): raw is GameState {
|
|
34
38
|
if (!raw || typeof raw !== 'object') return false;
|
|
35
39
|
const state = raw as GameState;
|
|
36
|
-
|
|
37
|
-
if (!
|
|
40
|
+
const c = state.character;
|
|
41
|
+
if (!c?.name || typeof state.location !== 'string') return false;
|
|
42
|
+
if (!state.inventory || !isFiniteNumber(state.inventory.silver)) return false;
|
|
38
43
|
if (!Array.isArray(state.inventory.items)) return false;
|
|
39
|
-
if (
|
|
44
|
+
if (!isFiniteNumber(state.week)) return false;
|
|
45
|
+
if (!isFiniteNumber(c.hp) || !isFiniteNumber(c.mp)) return false;
|
|
46
|
+
if (!isFiniteNumber(c.stamina)) return false;
|
|
40
47
|
return true;
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -44,12 +51,30 @@ function isValidGameState(raw: unknown): raw is GameState {
|
|
|
44
51
|
function migrateGameState(state: GameState): GameState {
|
|
45
52
|
initConfigs();
|
|
46
53
|
|
|
47
|
-
if (
|
|
54
|
+
if (typeof state.character.skills === 'string') {
|
|
55
|
+
state.character.skills = [state.character.skills];
|
|
56
|
+
} else if (!Array.isArray(state.character.skills)) {
|
|
48
57
|
state.character.skills = [...(getTemplates().defaultCharacter.skills ?? ['基本拳法'])];
|
|
49
58
|
}
|
|
50
59
|
if (!state.character.skillLevels) {
|
|
51
60
|
state.character.skillLevels = Object.fromEntries(state.character.skills.map((s) => [s, 0]));
|
|
52
61
|
}
|
|
62
|
+
for (const skill of state.character.skills) {
|
|
63
|
+
if (state.character.skillLevels[skill] == null) {
|
|
64
|
+
state.character.skillLevels[skill] = 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!state.character.skillExp) {
|
|
68
|
+
state.character.skillExp = Object.fromEntries(state.character.skills.map((s) => [s, 0]));
|
|
69
|
+
}
|
|
70
|
+
for (const skill of state.character.skills) {
|
|
71
|
+
if (state.character.skillExp![skill] == null) {
|
|
72
|
+
state.character.skillExp![skill] = 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!state.character.buffs) {
|
|
76
|
+
state.character.buffs = {};
|
|
77
|
+
}
|
|
53
78
|
if (!state.character.attributes || typeof state.character.attributes !== 'object') {
|
|
54
79
|
state.character.attributes = { ...DEFAULT_ATTRIBUTES };
|
|
55
80
|
} else {
|
|
@@ -74,11 +99,21 @@ function migrateGameState(state: GameState): GameState {
|
|
|
74
99
|
const c = state.character;
|
|
75
100
|
if (typeof c.maxHp !== 'number') c.maxHp = DEFAULT_ATTRIBUTES.maxHp;
|
|
76
101
|
if (typeof c.maxMp !== 'number') c.maxMp = DEFAULT_ATTRIBUTES.maxMp;
|
|
102
|
+
c.level = Math.max(1, Math.min(MAX_LEVEL, Math.floor(c.level ?? 1)));
|
|
103
|
+
c.exp = Math.max(0, Math.min(MAX_EXP, Math.floor(c.exp ?? 0)));
|
|
77
104
|
c.hp = Math.max(0, Math.min(c.maxHp, c.hp));
|
|
78
105
|
c.mp = Math.max(0, Math.min(c.maxMp, c.mp));
|
|
79
106
|
c.stamina = Math.max(0, Math.min(MAX_STAMINA, c.stamina));
|
|
80
107
|
c.poison = Math.max(0, c.poison);
|
|
81
108
|
c.hurt = Math.max(0, c.hurt);
|
|
109
|
+
c.attributes.level = c.level;
|
|
110
|
+
c.attributes.exp = c.exp;
|
|
111
|
+
|
|
112
|
+
state.inventory.silver = Math.max(0, Math.floor(state.inventory.silver));
|
|
113
|
+
for (const item of state.inventory.items) {
|
|
114
|
+
item.count = Math.max(0, Math.floor(item.count));
|
|
115
|
+
}
|
|
116
|
+
state.inventory.items = state.inventory.items.filter((i) => i.count > 0);
|
|
82
117
|
|
|
83
118
|
if (!getMap(state.location)) {
|
|
84
119
|
state.location = getTemplates().startLocation ?? '小村';
|