This commit is contained in:
hantao 2025-12-19 18:15:31 +08:00
parent 2d7481c0e7
commit 6f6125e640
9 changed files with 439 additions and 33 deletions

View File

@ -4,7 +4,8 @@
"name": "新手挑战:击败哥布林", "name": "新手挑战:击败哥布林",
"description": "前往附近的森林,击败一只哥布林来证明你的勇气。", "description": "前往附近的森林,击败一只哥布林来证明你的勇气。",
"type": "kill", "type": "kill",
"target_npc_id": "VILLAGER_1", "triggerType": "NPC",
"triggerValue": "VILLAGER_1",
"target": { "target": {
"entityId": "GOBLIN", "entityId": "GOBLIN",
"count": 1 "count": 1
@ -16,6 +17,25 @@
"item_id": 1, "item_id": 1,
"item_quantity": 1 "item_quantity": 1
}, },
"dialogue": {
"root": {
"text": "你好啊,年轻人。村外的哥布林最近越来越猖狂了。",
"options": [
{ "text": "哥布林?我可以帮忙处理。", "next": "accept" },
{ "text": "那真是太可怕了,再见。", "next": "end" }
]
},
"accept": {
"text": "真的吗?那太好了!去森林里消灭一只哥布林,证明你的实力吧。",
"options": [
{ "text": "交给我吧!(接受任务)", "next": null, "action": "accept_quest:KILL_GOBLIN" }
]
},
"end": {
"text": "好吧,路上小心。",
"options": []
}
},
"next_quest_id": "FIND_NPC" "next_quest_id": "FIND_NPC"
}, },
{ {
@ -27,10 +47,20 @@
"npcId": "BLACKSMITH", "npcId": "BLACKSMITH",
"count": 1 "count": 1
}, },
"required_level": 1, "triggerType": "SYSTEM",
"triggerValue": "LEVEL_UP",
"required_level": 2,
"rewards": { "rewards": {
"xp": 100 "xp": 100
}, },
"dialogue": {
"root": {
"text": "你的等级提升了!村里的铁匠似乎想见你。",
"options": [
{ "text": "我会去看看的。(接受任务)", "next": null, "action": "accept_quest:FIND_NPC" }
]
}
},
"next_quest_id": null "next_quest_id": null
} }
] ]

View File

