This commit is contained in:
hant 2025-12-15 23:02:21 +08:00
commit 8e3c3a52de
33 changed files with 3205 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
/vendor/
/tmp/
/saves/
/data/
/build/
/.claude/
/.idea

18
composer.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "hant/fanren",
"description": "A PHP terminal game using Symfony src/Console",
"type": "project",
"require": {
"php": ">=8.1",
"symfony/var-dumper": "^6.4",
"symfony/console": "^6.4",
"symfony/yaml": "^6.4",
"doctrine/dbal": "^3.5",
"ext-readline": "*"
},
"autoload": {
"psr-4": {
"Game\\": "src/"
}
}
}

1438
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
config/enemy_data.json Normal file
View File

@ -0,0 +1,18 @@
[
{
"name": "森林哥布林",
"health": 30,
"attack": 10,
"defense": 5,
"xp": 25,
"dropId": 1
},
{
"name": "愤怒的野猪",
"health": 45,
"attack": 15,
"defense": 5,
"xp": 40,
"dropId": 2
}
]

20
config/map_data.json Normal file
View File

@ -0,0 +1,20 @@
{
"TOWN_01": {
"name": "新手村",
"description": "安全的小镇,可以休息。",
"connections": {"N": "FIELD_01"},
"eventPoolId": 0
},
"FIELD_01": {
"name": "新手田野",
"description": "微风习习,有低级怪物出没。",
"connections": {"S": "TOWN_01", "E": "FOREST_01"},
"eventPoolId": 1
},
"FOREST_01": {
"name": "幽暗密林",
"description": "树木茂密,光线昏暗,怪物更强。",
"connections": {"W": "FIELD_01"},
"eventPoolId": 2
}
}

23
index.php Normal file
View File

@ -0,0 +1,23 @@
<?php
// 加载 Composer 自动加载器
require __DIR__ . '/vendor/autoload.php';
// 引入 Symfony Console 类
use Symfony\Component\Console\Application;
// 引入我们的主命令类 (稍后创建)
use Game\Core\GameCommand;
// 1. 实例化 Symfony Console Application
$application = new Application('PHP CLI RPG', 'v0.1.0');
// 2. 将游戏的命令添加到 Application 中
// GameCommand 将是游戏的入口和主循环驱动者
$application->add(new GameCommand());
// 3. 设置默认命令,让用户直接运行 `php index.php` 即可进入游戏
$application->setDefaultCommand('game:start', true);
// 4. 运行 Application
$application->run();

123
src/Core/GameCommand.php Normal file
View File

