@sdd330dev/jy-skill 0.3.1 → 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 {
@@ -64,10 +99,17 @@ export interface CharacterConfig {
64
99
  source: string;
65
100
  }
66
101
 
102
+ export interface DialogChoiceConfig {
103
+ text: string;
104
+ nextId: string;
105
+ actions?: MapEventAction[];
106
+ }
107
+
67
108
  export interface DialogConfig {
68
109
  id: string;
69
110
  speaker: string;
70
111
  text: string;
112
+ choices?: DialogChoiceConfig[];
71
113
  }
72
114
 
73
115
  export interface EnemyTemplate {
@@ -79,6 +121,17 @@ export interface EnemyTemplate {
79
121
  onHitHurt?: number;
80
122
  }
81
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>;
133
+ }
134
+
82
135
  export interface GameTemplates {
83
136
  defaultCharacter: {
84
137
  name: string;
@@ -90,15 +143,7 @@ export interface GameTemplates {
90
143
  };
91
144
  startLocation: string;
92
145
  enemies: Record<string, EnemyTemplate | { characterId: number }>;
93
- maps?: Record<
94
- string,
95
- {
96
- npcs: string[];
97
- shops: string[];
98
- connections: string[];
99
- encounters?: number | { rate: number; enemies: string[] };
100
- }
101
- >;
146
+ maps?: Record<string, TemplateMapEntry>;
102
147
  }
103
148
 
104
149
  function parseEncounters(
@@ -111,6 +156,17 @@ function parseEncounters(
111
156
  return { encounterRate: encounters.rate, encounterEnemies: [...encounters.enemies] };
112
157
  }
113
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
+
114
170
  // ============================================================================
115
171
  // 加载
116
172
  // ============================================================================
@@ -147,6 +203,7 @@ function buildMapsFromConfig(): void {
147
203
  maps: Array<{
148
204
  id: number;
149
205
  name: string;
206
+ desc?: string;
150
207
  npcs: Array<{
151
208
  roleId: number;
152
209
  dialogId: string;
@@ -154,13 +211,14 @@ function buildMapsFromConfig(): void {
154
211
  shopItems: number[];
155
212
  }>;
156
213
  connections: Array<{ targetMapId: number }>;
214
+ events?: MapEvent[];
157
215
  }>;
158
216
  dialogs: Record<string, DialogConfig>;
159
217
  }>('game-config.json');
160
218
 
161
219
  const mapIdToName = new Map<number, string>();
162
220
  for (const map of gameConfig.maps) {
163
- mapIdToName.set(map.id, map.name);
221
+ mapIdToName.set(map.id, normalizeMapName(map.name));
164
222
  }
165
223
 
166
224
  for (const [id, dialog] of Object.entries(gameConfig.dialogs)) {
@@ -170,7 +228,8 @@ function buildMapsFromConfig(): void {
170
228
  const templateMaps = templates.maps ?? {};
171
229
 
172
230
  for (const map of gameConfig.maps) {
173
- const templateMap = templateMaps[map.name as keyof typeof templateMaps];
231
+ const mapName = normalizeMapName(map.name);
232
+ const templateMap = templateMaps[mapName as keyof typeof templateMaps];
174
233
  const npcDialogs: Record<string, string> = {};
175
234
  const npcNames: string[] = [];
176
235
  const shopNames = new Set<string>();
@@ -193,18 +252,25 @@ function buildMapsFromConfig(): void {
193
252
  ? [...templateMap.connections]
194
253
  : map.connections
195
254
  .map((c) => mapIdToName.get(c.targetMapId))
196
- .filter((name): name is string => Boolean(name));
255
+ .filter((name): name is string => Boolean(name))
256
+ .map(normalizeMapName);
197
257
 
198
258
  const shops =
199
259
  templateMap && templateMap.shops?.length > 0 ? [...templateMap.shops] : [...shopNames];
200
260
 
201
261
  const encounterConfig = parseEncounters(templateMap?.encounters);
262
+ const events = map.events ?? [];
202
263
 
203
- mapsByName.set(map.name, {
264
+ mapsByName.set(mapName, {
204
265
  npcs: templateMap?.npcs?.length ? [...templateMap.npcs] : npcNames,
205
266
  shops,
206
267
  connections,
207
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,
208
274
  ...encounterConfig,
209
275
  });
210
276
  }
@@ -218,6 +284,10 @@ function buildMapsFromConfig(): void {
218
284
  shops: [...map.shops],
219
285
  connections: [...map.connections],
220
286
  npcDialogs: {},
287
+ description: map.description,
288
+ atmosphere: map.atmosphere,
289
+ dangerLevel: deriveDangerLevel(map, encounterConfig, []),
290
+ npcCards: map.npcCards ? { ...map.npcCards } : undefined,
221
291
  ...encounterConfig,
222
292
  });
223
293
  }
@@ -306,7 +376,7 @@ export function getTemplates(): GameTemplates {
306
376
 
307
377
  export function getMap(name: string): MapInfo | undefined {
308
378
  initConfigs();
309
- return mapsByName.get(name);
379
+ return mapsByName.get(normalizeMapName(name));
310
380
  }
311
381
 
312
382
  export function getAllMapNames(): string[] {
@@ -319,6 +389,11 @@ export function getItem(name: string): ItemConfig | undefined {
319
389
  return itemsByName.get(name);
320
390
  }
321
391
 
392
+ export function getItemById(id: number): ItemConfig | undefined {
393
+ initConfigs();
394
+ return itemsById.get(id);
395
+ }
396
+
322
397
  export function getSkill(name: string): SkillConfig | undefined {
323
398
  initConfigs();
324
399
  return skillsByName.get(name);
@@ -362,6 +437,42 @@ export function getDialog(dialogId: string): DialogConfig | undefined {
362
437
  return dialogsById.get(dialogId);
363
438
  }
364
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
+
365
476
  export function getEnemyTemplate(enemyName: string): EnemyTemplate | undefined {
366
477
  initConfigs();
367
478
  const entry = templates.enemies[enemyName];
@@ -437,6 +548,13 @@ export function validateAssets(): string[] {
437
548
  errors.push(`Map "${mapName}" connection target unknown: ${conn}`);
438
549
  }
439
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
+ }
440
558
  }
441
559
 
442
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
+ }