@ -26,6 +26,7 @@ use Game\System\ItemService;
use Game\System\InteractionSystem; use Game\System\InteractionSystem;
use Game\System\QuestService; use Game\System\QuestService;
use Game\System\ShopService; use Game\System\ShopService;
use Game\System\DialogueService; // ⭐ 新增
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -99,12 +100,29 @@ class ServiceContainer {
$this->register(LootService::class, new LootService($this->eventDispatcher, $this->stateManager, $this->itemRepository, $this->enemyRepository)); $this->register(LootService::class, new LootService($this->eventDispatcher, $this->stateManager, $this->itemRepository, $this->enemyRepository));
$this->register(ItemService::class, new ItemService($this->eventDispatcher, $this->stateManager)); $this->register(ItemService::class, new ItemService($this->eventDispatcher, $this->stateManager));
$this->register(QuestService::class, new QuestService($this->eventDispatcher, $this->stateManager, $this->questionRepository)); $this->register(QuestService::class, new QuestService($this->eventDispatcher, $this->stateManager, $this->questionRepository));
// ⭐ 实例化 EquipmentService // ⭐ 实例化 AbilityService
$this->register(EquipmentService::class, new EquipmentService($this->eventDispatcher, $this->stateManager)); $abilityService = new AbilityService($this->eventDispatcher, $this->stateManager, $this->abilityRepository);
$this->register(AbilityService::class, $abilityService);
// ⭐ 实例化 DialogueService
$dialogueService = new DialogueService($this->eventDispatcher, $this->stateManager, $this->input, $this->output, $this->questionHelper);
$this->register(DialogueService::class, $dialogueService);
// 3. I/O 交互服务
// ⭐ 使用 get 方法获取已注册的服务实例
// $dialogueService = $this->services[DialogueService::class]; // No need to fetch from array if we have var
// 3. I/O 交互服务 (依赖 Dispatcher, StateManager, I/O 接口)
$this->register(InteractionSystem::class, $this->register(InteractionSystem::class,
new InteractionSystem($this->eventDispatcher, $this->stateManager, $this->npcRepository, $this->input, $this->output, $this->questionHelper) new InteractionSystem(
$this->eventDispatcher,
$this->stateManager,
$this->npcRepository,
$this->questionRepository, // ⭐ 新增注入
$dialogueService, // ⭐ 新增注入
$this->input,
$this->output,
$this->questionHelper
)
); );
$this->register(BattleService::class, $this->register(BattleService::class,
new BattleService($this->eventDispatcher, $this->stateManager, $this->input, $this->output, $this->questionHelper) new BattleService($this->eventDispatcher, $this->stateManager, $this->input, $this->output, $this->questionHelper)
@ -113,9 +131,7 @@ class ServiceContainer {
new ShopService($this->eventDispatcher, $this->stateManager, $this->input, $this->output, $this->questionHelper) new ShopService($this->eventDispatcher, $this->stateManager, $this->input, $this->output, $this->questionHelper)
); );
// ⭐ 实例化 AbilityService
$abilityService = new AbilityService($this->eventDispatcher, $this->stateManager, $this->abilityRepository);
$this->register(AbilityService::class, $abilityService);
// 4. 输入处理服务 (驱动主循环,必须最后注册,因为它依赖于所有 I/O 组件) // 4. 输入处理服务 (驱动主循环,必须最后注册,因为它依赖于所有 I/O 组件)
$this->register(InputHandler::class, $this->register(InputHandler::class,

View File

@ -21,13 +21,20 @@ class QuestRepository implements RepositoryInterface {
*/ */
private function buildNpcQuestIndex(): void { private function buildNpcQuestIndex(): void {
foreach ($this->data as $questId => $questData) { foreach ($this->data as $questId => $questData) {
// 检查任务是否有目标 NPC ID (target_npc_id) 或目标实体 ID (target_entity_id) // 优先使用 triggerType = NPC 的 triggerValue
// 根据您提供的 quests.json if (isset($questData['triggerType']) && $questData['triggerType'] === 'NPC' && isset($questData['triggerValue'])) {
$targetId = $questData['target_npc_id'] ?? null; $npcId = $questData['triggerValue'];
$this->questsByNpc[$npcId][] = $questId;
if ($targetId) { }
// 将任务 ID 添加到该 NPC 对应的列表 // 兼容旧数据:检查 target_npc_id (如果是 talk 类型或者仅仅是关联)
$this->questsByNpc[$targetId][] = $questId; // 但如果不作为触发条件,可能不应该在这里索引?
// 保持兼容性:如果还没索引过,且有 target_npc_id
elseif (isset($questData['target_npc_id'])) {
$targetId = $questData['target_npc_id'];
// 避免重复?
if (!in_array($questId, $this->questsByNpc[$targetId] ?? [])) {
$this->questsByNpc[$targetId][] = $questId;
}
} }
} }
} }

View File

@ -0,0 +1,25 @@
<?php
namespace Game\Model;
class DialogueNode {
public string $id;
public string $text;
/**
* @var array<int, array{text: string, next: ?string, action: ?string}>
*/
public array $options;
public function __construct(string $id, string $text, array $options = []) {
$this->id = $id;
$this->text = $text;
$this->options = $options;
}
public static function fromArray(string $id, array $data): self {
return new self(
$id,
$data['text'] ?? '',
$data['options'] ?? []
);
}
}

View File

@ -17,13 +17,21 @@ class Quest {
// ⭐ 新增:运行时状态,默认为 0 // ⭐ 新增:运行时状态,默认为 0
protected int $currentCount = 0; protected int $currentCount = 0;
public function __construct(string $id, string $name, string $description, string $type, array $target, array $rewards) { // ⭐ 新增:触发器和对话数据
public ?string $triggerType; // 'NPC' or 'SYSTEM'
public ?string $triggerValue; // NPC_ID or EVENT_NAME
public array $dialogue; // Dialogue Tree
public function __construct(string $id, string $name, string $description, string $type, array $target, array $rewards, ?string $triggerType = null, ?string $triggerValue = null, array $dialogue = []) {
$this->id = $id; $this->id = $id;
$this->name = $name; $this->name = $name;
$this->description = $description; $this->description = $description;
$this->type = $type; $this->type = $type;
$this->target = $target; $this->target = $target;
$this->rewards = $rewards; $this->rewards = $rewards;
$this->triggerType = $triggerType;
$this->triggerValue = $triggerValue;
$this->dialogue = $dialogue;
} }
// --- 进度管理方法 (用于业务逻辑) --- // --- 进度管理方法 (用于业务逻辑) ---
@ -77,6 +85,9 @@ class Quest {
'rewards' => $this->rewards, 'rewards' => $this->rewards,
'isRepeatable' => $this->isRepeatable, 'isRepeatable' => $this->isRepeatable,
'currentCount' => $this->currentCount, 'currentCount' => $this->currentCount,
'triggerType' => $this->triggerType,
'triggerValue' => $this->triggerValue,
'dialogue' => $this->dialogue,
]; ];
} }
} }