@ -0,0 +1,123 @@
<?php
namespace Game\Core;
use Game\Event\MapExploreEvent;
use Game\Event\MapMoveEvent;
use Game\Event\ShowStatsEvent;
use Game\System\BattleService;
use Game\System\CharacterService;
use Game\System\InputHandler;
use Game\System\InteractionSystem;
use Game\System\LootService;
use Game\System\MapSystem;
use Game\System\QuestService;
use Game\System\StateManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Game\Database\DatabaseManager;
use Game\Event\EventDispatcher;
use Game\Event\Event;
use Game\Model\Player;
use Game\System\UIService; // 引入 UIService
class GameCommand extends Command {
protected static $defaultName = 'game:start';
private DatabaseManager $dbManager;
private EventDispatcher $eventDispatcher;
private UIService $uiService;
private Player $player;
private MapSystem $mapSystem;
private StateManager $stateManager; // 新增 StateManager 属性
private InputHandler $inputHandler;
protected function configure(): void {
$this
->setDescription('Starts the main PHP CLI RPG game loop.')
->setHelp('Runs the main interactive game loop in the console.');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
// 1. 初始化数据库管理器
$this->dbManager = new DatabaseManager();
$this->dbManager->loadInitialData();
// 2. 初始化 Event Dispatcher 和所有服务
$this->initializeServices($input, $output);
// 3. 角色创建/加载存档
$helper = $this->getHelper('question');
$question = new Question("请输入你的角色名称:", "旅行者");
$playerName = $helper->ask($input, $output, $question);
// 创建玩家实例
$player = new Player($playerName, 100, 15, 5);
// **将玩家实例交给 StateManager 管理**
$this->stateManager->setPlayer($player);
// 通知 UI 服务
$this->eventDispatcher->dispatch(new Event('SystemMessage', ['message' => "角色 {$player->getName()} 创建成功!"]));
return $this->mainLoop($input, $output);
}
private function initializeServices(InputInterface $input, OutputInterface $output): void {
// 实例化 QuestionHelper
$questionHelper = $this->getHelper('question');
// 实例化 Event Dispatcher
$this->eventDispatcher = new EventDispatcher();
$this->stateManager = new StateManager($this->dbManager->getConnection());
// ⭐ 实例化 CharacterService
$characterService = new CharacterService($this->eventDispatcher, $this->stateManager);
$this->eventDispatcher->registerListener($characterService);
// ⭐ 实例化 LootService
$lootService = new LootService($this->eventDispatcher, $this->stateManager);
$this->eventDispatcher->registerListener($lootService);
// ⭐ 实例化 InteractionSystem
$interactionSystem = new InteractionSystem($this->eventDispatcher, $this->stateManager, $input, $output, $questionHelper);
$this->eventDispatcher->registerListener($interactionSystem);
// ⭐ 实例化 QuestService
$questService = new QuestService($this->eventDispatcher, $this->stateManager);
$this->eventDispatcher->registerListener($questService);
// ⭐ 实例化 InputHandler 并注入依赖
$this->inputHandler = new InputHandler($this->eventDispatcher, $input, $output, $questionHelper);
// 实例化和注册 UIService (监听器)
$this->uiService = new UIService($output, $this->stateManager);
$this->eventDispatcher->registerListener($this->uiService);
// MapSystem 注册 (需要 EventDispatcher 和 DB 连接)
$this->mapSystem = new MapSystem($this->eventDispatcher, $this->stateManager);
$this->eventDispatcher->registerListener($this->mapSystem);
$questionHelper = $this->getHelper('question');
$battleService = new BattleService($this->eventDispatcher, $this->stateManager, $input,$output, $questionHelper);
$this->eventDispatcher->registerListener($battleService);
// 触发一个初始事件,让 UI 服务打印欢迎信息
$welcomeEvent = new Event('GameStartEvent', ['message' => '核心系统已就绪,请输入指令开始游戏。']);
$this->eventDispatcher->dispatch($welcomeEvent);
}
// src/Core/GameCommand.php (mainLoop 方法片段)
private function mainLoop(InputInterface $input, OutputInterface $output): int {
$running = true;
while ($running) {
$running = $this->inputHandler->handleMainCommand();
}
$output->writeln("<info>游戏结束。感谢游玩!</info>");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace Game\Database;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Schema;
/**
* DatabaseManager: 负责 Doctrine DBAL 连接SQLite Schema 初始化和初始配置数据填充。
*/
class DatabaseManager {
private Connection $connection;
public function __construct() {
// 确保使用绝对路径定位 storage 目录下的 SQLite 文件
$dbPath = __DIR__ . '/../../storage/game.sqlite';
// 1. 设置连接参数
$connectionParams = [
'url' => "sqlite:///{$dbPath}",
];
// 2. 建立连接
$this->connection = DriverManager::getConnection($connectionParams);
// 3. 检查并创建数据库结构
$this->initializeSchema();
}
/**
* 初始化/更新数据库结构 (Schema)
*/
private function initializeSchema(): void {
$schemaManager = $this->connection->getSchemaManager();
// 只有在数据库为空时才尝试创建 Schema
if (!$schemaManager->getDatabasePlatform()->supportsSchemas() && empty($schemaManager->listTables())) {
$schema = new Schema();
// --- 游戏配置表:敌人数据 ---
$enemiesTable = $schema->createTable('enemies');
$enemiesTable->addColumn('id', 'integer', ['autoincrement' => true]);
$enemiesTable->addColumn('name', 'string', ['length' => 50]);
$enemiesTable->addColumn('health', 'integer');
$enemiesTable->addColumn('attack', 'integer');
$enemiesTable->addColumn('defense', 'integer'); // 新增 defense 字段
$enemiesTable->addColumn('xp_value', 'integer');
$enemiesTable->addColumn('drop_table_id', 'integer');
$enemiesTable->setPrimaryKey(['id']);
// --- 玩家存档表:存档数据 (后续使用) ---
$saveTable = $schema->createTable('player_save');
$saveTable->addColumn('id', 'integer', ['autoincrement' => true]);
$saveTable->addColumn('player_name', 'string');
$saveTable->addColumn('data_json', 'text'); // 存储玩家对象序列化数据
$saveTable->setPrimaryKey(['id']);
// 应用 Schema 变更
$queries = $schema->toSql($this->connection->getDatabasePlatform());
foreach ($queries as $query) {
// 安全地执行 SQL 语句
$this->connection->executeStatement($query);
}
}
}
public function getConnection(): Connection {
return $this->connection;
}
/**
* 初始配置加载:将 JSON 数据填充到 SQLite
*/
public function loadInitialData(): void {
$jsonPath = __DIR__ . '/../../config/enemy_data.json';
if (!file_exists($jsonPath)) {
echo "❌ 警告: 找不到初始配置数据文件 enemy_data.json\n";
return;
}
// 仅在敌人表为空时填充数据
if ($this->connection->fetchOne('SELECT COUNT(*) FROM enemies') == 0) {
$data = json_decode(file_get_contents($jsonPath), true);
$this->connection->beginTransaction();
try {
foreach ($data as $enemy) {
$this->connection->insert('enemies', [
'name' => $enemy['name'],
'health' => $enemy['health'],
'attack' => $enemy['attack'],
'defense' => $enemy['defense'],
'xp_value' => $enemy['xp'],
'drop_table_id' => $enemy['dropId']
]);
}
$this->connection->commit();
echo "✅ 初始敌人数据已载入 SQLite 数据库。\n";
} catch (\Exception $e) {
$this->connection->rollBack();
echo "❌ 数据填充失败: " . $e->getMessage() . "\n";
}
}
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Game\Event;
use Game\Model\Enemy;
/**
* 战斗结束时触发,无论胜负。
*/
class BattleEndEvent extends Event {
public function __construct(bool $isWin, Enemy $enemy) {
// Payload 携带战斗结果和被击败的敌人实例
parent::__construct('BattleEndEvent', [
'isWin' => $isWin,
'enemy' => $enemy
]);
}
}

32
src/Event/Event.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace Game\Event;
/**
* Event: 基础事件类 (Abstract DTO)
* 所有具体的事件都应继承此类。
*/
class Event {
protected string $type;
protected array $payload;
public function __construct(string $type, array $payload = []) {
$this->type = $type;
$this->payload = $payload;
}
/**
* 获取事件的类型字符串。
*/
public final function getType(): string {
return $this->type;
}
/**
* 获取事件携带的数据。
*/
public final function getPayload(): array {
return $this->payload;
}
// final 关键字防止子类修改核心 getter 方法,保证事件数据的一致性。
}

View File

@ -0,0 +1,43 @@
<?php
namespace Game\Event;
/**
* EventDispatcher: 游戏的事件总线,负责事件的注册和分发。
*/
class EventDispatcher {
/**
* @var array<string, array<EventListenerInterface>>
*/
private array $listeners = [];
/**
* 注册一个监听器到总线。
* 监听器需要自己判断对哪些事件感兴趣。
*/
public function registerListener(EventListenerInterface $listener): void {
// 我们可以简化处理,直接将监听器按类名存储,因为我们只有一个 handleEvent 方法
$listenerClass = get_class($listener);
if (!in_array($listener, $this->listeners, true)) {
$this->listeners[] = $listener;
}
// 更好做法是按事件类型注册,但这里为了简化,我们让所有监听器接收所有事件
}
/**
* 分发一个事件给所有注册的监听器。
*/
public function dispatch(Event $event): void {
//
$eventType = $event->getType();
// 打印调试信息(可选,但推荐)
// echo ">> DEBUG: 触发事件: {$eventType}\n";
foreach ($this->listeners as $listener) {
// 将事件转发给每个监听器处理
$listener->handleEvent($event);
}
}
}

View File

@ -0,0 +1,6 @@
<?php
namespace Game\Event;
interface EventListenerInterface {
public function handleEvent(Event $event): void;
}

View File

@ -0,0 +1,11 @@
<?php
namespace Game\Event;
/**
* 当玩家选择“原地探索”时触发,用于决定遭遇什么(战斗、宝箱、对话)。
*/
class MapExploreEvent extends Event {
public function __construct(string $tileId) {
parent::__construct('MapExploreEvent', ['tileId' => $tileId]);
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace Game\Event;
/**
* 当玩家移动到一个新的 MapTile 时触发。
*/
class MapMoveEvent extends Event {
public function __construct(string $newTileId) {
// Payload 携带新区域的 ID
parent::__construct('MapMoveEvent', ['newTileId' => $newTileId]);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Game\Event;
use Game\Model\Player;
/**
* 玩家要求查看状态时触发。
*/
class ShowStatsEvent extends Event {
public function __construct(Player $player) {
// Payload 携带玩家实例本身
parent::__construct('ShowStatsEvent', ['player' => $player]);
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace Game\Event;
/**
* 触发一场新的战斗。
*/
class StartBattleEvent extends Event {
public function __construct(int $enemyId) {
// Payload 携带要加载的敌人配置 ID (来自 SQLite)
parent::__construct('StartBattleEvent', ['enemyId' => $enemyId]);
}
}

105
src/Model/Character.php Normal file
View File

@ -0,0 +1,105 @@
<?php
namespace Game\Model;
/**
* Character: 所有游戏角色的基类 (Model)
*/
class Character {
protected string $name;
protected int $health;
protected int $maxHealth;
protected int $attack;
protected int $defense;
protected array $activeQuests = []; // 存储进行中的任务进度,格式: ['questId' => ['currentCount' => 0, 'isCompleted' => false]]
protected array $completedQuests = []; // 存储已完成的任务 ID
// ⭐ 新增方法:添加/接受任务
public function addActiveQuest(string $questId, int $targetCount): void {
$this->activeQuests[$questId] = ['currentCount' => 0, 'isCompleted' => false, 'targetCount' => $targetCount];
}
// ⭐ 新增方法:获取进行中的任务
public function getActiveQuests(): array {
return $this->activeQuests;
}
// ⭐ 新增方法:更新任务进度
public function updateQuestProgress(string $questId, int $count = 1): void {
if (isset($this->activeQuests[$questId])) {
$progress = &$this->activeQuests[$questId]; // 使用引用
if (!$progress['isCompleted']) {
$progress['currentCount'] += $count;
if ($progress['currentCount'] >= $progress['targetCount']) {
$progress['currentCount'] = $progress['targetCount'];
$progress['isCompleted'] = true;
// 触发 QuestCompletedEventRequest
// 注意:实际的奖励和标记完成应在 QuestService 确认后进行
}
}
}
}
// ⭐ 新增方法:标记任务完成
public function markQuestCompleted(string $questId): void {
unset($this->activeQuests[$questId]);
$this->completedQuests[] = $questId;
}
// ⭐ 新增方法:检查任务是否已完成
public function isQuestCompleted(string $questId): bool {
return in_array($questId, $this->completedQuests);
}
// ⭐ 新增:玩家背包 (存储 Item 实例)
protected array $inventory = [];
// ... 现有构造函数和 Getter ...
// ⭐ 新增方法:添加物品到背包
public function addItem(Item $item): void {
$this->inventory[] = $item;
}
// ⭐ 新增方法:获取背包
public function getInventory(): array {
return $this->inventory;
}
public function __construct(string $name, int $maxHealth, int $attack, int $defense) {
$this->name = $name;
$this->maxHealth = $maxHealth;
$this->health = $maxHealth;
$this->attack = $attack;
$this->defense = $defense;
}
public function getName(): string { return $this->name; }
public function getHealth(): int { return $this->health; }
public function getMaxHealth(): int { return $this->maxHealth; }
public function getAttack(): int { return $this->attack; }
public function getDefense(): int { return $this->defense; }
public function isAlive(): bool { return $this->health > 0; }
/**
* 接收伤害,返回实际受到的伤害量
*/
public function takeDamage(int $damage): int {
$actualDamage = max(0, $damage - $this->defense);
$this->health -= $actualDamage;
if ($this->health < 0) {
$this->health = 0;
}
return $actualDamage;
}
/**
* 治疗角色
*/
public function heal(int $amount): void {
$this->health += $amount;
if ($this->health > $this->maxHealth) {
$this->health = $this->maxHealth;
}
}
}

22
src/Model/Enemy.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace Game\Model;
// 继承 Character 基类
class Enemy extends Character {
public string $id;
public int $xpValue; // 击败后获得的经验值
public function __construct(string $id, string $name, int $health, int $attack, int $defense, int $xpValue) {
// 调用父类 (Character) 的构造函数来初始化核心属性
parent::__construct($name, $health, $attack, $defense);
$this->id = $id;
$this->xpValue = $xpValue;
}
// Enemy 特有的 Getter 方法
public function getId(): string { return $this->id; }
public function getXpValue(): int { return $this->xpValue; }
// 注意takeDamage() 和 isAlive() 等方法都直接继承自 Character无需重复实现
}

21
src/Model/Item.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace Game\Model;
/**
* Item: 所有游戏物品的基类
*/
class Item {
public int $id;
public string $name;
public string $type; // e.g., 'potion', 'weapon', 'material'
public string $description;
public int $value; // 卖出价格
public function __construct(int $id, string $name, string $type, string $description, int $value) {
$this->id = $id;
$this->name = $name;
$this->type = $type;
$this->description = $description;
$this->value = $value;
}
}

21
src/Model/MapTile.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace Game\Model;
/**
* MapTile: 单个地图区域的数据模型。
*/
class MapTile {
public string $id;
public string $name;
public string $description;
public array $connections; // 存储连接:['N' => 'FOREST_02', 'S' => 'TOWN_01']
public int $eventPoolId; // 区域对应的事件池ID
public function __construct(string $id, string $name, string $description, array $connections, int $eventPoolId) {
$this->id = $id;
$this->name = $name;
$this->description = $description;
$this->connections = $connections;
$this->eventPoolId = $eventPoolId;
}
}

28
src/Model/NPC.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace Game\Model;
// NPC 可以继承 Character但如果它不参与战斗可以简化为独立模型。
// 为了简化,我们让它独立存在,只关注对话。
class NPC {
public string $id;
public string $name;
public array $dialogue; // 存储对话行或对话树结构
public function __construct(string $id, string $name, array $dialogue) {
$this->id = $id;
$this->name = $name;
$this->dialogue = $dialogue;
}
public function getName(): string {
return $this->name;
}
/**
* 获取指定对话ID的文本
*/
public function getDialogueText(string $key): string {
return $this->dialogue[$key] ?? "NPC对不起我不知道你在说什么。";
}
}

25
src/Model/Player.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace Game\Model;
// 继承 Character 基类
class Player extends Character {
protected int $level = 1;
protected int $currentXp = 0;
protected int $xpToNextLevel = 100;
public function __construct(string $name, int $maxHealth, int $attack, int $defense) {
// 调用父类 (Character) 的构造函数来初始化核心属性
parent::__construct($name, $maxHealth, $attack, $defense);
}
// Player 特有的 Getter 方法
public function getLevel(): int { return $this->level; }
public function getCurrentXp(): int { return $this->currentXp; }
public function getXpToNextLevel(): int { return $this->xpToNextLevel; }
// Player 特有的 Setter/Modifier 方法
public function gainXp(int $amount): void {
$this->currentXp += $amount;
// TODO: 未来在这里实现升级逻辑 (LevelUpEvent)
}
}

24
src/Model/Quest.php Normal file
View File

@ -0,0 +1,24 @@
<?php
namespace Game\Model;
/**
* Quest: 任务模型
*/
class Quest {
public string $id;
public string $name;
public string $description;
public string $type; // e.g., 'kill', 'fetch', 'talk'
public array $target; // 目标要求e.g., ['targetId' => 'GOBLIN_1', 'count' => 5]
public array $rewards; // 奖励e.g., ['xp' => 100, 'itemId' => 1]
public bool $isRepeatable = false;
public function __construct(string $id, string $name, string $description, string $type, array $target, array $rewards) {
$this->id = $id;
$this->name = $name;
$this->description = $description;
$this->type = $type;
$this->target = $target;
$this->rewards = $rewards;
}
}

View File

@ -0,0 +1,232 @@
<?php
namespace Game\System;
use Game\Event\Event;
use Game\Event\EventListenerInterface;
use Game\Event\EventDispatcher;
use Game\Model\Enemy;
use Game\Model\Player; // 即使是 Party也依赖 Player
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Question\Question;
/**
* BattleService: 负责处理回合制战斗逻辑。
* 战斗循环采用阻塞模式 (CLI 常见),直接获取输入。
*/
class BattleService implements EventListenerInterface {
private EventDispatcher $dispatcher;
private StateManager $stateManager;
// 战斗内输入依赖 (暂时不解耦,简化战斗循环)
private InputInterface $input;
private OutputInterface $output;
private QuestionHelper $helper;
private bool $inBattle = false;
private ?Enemy $currentEnemy = null; // 使用 ?Type 确保在非战斗状态下为 null
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 {
if ($this->inBattle) {
// 如果在战斗中,可以监听 'BattleCommandEvent' 等事件来处理输入
// 当前版本,我们通过 battleLoop() 内部阻塞输入
return;
}
switch ($event->getType()) {
case 'StartBattleEvent':
// 收到 MapSystem 触发的战斗开始事件
$enemyId = $event->getPayload()['enemyId'];
$this->startBattle($enemyId);
break;
}
}
/**
* 1. 初始化战斗状态
*/
private function startBattle(int $enemyId): void {
// ... 初始化敌人逻辑 (继承自 Character) ...
$enemyData = $this->loadEnemyData($enemyId);
$this->currentEnemy = new Enemy(
(string)$enemyId,
$enemyData['name'],
$enemyData['health'],
$enemyData['attack'],
$enemyData['defense'],
$enemyData['xp']
);
$this->inBattle = true;
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "⚔️ 你遭遇了 <fg=red;options=bold>{$this->currentEnemy->getName()}</>!战斗开始!"
]));
// 立即进入战斗循环
$this->battleLoop();
}
/**
* 2. 核心战斗循环
*/
private function battleLoop(): void {
while ($this->inBattle) {
// 确保 UI 服务打印了战斗状态
$this->dispatcher->dispatch(new Event('ShowBattleStatsEvent', [
'enemy' => $this->currentEnemy,
'player' => $this->stateManager->getPlayer(),
]));
// 玩家回合 (阻塞输入)
$playerAction = $this->promptPlayerAction();
if ($playerAction === 'A') {
$this->playerAttack();
} elseif ($playerAction === 'R') {
if ($this->tryRunAway()) {
$this->endBattle(false);
return;
}
}
if (!$this->inBattle) break;
// 敌人回合
$this->enemyAttack();
if (!$this->inBattle) break;
}
}
/**
* 获取玩家战斗指令 (直接使用注入的 I/O 接口)
*/
private function promptPlayerAction(): string {
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "\n--- 你的回合 --- [A] 攻击 | [R] 逃跑"
]));
$question = new Question("> 请选择指令 (A/R)");
// 关键:使用注入的 I/O 接口
$choice = strtoupper($this->helper->ask($this->input, $this->output, $question) ?? '');
// 简单的输入验证
if (in_array($choice, ['A', 'R'])) {
return $choice;
} else {
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => '无效的战斗指令。']));
return $this->promptPlayerAction();
}
}
/**
* 玩家攻击逻辑
*/
private function playerAttack(): void {
$player = $this->stateManager->getPlayer();
$rawDamage = $player->getAttack();
$actualDamage = $this->currentEnemy->takeDamage($rawDamage);
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "⚡ 你对 {$this->currentEnemy->getName()} 造成了 <fg=yellow>{$actualDamage}</> 点伤害!"
]));
if (!$this->currentEnemy->isAlive()) {
$this->handleWin();
}
}
/**
* 敌人攻击逻辑
*/
private function enemyAttack(): void {
$player = $this->stateManager->getPlayer();
$rawDamage = $this->currentEnemy->getAttack();
// Player 的 takeDamage 会更新 StateManager 中的 Player 实例
$actualDamage = $player->takeDamage($rawDamage);
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "💥 {$this->currentEnemy->getName()} 对你造成了 <fg=red>{$actualDamage}</> 点伤害!"
]));
if (!$player->isAlive()) {
$this->handleLoss();
}
}
/**
* 尝试逃跑
*/
private function tryRunAway(): bool {
if (rand(1, 100) > 50) {
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => "💨 你成功逃离了战斗!"]));
return true;
} else {
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => "{$this->currentEnemy->getName()} 拦住了!逃跑失败。"]));
return false;
}
}
/**
* 3. 战斗胜利处理
*/
private function handleWin(): void {
$xpGained = $this->currentEnemy->getXpValue();
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "🏆 恭喜你击败了 {$this->currentEnemy->getName()}"
]));
// 触发 XP 和 Loot 事件,交由其他系统处理
$this->dispatcher->dispatch(new Event('XpGainedEvent', ['xp' => $xpGained]));
$this->dispatcher->dispatch(new Event('LootDropEvent', ['enemyId' => $this->currentEnemy->getId()]));
$this->endBattle(true);
}
/**
* 4. 战斗失败处理
*/
private function handleLoss(): void {
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => "💀 你被击败了... 游戏结束。"]));
// TODO: 触发 GameEndEvent 或 RespawnEvent
$this->endBattle(false);
}
/**
* 结束战斗状态
*/
private function endBattle(bool $isWin): void {
$this->inBattle = false;
$this->currentEnemy = null;
// 战斗结束后,重新打印主菜单请求,以继续主循环
$this->dispatcher->dispatch(new Event('ShowMenuEvent'));
}
/**
* 模拟从配置中加载敌人数据
*/
private function loadEnemyData(int $id): array {
// 在实际项目中,这应该从数据库或 JSON 文件加载
return match ($id) {
1 => ['name' => '弱小的哥布林', 'health' => 20, 'attack' => 5, 'defense' => 1, 'xp' => 10],
2 => ['name' => '愤怒的野猪', 'health' => 35, 'attack' => 8, 'defense' => 3, 'xp' => 25],
3 => ['name' => '森林狼', 'health' => 40, 'attack' => 10, 'defense' => 5, 'xp' => 40],
default => ['name' => '未知生物', 'health' => 1, 'attack' => 1, 'defense' => 0, 'xp' => 1],
};
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace Game\System;
use Game\Event\Event;
use Game\Event\EventListenerInterface;
use Game\Event\EventDispatcher;
use Game\Model\Player;
/**
* CharacterService: 负责处理角色的成长、经验值和升级逻辑。
*/
class CharacterService implements EventListenerInterface {
private EventDispatcher $dispatcher;
private StateManager $stateManager;
public function __construct(EventDispatcher $dispatcher, StateManager $stateManager) {
$this->dispatcher = $dispatcher;
$this->stateManager = $stateManager;
}
public function handleEvent(Event $event): void {
switch ($event->getType()) {
case 'XpGainedEvent':
$this->handleXpGain($event->getPayload()['xp']);
break;
// TODO: 未来添加 'HealRequest' 或 'UseItemEvent' 等事件处理
}
}
/**
* 处理经验值获取和潜在升级逻辑
*/
private function handleXpGain(int $xpAmount): void {
$player = $this->stateManager->getPlayer();
$initialLevel = $player->getLevel();
// 尝试添加经验值 (这个方法现在在 Player 模型中)
$player->gainXp($xpAmount);
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "🌟 获得了 <fg=yellow>{$xpAmount}</> 点经验值!"
]));
// 检查是否升级
while ($player->getCurrentXp() >= $player->getXpToNextLevel()) {
$this->levelUp($player);
}
if ($player->getLevel() > $initialLevel) {
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "🎉 <fg=green;options=bold>恭喜你,升到了等级 {$player->getLevel()}</>"
]));
}
}
/**
* 执行升级操作
*/
private function levelUp(Player $player): void {
// 1. 扣除经验,提升等级
$player->subtractXpToNextLevel(); // 假设我们在 Player 模型中添加此方法
$player->incrementLevel(); // 假设我们在 Player 模型中添加此方法
// 2. 提升属性 (简化版:每次升级增加固定属性)
$player->increaseMaxHealth(10);
$player->increaseAttack(2);
$player->increaseDefense(1);
// 3. 升级后回满血
$player->heal($player->getMaxHealth()); // 使用 Character 基类中的 heal()
// 4. 触发升级事件 (如果需要其他系统知道)
$this->dispatcher->dispatch(new Event('LevelUpEvent', ['newLevel' => $player->getLevel()]));
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace Game\System;
use Game\Event\Event;
use Game\Event\EventDispatcher;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Helper\QuestionHelper;
/**
* InputHandler: 负责从 CLI 获取用户输入,并将其解析为系统事件。
* 遵循单一职责原则:只处理输入和事件转发。
*/
class InputHandler {
private EventDispatcher $dispatcher;
private InputInterface $input;
private OutputInterface $output;
private QuestionHelper $helper;
public function __construct(EventDispatcher $dispatcher, InputInterface $input, OutputInterface $output, QuestionHelper $helper) {
$this->dispatcher = $dispatcher;
$this->input = $input;
$this->output = $output;
$this->helper = $helper;
}
/**
* 获取主循环操作指令,并分派事件
*/
public function handleMainCommand(): bool {
// 1. 请求 UI 服务打印主菜单 (确保 UI 已输出提示)
$this->dispatcher->dispatch(new Event('ShowMenuEvent'));
$question = new Question("> 请选择操作 (M/E/S/I/Q)");
$choice = strtoupper($this->helper->ask($this->input, $this->output, $question) ?? '');
// 2. 解析并分派事件
switch ($choice) {
case 'M':
$this->handleMoveInput();
break;
case 'E':
$this->dispatcher->dispatch(new Event('MapExploreRequest'));
break;
case 'S':
// 状态显示请求,由于 StateManager 是状态持有者EventDispatcher 会分发给 UIService
$this->dispatcher->dispatch(new Event('ShowStatsRequest'));
break;
case 'I': // ⭐ 新增交互指令
// 模拟 MapSystem 发现了一个 NPC
$this->dispatcher->dispatch(new Event('AttemptInteractEvent', ['npcId' => 'VILLAGER_1']));
break;
case 'Q':
return false; // 返回 false 通知 GameCommand 退出主循环
default:
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => '无效的指令。请使用 M, E, S, I, Q。']));
}
return true; // 继续主循环
}
/**
* 处理移动输入和事件分派
*/
private function handleMoveInput(): void {
$directionQuestion = new Question("请输入移动方向 (<fg=yellow>N/S/E/W</>):");
$direction = strtoupper($this->helper->ask($this->input, $this->output, $directionQuestion) ?? '');
$validDirections = ['N', 'S', 'E', 'W'];
if (in_array($direction, $validDirections)) {
$this->dispatcher->dispatch(new Event('AttemptMoveEvent', ['direction' => $direction]));
} else {
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => '无效的移动方向。']));
}
}
// TODO: 可以在这里添加 handleBattleInput() 等,进一步解耦 BattleService
}

