等级优化
This commit is contained in:
parent
d63e756265
commit
5136e55932
|
|
@ -15,6 +15,9 @@
|
|||
<Configuration>
|
||||
<option name="path" value="$PROJECT_DIR$/tests" />
|
||||
</Configuration>
|
||||
<Configuration>
|
||||
<option name="path" value="$PROJECT_DIR$/tests" />
|
||||
</Configuration>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@
|
|||
<PhpSpecSuiteConfiguration>
|
||||
<option name="myPath" value="$PROJECT_DIR$" />
|
||||
</PhpSpecSuiteConfiguration>
|
||||
<PhpSpecSuiteConfiguration>
|
||||
<option name="myPath" value="$PROJECT_DIR$" />
|
||||
</PhpSpecSuiteConfiguration>
|
||||
</suites>
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<?php
|
||||
|
||||
namespace Game\Core;
|
||||
|
||||
use Ratchet\MessageComponentInterface;
|
||||
|
|
@ -16,7 +17,13 @@ class GameProcessServer implements MessageComponentInterface
|
|||
protected array $clients = [];
|
||||
protected array $processes = [];
|
||||
protected ?LoopInterface $loop = null;
|
||||
protected array $timers = [];
|
||||
protected array $outputBuffer = []; // 消息缓冲区: $connId => { stdout: string, stderr: string }
|
||||
protected array $flushTimers = []; // 防抖定时器: $connId => timer
|
||||
protected array $lastActivityTime = []; // 最后活动时间: $connId => timestamp
|
||||
protected array $idleTimers = []; // 空闲检查定时器: $connId => timer
|
||||
protected const IDLE_TIMEOUT = 300; // 5分钟空闲超时(秒)
|
||||
protected const IDLE_CHECK_INTERVAL = 60; // 每60秒检查一次空闲状态
|
||||
protected const BUFFER_SIZE = 8192; // 读取缓冲区大小
|
||||
|
||||
public function __construct(?LoopInterface $loop = null)
|
||||
{
|
||||
|
|
@ -51,26 +58,6 @@ class GameProcessServer implements MessageComponentInterface
|
|||
'message' => '游戏进程已启动,正在加载...'
|
||||
]);
|
||||
|
||||
// 设置定时器,每100ms读取一次进程输出(实时转发)
|
||||
// 只有在事件循环可用时才设置
|
||||
if ($this->loop) {
|
||||
$connId = $conn->resourceId;
|
||||
$timer = $this->loop->addPeriodicTimer(0.1, function() use ($conn, $connId) {
|
||||
// 检查连接是否仍然有效
|
||||
if (!isset($this->clients[$connId]) || !isset($this->processes[$connId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取进程输出并发送给客户端
|
||||
$this->readProcessOutput($conn);
|
||||
});
|
||||
|
||||
$this->timers[$connId] = $timer;
|
||||
echo "[定时器] 为连接 {$connId} 启动输出轮询\n";
|
||||
} else {
|
||||
echo "[警告] 事件循环未设置,无法启动输出轮询\n";
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->sendError($conn, '启动游戏失败: ' . $e->getMessage());
|
||||
$conn->close();
|
||||
|
|
@ -99,7 +86,7 @@ class GameProcessServer implements MessageComponentInterface
|
|||
|
||||
// 启动进程
|
||||
$process = proc_open(
|
||||
'php ' . escapeshellarg($gameScript),
|
||||
'/opt/homebrew/opt/php@8.1/bin/php ' . escapeshellarg($gameScript),
|
||||
$descriptorspec,
|
||||
$pipes,
|
||||
$gameDir,
|
||||
|
|
@ -118,10 +105,25 @@ class GameProcessServer implements MessageComponentInterface
|
|||
stream_set_blocking($pipes[1], false);
|
||||
stream_set_blocking($pipes[2], false);
|
||||
|
||||
echo "[进程] 已启动进程 {$connId}: " . getmypid() . "\n";
|
||||
// 监听 stdout
|
||||
$this->loop->addReadStream($pipes[1], function () use ($connId, &$pipes) {
|
||||
$this->onProcessOutput($connId, $pipes[1], false);
|
||||
});
|
||||
|
||||
// 启动输出读取线程(使用select轮询)
|
||||
$this->startOutputReader($connId, $pipes[1], $pipes[2]);
|
||||
// 监听 stderr
|
||||
$this->loop->addReadStream($pipes[2], function () use ($connId, &$pipes) {
|
||||
$this->onProcessOutput($connId, $pipes[2], true);
|
||||
});
|
||||
|
||||
// 初始化最后活动时间
|
||||
$this->lastActivityTime[$connId] = time();
|
||||
|
||||
// 启动空闲检查定时器
|
||||
$this->idleTimers[$connId] = $this->loop->addPeriodicTimer(self::IDLE_CHECK_INTERVAL, function () use ($connId) {
|
||||
$this->checkIdleTimeout($connId);
|
||||
});
|
||||
|
||||
echo "[进程] 已启动进程 {$connId}: " . getmypid() . "\n";
|
||||
|
||||
return [
|
||||
'process' => $process,
|
||||
|
|
@ -132,15 +134,6 @@ class GameProcessServer implements MessageComponentInterface
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动异步输出读取
|
||||
*/
|
||||
private function startOutputReader(string $connId, $stdout, $stderr): void
|
||||
{
|
||||
// 已由 onOpen() 中的事件循环定时器处理
|
||||
// 不再需要额外的初始化
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收消息(用户输入)
|
||||
*/
|
||||
|
|
@ -153,19 +146,23 @@ class GameProcessServer implements MessageComponentInterface
|
|||
|
||||
$type = $data['type'] ?? null;
|
||||
$input = $data['input'] ?? '';
|
||||
$connId = $from->resourceId;
|
||||
|
||||
if ($type === 'input') {
|
||||
// 更新最后活动时间(用户有输入)
|
||||
$this->lastActivityTime[$connId] = time();
|
||||
|
||||
// 将输入写入进程的STDIN
|
||||
$process = $this->processes[$from->resourceId] ?? null;
|
||||
$process = $this->processes[$connId] ?? null;
|
||||
if ($process && is_resource($process['stdin'])) {
|
||||
$bytesWritten = @fwrite($process['stdin'], $input . "\n");
|
||||
if ($bytesWritten === false || $bytesWritten === 0) {
|
||||
// 写入失败,可能进程已关闭
|
||||
echo "[警告] 无法写入进程 {$from->resourceId},进程可能已关闭\n";
|
||||
echo "[警告] 无法写入进程 {$connId},进程可能已关闭\n";
|
||||
// 不中断连接,等待下一次尝试或超时
|
||||
} else {
|
||||
fflush($process['stdin']);
|
||||
echo "[输入] {$from->resourceId}: {$input}\n";
|
||||
echo "[输入] {$connId}: {$input}\n";
|
||||
}
|
||||
}
|
||||
} elseif ($type === 'ping') {
|
||||
|
|
@ -194,24 +191,7 @@ class GameProcessServer implements MessageComponentInterface
|
|||
echo "[进程] 进程 {$connId} 已退出,状态码: " . $processStatus['exitcode'] . "\n";
|
||||
|
||||
// 尝试读取任何剩余的输出
|
||||
$finalOutput = '';
|
||||
$bufferSize = 8192;
|
||||
|
||||
while (true) {
|
||||
$chunk = @fread($process['stdout'], $bufferSize);
|
||||
if ($chunk === '' || $chunk === false) {
|
||||
break;
|
||||
}
|
||||
$finalOutput .= $chunk;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
$chunk = @fread($process['stderr'], $bufferSize);
|
||||
if ($chunk === '' || $chunk === false) {
|
||||
break;
|
||||
}
|
||||
$finalOutput .= $chunk;
|
||||
}
|
||||
$finalOutput = $this->readStreamContent($process['stdout']) . $this->readStreamContent($process['stderr']);
|
||||
|
||||
if ($finalOutput) {
|
||||
$this->sendMessage($conn, [
|
||||
|
|
@ -231,29 +211,9 @@ class GameProcessServer implements MessageComponentInterface
|
|||
return;
|
||||
}
|
||||
|
||||
$output = '';
|
||||
|
||||
// 在非阻塞模式下,使用fread读取可用数据
|
||||
// 这比fgets更适合处理交互式程序的输出
|
||||
$bufferSize = 8192;
|
||||
|
||||
// 读取stdout
|
||||
while (true) {
|
||||
$chunk = @fread($process['stdout'], $bufferSize);
|
||||
if ($chunk === '' || $chunk === false) {
|
||||
break;
|
||||
}
|
||||
$output .= $chunk;
|
||||
}
|
||||
|
||||
// 读取stderr
|
||||
while (true) {
|
||||
$chunk = @fread($process['stderr'], $bufferSize);
|
||||
if ($chunk === '' || $chunk === false) {
|
||||
break;
|
||||
}
|
||||
$output .= $chunk;
|
||||
}
|
||||
$output = $this->readStreamContent($process['stdout']) . $this->readStreamContent($process['stderr']);
|
||||
|
||||
// 如果有输出,发送给客户端
|
||||
if ($output) {
|
||||
|
|
@ -264,6 +224,22 @@ class GameProcessServer implements MessageComponentInterface
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取流的所有可用内容
|
||||
*/
|
||||
private function readStreamContent($stream): string
|
||||
{
|
||||
$content = '';
|
||||
while (true) {
|
||||
$chunk = @fread($stream, self::BUFFER_SIZE);
|
||||
if ($chunk === '' || $chunk === false) {
|
||||
break;
|
||||
}
|
||||
$content .= $chunk;
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端关闭连接
|
||||
*/
|
||||
|
|
@ -271,29 +247,45 @@ class GameProcessServer implements MessageComponentInterface
|
|||
{
|
||||
$connId = $conn->resourceId;
|
||||
|
||||
// 取消定时器
|
||||
if (isset($this->timers[$connId]) && $this->loop) {
|
||||
$this->loop->cancelTimer($this->timers[$connId]);
|
||||
unset($this->timers[$connId]);
|
||||
echo "[定时器] 取消连接 {$connId} 的输出轮询\n";
|
||||
// 清理缓冲区和防抖定时器
|
||||
if (isset($this->flushTimers[$connId])) {
|
||||
$this->loop->cancelTimer($this->flushTimers[$connId]);
|
||||
unset($this->flushTimers[$connId]);
|
||||
}
|
||||
unset($this->outputBuffer[$connId]);
|
||||
|
||||
// 清理空闲检查定时器
|
||||
if (isset($this->idleTimers[$connId])) {
|
||||
$this->loop->cancelTimer($this->idleTimers[$connId]);
|
||||
unset($this->idleTimers[$connId]);
|
||||
}
|
||||
unset($this->lastActivityTime[$connId]);
|
||||
|
||||
// 关闭进程
|
||||
$process = $this->processes[$connId] ?? null;
|
||||
if ($process) {
|
||||
if (is_resource($process['stdin'])) {
|
||||
fclose($process['stdin']);
|
||||
}
|
||||
if (is_resource($process['stdout'])) {
|
||||
// 移除读取流(在关闭之前)
|
||||
if (isset($process['stdout']) && is_resource($process['stdout'])) {
|
||||
$this->loop->removeReadStream($process['stdout']);
|
||||
fclose($process['stdout']);
|
||||
}
|
||||
if (is_resource($process['stderr'])) {
|
||||
|
||||
if (isset($process['stderr']) && is_resource($process['stderr'])) {
|
||||
$this->loop->removeReadStream($process['stderr']);
|
||||
fclose($process['stderr']);
|
||||
}
|
||||
|
||||
// 关闭stdin
|
||||
if (isset($process['stdin']) && is_resource($process['stdin'])) {
|
||||
fclose($process['stdin']);
|
||||
}
|
||||
|
||||
// 终止进程
|
||||
if (is_resource($process['process'])) {
|
||||
proc_terminate($process['process']);
|
||||
proc_close($process['process']);
|
||||
}
|
||||
|
||||
unset($this->processes[$connId]);
|
||||
echo "[进程] 已终止进程: {$connId}\n";
|
||||
}
|
||||
|
|
@ -308,14 +300,6 @@ class GameProcessServer implements MessageComponentInterface
|
|||
public function onError(ConnectionInterface $conn, \Exception $e)
|
||||
{
|
||||
echo "[错误] {$conn->resourceId}: {$e->getMessage()}\n";
|
||||
|
||||
// 取消定时器
|
||||
$connId = $conn->resourceId;
|
||||
if (isset($this->timers[$connId]) && $this->loop) {
|
||||
$this->loop->cancelTimer($this->timers[$connId]);
|
||||
unset($this->timers[$connId]);
|
||||
}
|
||||
|
||||
$this->onClose($conn);
|
||||
}
|
||||
|
||||
|
|
@ -342,4 +326,101 @@ class GameProcessServer implements MessageComponentInterface
|
|||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
private function onProcessOutput(string $connId, $stream, bool $isErr)
|
||||
{
|
||||
if (!isset($this->clients[$connId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chunk = stream_get_contents($stream);
|
||||
if ($chunk === '' || $chunk === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新最后活动时间(有输出意味着进程还在活动)
|
||||
$this->lastActivityTime[$connId] = time();
|
||||
|
||||
// 初始化缓冲区
|
||||
if (!isset($this->outputBuffer[$connId])) {
|
||||
$this->outputBuffer[$connId] = ['stdout' => '', 'stderr' => ''];
|
||||
}
|
||||
|
||||
// 将数据添加到缓冲区
|
||||
$key = $isErr ? 'stderr' : 'stdout';
|
||||
$this->outputBuffer[$connId][$key] .= $chunk;
|
||||
|
||||
// 如果已有防抖定时器,取消它
|
||||
if (isset($this->flushTimers[$connId])) {
|
||||
$this->loop->cancelTimer($this->flushTimers[$connId]);
|
||||
}
|
||||
|
||||
// 设置新的防抖定时器,50ms 后发送缓冲的消息
|
||||
$this->flushTimers[$connId] = $this->loop->addTimer(0.05, function () use ($connId) {
|
||||
$this->flushOutputBuffer($connId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送缓冲的输出
|
||||
*/
|
||||
private function flushOutputBuffer(string $connId): void
|
||||
{
|
||||
if (!isset($this->outputBuffer[$connId]) || !isset($this->clients[$connId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$buffer = $this->outputBuffer[$connId];
|
||||
$conn = $this->clients[$connId];
|
||||
|
||||
// 合并 stdout 和 stderr 的内容
|
||||
$output = '';
|
||||
if ($buffer['stdout']) {
|
||||
$output .= $buffer['stdout'];
|
||||
}
|
||||
if ($buffer['stderr']) {
|
||||
$output .= $buffer['stderr'];
|
||||
}
|
||||
|
||||
// 发送合并后的消息
|
||||
if ($output) {
|
||||
$this->sendMessage($conn, [
|
||||
'type' => 'output',
|
||||
'text' => $output
|
||||
]);
|
||||
}
|
||||
|
||||
// 清空缓冲区
|
||||
unset($this->outputBuffer[$connId]);
|
||||
unset($this->flushTimers[$connId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查进程是否空闲超时
|
||||
*/
|
||||
private function checkIdleTimeout(string $connId): void
|
||||
{
|
||||
// 检查连接和进程是否还存在
|
||||
if (!isset($this->clients[$connId]) || !isset($this->processes[$connId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lastActivity = $this->lastActivityTime[$connId] ?? time();
|
||||
$idleSeconds = time() - $lastActivity;
|
||||
|
||||
if ($idleSeconds >= self::IDLE_TIMEOUT) {
|
||||
echo "[空闲] 进程 {$connId} 已空闲 {$idleSeconds} 秒,准备关闭\n";
|
||||
|
||||
// 向客户端发送空闲超时消息
|
||||
if (isset($this->clients[$connId])) {
|
||||
$this->sendMessage($this->clients[$connId], [
|
||||
'type' => 'system',
|
||||
'message' => "进程因空闲5分钟已自动关闭"
|
||||
]);
|
||||
|
||||
// 关闭连接
|
||||
$this->clients[$connId]->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,198 +0,0 @@
|
|||
<?php
|
||||
namespace Game\Core;
|
||||
|
||||
/**
|
||||
* Web 游戏会话 - 使用完整的 Game 逻辑
|
||||
*/
|
||||
class GameSession
|
||||
{
|
||||
private Game $game;
|
||||
private WebOutput $output;
|
||||
private string $userId;
|
||||
|
||||
public function __construct(string $userId)
|
||||
{
|
||||
$this->userId = $userId;
|
||||
$this->output = new WebOutput();
|
||||
|
||||
// 设置 Web 输入模式
|
||||
$webInput = WebInput::getInstance();
|
||||
$webInput->setWebMode(true);
|
||||
|
||||
// 创建 Game 实例,使用 WebOutput 和用户 ID
|
||||
$this->game = new Game(null, $this->output, $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染当前界面(不需要输入)
|
||||
*/
|
||||
public function render(): string
|
||||
{
|
||||
$this->output->clear();
|
||||
|
||||
try {
|
||||
$this->runCurrentState();
|
||||
} catch (NeedInputException $e) {
|
||||
// 正常情况,界面渲染完成,等待输入
|
||||
}
|
||||
|
||||
return $this->output->getOutput();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户输入并返回新界面(支持时间戳数据)
|
||||
*/
|
||||
public function handleInput(string $input): string|array
|
||||
{
|
||||
$this->output->clear();
|
||||
|
||||
// 将输入推入队列
|
||||
$webInput = WebInput::getInstance();
|
||||
$webInput->clear();
|
||||
$webInput->push($input);
|
||||
|
||||
try {
|
||||
$this->runCurrentState();
|
||||
} catch (NeedInputException $e) {
|
||||
// 需要更多输入,返回当前输出
|
||||
}
|
||||
|
||||
// 保存状态
|
||||
$this->game->saveState();
|
||||
|
||||
// 获取当前状态信息
|
||||
$stateInfo = $this->getStateInfo();
|
||||
$output = $this->output->getOutput();
|
||||
|
||||
// 返回包含状态信息的结构化数据
|
||||
return [
|
||||
'output' => $output,
|
||||
'state' => $stateInfo['state'],
|
||||
'stateName' => $stateInfo['stateName'],
|
||||
'playerInfo' => $stateInfo['playerInfo'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 流式战斗模式
|
||||
* 当用户进入战斗时,使用SSE直接推送日志
|
||||
*/
|
||||
public function streamBattle(string $input): void
|
||||
{
|
||||
// 使用SSEOutput替换普通输出
|
||||
$sseOutput = new \Game\Core\SSEOutput();
|
||||
|
||||
// 替换game的output对象
|
||||
$this->game->output = $sseOutput;
|
||||
|
||||
// 将输入推入队列
|
||||
$webInput = WebInput::getInstance();
|
||||
$webInput->clear();
|
||||
$webInput->push($input);
|
||||
|
||||
try {
|
||||
$this->runCurrentState();
|
||||
} catch (NeedInputException $e) {
|
||||
// 战斗中可能需要更多输入
|
||||
}
|
||||
|
||||
// 保存状态
|
||||
$this->game->saveState();
|
||||
|
||||
// 发送完成信号
|
||||
$sseOutput->complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行当前状态的逻辑
|
||||
*/
|
||||
private function runCurrentState(): void
|
||||
{
|
||||
if ($this->game->state == 0){
|
||||
$this->game->state = 1;
|
||||
}
|
||||
switch ($this->game->state) {
|
||||
case Game::MENU:
|
||||
(new \Game\Modules\Menu($this->game))->show();
|
||||
break;
|
||||
case Game::DUNGEON_SELECT:
|
||||
(new \Game\Modules\DungeonSelectPanel($this->game))->show();
|
||||
break;
|
||||
case Game::BATTLE:
|
||||
(new \Game\Modules\Battle($this->game))->start();
|
||||
break;
|
||||
case Game::STATS:
|
||||
(new \Game\Modules\StatsPanel($this->game))->show();
|
||||
break;
|
||||
case Game::INVENTORY:
|
||||
(new \Game\Modules\InventoryPanel($this->game))->show();
|
||||
break;
|
||||
case Game::EQUIPMENT_ENHANCE:
|
||||
(new \Game\Modules\EquipmentEnhancePanel($this->game))->show();
|
||||
break;
|
||||
case Game::NPC:
|
||||
(new \Game\Modules\NpcPanel($this->game))->show();
|
||||
break;
|
||||
case Game::TALENT:
|
||||
(new \Game\Modules\TalentPanel($this->game))->show();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前游戏状态
|
||||
*/
|
||||
public function getState(): int
|
||||
{
|
||||
return $this->game->state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取玩家信息
|
||||
*/
|
||||
public function getPlayerInfo(): array
|
||||
{
|
||||
$stats = $this->game->player->getStats();
|
||||
return [
|
||||
'level' => $this->game->player->level,
|
||||
'hp' => $this->game->player->hp,
|
||||
'maxHp' => $stats['maxHp'],
|
||||
'mana' => $this->game->player->mana,
|
||||
'maxMana' => $stats['maxMana'],
|
||||
'spiritStones' => $this->game->player->spiritStones,
|
||||
'exp' => $this->game->player->exp,
|
||||
'maxExp' => $this->game->player->maxExp,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前游戏状态信息
|
||||
*/
|
||||
public function getStateInfo(): array
|
||||
{
|
||||
return [
|
||||
'state' => $this->game->state,
|
||||
'stateName' => $this->getStateName(),
|
||||
'playerInfo' => $this->getPlayerInfo(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将状态常量转换为名称
|
||||
*/
|
||||
private function getStateName(): string
|
||||
{
|
||||
return match($this->game->state) {
|
||||
Game::MENU => 'MENU',
|
||||
Game::DUNGEON_SELECT => 'DUNGEON_SELECT',
|
||||
Game::BATTLE => 'BATTLE',
|
||||
Game::STATS => 'STATS',
|
||||
Game::INVENTORY => 'INVENTORY',
|
||||
Game::EQUIPMENT_ENHANCE => 'EQUIPMENT_ENHANCE',
|
||||
Game::NPC => 'NPC',
|
||||
Game::TALENT => 'TALENT',
|
||||
Game::EXIT => 'EXIT',
|
||||
default => 'UNKNOWN'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
<?php
|
||||
namespace Game\Core;
|
||||
|
||||
use Ratchet\MessageComponentInterface;
|
||||
use Ratchet\ConnectionInterface;
|
||||
|
||||
/**
|
||||
* WebSocket 游戏服务器
|
||||
* 处理前端的实时通信
|
||||
*/
|
||||
class GameWebSocketServer implements MessageComponentInterface
|
||||
{
|
||||
// 存储所有连接和对应的用户会话
|
||||
protected array $clients = [];
|
||||
protected array $sessions = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
echo "[WebSocket] 游戏服务器初始化\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端连接时
|
||||
*/
|
||||
public function onOpen(ConnectionInterface $conn)
|
||||
{
|
||||
echo "[连接] 新连接: {$conn->resourceId}\n";
|
||||
$this->clients[$conn->resourceId] = $conn;
|
||||
|
||||
// 发送欢迎消息
|
||||
$this->sendMessage($conn, [
|
||||
'type' => 'welcome',
|
||||
'message' => '连接成功,请登录'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收消息
|
||||
*/
|
||||
public function onMessage(ConnectionInterface $from, $msg)
|
||||
{
|
||||
$data = json_decode($msg, true);
|
||||
if (!$data) {
|
||||
$this->sendError($from, '无效的JSON格式');
|
||||
return;
|
||||
}
|
||||
|
||||
$type = $data['type'] ?? null;
|
||||
|
||||
switch ($type) {
|
||||
case 'login':
|
||||
$this->handleLogin($from, $data);
|
||||
break;
|
||||
|
||||
case 'game-input':
|
||||
$this->handleGameInput($from, $data);
|
||||
break;
|
||||
|
||||
case 'sync-state':
|
||||
$this->handleSyncState($from, $data);
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
$this->sendMessage($from, ['type' => 'pong']);
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->sendError($from, "未知消息类型: $type");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端关闭连接
|
||||
*/
|
||||
public function onClose(ConnectionInterface $conn)
|
||||
{
|
||||
unset($this->clients[$conn->resourceId]);
|
||||
if (isset($this->sessions[$conn->resourceId])) {
|
||||
unset($this->sessions[$conn->resourceId]);
|
||||
}
|
||||
echo "[断开] 连接已关闭: {$conn->resourceId}\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接错误
|
||||
*/
|
||||
public function onError(ConnectionInterface $conn, \Exception $e)
|
||||
{
|
||||
echo "[错误] {$conn->resourceId}: {$e->getMessage()}\n";
|
||||
$conn->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录
|
||||
*/
|
||||
private function handleLogin(ConnectionInterface $conn, array $data): void
|
||||
{
|
||||
$userId = $data['userId'] ?? null;
|
||||
$username = $data['username'] ?? null;
|
||||
|
||||
if (!$userId || !$username) {
|
||||
$this->sendError($conn, '缺少userId或username');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建游戏会话
|
||||
$session = new GameSession($userId);
|
||||
$this->sessions[$conn->resourceId] = [
|
||||
'userId' => $userId,
|
||||
'username' => $username,
|
||||
'session' => $session,
|
||||
];
|
||||
|
||||
// 发送登录成功和初始界面
|
||||
$output = $session->render();
|
||||
$stateInfo = $session->getStateInfo();
|
||||
|
||||
$this->sendMessage($conn, [
|
||||
'type' => 'login-success',
|
||||
'userId' => $userId,
|
||||
'username' => $username,
|
||||
'output' => $output,
|
||||
'state' => $stateInfo['state'],
|
||||
'stateName' => $stateInfo['stateName'],
|
||||
'playerInfo' => $stateInfo['playerInfo'],
|
||||
]);
|
||||
|
||||
echo "[登录] 用户 {$username} (ID: {$userId}) 已连接\n";
|
||||
} catch (\Exception $e) {
|
||||
$this->sendError($conn, '登录失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理游戏输入
|
||||
*/
|
||||
private function handleGameInput(ConnectionInterface $conn, array $data): void
|
||||
{
|
||||
$input = $data['input'] ?? '';
|
||||
|
||||
if (!isset($this->sessions[$conn->resourceId])) {
|
||||
$this->sendError($conn, '未登录');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$sessionData = $this->sessions[$conn->resourceId];
|
||||
/** @var GameSession $session */
|
||||
$session = $sessionData['session'];
|
||||
|
||||
// 处理输入 - 现在返回结构化数据
|
||||
$result = $session->handleInput($input);
|
||||
|
||||
// result是数组,包含:output, state, stateName, playerInfo
|
||||
$this->sendMessage($conn, array_merge([
|
||||
'type' => 'game-output',
|
||||
], $result));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->sendError($conn, '输入处理失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理战斗流(SSE转WebSocket)
|
||||
*/
|
||||
private function handleBattleStream(ConnectionInterface $conn, GameSession $session): void
|
||||
{
|
||||
// 发送战斗开始信号
|
||||
$this->sendMessage($conn, [
|
||||
'type' => 'battle-start',
|
||||
'message' => '战斗开始'
|
||||
]);
|
||||
|
||||
// 创建SSEOutput替代品 - 收集输出然后发送
|
||||
// 为了简化,我们直接用WebSocket消息逐行发送
|
||||
echo "[战斗] 用户 {$this->sessions[$conn->resourceId]['username']} 进入战斗\n";
|
||||
// 发送战斗结束信号(实际战斗已经完成)
|
||||
$stateInfo = $session->getStateInfo();
|
||||
$this->sendMessage($conn, [
|
||||
'type' => 'battle-end',
|
||||
'state' => $stateInfo['state'],
|
||||
'stateName' => $stateInfo['stateName'],
|
||||
'playerInfo' => $stateInfo['playerInfo'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理状态同步
|
||||
*/
|
||||
private function handleSyncState(ConnectionInterface $conn, array $data): void
|
||||
{
|
||||
if (!isset($this->sessions[$conn->resourceId])) {
|
||||
$this->sendError($conn, '未登录');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$sessionData = $this->sessions[$conn->resourceId];
|
||||
$session = $sessionData['session'];
|
||||
|
||||
$stateInfo = $session->getStateInfo();
|
||||
$output = $session->render();
|
||||
|
||||
$this->sendMessage($conn, [
|
||||
'type' => 'state-sync',
|
||||
'output' => $output,
|
||||
'state' => $stateInfo['state'],
|
||||
'stateName' => $stateInfo['stateName'],
|
||||
'playerInfo' => $stateInfo['playerInfo'],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->sendError($conn, '状态同步失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息给客户端
|
||||
*/
|
||||
protected function sendMessage(ConnectionInterface $conn, array $data): void
|
||||
{
|
||||
$msg = json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
$conn->send($msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误消息
|
||||
*/
|
||||
protected function sendError(ConnectionInterface $conn, string $message): void
|
||||
{
|
||||
$this->sendMessage($conn, [
|
||||
'type' => 'error',
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播消息给所有连接(管理员通知等)
|
||||
*/
|
||||
protected function broadcast(array $data): void
|
||||
{
|
||||
$msg = json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
foreach ($this->clients as $conn) {
|
||||
$conn->send($msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
<?php
|
||||
namespace Game\Core;
|
||||
|
||||
/**
|
||||
* Server-Sent Events 输出类
|
||||
* 直接推送数据到客户端,用于实时战斗流
|
||||
*/
|
||||
class SSEOutput
|
||||
{
|
||||
private int $eventId = 0;
|
||||
private float $startTime = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->startTime = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入消息(不换行)
|
||||
*/
|
||||
public function write(string $message): void
|
||||
{
|
||||
// 对于ANSI清屏等控制字符,直接发送不需要特殊处理
|
||||
$this->sendEvent([
|
||||
'type' => 'write',
|
||||
'text' => $message,
|
||||
'time' => round((microtime(true) - $this->startTime) * 1000)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入消息(换行)
|
||||
*/
|
||||
public function writeln(string $message): void
|
||||
{
|
||||
$this->sendEvent([
|
||||
'type' => 'writeln',
|
||||
'text' => $message,
|
||||
'time' => round((microtime(true) - $this->startTime) * 1000)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送事件到客户端
|
||||
*/
|
||||
private function sendEvent(array $data): void
|
||||
{
|
||||
// SSE格式:
|
||||
// event: message\n
|
||||
// id: {eventId}\n
|
||||
// data: {JSON}\n\n
|
||||
|
||||
echo "event: message\n";
|
||||
echo "id: " . ($this->eventId++) . "\n";
|
||||
echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
|
||||
|
||||
// 立即刷新缓冲区发送给客户端
|
||||
ob_flush();
|
||||
flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(用于通知而非日志)
|
||||
*/
|
||||
public function sendMessage(string $type, $data): void
|
||||
{
|
||||
echo "event: " . $type . "\n";
|
||||
echo "id: " . ($this->eventId++) . "\n";
|
||||
echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
|
||||
|
||||
ob_flush();
|
||||
flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加延迟(在SSE模式下实际延迟)
|
||||
* 这样可以控制日志的显示速度
|
||||
*/
|
||||
public function delay(int $milliseconds): void
|
||||
{
|
||||
if ($milliseconds > 0) {
|
||||
usleep($milliseconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送完成信号
|
||||
*/
|
||||
public function complete(): void
|
||||
{
|
||||
$this->sendMessage('complete', [
|
||||
'message' => '战斗结束',
|
||||
'totalTime' => round((microtime(true) - $this->startTime) * 1000)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误
|
||||
*/
|
||||
public function error(string $message): void
|
||||
{
|
||||
$this->sendMessage('error', ['message' => $message]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容WebOutput的enableTiming方法
|
||||
* SSE模式不需要计时,直接返回
|
||||
*/
|
||||
public function enableTiming(): void
|
||||
{
|
||||
// SSE模式下不需要计时,直接在运行时实时推送
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容WebOutput的getLineCount方法
|
||||
*/
|
||||
public function getLineCount(): int
|
||||
{
|
||||
return 0; // SSE不缓冲
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓冲的输出(SSE不使用)
|
||||
*/
|
||||
public function getOutput(): string
|
||||
{
|
||||
return ''; // SSE模式不缓冲
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓冲区(SSE不使用)
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
// SSE不缓冲,无需清空
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ class Screen
|
|||
/**
|
||||
* 暂停等待回车
|
||||
*/
|
||||
public static function pause($out, string $msg = "按回车继续..."): void
|
||||
public static function pause($out, string $msg = "输入任何继续..."): void
|
||||
{
|
||||
$out->writeln($msg);
|
||||
$webInput = WebInput::getInstance();
|
||||
|
|
|
|||
|
|
@ -74,6 +74,53 @@ class Actor
|
|||
'critdmg' => 5,
|
||||
];
|
||||
|
||||
/**
|
||||
* 计算装备强化倍数(非线性)
|
||||
* 强化等级越高,属性提升越快
|
||||
*
|
||||
* 公式: 1 + (0.05 * level) + (0.01 * level^2)
|
||||
*
|
||||
* 示例:
|
||||
* 0级: 1.00x (5%)
|
||||
* 3级: 1.24x (24%)
|
||||
* 5级: 1.60x (60%)
|
||||
* 10级: 2.50x (150%)
|
||||
* 14级: 3.96x (296%)
|
||||
*/
|
||||
private function calculateEnhanceMultiplier(int $enhanceLevel): float
|
||||
{
|
||||
if ($enhanceLevel <= 0) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// 非线性公式:基础 + 一次项 + 二次项
|
||||
// 0.05: 基础系数 (1级 5%)
|
||||
// 0.01: 递增系数 (随等级平方增长)
|
||||
$base = 1.0;
|
||||
$linear = 0.05 * $enhanceLevel; // 线性部分
|
||||
$quadratic = 0.01 * ($enhanceLevel * $enhanceLevel); // 平方部分
|
||||
|
||||
return $base + $linear + $quadratic;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取强化等级的属性提升百分比 (用于显示)
|
||||
*/
|
||||
public function getEnhanceBonus(int $enhanceLevel): string
|
||||
{
|
||||
$multiplier = $this->calculateEnhanceMultiplier($enhanceLevel);
|
||||
$bonus = ($multiplier - 1) * 100;
|
||||
return number_format($bonus, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开获取强化倍数
|
||||
*/
|
||||
public function getEnhanceMultiplier(int $enhanceLevel): float
|
||||
{
|
||||
return $this->calculateEnhanceMultiplier($enhanceLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heal by amount, not exceeding max HP. Returns actual healed amount.
|
||||
*/
|
||||
|
|
@ -180,6 +227,44 @@ class Actor
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 计算指定等级需要的经验值
|
||||
*
|
||||
* 改进的公式 (多项式): maxExp = 100 + level*10 + level²*0.5
|
||||
*
|
||||
* 示例:
|
||||
* 1级升2级: 100 + 2*10 + 2²*0.5 = 122
|
||||
* 10级升11级: 100 + 11*10 + 11²*0.5 = 251
|
||||
* 30级升31级: 100 + 31*10 + 31²*0.5 = 851
|
||||
* 50级升51级: 100 + 51*10 + 51²*0.5 = 1851
|
||||
*/
|
||||
protected function calculateMaxExp(int $level): int
|
||||
{
|
||||
// 多项式公式,避免过度指数增长
|
||||
// base: 100 (1级升2级的基础经验)
|
||||
// linear: 10 * level (线性增长部分)
|
||||
// quadratic: 0.5 * level² (二次增长部分,增长速度比1.2x指数慢得多)
|
||||
$base = 100;
|
||||
$linear = 10 * $level;
|
||||
$quadratic = (int)(0.5 * $level * $level);
|
||||
|
||||
return $base + $linear + $quadratic;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取升级所需总经验(从1级到目标等级)
|
||||
* 用于显示进度
|
||||
*/
|
||||
public function getTotalExpForLevel(int $targetLevel): int
|
||||
{
|
||||
$total = 0;
|
||||
for ($level = 1; $level < $targetLevel; $level++) {
|
||||
$total += $this->calculateMaxExp($level);
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得经验并检查升级
|
||||
*/
|
||||
|
|
@ -195,8 +280,8 @@ class Actor
|
|||
// 分配天赋点(每级3点)
|
||||
$this->autoAllocateTalents(3);
|
||||
|
||||
// 增加下一级所需的经验
|
||||
$this->maxExp = (int)($this->maxExp * 1.2);
|
||||
// 使用新的多项式公式计算下一级所需的经验
|
||||
$this->maxExp = $this->calculateMaxExp($this->level);
|
||||
|
||||
$leveled = true;
|
||||
}
|
||||
|
|
@ -237,7 +322,7 @@ class Actor
|
|||
if (empty($item)) continue;
|
||||
|
||||
$enhanceLevel = $item['enhanceLevel'] ?? 0;
|
||||
$enhanceMultiplier = 1 + ($enhanceLevel * 0.05);
|
||||
$enhanceMultiplier = $this->calculateEnhanceMultiplier($enhanceLevel);
|
||||
|
||||
foreach (['hp','patk','matk','pdef','mdef','crit','critdmg'] as $statKey) {
|
||||
if (isset($item[$statKey])) {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class Partner extends Actor
|
|||
$this->name = $data['name'];
|
||||
$this->level = $data['level'] ?? 1;
|
||||
$this->exp = $data['exp'] ?? 0;
|
||||
$this->maxExp = $data['maxExp'] ?? (int)(100 * pow(1.5, $this->level - 1));
|
||||
$this->maxExp = $data['maxExp'] ?? $this->calculateMaxExp($this->level);
|
||||
|
||||
$this->patk = $data['patk'] ;
|
||||
$this->matk = $data['matk'] ;
|
||||
|
|
@ -91,17 +91,22 @@ class Partner extends Actor
|
|||
public function gainExp(int $amount): bool
|
||||
{
|
||||
$this->exp += $amount;
|
||||
if ($this->exp >= $this->maxExp) {
|
||||
$this->level++;
|
||||
|
||||
$leveled = false;
|
||||
while ($this->exp >= $this->maxExp) {
|
||||
$this->exp -= $this->maxExp;
|
||||
$this->maxExp = (int)($this->maxExp * 1.5);
|
||||
$this->level++;
|
||||
|
||||
// 升级时自动分配3点天赋(根据权重,且 HP 至少加1点)
|
||||
$this->autoAllocateTalent(3);
|
||||
|
||||
return true;
|
||||
// 使用新的多项式公式计算下一级所需的经验
|
||||
$this->maxExp = $this->calculateMaxExp($this->level);
|
||||
|
||||
$leveled = true;
|
||||
}
|
||||
return false;
|
||||
|
||||
return $leveled;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -80,19 +80,25 @@ class Player extends Actor
|
|||
public function gainExp(int $amount): bool
|
||||
{
|
||||
$this->exp += $amount;
|
||||
if ($this->exp >= $this->maxExp) {
|
||||
$this->level++;
|
||||
$this->exp -= $this->maxExp;
|
||||
$this->maxExp = (int)($this->maxExp * 1.5);
|
||||
|
||||
$leveled = false;
|
||||
while ($this->exp >= $this->maxExp) {
|
||||
$this->exp -= $this->maxExp;
|
||||
$this->level++;
|
||||
|
||||
// 分配天赋点(每级3点)
|
||||
$this->talentPoints += 3;
|
||||
|
||||
// 升级时恢复全部生命值
|
||||
$this->fullHeal();
|
||||
|
||||
return true; // Leveled up
|
||||
// 使用新的多项式公式计算下一级所需的经验
|
||||
$this->maxExp = $this->calculateMaxExp($this->level);
|
||||
|
||||
$leveled = true;
|
||||
}
|
||||
return false;
|
||||
|
||||
return $leveled;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -536,7 +536,9 @@ class StatsPanel
|
|||
if (!empty($actor->equip[$slot])) {
|
||||
$item = $actor->equip[$slot];
|
||||
$enhanceLevel = $item['enhanceLevel'] ?? 0;
|
||||
$this->game->output->writeln("[{$idx}] {$name}: " . ItemDisplay::renderListItem($item) . " {$this->yellow}+{$enhanceLevel}{$this->reset}");
|
||||
$bonusPercent = $enhanceLevel > 0 ? $actor->getEnhanceBonus($enhanceLevel) : '0';
|
||||
$bonusText = $enhanceLevel > 0 ? " {$this->green}(+{$bonusPercent}%){$this->reset}" : '';
|
||||
$this->game->output->writeln("[{$idx}] {$name}: " . ItemDisplay::renderListItem($item) . " {$this->yellow}+{$enhanceLevel}{$this->reset}{$bonusText}");
|
||||
$equipped[$idx] = $slot;
|
||||
$idx++;
|
||||
}
|
||||
|
|
@ -594,7 +596,8 @@ class StatsPanel
|
|||
$out->writeln("{$this->cyan}════════════════════════════════════{$this->reset}");
|
||||
$out->writeln("");
|
||||
$out->writeln("装备: " . ItemDisplay::renderListItem($item));
|
||||
$out->writeln("当前等级: {$this->yellow}+{$enhanceLevel}{$this->reset}");
|
||||
$currentBonus = $enhanceLevel > 0 ? $actor->getEnhanceBonus($enhanceLevel) : '0';
|
||||
$out->writeln("当前等级: {$this->yellow}+{$enhanceLevel}{$this->reset} {$this->green}(属性提升: +{$currentBonus}%){$this->reset}");
|
||||
$out->writeln("");
|
||||
$out->writeln("可选等级:");
|
||||
$out->writeln("");
|
||||
|
|
@ -603,7 +606,8 @@ class StatsPanel
|
|||
for ($target = $enhanceLevel + 1; $target <= $maxLevel; $target++) {
|
||||
$totalCost = EquipmentEnhancer::getTotalCost($enhanceLevel, $target);
|
||||
$canAfford = $player->spiritStones >= $totalCost ? "{$this->green}✓{$this->reset}" : "{$this->red}✗{$this->reset}";
|
||||
$out->writeln(" [{$target}] +{$target} (需要: {$totalCost} 灵石) {$canAfford}");
|
||||
$targetBonus = $actor->getEnhanceBonus($target);
|
||||
$out->writeln(" [{$target}] +{$target} (属性提升: +{$targetBonus}%, 需要: {$totalCost} 灵石) {$canAfford}");
|
||||
}
|
||||
|
||||
$out->writeln("");
|
||||
|
|
@ -735,6 +739,7 @@ class StatsPanel
|
|||
$out->writeln("第 {$totalAttempts} 次: {$this->yellow}✗ 失败{$this->reset} 等级保持 +{$oldLevel}");
|
||||
}
|
||||
}
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
// 显示最终结果
|
||||
|
|
@ -783,6 +788,20 @@ class StatsPanel
|
|||
return $level * 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取技能类型的本地化名称
|
||||
*/
|
||||
private function getSpellTypeNames(): array
|
||||
{
|
||||
return [
|
||||
'heal_single' => '单体治疗',
|
||||
'heal_aoe' => '群体治疗',
|
||||
'damage_single' => '单体伤害',
|
||||
'damage_aoe' => '群体伤害',
|
||||
'support' => '辅助'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 装备技能
|
||||
*/
|
||||
|
|
@ -791,25 +810,55 @@ class StatsPanel
|
|||
$out = $this->game->output;
|
||||
Screen::clear($out);
|
||||
|
||||
// 1. 获取背包中的法术
|
||||
$spells = [];
|
||||
// 1. 获取背包中的法术并按类型分组
|
||||
$spellsByType = [];
|
||||
$typeNames = $this->getSpellTypeNames();
|
||||
|
||||
foreach ($this->game->player->inventory as $index => $item) {
|
||||
if (($item['type'] ?? '') === 'spell') {
|
||||
$spells[$index] = $item;
|
||||
$spellType = $item['spellType'] ?? 'unknown';
|
||||
if (!isset($spellsByType[$spellType])) {
|
||||
$spellsByType[$spellType] = [];
|
||||
}
|
||||
$spellsByType[$spellType][$index] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($spells)) {
|
||||
if (empty($spellsByType)) {
|
||||
$out->writeln("背包中没有法术!");
|
||||
Screen::sleep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 显示法术列表
|
||||
$out->writeln("{$this->cyan}选择要装备的法术:{$this->reset}");
|
||||
// 2. 显示法术类型列表
|
||||
Screen::clear($out);
|
||||
$out->writeln("{$this->cyan}选择法术类型:{$this->reset}");
|
||||
$typeIndices = [];
|
||||
$i = 1;
|
||||
foreach ($spellsByType as $type => $spells) {
|
||||
$typeName = $typeNames[$type] ?? $type;
|
||||
$count = count($spells);
|
||||
$out->writeln("[{$i}] {$typeName} ({$count}个)");
|
||||
$typeIndices[$i] = $type;
|
||||
$i++;
|
||||
}
|
||||
$out->writeln("[0] 返回");
|
||||
|
||||
$typeChoice = Screen::input($out, "请选择: ");
|
||||
if ($typeChoice == 0 || !isset($typeIndices[$typeChoice])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$selectedType = $typeIndices[$typeChoice];
|
||||
$availableSpells = $spellsByType[$selectedType];
|
||||
|
||||
// 3. 显示该类型的法术列表
|
||||
Screen::clear($out);
|
||||
$typeName = $typeNames[$selectedType] ?? $selectedType;
|
||||
$out->writeln("{$this->cyan}选择{$typeName}法术:{$this->reset}");
|
||||
$spellIndices = [];
|
||||
$i = 1;
|
||||
foreach ($spells as $index => $item) {
|
||||
foreach ($availableSpells as $index => $item) {
|
||||
$out->writeln("[{$i}] " . ItemDisplay::renderListItem($item));
|
||||
$spellIndices[$i] = $index;
|
||||
$i++;
|
||||
|
|
@ -817,12 +866,14 @@ class StatsPanel
|
|||
$out->writeln("[0] 返回");
|
||||
|
||||
$choice = Screen::input($out, "请选择: ");
|
||||
if ($choice == 0 || !isset($spellIndices[$choice])) return;
|
||||
if ($choice == 0 || !isset($spellIndices[$choice])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$inventoryIndex = $spellIndices[$choice];
|
||||
$selectedSpell = $spells[$inventoryIndex];
|
||||
$selectedSpell = $availableSpells[$inventoryIndex];
|
||||
|
||||
// 3. 选择技能槽位
|
||||
// 4. 选择技能槽位
|
||||
Screen::clear($out);
|
||||
$out->writeln("{$this->cyan}选择技能槽位:{$this->reset}");
|
||||
$skillSlots = ['skill1', 'skill2', 'skill3', 'skill4'];
|
||||
|
|
@ -839,11 +890,13 @@ class StatsPanel
|
|||
$out->writeln("[0] 返回");
|
||||
|
||||
$slotChoice = Screen::input($out, "请选择槽位: ");
|
||||
if ($slotChoice < 1 || $slotChoice > 4) return;
|
||||
if ($slotChoice < 1 || $slotChoice > 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetSlot = $skillSlots[$slotChoice - 1];
|
||||
|
||||
// 4. 执行装备
|
||||
// 5. 执行装备
|
||||
// 如果槽位已有技能,先卸下
|
||||
if (isset($actor->skillSlots[$targetSlot])) {
|
||||
$this->game->player->addItem($actor->skillSlots[$targetSlot]);
|
||||
|
|
@ -851,7 +904,7 @@ class StatsPanel
|
|||
|
||||
// 装备新技能
|
||||
$actor->skillSlots[$targetSlot] = $selectedSpell;
|
||||
|
||||
|
||||
// 从背包移除
|
||||
unset($this->game->player->inventory[$inventoryIndex]);
|
||||
$this->game->player->inventory = array_values($this->game->player->inventory);
|
||||
|
|
@ -920,37 +973,75 @@ class StatsPanel
|
|||
$out = $this->game->output;
|
||||
Screen::clear($out);
|
||||
|
||||
$out->writeln("{$this->cyan}选择要强化的技能:{$this->reset}");
|
||||
// 1. 按类型分组已装备的技能
|
||||
$skillsByType = [];
|
||||
$typeNames = $this->getSpellTypeNames();
|
||||
$skillSlots = ['skill1', 'skill2', 'skill3', 'skill4'];
|
||||
$equipped = [];
|
||||
$hasSkill = false;
|
||||
$i = 1;
|
||||
|
||||
foreach ($skillSlots as $slot) {
|
||||
$spell = $actor->skillSlots[$slot] ?? null;
|
||||
if ($spell) {
|
||||
$out->writeln("[{$i}] 技能{$i}: " . ItemDisplay::renderListItem($spell));
|
||||
$equipped[$i] = $slot;
|
||||
$hasSkill = true;
|
||||
} else {
|
||||
$out->writeln("[{$i}] 技能{$i}: (空)");
|
||||
$spellType = $spell['spellType'] ?? 'unknown';
|
||||
if (!isset($skillsByType[$spellType])) {
|
||||
$skillsByType[$spellType] = [];
|
||||
}
|
||||
$skillsByType[$spellType][] = ['slot' => $slot, 'spell' => $spell];
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
$out->writeln("[0] 返回");
|
||||
|
||||
if (!$hasSkill) {
|
||||
if (empty($skillsByType)) {
|
||||
$out->writeln("没有装备任何技能!");
|
||||
Screen::sleep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
$choice = Screen::input($out, "请选择: ");
|
||||
if ($choice == 0 || !isset($equipped[$choice])) return;
|
||||
// 2. 显示技能类型列表
|
||||
Screen::clear($out);
|
||||
$out->writeln("{$this->cyan}选择要强化的技能类型:{$this->reset}");
|
||||
$typeIndices = [];
|
||||
$i = 1;
|
||||
foreach ($skillsByType as $type => $skills) {
|
||||
$typeName = $typeNames[$type] ?? $type;
|
||||
$count = count($skills);
|
||||
$out->writeln("[{$i}] {$typeName} ({$count}个)");
|
||||
$typeIndices[$i] = $type;
|
||||
$i++;
|
||||
}
|
||||
$out->writeln("[0] 返回");
|
||||
|
||||
$slot = $equipped[$choice];
|
||||
|
||||
// 复用 showEnhancePanel,但需要修改 doEnhance 支持技能
|
||||
// 为了简单,我们创建一个专门的 showSkillEnhancePanel
|
||||
$typeChoice = Screen::input($out, "请选择: ");
|
||||
if ($typeChoice == 0 || !isset($typeIndices[$typeChoice])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$selectedType = $typeIndices[$typeChoice];
|
||||
$availableSkills = $skillsByType[$selectedType];
|
||||
|
||||
// 3. 显示该类型的技能列表
|
||||
Screen::clear($out);
|
||||
$typeName = $typeNames[$selectedType] ?? $selectedType;
|
||||
$out->writeln("{$this->cyan}选择要强化的{$typeName}技能:{$this->reset}");
|
||||
$skillIndices = [];
|
||||
$i = 1;
|
||||
foreach ($availableSkills as $skillInfo) {
|
||||
$slotName = $skillInfo['slot'];
|
||||
$slotNum = array_search($slotName, $skillSlots) + 1;
|
||||
$spell = $skillInfo['spell'];
|
||||
$enhanceLevel = $spell['enhanceLevel'] ?? 0;
|
||||
$out->writeln("[{$i}] 技能{$slotNum}: " . ItemDisplay::renderListItem($spell) . " {$this->yellow}+{$enhanceLevel}{$this->reset}");
|
||||
$skillIndices[$i] = $slotName;
|
||||
$i++;
|
||||
}
|
||||
$out->writeln("[0] 返回");
|
||||
|
||||
$choice = Screen::input($out, "请选择: ");
|
||||
if ($choice == 0 || !isset($skillIndices[$choice])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$slot = $skillIndices[$choice];
|
||||
|
||||
// 4. 进入强化面板
|
||||
$this->showSkillEnhancePanel($slot, $actor);
|
||||
}
|
||||
|
||||
|
|
@ -979,7 +1070,8 @@ class StatsPanel
|
|||
$out->writeln("{$this->cyan}════════════════════════════════════{$this->reset}");
|
||||
$out->writeln("");
|
||||
$out->writeln("技能: " . ItemDisplay::renderListItem($item));
|
||||
$out->writeln("当前等级: {$this->yellow}+{$enhanceLevel}{$this->reset}");
|
||||
$currentBonus = $enhanceLevel > 0 ? $actor->getEnhanceBonus($enhanceLevel) : '0';
|
||||
$out->writeln("当前等级: {$this->yellow}+{$enhanceLevel}{$this->reset} {$this->green}(属性提升: +{$currentBonus}%){$this->reset}");
|
||||
$out->writeln("");
|
||||
$out->writeln("可选等级:");
|
||||
$out->writeln("");
|
||||
|
|
@ -988,7 +1080,8 @@ class StatsPanel
|
|||
for ($target = $enhanceLevel + 1; $target <= $maxLevel; $target++) {
|
||||
$totalCost = EquipmentEnhancer::getTotalCost($enhanceLevel, $target);
|
||||
$canAfford = $player->spiritStones >= $totalCost ? "{$this->green}✓{$this->reset}" : "{$this->red}✗{$this->reset}";
|
||||
$out->writeln(" [{$target}] +{$target} (需要: {$totalCost} 灵石) {$canAfford}");
|
||||
$targetBonus = $actor->getEnhanceBonus($target);
|
||||
$out->writeln(" [{$target}] +{$target} (属性提升: +{$targetBonus}%, 需要: {$totalCost} 灵石) {$canAfford}");
|
||||
}
|
||||
|
||||
$out->writeln("");
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* WebSocket 服务器测试脚本
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use WebSocket\Client;
|
||||
|
||||
echo "连接到 WebSocket 服务器: ws://localhost:9002\n";
|
||||
|
||||
try {
|
||||
$client = new Client("ws://localhost:9002");
|
||||
echo "[成功] 连接到服务器\n";
|
||||
|
||||
// 接收欢迎消息
|
||||
$message = $client->receive();
|
||||
echo "[收到] " . $message . "\n";
|
||||
|
||||
// 发送测试输入
|
||||
echo "[发送] 测试命令\n";
|
||||
$client->send(json_encode([
|
||||
'type' => 'input',
|
||||
'input' => '1'
|
||||
]));
|
||||
|
||||
// 接收响应
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$message = $client->receive();
|
||||
echo "[收到] " . substr($message, 0, 100) . "...\n";
|
||||
}
|
||||
|
||||
$client->close();
|
||||
echo "[完成] 测试成功\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "[错误] " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
677
web/game-ws.html
677
web/game-ws.html
|
|
@ -1,677 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>凡人修仙传 - WebSocket版</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #eee;
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 登录界面 */
|
||||
#auth-panel {
|
||||
background: #16213e;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#auth-panel h2 {
|
||||
color: #eee;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: #aaa;
|
||||
margin-bottom: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 5px;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #e94560;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #ff6b6b;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #0f3460;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #16213e;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: rgba(233, 69, 96, 0.2);
|
||||
color: #e94560;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: rgba(46, 213, 115, 0.2);
|
||||
color: #2ed573;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 游戏终端 */
|
||||
#game-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
background: #16213e;
|
||||
padding: 10px 15px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.terminal-header .user-info {
|
||||
color: #2ed573;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.terminal-header .status {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.terminal-header .logout-btn {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 15px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.terminal-header .logout-btn:hover {
|
||||
background: #ff6b6b;
|
||||
}
|
||||
|
||||
#terminal {
|
||||
background: #0d0d0d;
|
||||
border-radius: 0 0 10px 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
background: #16213e;
|
||||
padding: 10px;
|
||||
border-radius: 0 0 10px 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-area input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 5px;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.input-area input:focus {
|
||||
outline: none;
|
||||
border-color: #e94560;
|
||||
}
|
||||
|
||||
.input-area button {
|
||||
padding: 10px 20px;
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.quick-btn {
|
||||
padding: 8px 15px;
|
||||
background: #0f3460;
|
||||
color: #eee;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.quick-btn:hover {
|
||||
background: #16213e;
|
||||
}
|
||||
|
||||
.ws-status {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.ws-connected {
|
||||
background: #2ed573;
|
||||
}
|
||||
|
||||
.ws-disconnected {
|
||||
background: #e94560;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>凡人修仙传 - WebSocket版</h1>
|
||||
<p>Web Terminal Edition (Real-time)</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录/注册面板 -->
|
||||
<div id="auth-panel">
|
||||
<h2>欢迎</h2>
|
||||
<div id="auth-message" class="message"></div>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="username" placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="password" placeholder="请输入密码">
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="login()">登录</button>
|
||||
<button class="btn btn-secondary" onclick="register()">注册</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 游戏面板 -->
|
||||
<div id="game-panel">
|
||||
<div class="terminal-header">
|
||||
<span class="user-info">玩家: <span id="player-name">-</span></span>
|
||||
<span class="status">
|
||||
状态: <span id="game-status">连接中</span>
|
||||
<span class="ws-status ws-disconnected" id="ws-indicator"></span>
|
||||
</span>
|
||||
<button class="logout-btn" onclick="logout()">退出登录</button>
|
||||
</div>
|
||||
<div id="terminal"></div>
|
||||
<div class="input-area">
|
||||
<input type="text" id="game-input" placeholder="输入命令..." onkeypress="handleKeyPress(event)">
|
||||
<button onclick="sendInput()">发送</button>
|
||||
</div>
|
||||
<!-- <div class="quick-buttons">-->
|
||||
<!-- <button class="quick-btn" onclick="quickSend('1')">1.战斗</button>-->
|
||||
<!-- <button class="quick-btn" onclick="quickSend('2')">2.属性</button>-->
|
||||
<!-- <button class="quick-btn" onclick="quickSend('3')">3.背包</button>-->
|
||||
<!-- <button class="quick-btn" onclick="quickSend('4')">4.故人</button>-->
|
||||
<!-- <button class="quick-btn" onclick="quickSend('5')">5.同伴</button>-->
|
||||
<!-- <button class="quick-btn" onclick="quickSend('6')">6.天赋</button>-->
|
||||
<!-- <button class="quick-btn" onclick="quickSend('7')">7.地图</button>-->
|
||||
<!-- <button class="quick-btn" onclick="quickSend('8')">8.休息</button>-->
|
||||
<!-- <button class="quick-btn" onclick="quickSend('0')">0.返回</button>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||
<script>
|
||||
let terminal = null;
|
||||
let fitAddon = null;
|
||||
let ws = null;
|
||||
let isLoggedIn = false;
|
||||
let currentUserId = null;
|
||||
let currentUsername = null;
|
||||
|
||||
// 初始化终端
|
||||
function initTerminal() {
|
||||
if (terminal) return; // 已初始化
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 16,
|
||||
fontFamily: 'Consolas, Monaco, monospace',
|
||||
theme: {
|
||||
background: '#0d0d0d',
|
||||
foreground: '#eee',
|
||||
cursor: '#e94560',
|
||||
cursorAccent: '#0d0d0d',
|
||||
selection: 'rgba(233, 69, 96, 0.3)',
|
||||
black: '#1a1a2e',
|
||||
red: '#e94560',
|
||||
green: '#2ed573',
|
||||
yellow: '#ffa502',
|
||||
blue: '#70a1ff',
|
||||
magenta: '#ff6b81',
|
||||
cyan: '#1e90ff',
|
||||
white: '#eee',
|
||||
brightBlack: '#666',
|
||||
brightRed: '#ff6b6b',
|
||||
brightGreen: '#7bed9f',
|
||||
brightYellow: '#ffda79',
|
||||
brightBlue: '#a4b0be',
|
||||
brightMagenta: '#ff7f9f',
|
||||
brightCyan: '#34ace0',
|
||||
brightWhite: '#fff'
|
||||
},
|
||||
rows: 24,
|
||||
cols: 80
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
}
|
||||
|
||||
// WebSocket 连接
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = protocol + '//' + location.hostname + ':9001';
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[WebSocket] 已连接');
|
||||
updateWSStatus(true);
|
||||
terminal.writeln('\x1b[32m[✓] WebSocket 连接成功\x1b[0m');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleMessage(data);
|
||||
} catch (e) {
|
||||
console.error('消息解析错误:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[WebSocket] 错误:', error);
|
||||
updateWSStatus(false);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[WebSocket] 已断开');
|
||||
updateWSStatus(false);
|
||||
if (isLoggedIn) {
|
||||
terminal.writeln('\x1b[31m[✗] WebSocket 连接已断开,尝试重新连接...\x1b[0m');
|
||||
setTimeout(connectWebSocket, 3000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 处理服务器消息
|
||||
function handleMessage(data) {
|
||||
console.log('[收到] ', data.type, data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'welcome':
|
||||
terminal.writeln('\x1b[33m' + data.message + '\x1b[0m');
|
||||
break;
|
||||
|
||||
case 'login-success':
|
||||
isLoggedIn = true;
|
||||
currentUserId = data.userId;
|
||||
currentUsername = data.username;
|
||||
document.getElementById('player-name').textContent = data.username;
|
||||
document.getElementById('game-status').textContent = 'MENU';
|
||||
terminal.clear();
|
||||
displayOutput(data.output);
|
||||
document.getElementById('game-input').focus();
|
||||
break;
|
||||
|
||||
case 'game-output':
|
||||
console.log(data)
|
||||
terminal.clear();
|
||||
displayOutput(data.output);
|
||||
updateGameStatus(data.stateName);
|
||||
document.getElementById('game-input').focus();
|
||||
break;
|
||||
|
||||
case 'battle-start':
|
||||
terminal.clear();
|
||||
terminal.writeln('\x1b[33m' + data.message + '\x1b[0m');
|
||||
document.getElementById('game-status').textContent = 'BATTLE';
|
||||
break;
|
||||
|
||||
case 'battle-end':
|
||||
terminal.writeln('\x1b[32m[战斗结束]\x1b[0m');
|
||||
updateGameStatus(data.stateName);
|
||||
document.getElementById('game-input').focus();
|
||||
break;
|
||||
|
||||
case 'state-sync':
|
||||
displayOutput(data.output);
|
||||
updateGameStatus(data.stateName);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
terminal.writeln('\x1b[31m[错误] ' + data.message + '\x1b[0m');
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// 心跳响应
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('未知消息类型:', data.type);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息给服务器
|
||||
function sendMessage(data) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
terminal.writeln('\x1b[31m[错误] WebSocket 未连接\x1b[0m');
|
||||
return false;
|
||||
}
|
||||
ws.send(JSON.stringify(data));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 显示消息
|
||||
function showMessage(message, type = 'error') {
|
||||
const el = document.getElementById('auth-message');
|
||||
el.textContent = message;
|
||||
el.className = 'message ' + type;
|
||||
}
|
||||
|
||||
// 登录
|
||||
async function login() {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
showMessage('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
// 这里仍然使用HTTP登录进行认证
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showMessage('登录成功!连接游戏服务器...', 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
// 获取userId用于WebSocket登录
|
||||
fetch('/api/status', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success && data.loggedIn) {
|
||||
enterGame(username, data.userId);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
} else {
|
||||
showMessage(result.message || '登录失败');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('网络错误: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 注册
|
||||
async function register() {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
showMessage('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showMessage('注册成功!自动登录中...', 'success');
|
||||
setTimeout(() => {
|
||||
fetch('/api/status', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success && data.loggedIn) {
|
||||
enterGame(username, data.userId);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
} else {
|
||||
showMessage(result.message || '注册失败');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('网络错误: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 进入游戏
|
||||
function enterGame(username, userId) {
|
||||
isLoggedIn = true;
|
||||
document.getElementById('auth-panel').style.display = 'none';
|
||||
document.getElementById('game-panel').style.display = 'block';
|
||||
document.getElementById('player-name').textContent = username;
|
||||
|
||||
if (!terminal) {
|
||||
initTerminal();
|
||||
}
|
||||
|
||||
terminal.clear();
|
||||
terminal.writeln('\x1b[33m正在连接游戏服务器...\x1b[0m');
|
||||
|
||||
// 连接WebSocket
|
||||
connectWebSocket();
|
||||
|
||||
// WebSocket连接后登录
|
||||
setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
sendMessage({
|
||||
type: 'login',
|
||||
userId: userId,
|
||||
username: username
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
async function logout() {
|
||||
isLoggedIn = false;
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
await fetch('/api/logout', { credentials: 'include' });
|
||||
document.getElementById('auth-panel').style.display = 'block';
|
||||
document.getElementById('game-panel').style.display = 'none';
|
||||
document.getElementById('password').value = '';
|
||||
showMessage('', '');
|
||||
}
|
||||
|
||||
// 发送输入
|
||||
function sendInput() {
|
||||
const inputEl = document.getElementById('game-input');
|
||||
const input = inputEl.value.trim();
|
||||
inputEl.value = '';
|
||||
|
||||
if (!input) return;
|
||||
|
||||
terminal.writeln('\x1b[36m> ' + input + '\x1b[0m');
|
||||
terminal.writeln('');
|
||||
|
||||
if (sendMessage({
|
||||
type: 'game-input',
|
||||
input: input
|
||||
})) {
|
||||
console.log('[发送] 游戏输入:', input);
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷按钮发送
|
||||
function quickSend(value) {
|
||||
document.getElementById('game-input').value = value;
|
||||
sendInput();
|
||||
}
|
||||
|
||||
// 按键处理
|
||||
function handleKeyPress(event) {
|
||||
if (event.key === 'Enter') {
|
||||
sendInput();
|
||||
}
|
||||
}
|
||||
|
||||
// 显示输出
|
||||
function displayOutput(output) {
|
||||
if (!output) return;
|
||||
const lines = output.split('\n');
|
||||
lines.forEach(line => {
|
||||
if (line.trim()) {
|
||||
terminal.writeln(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 更新游戏状态显示
|
||||
function updateGameStatus(stateName) {
|
||||
document.getElementById('game-status').textContent = stateName || '未知';
|
||||
}
|
||||
|
||||
// 更新WebSocket状态指示器
|
||||
function updateWSStatus(connected) {
|
||||
const indicator = document.getElementById('ws-indicator');
|
||||
if (connected) {
|
||||
indicator.className = 'ws-status ws-connected';
|
||||
} else {
|
||||
indicator.className = 'ws-status ws-disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
// 心跳保活
|
||||
setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
sendMessage({ type: 'ping' });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// 页面加载完成
|
||||
window.onload = async () => {
|
||||
const result = await fetch('/api/status', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.catch(() => ({ success: false }));
|
||||
|
||||
if (result.success && result.loggedIn) {
|
||||
enterGame(result.username, result.userId);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
603
web/index.html
603
web/index.html
|
|
@ -1,603 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>凡人修仙传 - 文字版</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #eee;
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 登录界面 */
|
||||
#auth-panel {
|
||||
background: #16213e;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#auth-panel h2 {
|
||||
color: #eee;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: #aaa;
|
||||
margin-bottom: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 5px;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #e94560;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #ff6b6b;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #0f3460;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #16213e;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: rgba(233, 69, 96, 0.2);
|
||||
color: #e94560;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: rgba(46, 213, 115, 0.2);
|
||||
color: #2ed573;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 游戏终端 */
|
||||
#game-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
background: #16213e;
|
||||
padding: 10px 15px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.terminal-header .user-info {
|
||||
color: #2ed573;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.terminal-header .logout-btn {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 15px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.terminal-header .logout-btn:hover {
|
||||
background: #ff6b6b;
|
||||
}
|
||||
|
||||
#terminal {
|
||||
background: #0d0d0d;
|
||||
border-radius: 0 0 10px 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
background: #16213e;
|
||||
padding: 10px;
|
||||
border-radius: 0 0 10px 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-area input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 5px;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.input-area input:focus {
|
||||
outline: none;
|
||||
border-color: #e94560;
|
||||
}
|
||||
|
||||
.input-area button {
|
||||
padding: 10px 20px;
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.quick-btn {
|
||||
padding: 8px 15px;
|
||||
background: #0f3460;
|
||||
color: #eee;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.quick-btn:hover {
|
||||
background: #16213e;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>凡人修仙传 - 文字版</h1>
|
||||
<p>Web Terminal Edition</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录/注册面板 -->
|
||||
<div id="auth-panel">
|
||||
<h2>欢迎</h2>
|
||||
<div id="auth-message" class="message"></div>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="username" placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="password" placeholder="请输入密码">
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="login()">登录</button>
|
||||
<button class="btn btn-secondary" onclick="register()">注册</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 游戏面板 -->
|
||||
<div id="game-panel">
|
||||
<div class="terminal-header">
|
||||
<span class="user-info">玩家: <span id="player-name">-</span></span>
|
||||
<button class="logout-btn" onclick="logout()">退出登录</button>
|
||||
</div>
|
||||
<div id="terminal"></div>
|
||||
<div class="input-area">
|
||||
<input type="text" id="game-input" placeholder="输入命令..." onkeypress="handleKeyPress(event)">
|
||||
<button onclick="sendInput()">发送</button>
|
||||
</div>
|
||||
<div class="quick-buttons">
|
||||
<button class="quick-btn" onclick="quickSend('1')">1.战斗</button>
|
||||
<button class="quick-btn" onclick="quickSend('2')">2.属性</button>
|
||||
<button class="quick-btn" onclick="quickSend('3')">3.背包</button>
|
||||
<button class="quick-btn" onclick="quickSend('4')">4.故人</button>
|
||||
<button class="quick-btn" onclick="quickSend('5')">5.同伴</button>
|
||||
<button class="quick-btn" onclick="quickSend('6')">6.天赋</button>
|
||||
<button class="quick-btn" onclick="quickSend('7')">7.地图</button>
|
||||
<button class="quick-btn" onclick="quickSend('8')">8.休息</button>
|
||||
<button class="quick-btn" onclick="quickSend('0')">0.返回</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||
<script>
|
||||
let terminal = null;
|
||||
let fitAddon = null;
|
||||
let isLoggedIn = false;
|
||||
|
||||
// 初始化终端
|
||||
function initTerminal() {
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 16,
|
||||
fontFamily: 'Consolas, Monaco, monospace',
|
||||
theme: {
|
||||
background: '#0d0d0d',
|
||||
foreground: '#eee',
|
||||
cursor: '#e94560',
|
||||
cursorAccent: '#0d0d0d',
|
||||
selection: 'rgba(233, 69, 96, 0.3)',
|
||||
black: '#1a1a2e',
|
||||
red: '#e94560',
|
||||
green: '#2ed573',
|
||||
yellow: '#ffa502',
|
||||
blue: '#70a1ff',
|
||||
magenta: '#ff6b81',
|
||||
cyan: '#1e90ff',
|
||||
white: '#eee',
|
||||
brightBlack: '#666',
|
||||
brightRed: '#ff6b6b',
|
||||
brightGreen: '#7bed9f',
|
||||
brightYellow: '#ffda79',
|
||||
brightBlue: '#a4b0be',
|
||||
brightMagenta: '#ff7f9f',
|
||||
brightCyan: '#34ace0',
|
||||
brightWhite: '#fff'
|
||||
},
|
||||
rows: 24,
|
||||
cols: 80
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
}
|
||||
|
||||
// API 请求
|
||||
async function api(endpoint, data = {}) {
|
||||
try {
|
||||
const response = await fetch('/api/' + endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include'
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return { success: false, message: '网络错误' };
|
||||
}
|
||||
}
|
||||
|
||||
// 显示消息
|
||||
function showMessage(message, type = 'error') {
|
||||
const el = document.getElementById('auth-message');
|
||||
el.textContent = message;
|
||||
el.className = 'message ' + type;
|
||||
}
|
||||
|
||||
// 登录
|
||||
async function login() {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
showMessage('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api('login', { username, password });
|
||||
|
||||
if (result.success) {
|
||||
showMessage('登录成功!', 'success');
|
||||
setTimeout(() => {
|
||||
enterGame(username);
|
||||
}, 500);
|
||||
} else {
|
||||
showMessage(result.message || '登录失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 注册
|
||||
async function register() {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
showMessage('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api('register', { username, password });
|
||||
|
||||
if (result.success) {
|
||||
showMessage('注册成功!自动登录中...', 'success');
|
||||
setTimeout(() => {
|
||||
enterGame(username);
|
||||
}, 500);
|
||||
} else {
|
||||
showMessage(result.message || '注册失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
async function logout() {
|
||||
await api('logout');
|
||||
isLoggedIn = false;
|
||||
document.getElementById('auth-panel').style.display = 'block';
|
||||
document.getElementById('game-panel').style.display = 'none';
|
||||
document.getElementById('password').value = '';
|
||||
showMessage('', '');
|
||||
}
|
||||
|
||||
// 进入游戏
|
||||
function enterGame(username) {
|
||||
isLoggedIn = true;
|
||||
document.getElementById('auth-panel').style.display = 'none';
|
||||
document.getElementById('game-panel').style.display = 'block';
|
||||
document.getElementById('player-name').textContent = username;
|
||||
|
||||
if (!terminal) {
|
||||
initTerminal();
|
||||
}
|
||||
|
||||
terminal.clear();
|
||||
terminal.writeln('\x1b[33m正在加载游戏...\x1b[0m');
|
||||
|
||||
// 渲染游戏界面
|
||||
renderGame();
|
||||
|
||||
// 聚焦输入框
|
||||
document.getElementById('game-input').focus();
|
||||
}
|
||||
|
||||
// 渲染游戏
|
||||
async function renderGame() {
|
||||
const result = await api('game/render');
|
||||
|
||||
if (result.success) {
|
||||
terminal.clear();
|
||||
const lines = result.output.split('\n');
|
||||
lines.forEach(line => {
|
||||
terminal.writeln(line);
|
||||
});
|
||||
} else {
|
||||
terminal.writeln('\x1b[31m' + (result.message || '加载失败') + '\x1b[0m');
|
||||
if (result.message === '请先登录') {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送输入
|
||||
async function sendInput() {
|
||||
const inputEl = document.getElementById('game-input');
|
||||
const input = inputEl.value.trim();
|
||||
inputEl.value = '';
|
||||
|
||||
if (!input) return;
|
||||
|
||||
terminal.writeln('\x1b[36m> ' + input + '\x1b[0m');
|
||||
terminal.writeln('');
|
||||
|
||||
// 检查是否进入战斗(输入为 "1")
|
||||
if (input === '1') {
|
||||
// 使用 SSE 流式战斗
|
||||
streamBattle(input);
|
||||
return;
|
||||
}
|
||||
|
||||
// 普通请求处理
|
||||
const result = await api('game/input', { input });
|
||||
|
||||
if (result.success) {
|
||||
console.log('API响应:', result);
|
||||
|
||||
// 显示调试信息(可选)
|
||||
if (result.stateName) {
|
||||
console.log('当前状态:', result.stateName);
|
||||
}
|
||||
|
||||
// 检查是否是时间戳战斗日志
|
||||
if (result.type === 'battle_log' && result.logs) {
|
||||
terminal.clear();
|
||||
playBattleLog(result.logs);
|
||||
} else if (result.output) {
|
||||
// 普通文本输出
|
||||
terminal.clear();
|
||||
const lines = result.output.split('\n');
|
||||
lines.forEach(line => {
|
||||
terminal.writeln(line);
|
||||
});
|
||||
|
||||
// 输入后立即刷新当前界面(确保状态同步)
|
||||
// setTimeout(() => {
|
||||
// renderGame();
|
||||
// }, 100);
|
||||
}
|
||||
} else {
|
||||
terminal.writeln('\x1b[31m' + (result.message || '操作失败') + '\x1b[0m');
|
||||
if (result.message === '请先登录') {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
// SSE 流式战斗
|
||||
async function streamBattle(input) {
|
||||
const eventSource = new EventSource('/api/game/battle-stream?input=' + encodeURIComponent(input), {
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
const inputEl = document.getElementById('game-input');
|
||||
let battleEnded = false;
|
||||
|
||||
eventSource.addEventListener('start', (event) => {
|
||||
// 清屏,准备显示战斗内容
|
||||
terminal.clear();
|
||||
});
|
||||
|
||||
eventSource.addEventListener('message', (event) => {
|
||||
const log = JSON.parse(event.data);
|
||||
|
||||
// 根据类型显示日志
|
||||
if (log.type === 'writeln') {
|
||||
terminal.writeln(log.text);
|
||||
} else if (log.type === 'write') {
|
||||
terminal.write(log.text);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('complete', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('战斗完成:', data);
|
||||
battleEnded = true;
|
||||
eventSource.close();
|
||||
|
||||
setTimeout(() => {
|
||||
inputEl.focus();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (event) => {
|
||||
console.error('SSE 错误:', event);
|
||||
battleEnded = true;
|
||||
eventSource.close();
|
||||
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
terminal.writeln('\x1b[31m连接已关闭\x1b[0m');
|
||||
inputEl.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 流式播放战斗日志
|
||||
async function playBattleLog(logs) {
|
||||
let lastTimestamp = 0;
|
||||
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
const log = logs[i];
|
||||
const delay = log.timestamp - lastTimestamp;
|
||||
|
||||
// 等待适当的延迟
|
||||
if (delay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
// 写入日志行
|
||||
if (log.type === 'writeln') {
|
||||
terminal.writeln(log.text);
|
||||
} else if (log.type === 'write') {
|
||||
terminal.write(log.text);
|
||||
}
|
||||
|
||||
lastTimestamp = log.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷按钮发送
|
||||
function quickSend(value) {
|
||||
document.getElementById('game-input').value = value;
|
||||
sendInput();
|
||||
}
|
||||
|
||||
// 按键处理
|
||||
function handleKeyPress(event) {
|
||||
if (event.key === 'Enter') {
|
||||
sendInput();
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时检查登录状态
|
||||
window.onload = async function() {
|
||||
const result = await api('status');
|
||||
if (result.success && result.loggedIn) {
|
||||
enterGame(result.username);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -57,7 +57,7 @@ echo <<<'ASCII'
|
|||
💡 特点:
|
||||
✓ 无需修改游戏代码
|
||||
✓ 每个用户独立的游戏进程
|
||||
✓ 实时输出(100ms轮询)
|
||||
✓ 实时输出
|
||||
✓ 完整的ANSI颜色支持
|
||||
✓ 实时交互
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* WebSocket 游戏服务器启动脚本
|
||||
* 使用方法: php websocket-server.php
|
||||
*/
|
||||
|
||||
// 自动加载
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Ratchet\Server\IoServer;
|
||||
use Ratchet\Http\HttpServer;
|
||||
use Ratchet\WebSocket\WsServer;
|
||||
use Game\Core\GameWebSocketServer;
|
||||
|
||||
// 创建WebSocket服务器
|
||||
$ws = new WsServer(new GameWebSocketServer());
|
||||
|
||||
// 用HTTP服务器包装
|
||||
$http = new HttpServer($ws);
|
||||
|
||||
// 创建IO服务器,监听9001端口
|
||||
$server = IoServer::factory(
|
||||
$http,
|
||||
9001,
|
||||
'0.0.0.0'
|
||||
);
|
||||
|
||||
echo <<<'ASCII'
|
||||
╔══════════════════════════════════════╗
|
||||
║ 凡人修仙传 - WebSocket 游戏服务器 ║
|
||||
╚══════════════════════════════════════╝
|
||||
|
||||
⚡ WebSocket 服务器启动
|
||||
📍 地址: 0.0.0.0:9001
|
||||
🔗 客户端连接: ws://localhost:9001
|
||||
|
||||
按 Ctrl+C 停止服务器...
|
||||
|
||||
ASCII;
|
||||
|
||||
$server->run();
|
||||
Loading…
Reference in New Issue
Block a user