View File

@ -0,0 +1,166 @@
<?php
namespace Game\System;
use Game\Event\Event;
use Game\Event\EventDispatcher;
use Game\Model\DialogueNode;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Question\Question;
use Game\Event\EventListenerInterface; // ⭐ Add import
/**
* DialogueService: 负责处理对话树的流程逻辑和交互。
*/
class DialogueService implements EventListenerInterface {
private EventDispatcher $dispatcher;
private StateManager $stateManager; // ⭐ Add StateManager
private InputInterface $input;
private OutputInterface $output;
private QuestionHelper $helper;
private array $currentDialogueTree = [];
private string $currentNodeId = '';
public function __construct(EventDispatcher $dispatcher, StateManager $stateManager, InputInterface $input, OutputInterface $output, QuestionHelper $helper) {
$this->dispatcher = $dispatcher;
$this->stateManager = $stateManager;
$this->input = $input;
$this->output = $output;
$this->helper = $helper;
}
public function handleEvent(Event $event): void {
switch ($event->getType()) {
case 'StartDialogueEvent':
$dialogue = $event->getPayload()['dialogue'];
$this->startDialogue($dialogue);
break;
case 'DialogueChoice': // ⭐ Listen for choice event
$choice = $event->getPayload()['choice'];
$this->handleChoice($choice);
break;
}
}
/**
* 开始一段对话 (非阻塞,初始化状态)
*/
public function startDialogue(array $dialogueTree): void {
$this->currentDialogueTree = $dialogueTree;
$this->currentNodeId = 'root';
// 切换游戏模式
$this->stateManager->setMode(StateManager::MODE_DIALOGUE);
// 显示第一个节点
$this->displayCurrentNode();
}
private function displayCurrentNode(): void {
if (!isset($this->currentDialogueTree[$this->currentNodeId])) {
$this->endDialogue();
return;
}
$nodeData = $this->currentDialogueTree[$this->currentNodeId];
$node = DialogueNode::fromArray($this->currentNodeId, $nodeData);
$this->output->writeln("\n<fg=cyan>🗣️ 对话</>");
$this->output->writeln("<fg=white>" . str_repeat("-", 40) . "</>");
$lines = explode("\n", $node->text);
foreach ($lines as $line) {
$this->output->writeln(" " . trim($line));
}
$this->output->writeln("<fg=white>" . str_repeat("-", 40) . "</>\n");
if (empty($node->options)) {
$this->output->writeln(" [按回车键或输入任意值结束]");
} else {
foreach ($node->options as $index => $option) {
$this->output->writeln(" [<fg=yellow>{$index}</>] {$option['text']}");
}
}
}
/**
* 处理玩家选择
*/
private function handleChoice(string $input): void {
if (!isset($this->currentDialogueTree[$this->currentNodeId])) {
$this->endDialogue();
return;
}
$nodeData = $this->currentDialogueTree[$this->currentNodeId];
$node = DialogueNode::fromArray($this->currentNodeId, $nodeData);
// 如果没有选项,任意输入都结束对话 (或者跳转 next)
if (empty($node->options)) {
$this->endDialogue();
return;
}
if (!is_numeric($input)) {
$this->output->writeln("<fg=red>输入无效,请输入选项数字。</>");
// 这里不需要循环,直接返回,等待下一次输入事件
return;
}
$index = (int)$input;
if (!isset($node->options[$index])) {
$this->output->writeln("<fg=red>选项不存在。</>");
return;
}
// 处理选中项
$selectedOption = $node->options[$index];
// 1. 执行动作
if (!empty($selectedOption['action'])) {
$this->handleAction($selectedOption['action']);
}
// 2. 跳转
if (empty($selectedOption['next'])) {
$this->endDialogue();
} else {
$this->currentNodeId = $selectedOption['next'];
$this->displayCurrentNode();
}
}
private function endDialogue(): void {
$this->currentDialogueTree = [];
$this->currentNodeId = '';
$this->output->writeln("\n[对话结束]");
// 恢复地图模式 (或者之前的模式,稍微简化处理)
$this->stateManager->setMode(StateManager::MODE_MAP);
// 触发菜单显示,让玩家知道回到了地图
$this->dispatcher->dispatch(new Event('ShowMenuEvent'));
}
private function handleAction(string $actionString): void {
$parts = explode(':', $actionString, 2);
$actionType = $parts[0];
$payload = $parts[1] ?? null;
switch ($actionType) {
case 'accept_quest':
if ($payload) {
$this->dispatcher->dispatch(new Event('QuestAcceptRequest', ['questId' => $payload]));
}
break;
case 'open_shop':
if ($payload) {
$this->dispatcher->dispatch(new Event('OpenShopEvent', ['npcId' => $payload]));
}
break;
}
}
}