View File

@ -0,0 +1,137 @@
<?php
namespace Game\System;
use Game\Event\Event;
use Game\Event\EventListenerInterface;
use Game\Event\EventDispatcher;
use Game\Model\NPC;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Question\Question;
/**
* InteractionSystem: 负责处理玩家与 NPC 的对话和交互流程。
*/
class InteractionSystem implements EventListenerInterface {
private EventDispatcher $dispatcher;
private StateManager $stateManager;
// 输入依赖
private InputInterface $input;
private OutputInterface $output;
private QuestionHelper $helper;
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 'AttemptInteractEvent':
// 假设 MapSystem 会提供当前 Tile 上的 NPC ID
$npcId = $event->getPayload()['npcId'];
$this->startInteraction($npcId);
break;
}
}
/**
* 1. 初始化 NPC 交互
*/
private function startInteraction(string $npcId): void {
$npc = $this->loadNPC($npcId);
if (!$npc) {
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => "这里没有人可以交谈。"]));
return;
}
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "👤 你走近了 <fg=cyan;options=bold>{$npc->getName()}</>。"
]));
// 启动对话流程
$this->dialogueLoop($npc);
// 交互结束后,重新打印主菜单请求
$this->dispatcher->dispatch(new Event('ShowMenuEvent'));
}
/**
* 2. 核心对话循环
*/
private function dialogueLoop(NPC $npc): void {
$currentDialogueKey = 'greeting';
$running = true;
while ($running) {
// 打印 NPC 对话
$text = $npc->getDialogueText($currentDialogueKey);
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "<fg=cyan>{$npc->getName()}</><fg=white>{$text}</>"
]));
// 检查交互类型并获取玩家选择
$choice = $this->promptPlayerChoice();
switch ($choice) {
case 'T': // 触发任务/特殊事件
$this->dispatcher->dispatch(new Event('QuestCheckEvent', ['npcId' => $npc->id]));
$currentDialogueKey = 'quest_response';
break;
case 'S': // 触发商店
$this->dispatcher->dispatch(new Event('OpenShopEvent', ['npcId' => $npc->id]));
$currentDialogueKey = 'shop_response';
break;
case 'E': // 结束对话
$running = false;
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => "🤝 你结束了与 {$npc->getName()} 的对话。"]));
break;
default:
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => '无效的交互指令。']));
}
}
}
/**
* 获取玩家交互指令
*/
private function promptPlayerChoice(): string {
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "\n--- 交互菜单 --- [T] 任务 | [S] 商店 | [E] 结束"
]));
$question = new Question("> 请选择指令 (T/S/E)");
$choice = strtoupper($this->helper->ask($this->input, $this->output, $question) ?? '');
return $choice;
}
/**
* 模拟从配置中加载 NPC 数据
*/
private function loadNPC(string $id): ?NPC {
$data = match ($id) {
'VILLAGER_1' => [
'name' => '老村长',
'dialogue' => [
'greeting' => '你好,旅行者。你看起来很强大。',
'quest_response' => '你想要帮忙吗?我们的地窖里有老鼠。',
'shop_response' => '我现在没有东西卖给你。',
]
],
default => null,
};
if ($data) {
return new NPC($id, $data['name'], $data['dialogue']);
}
return null;
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace Game\System;
use Game\Event\Event;
use Game\Event\EventListenerInterface;
use Game\Event\EventDispatcher;
use Game\Model\Item;
/**
* LootService: 负责处理物品掉落、背包管理和物品获取。
*/
class LootService implements EventListenerInterface {
private EventDispatcher $dispatcher;
private StateManager $stateManager;
public function __construct(EventDispatcher $dispatcher, StateManager $stateManager) {
$this->dispatcher = $dispatcher;
$this->stateManager = $stateManager;
}
public function handleEvent(Event $event): void {
switch ($event->getType()) {
case 'LootDropEvent':
$enemyId = $event->getPayload()['enemyId'];
$this->handleLootDrop($enemyId);
break;
case 'LootFoundEvent': // 响应 MapSystem 探索时发现的宝箱
$lootId = $event->getPayload()['lootId'];
$this->handleLootFound($lootId);
break;
}
}
/**
* 处理敌人死亡时的掉落逻辑
*/
private function handleLootDrop(string $enemyId): void {
// 简化:总是掉落物品 ID 1 (小药水)
$roll = rand(1, 100);
if ($roll <= 70) { // 70% 掉落几率
$this->giveItemToPlayer(1);
} else {
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "战利品很少,只找到了一些零钱。"
]));
}
}
/**
* 处理探索时发现的宝箱/固定物品
*/
private function handleLootFound(int $lootId): void {
// 假设 lootId 5 是一个宝箱,里面是物品 ID 2
if ($lootId === 5) {
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "🗝️ 你打开了宝箱!"
]));
$this->giveItemToPlayer(2);
}
}
/**
* 核心逻辑:创建物品实例并添加到玩家背包
*/
private function giveItemToPlayer(int $itemId): void {
$itemData = $this->loadItemData($itemId);
$item = new Item(
$itemId,
$itemData['name'],
$itemData['type'],
$itemData['description'],
$itemData['value']
);
$player = $this->stateManager->getPlayer();
$player->addItem($item);
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => " 获得了物品:<fg=green>{$item->name}</>"
]));
// 触发 InventoryUpdateEvent (未来用于 UI 实时更新)
$this->dispatcher->dispatch(new Event('InventoryUpdateEvent', ['playerInventory' => $player->getInventory()]));
}
/**
* 模拟从配置中加载物品数据
*/
private function loadItemData(int $id): array {
// 实际项目中应从数据库或 JSON 加载
return match ($id) {
1 => ['name' => '小型治疗药水', 'type' => 'potion', 'description' => '恢复少量生命。', 'value' => 10],
2 => ['name' => '破旧的短剑', 'type' => 'weapon', 'description' => '攻击力微弱。', 'value' => 50],
default => ['name' => '垃圾', 'type' => 'misc', 'description' => '毫无价值的杂物。', 'value' => 1],
};
}
}

