@sdd330dev/jy-skill 0.3.0 → 0.7.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.
@@ -5,14 +5,44 @@
5
5
  import { readFileSync, readdirSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import type { NpcCard } from './game-types';
8
9
 
9
10
  const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
10
11
  const ASSETS_DIR = join(ROOT_DIR, 'assets');
11
12
 
13
+ /** 地图名归一化(game-config 与 templates 不一致时) */
14
+ const MAP_ALIASES: Record<string, string> = {
15
+ 终南山全真教: '全真教',
16
+ };
17
+
18
+ function normalizeMapName(name: string): string {
19
+ return MAP_ALIASES[name] ?? name;
20
+ }
21
+
12
22
  // ============================================================================
13
23
  // 类型
14
24
  // ============================================================================
15
25
 
26
+ export interface MapEventCondition {
27
+ type: string;
28
+ params: Record<string, unknown>;
29
+ }
30
+
31
+ export interface MapEventAction {
32
+ type: string;
33
+ params: Record<string, unknown>;
34
+ }
35
+
36
+ export interface MapEvent {
37
+ id: string;
38
+ triggerType: string;
39
+ x?: number;
40
+ y?: number;
41
+ npcName?: string;
42
+ conditions: MapEventCondition[];
43
+ actions: MapEventAction[];
44
+ }
45
+
16
46
  export interface MapInfo {
17
47
  npcs: string[];
18
48
  shops: string[];
@@ -20,6 +50,11 @@ export interface MapInfo {
20
50
  npcDialogs: Record<string, string>;
21
51
  encounterRate?: number;
22
52
  encounterEnemies?: string[];
53
+ description?: string;
54
+ atmosphere?: string;
55
+ dangerLevel?: 'safe' | 'cautious' | 'dangerous';
56
+ npcCards?: Record<string, NpcCard>;
57
+ events?: MapEvent[];
23
58
  }
24
59
 
25
60
  export interface ItemConfig {
@@ -35,6 +70,13 @@ export interface ItemConfig {
35
70
  useAddMp: number;
36
71
  useAddStamina: number;
37
72
  useDePoison: number;
73
+ useAddAttack?: number;
74
+ useAddAgility?: number;
75
+ useAddDefence?: number;
76
+ useAddPoison?: number;
77
+ skillId?: number;
78
+ needIQ?: number;
79
+ needExp?: number;
38
80
  }
39
81
 
40
82
  export interface SkillConfig {
@@ -43,6 +85,7 @@ export interface SkillConfig {
43
85
  desc: string;
44
86
  mpCost: number;
45
87
  damageType: number;
88
+ poison?: number;
46
89
  levels: number[][];
47
90
  }
48
91
 
@@ -56,10 +99,17 @@ export interface CharacterConfig {
56
99
  source: string;
57
100
  }
58
101
 
102
+ export interface DialogChoiceConfig {
103
+ text: string;
104
+ nextId: string;
105
+ actions?: MapEventAction[];
106
+ }
107
+
59
108
  export interface DialogConfig {
60
109
  id: string;
61
110
  speaker: string;
62
111
  text: string;
112
+ choices?: DialogChoiceConfig[];
63
113
  }
64
114
 
65
115
  export interface EnemyTemplate {
@@ -67,6 +117,19 @@ export interface EnemyTemplate {
67
117
  attack: number;
68
118
  defence: number;
69
119
  solo?: boolean;
120
+ onHitPoison?: number;
121
+ onHitHurt?: number;
122
+ }
123
+
124
+ export interface TemplateMapEntry {
125
+ npcs: string[];
126
+ shops: string[];
127
+ connections: string[];
128
+ encounters?: number | { rate: number; enemies: string[] };
129
+ description?: string;
130
+ atmosphere?: string;
131
+ dangerLevel?: 'safe' | 'cautious' | 'dangerous';
132
+ npcCards?: Record<string, NpcCard>;
70
133
  }
71
134
 
72
135
  export interface GameTemplates {
@@ -80,15 +143,7 @@ export interface GameTemplates {
80
143
  };
81
144
  startLocation: string;
82
145
  enemies: Record<string, EnemyTemplate | { characterId: number }>;
83
- maps?: Record<
84
- string,
85
- {
86
- npcs: string[];
87
- shops: string[];
88
- connections: string[];
89
- encounters?: number | { rate: number; enemies: string[] };
90
- }
91
- >;
146
+ maps?: Record<string, TemplateMapEntry>;
92
147
  }
93
148
 
94
149
  function parseEncounters(
@@ -101,6 +156,17 @@ function parseEncounters(
101
156
  return { encounterRate: encounters.rate, encounterEnemies: [...encounters.enemies] };
102
157
  }
103
158
 
159
+ function deriveDangerLevel(
160
+ templateMap: TemplateMapEntry | undefined,
161
+ encounterConfig: Pick<MapInfo, 'encounterRate' | 'encounterEnemies'>,
162
+ events: MapEvent[],
163
+ ): 'safe' | 'cautious' | 'dangerous' {
164
+ if (templateMap?.dangerLevel) return templateMap.dangerLevel;
165
+ if (encounterConfig.encounterRate && encounterConfig.encounterRate > 0) return 'dangerous';
166
+ if (events.some((e) => e.triggerType === 'interact')) return 'cautious';
167
+ return 'safe';
168
+ }
169
+
104
170
  // ============================================================================
105
171
  // 加载
106
172
  // ============================================================================
@@ -114,6 +180,7 @@ const mapsByName = new Map<string, MapInfo>();
114
180
  const itemsByName = new Map<string, ItemConfig>();
115
181
  const itemsById = new Map<number, ItemConfig>();
116
182
  const skillsByName = new Map<string, SkillConfig>();
183
+ const skillsById = new Map<number, SkillConfig>();
117
184
  const charactersById = new Map<number, CharacterConfig>();
118
185
  const charactersByName = new Map<string, CharacterConfig>();
119
186
  const dialogsById = new Map<string, DialogConfig>();
@@ -136,6 +203,7 @@ function buildMapsFromConfig(): void {
136
203
  maps: Array<{
137
204
  id: number;
138
205
  name: string;
206
+ desc?: string;
139
207
  npcs: Array<{
140
208
  roleId: number;
141
209
  dialogId: string;
@@ -143,13 +211,14 @@ function buildMapsFromConfig(): void {
143
211
  shopItems: number[];
144
212
  }>;
145
213
  connections: Array<{ targetMapId: number }>;
214
+ events?: MapEvent[];
146
215
  }>;
147
216
  dialogs: Record<string, DialogConfig>;
148
217
  }>('game-config.json');
149
218
 
150
219
  const mapIdToName = new Map<number, string>();
151
220
  for (const map of gameConfig.maps) {
152
- mapIdToName.set(map.id, map.name);
221
+ mapIdToName.set(map.id, normalizeMapName(map.name));
153
222
  }
154
223
 
155
224
  for (const [id, dialog] of Object.entries(gameConfig.dialogs)) {
@@ -159,7 +228,8 @@ function buildMapsFromConfig(): void {
159
228
  const templateMaps = templates.maps ?? {};
160
229
 
161
230
  for (const map of gameConfig.maps) {
162
- const templateMap = templateMaps[map.name as keyof typeof templateMaps];
231
+ const mapName = normalizeMapName(map.name);
232
+ const templateMap = templateMaps[mapName as keyof typeof templateMaps];
163
233
  const npcDialogs: Record<string, string> = {};
164
234
  const npcNames: string[] = [];
165
235
  const shopNames = new Set<string>();
@@ -182,18 +252,25 @@ function buildMapsFromConfig(): void {
182
252
  ? [...templateMap.connections]
183
253
  : map.connections
184
254
  .map((c) => mapIdToName.get(c.targetMapId))
185
- .filter((name): name is string => Boolean(name));
255
+ .filter((name): name is string => Boolean(name))
256
+ .map(normalizeMapName);
186
257
 
187
258
  const shops =
188
- templateMap && templateMap.shops.length > 0 ? [...templateMap.shops] : [...shopNames];
259
+ templateMap && templateMap.shops?.length > 0 ? [...templateMap.shops] : [...shopNames];
189
260
 
190
261
  const encounterConfig = parseEncounters(templateMap?.encounters);
262
+ const events = map.events ?? [];
191
263
 
192
- mapsByName.set(map.name, {
264
+ mapsByName.set(mapName, {
193
265
  npcs: templateMap?.npcs?.length ? [...templateMap.npcs] : npcNames,
194
266
  shops,
195
267
  connections,
196
268
  npcDialogs,
269
+ description: templateMap?.description ?? map.desc,
270
+ atmosphere: templateMap?.atmosphere,
271
+ dangerLevel: deriveDangerLevel(templateMap, encounterConfig, events),
272
+ npcCards: templateMap?.npcCards ? { ...templateMap.npcCards } : undefined,
273
+ events: events.length > 0 ? [...events] : undefined,
197
274
  ...encounterConfig,
198
275
  });
199
276
  }
@@ -207,6 +284,10 @@ function buildMapsFromConfig(): void {
207
284
  shops: [...map.shops],
208
285
  connections: [...map.connections],
209
286
  npcDialogs: {},
287
+ description: map.description,
288
+ atmosphere: map.atmosphere,
289
+ dangerLevel: deriveDangerLevel(map, encounterConfig, []),
290
+ npcCards: map.npcCards ? { ...map.npcCards } : undefined,
210
291
  ...encounterConfig,
211
292
  });
212
293
  }
@@ -241,6 +322,7 @@ function loadSkills(): void {
241
322
  const data = loadJson<{ skills: SkillConfig[] }>('skills.json');
242
323
  for (const skill of data.skills) {
243
324
  skillsByName.set(skill.name, skill);
325
+ skillsById.set(skill.id, skill);
244
326
  }
245
327
  }
246
328
 
@@ -277,6 +359,7 @@ export function resetConfigsForTest(): void {
277
359
  itemsByName.clear();
278
360
  itemsById.clear();
279
361
  skillsByName.clear();
362
+ skillsById.clear();
280
363
  charactersById.clear();
281
364
  charactersByName.clear();
282
365
  dialogsById.clear();
@@ -293,7 +376,7 @@ export function getTemplates(): GameTemplates {
293
376
 
294
377
  export function getMap(name: string): MapInfo | undefined {
295
378
  initConfigs();
296
- return mapsByName.get(name);
379
+ return mapsByName.get(normalizeMapName(name));
297
380
  }
298
381
 
299
382
  export function getAllMapNames(): string[] {
@@ -306,11 +389,37 @@ export function getItem(name: string): ItemConfig | undefined {
306
389
  return itemsByName.get(name);
307
390
  }
308
391
 
392
+ export function getItemById(id: number): ItemConfig | undefined {
393
+ initConfigs();
394
+ return itemsById.get(id);
395
+ }
396
+
309
397
  export function getSkill(name: string): SkillConfig | undefined {
310
398
  initConfigs();
311
399
  return skillsByName.get(name);
312
400
  }
313
401
 
402
+ export function getSkillById(id: number): SkillConfig | undefined {
403
+ initConfigs();
404
+ return skillsById.get(id);
405
+ }
406
+
407
+ /** damageType 数字 → calculateStaminaCost 字符串键 */
408
+ export function mapDamageTypeToStaminaCost(damageType: number): string {
409
+ switch (damageType) {
410
+ case 1:
411
+ return 'absorbMp';
412
+ case 2:
413
+ return 'poison';
414
+ case 3:
415
+ return 'depoison';
416
+ case 4:
417
+ return 'heal';
418
+ default:
419
+ return 'normal';
420
+ }
421
+ }
422
+
314
423
  export function getSkillAttackAtLevel(skillName: string, levelIndex = 0): number {
315
424
  const skill = getSkill(skillName);
316
425
  if (!skill || !skill.levels.length) return 0;
@@ -328,6 +437,42 @@ export function getDialog(dialogId: string): DialogConfig | undefined {
328
437
  return dialogsById.get(dialogId);
329
438
  }
330
439
 
440
+ export function getMapEvents(mapName: string): MapEvent[] {
441
+ initConfigs();
442
+ return getMap(mapName)?.events ?? [];
443
+ }
444
+
445
+ export function getLocationMeta(mapName: string): {
446
+ description: string;
447
+ atmosphere: string;
448
+ dangerLevel: 'safe' | 'cautious' | 'dangerous';
449
+ } {
450
+ initConfigs();
451
+ const map = getMap(mapName);
452
+ return {
453
+ description: map?.description ?? `${mapName},江湖一处所在。`,
454
+ atmosphere: map?.atmosphere ?? '',
455
+ dangerLevel: map?.dangerLevel ?? 'safe',
456
+ };
457
+ }
458
+
459
+ export function getNpcCard(location: string, npcName: string): NpcCard | undefined {
460
+ initConfigs();
461
+ const map = getMap(location);
462
+ const card = map?.npcCards?.[npcName];
463
+ if (card) return { ...card, name: card.name ?? npcName };
464
+
465
+ const char = getCharacterByName(npcName);
466
+ if (char) {
467
+ return {
468
+ name: char.name,
469
+ persona: `${char.name},出自${char.source}。`,
470
+ knowledge: [],
471
+ };
472
+ }
473
+ return undefined;
474
+ }
475
+
331
476
  export function getEnemyTemplate(enemyName: string): EnemyTemplate | undefined {
332
477
  initConfigs();
333
478
  const entry = templates.enemies[enemyName];
@@ -344,7 +489,21 @@ export function isArmor(item: ItemConfig): boolean {
344
489
  }
345
490
 
346
491
  export function isConsumable(item: ItemConfig): boolean {
347
- return item.useAddHp > 0 || item.useAddMp > 0 || item.useAddStamina > 0 || item.useDePoison > 0;
492
+ return (
493
+ item.useAddHp > 0 ||
494
+ item.useAddMp > 0 ||
495
+ item.useAddStamina > 0 ||
496
+ item.useDePoison > 0 ||
497
+ (item.useAddAttack ?? 0) > 0 ||
498
+ (item.useAddAgility ?? 0) > 0 ||
499
+ (item.useAddDefence ?? 0) > 0 ||
500
+ (item.useAddPoison ?? 0) > 0 ||
501
+ isSkillBook(item)
502
+ );
503
+ }
504
+
505
+ export function isSkillBook(item: ItemConfig): boolean {
506
+ return item.type === 2 && (item.skillId ?? 0) > 0;
348
507
  }
349
508
 
350
509
  /** 校验 assets 完整性(CLI / CI 用) */
@@ -389,6 +548,13 @@ export function validateAssets(): string[] {
389
548
  errors.push(`Map "${mapName}" connection target unknown: ${conn}`);
390
549
  }
391
550
  }
551
+ if (map.npcCards) {
552
+ for (const npcName of Object.keys(map.npcCards)) {
553
+ if (!map.npcs.includes(npcName)) {
554
+ errors.push(`Map "${mapName}" npcCards key "${npcName}" not in npcs list`);
555
+ }
556
+ }
557
+ }
392
558
  }
393
559
 
394
560
  for (const enemyName of Object.keys(templates.enemies)) {
@@ -0,0 +1,205 @@
1
+ /**
2
+ * 事件引擎 — 处理 game-config.json 中的地图事件与对话动作
3
+ */
4
+
5
+ import type { GameState, EventResult, DialogChoice } from './game-types';
6
+ import type { MapEvent, MapEventCondition, MapEventAction, DialogConfig } from './config-loader';
7
+ import { getDialog, getItemById, getMapEvents, getMap } from './config-loader';
8
+
9
+ export interface EventContext {
10
+ mapName?: string;
11
+ eventId?: string;
12
+ npcName?: string;
13
+ }
14
+
15
+ export function evaluateConditions(state: GameState, conditions: MapEventCondition[]): boolean {
16
+ if (conditions.length === 0) return true;
17
+ return conditions.every((c) => evaluateCondition(state, c));
18
+ }
19
+
20
+ function evaluateCondition(state: GameState, condition: MapEventCondition): boolean {
21
+ const params = condition.params;
22
+ switch (condition.type) {
23
+ case 'flag': {
24
+ const flag = params.flag as string;
25
+ const expected = params.value as boolean | number;
26
+ const actual = state.flags[flag];
27
+ if (expected === false) return actual === undefined || actual === false;
28
+ return actual === expected;
29
+ }
30
+ case 'level': {
31
+ const min = (params.min as number) ?? 0;
32
+ return state.character.level >= min;
33
+ }
34
+ case 'item': {
35
+ const itemId = params.itemId as number;
36
+ const item = getItemById(itemId);
37
+ if (!item) return false;
38
+ const inv = state.inventory.items.find((i) => i.name === item.name);
39
+ const minCount = (params.count as number) ?? 1;
40
+ return (inv?.count ?? 0) >= minCount;
41
+ }
42
+ case 'quest': {
43
+ const questId = params.questId as string;
44
+ return state.completedQuests.includes(questId);
45
+ }
46
+ default:
47
+ return true;
48
+ }
49
+ }
50
+
51
+ export function checkEvents(
52
+ state: GameState,
53
+ trigger: 'auto' | 'interact' | 'talk',
54
+ ctx: EventContext = {},
55
+ ): MapEvent[] {
56
+ const mapName = ctx.mapName ?? state.location;
57
+ const events = getMapEvents(mapName);
58
+ return events.filter((event) => {
59
+ if (event.triggerType !== trigger) return false;
60
+ if (ctx.eventId && event.id !== ctx.eventId) return false;
61
+ if (trigger === 'talk' && ctx.npcName && event.npcName && event.npcName !== ctx.npcName) {
62
+ return false;
63
+ }
64
+ return evaluateConditions(state, event.conditions);
65
+ });
66
+ }
67
+
68
+ function dialogToChoices(dialog: DialogConfig): DialogChoice[] {
69
+ if (!dialog.choices?.length) return [];
70
+ return dialog.choices.map((c, index) => ({
71
+ text: c.text,
72
+ nextId: c.nextId,
73
+ index,
74
+ }));
75
+ }
76
+
77
+ function dialogToResult(dialog: DialogConfig): EventResult {
78
+ return {
79
+ type: 'dialog',
80
+ message: `${dialog.speaker}:「${dialog.text}」`,
81
+ dialogId: dialog.id,
82
+ choices: dialogToChoices(dialog),
83
+ };
84
+ }
85
+
86
+ export function processEventAction(state: GameState, action: MapEventAction): EventResult | null {
87
+ const params = action.params;
88
+ switch (action.type) {
89
+ case 'setFlag': {
90
+ const flag = params.flag as string;
91
+ state.flags[flag] = params.value as boolean | number;
92
+ return { type: 'setFlag', flag, message: '' };
93
+ }
94
+ case 'dialog': {
95
+ const dialogId = params.dialogId as string;
96
+ const dialog = getDialog(dialogId);
97
+ if (!dialog) return { type: 'message', message: '(对话缺失)' };
98
+ return dialogToResult(dialog);
99
+ }
100
+ case 'addItem': {
101
+ const itemId = params.itemId as number;
102
+ const count = (params.count as number) ?? 1;
103
+ const item = getItemById(itemId);
104
+ if (!item) return { type: 'message', message: '(物品缺失)' };
105
+ const existing = state.inventory.items.find((i) => i.name === item.name);
106
+ if (existing) {
107
+ existing.count += count;
108
+ } else {
109
+ state.inventory.items.push({
110
+ id: String(item.id),
111
+ name: item.name,
112
+ count,
113
+ });
114
+ }
115
+ return { type: 'addItem', itemName: item.name, message: `获得了${item.name}×${count}` };
116
+ }
117
+ case 'battle': {
118
+ const enemyName = (params.enemyName as string) ?? '山贼';
119
+ return { type: 'battle', enemyName, message: `⚔️ 遭遇${enemyName}!` };
120
+ }
121
+ case 'heal': {
122
+ state.character.hp = state.character.maxHp;
123
+ state.character.mp = state.character.maxMp;
124
+ state.character.stamina = 100;
125
+ state.character.poison = 0;
126
+ state.character.hurt = 0;
127
+ return { type: 'heal', message: '休息完毕,状态全满' };
128
+ }
129
+ default:
130
+ return null;
131
+ }
132
+ }
133
+
134
+ export function processEvent(state: GameState, event: MapEvent): EventResult[] {
135
+ const results: EventResult[] = [];
136
+ for (const action of event.actions) {
137
+ const result = processEventAction(state, action);
138
+ if (result) {
139
+ if (result.message) results.push(result);
140
+ else if (result.type !== 'setFlag') results.push(result);
141
+ else results.push(result);
142
+ }
143
+ }
144
+ return results.filter((r) => r.message || r.type === 'dialog' || r.type === 'battle');
145
+ }
146
+
147
+ export function processDialogChoiceActions(
148
+ state: GameState,
149
+ actions: MapEventAction[],
150
+ ): EventResult[] {
151
+ const results: EventResult[] = [];
152
+ for (const action of actions) {
153
+ const result = processEventAction(state, action);
154
+ if (result) results.push(result);
155
+ }
156
+ return results;
157
+ }
158
+
159
+ export function runTriggeredEvents(
160
+ state: GameState,
161
+ trigger: 'auto' | 'interact' | 'talk',
162
+ ctx: EventContext = {},
163
+ ): EventResult[] {
164
+ const matched = checkEvents(state, trigger, ctx);
165
+ const allResults: EventResult[] = [];
166
+ for (const event of matched) {
167
+ allResults.push(...processEvent(state, event));
168
+ }
169
+ return allResults;
170
+ }
171
+
172
+ export function formatEventResults(results: EventResult[]): string {
173
+ return results
174
+ .map((r) => r.message)
175
+ .filter(Boolean)
176
+ .join('\n');
177
+ }
178
+
179
+ export function getInteractEventLabel(event: MapEvent): string {
180
+ for (const action of event.actions) {
181
+ if (action.type === 'dialog') {
182
+ const dialogId = action.params.dialogId as string;
183
+ const dialog = getDialog(dialogId);
184
+ if (dialog) return dialog.text.slice(0, 20);
185
+ }
186
+ if (action.type === 'addItem') return '探索此处';
187
+ }
188
+ return '探索';
189
+ }
190
+
191
+ export function getInteractEventsForMap(mapName: string): MapEvent[] {
192
+ return getMapEvents(mapName).filter((e) => e.triggerType === 'interact');
193
+ }
194
+
195
+ export function hasInteractEvents(mapName: string): boolean {
196
+ return getInteractEventsForMap(mapName).length > 0;
197
+ }
198
+
199
+ export function getMapDangerHint(mapName: string): string | undefined {
200
+ const map = getMap(mapName);
201
+ if (!map) return undefined;
202
+ if (map.dangerLevel === 'dangerous') return '此地行路需当心,或有歹人埋伏';
203
+ if (map.dangerLevel === 'cautious') return '此地不宜大意,多加留神';
204
+ return undefined;
205
+ }