View File

@ -2,6 +2,7 @@
namespace Game\System; namespace Game\System;
use Game\Database\NPCRepository; use Game\Database\NPCRepository;
use Game\Database\QuestRepository; // ⭐ Add Import
use Game\Event\Event; use Game\Event\Event;
use Game\Event\EventListenerInterface; use Game\Event\EventListenerInterface;
use Game\Event\EventDispatcher; use Game\Event\EventDispatcher;
@ -19,16 +20,29 @@ class InteractionSystem implements EventListenerInterface {
private EventDispatcher $dispatcher; private EventDispatcher $dispatcher;
private StateManager $stateManager; private StateManager $stateManager;
private NPCRepository $npcRepository; // ⭐ 新增属性 private NPCRepository $npcRepository; // ⭐ 新增属性
private QuestRepository $questRepository; // ⭐ 新增
private DialogueService $dialogueService; // ⭐ 新增
// 输入依赖 // 输入依赖
private InputInterface $input; private InputInterface $input;
private OutputInterface $output; private OutputInterface $output;
private QuestionHelper $helper; private QuestionHelper $helper;
// ⭐ 修正构造函数:注入 NPCRepository // ⭐ 修正构造函数:注入 NPCRepository
public function __construct(EventDispatcher $dispatcher, StateManager $stateManager, NPCRepository $npcRepository, InputInterface $input, OutputInterface $output, QuestionHelper $helper) { public function __construct(
EventDispatcher $dispatcher,
StateManager $stateManager,
NPCRepository $npcRepository,
QuestRepository $questRepository, // ⭐ 参数
DialogueService $dialogueService, // ⭐ 参数
InputInterface $input,
OutputInterface $output,
QuestionHelper $helper
) {
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
$this->stateManager = $stateManager; $this->stateManager = $stateManager;
$this->npcRepository = $npcRepository; // ⭐ 赋值 $this->npcRepository = $npcRepository; // ⭐ 赋值
$this->questRepository = $questRepository; // ⭐ 赋值
$this->dialogueService = $dialogueService; // ⭐ 赋值
$this->input = $input; $this->input = $input;
$this->output = $output; $this->output = $output;
$this->helper = $helper; $this->helper = $helper;
@ -75,29 +89,25 @@ class InteractionSystem implements EventListenerInterface {
/** /**
* 2. 核心对话循环 * 2. 核心对话循环
*/ */
/**
* 2. 核心交互循环
*/
private function dialogueLoop(NPC $npc): void { private function dialogueLoop(NPC $npc): void {
$currentDialogueKey = 'greeting';
$running = true; $running = true;
while ($running) { while ($running) {
// 打印 NPC 对话
$text = $npc->getDialogueText($currentDialogueKey);
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "<fg=cyan>{$npc->getName()}</><fg=white>{$text}</>"
]));
// 检查交互类型并获取玩家选择 // 检查交互类型并获取玩家选择
$choice = $this->promptPlayerChoice(); $choice = $this->promptPlayerChoice($npc);
switch ($choice) { switch ($choice) {
case 'T': // 触发任务/特殊事件 case 'T': // 交谈 (可能触发任务)
$this->dispatcher->dispatch(new Event('QuestCheckEvent', ['npcId' => $npc->id])); if ($this->handleTalk($npc)) {
$currentDialogueKey = 'quest_response'; // 如果触发了对话系统,结束当前的 Interaction Loop交由 DialogueMode 接管
$running = false;
}
break; break;
case 'S': // 触发商店 case 'S': // 触发商店
$this->dispatcher->dispatch(new Event('OpenShopEvent', ['npcId' => $npc->id])); $this->dispatcher->dispatch(new Event('OpenShopEvent', ['npcId' => $npc->id]));
$currentDialogueKey = 'shop_response';
break; break;
case 'E': // 结束对话 case 'E': // 结束对话
$running = false; $running = false;
@ -109,10 +119,43 @@ class InteractionSystem implements EventListenerInterface {
} }
} }
private function handleTalk(NPC $npc): bool {
$player = $this->stateManager->getPlayer();
// 1. 查找此 NPC 提供的所有任务
$questIds = $this->questRepository->getQuestsByNpc($npc->id);
$foundQuest = false;
foreach ($questIds as $questId) {
// 检查任务状态:未接受 或 进行中 (如果是进行中,可能需要不同的对话,比如询问进度)
// 简单起见,这里优先查找“未接受”的任务
if (!$player->isQuestCompleted($questId) && !isset($player->getActiveQuests()[$questId])) {
// 找到了一个新任务!
$questData = $this->questRepository->find($questId);
if ($questData && !empty($questData['dialogue'])) {
// ⭐ 使用新版对话系统
$this->dialogueService->startDialogue($questData['dialogue']);
// 对话已启动,返回 true 以退出 InteractionLoop
return true;
}
}
}
if (!$foundQuest) {
// 如果没有新任务,显示 NPC 默认闲聊
$defaultMsg = is_array($npc->dialogue) ? ($npc->dialogue['greeting'] ?? '...') : '...';
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "<fg=cyan>{$npc->getName()}</><fg=white>{$defaultMsg}</>"
]));
}
return false;
}
/** /**
* 获取玩家交互指令 * 获取玩家交互指令
*/ */
private function promptPlayerChoice(): string { private function promptPlayerChoice(NPC $npc): string {
$this->dispatcher->dispatch(new Event('SystemMessage', [ $this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "\n--- 交互菜单 --- [T] 任务 | [S] 商店 | [E] 结束" 'message' => "\n--- 交互菜单 --- [T] 任务 | [S] 商店 | [E] 结束"
])); ]));