115
src/System/MapSystem.php Normal file
View File

@ -0,0 +1,115 @@
<?php
namespace Game\System;
use Game\Event\Event;
use Game\Event\EventListenerInterface;
use Game\Event\EventDispatcher;
use Game\Model\MapTile; // 引入 MapTile 模型
use Game\Event\StartBattleEvent;
class MapSystem implements EventListenerInterface {
private EventDispatcher $dispatcher;
private StateManager $stateManager;
private array $mapData;
public function __construct(EventDispatcher $dispatcher, StateManager $stateManager) {
$this->dispatcher = $dispatcher;
$this->stateManager = $stateManager;
$this->loadMapData();
// 游戏启动时,设置初始区域状态到 StateManager
$this->stateManager->setCurrentTile($this->getTile('TOWN_01'));
}
private function loadMapData(): void {
$jsonPath = __DIR__ . '/../../config/map_data.json';
if (!file_exists($jsonPath)) {
throw new \Exception("Map configuration file not found.");
}
$this->mapData = json_decode(file_get_contents($jsonPath), true);
}
public function getTile(string $tileId): MapTile {
if (!isset($this->mapData[$tileId])) {
throw new \Exception("MapTile ID '{$tileId}' not found in configuration.");
}
$data = $this->mapData[$tileId];
return new MapTile($tileId, $data['name'], $data['description'], $data['connections'], $data['eventPoolId']);
}
public function handleEvent(Event $event): void {
switch ($event->getType()) {
case 'AttemptMoveEvent':
$this->handleMoveAttempt($event->getPayload()['direction']);
break;
case 'MapExploreRequest': // 修正:监听 MapExploreRequest 事件 (来自 GameCommand)
$this->handleExplore();
break;
case 'GameStartEvent':
// 游戏开始,通知 UI 打印初始位置
$initialTile = $this->stateManager->getCurrentTile();
$this->dispatcher->dispatch(new Event('MapMoveEvent', ['newTileId' => $initialTile->id]));
break;
}
}
/**
* 处理玩家移动逻辑
*/
private function handleMoveAttempt(string $direction): void {
$direction = strtoupper($direction);
// 从 StateManager 获取当前 Tile 的连接信息
$currentTile = $this->stateManager->getCurrentTile();
$connections = $currentTile->connections;
if (isset($connections[$direction])) {
$newTileId = $connections[$direction];
$newTile = $this->getTile($newTileId);
// 1. 成功移动:更新 StateManager 中的状态
$this->stateManager->setCurrentTile($newTile);
// 2. 触发 MapMoveEvent通知 UI 和其他系统
$this->dispatcher->dispatch(new Event('MapMoveEvent', ['newTileId' => $newTileId]));
} else {
// 移动失败:通知 UI
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => "❌ 该方向 ({$direction}) 没有道路或无法通行。"]));
}
}
/**
* 处理玩家探索逻辑:根据 eventPoolId 决定遭遇什么
*/
private function handleExplore(): void {
// 从 StateManager 获取当前 Tile
$currentTile = $this->stateManager->getCurrentTile();
$roll = rand(1, 100);
if ($currentTile->eventPoolId === 0) { // 城镇
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => "你在镇上休息了一会儿,没有发现任何危险。"]));
} elseif ($roll <= 60) {
// 60% 几率遭遇战斗
$enemyId = $this->getEnemyIdForArea($currentTile->eventPoolId); // 使用当前 Tile 的 eventPoolId
$this->dispatcher->dispatch(new StartBattleEvent($enemyId));
} elseif ($roll <= 80) {
// 20% 几率发现宝箱/物品
$this->dispatcher->dispatch(new Event('LootFoundEvent', ['lootId' => 5]));
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => "🎁 你发现了一个宝箱!"]));
} else {
// 20% 几率没有遭遇
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => "你在周围探索了一番,但什么也没发现。"]));
}
}
// 简化的敌人 ID 获取 (应该从配置中获取)
private function getEnemyIdForArea(int $poolId): int {
return match ($poolId) {
1 => rand(1, 2), // 新手区敌人 ID 1 或 2
2 => 3, // 森林敌人 ID 3
default => 1,
};
}
}

