hanli/web/index.html
hant cb4b955bca Implement Server-Sent Events (SSE) for real-time battle streaming
Redesign web battle system from buffered to streaming architecture:

Backend Changes:
- New SSEOutput class for real-time event streaming to clients
- GameSession::streamBattle() for SSE-based battle execution
- Enhanced Screen::delay() to support SSE timing and buffering modes
- New /api/game/battle-stream endpoint handling SSE connections

Frontend Changes:
- Enhanced sendInput() to detect battle command (input "1")
- New streamBattle() function using EventSource for SSE connections
- Real-time log display matching terminal experience
- Event handlers for start, message, complete, error events

Benefits:
✓ Real-time streaming instead of waiting for complete battle
✓ Web frontend experience identical to terminal
✓ Lightweight implementation without WebSocket
✓ Automatic browser reconnection support
✓ ANSI colors fully preserved
✓ Backward compatible for non-battle screens

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 10:50:30 +08:00

592 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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) {
// 检查是否是时间戳战斗日志
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);
});
}
} 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>