View File

@ -38,6 +38,15 @@ class QuestService implements EventListenerInterface {
case 'BattleEndEvent': // 响应战斗结束,检查击杀目标 case 'BattleEndEvent': // 响应战斗结束,检查击杀目标
$this->checkKillQuests($event->getPayload()['enemyId']); $this->checkKillQuests($event->getPayload()['enemyId']);
break; break;
case 'MapMoveEvent': // ⭐ 响应移动,检查地点触发任务
$this->checkSystemTriggers('MAP_MOVE', $event->getPayload()['targetId'] ?? ''); // 假设 MapMoveEvent 携带 targetId (MapTile ID)
break;
case 'LevelUpEvent': // ⭐ 响应升级
$this->checkSystemTriggers('LEVEL_UP', (string)$event->getPayload()['level']);
break;
case 'QuestAcceptRequest': // ⭐ 响应对话中的接受任务请求
$this->startQuest($event->getPayload()['questId']);
break;
} }
} }
@ -179,10 +188,38 @@ class QuestService implements EventListenerInterface {
public function initializeQuests(): void { public function initializeQuests(): void {
$player = $this->stateManager->getPlayer(); $player = $this->stateManager->getPlayer();
if (empty($player->getActiveQuests()) && empty($player->getCompletedQuests())) { if (empty($player->getActiveQuests()) && empty($player->getCompletedQuests())) {
$startingQuestId = $this->questRepository->getStartingQuestId();
if ($startingQuestId) { if ($startingQuestId) {
$this->startQuest($startingQuestId); $this->startQuest($startingQuestId);
} }
} }
} }
/**
* 7. 检查系统触发的任务
*/
private function checkSystemTriggers(string $triggerType, string $triggerValue): void {
$player = $this->stateManager->getPlayer();
$allQuests = $this->questRepository->findAll(); // 假设 we can get all quests
foreach ($allQuests as $questData) {
$questId = $questData['id'];
// 检查触发条件
if (isset($questData['triggerType']) && $questData['triggerType'] === 'SYSTEM' &&
isset($questData['triggerValue']) && $questData['triggerValue'] === $triggerValue) {
// 检查是否已完成或已接取
if (!$player->isQuestCompleted($questId) && !isset($player->getActiveQuests()[$questId])) {
// 触发对话
if (!empty($questData['dialogue'])) {
$this->dispatcher->dispatch(new Event('StartDialogueEvent', ['dialogue' => $questData['dialogue']]));
} else {
// 无对话直接接取? 或者弹窗提示
// 简单起见,无对话也强制开始(可能会有默认提示)
$this->startQuest($questId);
}
}
}
}
}
} }