154
src/System/QuestService.php Normal file
View File

@ -0,0 +1,154 @@
<?php
namespace Game\System;
use Game\Event\Event;
use Game\Event\EventListenerInterface;
use Game\Event\EventDispatcher;
use Game\Model\Quest;
use Game\Model\Player;
/**
* QuestService: 管理任务的接受、追踪和奖励发放。
*/
class QuestService implements EventListenerInterface {
private EventDispatcher $dispatcher;
private StateManager $stateManager;
private array $questData; // 存储所有任务配置
public function __construct(EventDispatcher $dispatcher, StateManager $stateManager) {
$this->dispatcher = $dispatcher;
$this->stateManager = $stateManager;
$this->loadQuestData();
}
private function loadQuestData(): void {
// 模拟从配置加载任务数据
$this->questData = [
'RATS_1' => [
'name' => '地窖里的老鼠',
'description' => '为老村长清除地窖里 5 只弱小的哥布林。',
'type' => 'kill',
'target' => ['targetId' => 1, 'count' => 5], // 目标敌人 ID 1
'rewards' => ['xp' => 100, 'itemId' => 1]
],
// ... 其他任务 ...
];
}
public function handleEvent(Event $event): void {
switch ($event->getType()) {
case 'QuestCheckEvent': // 响应 InteractionSystem 的任务请求
$this->handleQuestCheck($event->getPayload()['npcId']);
break;
case 'BattleEndEvent': // 响应战斗结束,检查击杀目标
$this->checkKillQuests($event->getPayload()['enemyId']);
break;
}
}
/**
* 1. 处理 NPC 交互时的任务检查/接受
*/
private function handleQuestCheck(string $npcId): void {
$player = $this->stateManager->getPlayer();
$questId = $this->getQuestIdForNpc($npcId);
if (!$questId) return;
if ($player->isQuestCompleted($questId)) {
$this->dispatcher->dispatch(new Event('SystemMessage', ['message' => "(村长)我已经没什么可以教你的了,旅行者。"]));
} elseif (isset($player->getActiveQuests()[$questId])) {
$this->checkQuestCompletion($questId); // 检查是否可以提交
} else {
// 接受任务
$this->acceptQuest($questId);
}
}
/**
* 2. 接受任务逻辑
*/
private function acceptQuest(string $questId): void {
$player = $this->stateManager->getPlayer();
$questConfig = $this->questData[$questId];
$player->addActiveQuest($questId, $questConfig['target']['count']);
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "📜 <fg=yellow>接受任务:{$questConfig['name']}</> - 目标:{$questConfig['description']}"
]));
}
/**
* 3. 检查击杀类任务进度
*/
private function checkKillQuests(string $killedEnemyId): void {
$player = $this->stateManager->getPlayer();
$activeQuests = $player->getActiveQuests();
foreach ($activeQuests as $questId => $progress) {
$questConfig = $this->questData[$questId];
if ($questConfig['type'] === 'kill' && $questConfig['target']['targetId'] == $killedEnemyId) {
// 更新玩家任务进度
$player->updateQuestProgress($questId, 1);
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "任务进度更新:[{$questConfig['name']}] {$player->getActiveQuests()[$questId]['currentCount']}/{$questConfig['target']['count']}"
]));
// 如果任务完成,触发 QuestCompletedEventRequest
if ($player->getActiveQuests()[$questId]['isCompleted']) {
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "任务 [{$questConfig['name']}] <fg=green;options=bold>已完成!</>请回去找NPC提交。"
]));
}
}
}
}
/**
* 4. 检查任务是否可以提交并给予奖励
*/
private function checkQuestCompletion(string $questId): void {
$player = $this->stateManager->getPlayer();
$progress = $player->getActiveQuests()[$questId];
$questConfig = $this->questData[$questId];
if ($progress['isCompleted']) {
// 发放奖励
$rewards = $questConfig['rewards'];
// 经验值奖励 (交给 CharacterService)
if (isset($rewards['xp'])) {
$this->dispatcher->dispatch(new Event('XpGainedEvent', ['xp' => $rewards['xp']]));
}
// 物品奖励 (交给 LootService)
if (isset($rewards['itemId'])) {
$this->dispatcher->dispatch(new Event('LootFoundEvent', ['lootId' => $rewards['itemId']]));
}
// 标记玩家任务完成
$player->markQuestCompleted($questId);
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "🎉 <fg=yellow>任务提交成功!</> 获得了奖励。"
]));
} else {
$this->dispatcher->dispatch(new Event('SystemMessage', [
'message' => "(村长)任务 [{$questConfig['name']}] 还没有完成。目标:{$progress['currentCount']}/{$progress['targetCount']}"
]));
}
}
/**
* 模拟:根据 NPC ID 获取对应的任务 ID
*/
private function getQuestIdForNpc(string $npcId): ?string {
return match ($npcId) {
'VILLAGER_1' => 'RATS_1',
default => null,
};
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace Game\System;
use Game\Model\Player;
use Doctrine\DBAL\Connection;
use Game\Model\MapTile;
/**
* StateManager: 集中管理和持久化核心游戏状态 (Player, MapTile, etc.)
*/
class StateManager {
private Connection $db;
private Player $player;
private MapTile $currentTile;
public function __construct(Connection $db) {
$this->db = $db;
}
/**
* 初始化或加载玩家状态
* @param Player|null $player 如果为空,则尝试从数据库加载
*/
public function setPlayer(Player $player): void {
$this->player = $player;
}
public function getPlayer(): Player {
// 确保在访问前玩家实例已设置
if (!isset($this->player)) {
throw new \RuntimeException("Player instance not initialized in StateManager.");
}
return $this->player;
}
// --- 地图状态管理 ---
public function setCurrentTile(MapTile $tile): void {
$this->currentTile = $tile;
}
public function getCurrentTile(): MapTile {
if (!isset($this->currentTile)) {
// 假设 MapSystem 会在初始化时设置初始 Tile
throw new \RuntimeException("Current MapTile not set in StateManager.");
}
return $this->currentTile;
}
/**
* TODO: 实现存档逻辑 (使用 Doctrine DBAL)
*/
public function saveGame(): void {
// 示例:将玩家对象序列化并存入数据库
/*
$data = serialize($this->player);
$this->db->update('player_save',
['data_json' => $data],
['player_name' => $this->player->getName()]
);
*/
// $this->eventDispatcher->dispatch(new Event('SystemMessage', ['message' => '游戏已保存。']));
}
// TODO: loadGame() 方法
}

102
src/System/UIService.php Normal file
View File

@ -0,0 +1,102 @@
<?php
namespace Game\System;
use Game\Event\Event;
use Game\Event\EventListenerInterface;
use Game\Model\Player;
use Game\Model\MapTile; // 需要引入 MapTile 模型
use Symfony\Component\Console\Output\OutputInterface;
/**
* UIService: 负责所有终端输出的监听器。
* 确保 UI 与游戏状态一致,遵循单一输出源原则。
*/
class UIService implements EventListenerInterface {
private OutputInterface $output;
private StateManager $stateManager; // 替换 MapSystem
/**
* 注意UIService 需要 MapSystem 实例来查询地图数据进行显示
*/
public function __construct(OutputInterface $output, StateManager $stateManager) {
$this->output = $output;
$this->stateManager = $stateManager;
}
public function handleEvent(Event $event): void {
switch ($event->getType()) {
case 'GameStartEvent':
$this->output->writeln("🔔 <comment>{$event->getPayload()['message']}</comment>");
break;
case 'ShowStatsEvent':
// 确保 Payload 中包含 Player 实例
if (isset($event->getPayload()['player']) && $event->getPayload()['player'] instanceof Player) {
$this->displayPlayerStats($event->getPayload()['player']);
}
break;
case 'SystemMessage':
$this->output->writeln("📣 <fg=magenta>{$event->getPayload()['message']}</>");
break;
case 'ShowStatsRequest':
// UIService 现在从 StateManager 获取 Player 实例
$player = $this->stateManager->getPlayer();
$this->displayPlayerStats($player);
break;
case 'MapMoveEvent':
// 收到 MapSystem 触发的移动成功事件
$tileId = $event->getPayload()['newTileId'];
try {
// 通过 MapSystem 获取最新的 MapTile 数据
$tile = $this->stateManager->getCurrentTile();
// dd($tile);
$this->displayLocation($tile);
} catch (\Exception $e) {
$this->output->writeln("<error>UI 错误:无法加载地图区域 {$tileId} {$e->getMessage()}。</error>");
}
break;
case 'StartBattleEvent':
$this->output->writeln("\n\n<fg=red;options=bold>⚔️ 遭遇战触发!请选择战斗指令...</>");
break;
// TODO: 在后续步骤中添加 BattleEndEvent, DamageDealtEvent 等处理
}
}
/**
* 打印玩家状态信息
*/
private function displayPlayerStats(Player $player): void {
$this->output->writeln("\n--- <info>角色状态:{$player->getName()}</info> ---");
$this->output->writeln("等级: <comment>{$player->getLevel()}</comment>");
$this->output->writeln("HP: <fg=red>{$player->getHealth()}</>/{$player->getMaxHealth()}");
$this->output->writeln("攻击力: <fg=yellow>{$player->getAttack()}</>");
$this->output->writeln("防御力: <fg=blue>{$player->getDefense()}</>");
$this->output->writeln("经验值: {$player->getCurrentXp()}/{$player->getXpToNextLevel()}");
$this->output->writeln("--------------------------\n");
}
private function displayMainMenu(): void {
$this->output->writeln("\n--- <fg=white>主菜单</> ---");
$this->output->writeln(" [M] 移动 | [E] 探索 | [S] 状态 | [I] 交互 | [Q] 退出"); // ⭐ 增加 I 选项
}
/**
* 打印当前地图区域信息
*/
private function displayLocation(MapTile $tile): void {
// 清屏(可选,增强沉浸感)
// $this->output->write("\033[2J\033[H");
$this->output->writeln("\n======== [ <fg=cyan>{$tile->name}</> ] ========");
$this->output->writeln(" <fg=white>{$tile->description}</>");
// 格式化连接信息
$connections = array_map(fn($dir, $id) => "<fg=green>{$dir}</>(<fg=yellow>{$id}</>)", array_keys($tile->connections), $tile->connections);
$this->output->writeln(" -> 可移动方向: " . implode(' | ', $connections));
$this->output->writeln("===================================\n");
}
}

BIN
storage/game.sqlite Normal file

Binary file not shown.