View File

@ -0,0 +1,71 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
use Game\Core\ServiceContainer;
use Game\Event\Event;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Helper\HelperSet;
use Symfony\Component\Console\Helper\QuestionHelper;
// Mock Input with stream for InteractionSystem blocking prompt
$stream = fopen('php://memory', 'r+');
fwrite($stream, "T\n"); // Select 'Target' (Talk)
rewind($stream);
$input = new ArrayInput([]);
$input->setStream($stream);
$input->setInteractive(true);
$output = new BufferedOutput();
$helperSet = new HelperSet(['question' => new QuestionHelper()]);
$container = new ServiceContainer($input, $output, $helperSet);
$dispatcher = $container->registerServices();
$stateManager = $container->getStateManager();
// Manually create and set player for test environment
$player = new \Game\Model\Player("TestHero", 100, 10, 5);
$stateManager->setPlayer($player);
echo "Player Initialized.\n";
// 1. Simulate meeting NPC (Starts Dialogue)
echo "Dispatching Interaction Event...\n";
$dispatcher->dispatch(new Event('AttemptInteractEvent', ['npcId' => 'VILLAGER_1']));
echo "Checking output...\n";
$display = $output->fetch();
echo $display;
if (strpos($display, '你好啊,年轻人') !== false) {
echo "SUCCESS: Dialogue triggered.\n";
} else {
echo "FAILURE: Dialogue text not found.\n";
}
// 2. Simulate User Input: Select Option 0 (via Event, since InputHandler is not running loop)
echo "Dispatching Choice 0...\n";
$dispatcher->dispatch(new Event('DialogueChoice', ['choice' => '0']));
$display = $output->fetch();
echo $display;
if (strpos($display, '真的吗?那太好了') !== false) {
echo "SUCCESS: Dialogue advanced.\n";
} else {
echo "FAILURE: Dialogue advancement failed.\n";
}
// 3. Simulate User Input: Select Option 0 (Accept Quest)
echo "Dispatching Choice 0 (Accept)...\n";
$dispatcher->dispatch(new Event('DialogueChoice', ['choice' => '0']));
$display = $output->fetch();
echo $display;
if (strpos($display, '接受任务') !== false) {
echo "SUCCESS: Quest accepted.\n";
} else {
echo "FAILURE: Quest acceptance message not found.\n";
}