Files
ai-town/web/index.html
empty f2978c9b66 feat(web): 适配行动点系统
- 居民卡片显示行动点(AP 圆点指示器)
- 添加行动反馈 Toast 提示(成功/失败)
- 3 秒后自动消失的反馈动画

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 13:50:35 +08:00

498 lines
22 KiB
HTML
Raw Permalink 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>AI Town</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e4e4e7;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
header { text-align: center; margin-bottom: 20px; }
header h1 {
font-size: 28px;
background: linear-gradient(90deg, #60a5fa, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.status-bar { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 8px; font-size: 13px; color: #71717a; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }
.status-dot.connected { background: #22c55e; }
/* 输入区域 */
.input-section { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
.input-row { display: flex; gap: 8px; margin-bottom: 10px; }
.input-row input { flex: 1; padding: 10px 14px; border: 1px solid #3f3f46; border-radius: 8px; background: #27272a; color: #e4e4e7; font-size: 14px; outline: none; }
.input-row input:focus { border-color: #60a5fa; }
button { padding: 10px 18px; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
.quick-btns { display: flex; gap: 8px; }
.btn-quick { background: #3f3f46; color: #a1a1aa; }
.btn-quick:hover { background: #52525b; color: #e4e4e7; }
/* 投票按钮 */
.vote-section { display: flex; gap: 8px; margin-top: 10px; align-items: center; }
.vote-label { font-size: 12px; color: #71717a; }
.btn-vote { padding: 8px 16px; font-weight: 600; }
.btn-vote-opt { background: #166534; color: #86efac; }
.btn-vote-opt:hover { background: #15803d; }
.btn-vote-fear { background: #7f1d1d; color: #fca5a5; }
.btn-vote-fear:hover { background: #991b1b; }
/* 投票统计 */
.votes-display { display: flex; gap: 16px; margin-top: 8px; font-size: 13px; }
.vote-count { display: flex; align-items: center; gap: 4px; }
.vote-count.opt { color: #86efac; }
.vote-count.fear { color: #fca5a5; }
/* 能量条 - 极简 */
#global-meter { width: 100%; height: 16px; background: #27272a; border-radius: 8px; margin-bottom: 16px; overflow: hidden; }
#meter-bar { height: 100%; width: 0%; background: linear-gradient(90deg, #00ffcc, #ff0066); transition: width 0.3s ease; }
/* 世界事件提示 */
#world-event { background: #422006; border: 1px solid #f59e0b; border-radius: 8px; padding: 12px; margin-bottom: 16px; display: none; font-size: 14px; }
/* 持续影响 */
#world-effects { margin-bottom: 16px; }
.effect-item { display: inline-block; background: #1e3a5f; border-radius: 6px; padding: 6px 10px; margin-right: 8px; margin-bottom: 8px; font-size: 12px; }
.effect-item .remaining { color: #f59e0b; margin-left: 4px; }
/* 主内容区 */
.main-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 700px) { .main-grid { grid-template-columns: 1fr; } }
.card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 16px; }
.card-title { font-size: 12px; font-weight: 600; color: #71717a; text-transform: uppercase; margin-bottom: 12px; }
/* 世界状态 */
.world-stats { display: flex; gap: 20px; }
.stat-value { font-size: 24px; font-weight: 600; color: #60a5fa; }
.stat-label { font-size: 12px; color: #71717a; }
/* 角色 */
.agent { background: #27272a; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.agent:last-child { margin-bottom: 0; }
.agent-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.agent-name { font-weight: 600; }
.agent-emotion { font-size: 12px; padding: 2px 8px; border-radius: 10px; background: #3f3f46; }
.emotion-happy { background: #166534; color: #86efac; }
.emotion-anxious { background: #7f1d1d; color: #fca5a5; }
.emotion-calm { background: #1e3a5f; color: #93c5fd; }
.agent-info { font-size: 13px; color: #a1a1aa; }
/* 行动点 */
.agent-ap { display: flex; align-items: center; gap: 4px; margin-top: 6px; }
.ap-dots { display: flex; gap: 3px; }
.ap-dot { width: 8px; height: 8px; border-radius: 50%; background: #3f3f46; }
.ap-dot.filled { background: #60a5fa; }
.ap-label { font-size: 11px; color: #71717a; }
/* 行动反馈提示 */
#action-feedback { position: fixed; bottom: 20px; right: 20px; z-index: 100; }
.feedback-toast { padding: 10px 16px; border-radius: 8px; margin-top: 8px; font-size: 13px; animation: fadeIn 0.3s ease; }
.feedback-toast.success { background: #166534; color: #86efac; border: 1px solid #22c55e; }
.feedback-toast.fail { background: #7f1d1d; color: #fca5a5; border: 1px solid #ef4444; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
/* 行动日志 */
.actions-list { max-height: 300px; overflow-y: auto; }
.action-item { padding: 8px; background: #27272a; border-radius: 6px; margin-bottom: 6px; font-size: 13px; }
.action-agent { font-weight: 600; color: #a78bfa; }
.action-say { color: #fbbf24; }
.action-do { color: #34d399; }
/* 派系分布 */
.factions-bar { display: flex; height: 24px; border-radius: 6px; overflow: hidden; margin-top: 12px; }
.faction-segment { display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; transition: width 0.3s ease; }
.faction-optimists { background: #22c55e; color: #052e16; }
.faction-neutral { background: #71717a; color: #fafafa; }
.faction-fearful { background: #ef4444; color: #450a0a; }
.factions-legend { display: flex; gap: 12px; margin-top: 8px; font-size: 11px; color: #a1a1aa; }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 4px; }
/* 阵营能量条 */
.faction-power { display: flex; gap: 12px; margin-top: 12px; }
.power-item { flex: 1; }
.power-label { font-size: 11px; color: #a1a1aa; margin-bottom: 4px; display: flex; justify-content: space-between; }
.power-bar { height: 8px; background: #27272a; border-radius: 4px; overflow: hidden; }
.power-fill { height: 100%; transition: width 0.3s ease; }
.power-fill.optimist { background: linear-gradient(90deg, #22c55e, #86efac); }
.power-fill.fearful { background: linear-gradient(90deg, #ef4444, #fca5a5); }
/* 阵营技能提示 */
#faction-event { background: #1e3a5f; border: 1px solid #60a5fa; border-radius: 8px; padding: 12px; margin-bottom: 16px; display: none; font-size: 14px; }
/* 剧情线 */
.story-arcs { margin-top: 16px; padding-top: 12px; border-top: 1px solid #3f3f46; }
.arc-item { margin-bottom: 10px; }
.arc-header { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
.arc-name { color: #a1a1aa; }
.arc-stage { color: #f59e0b; }
.arc-bar { height: 6px; background: #27272a; border-radius: 3px; overflow: hidden; }
.arc-fill { height: 100%; background: linear-gradient(90deg, #a78bfa, #f472b6); transition: width 0.3s ease; }
/* 剧情事件提示 */
#story-event { background: #3b0764; border: 1px solid #a78bfa; border-radius: 8px; padding: 12px; margin-bottom: 16px; display: none; font-size: 14px; }
/* 技能树 */
.skill-trees { margin-top: 16px; padding-top: 12px; border-top: 1px solid #3f3f46; }
.skill-trees-title { font-size: 11px; color: #71717a; text-transform: uppercase; margin-bottom: 8px; letter-spacing: 0.05em; }
.skill-trees-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.skill-tree { background: #27272a; border-radius: 6px; padding: 10px; }
.skill-tree-header { font-size: 11px; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 4px; }
.skill-tree-header.opt { color: #86efac; }
.skill-tree-header.fear { color: #fca5a5; }
.skill-item { display: flex; align-items: center; gap: 6px; padding: 6px 8px; margin-bottom: 4px; border-radius: 4px; font-size: 11px; background: #1a1a2e; }
.skill-item:last-child { margin-bottom: 0; }
.skill-item.unlocked { border-left: 2px solid #22c55e; }
.skill-item.locked { border-left: 2px solid #3f3f46; opacity: 0.7; }
.skill-icon { font-size: 12px; }
.skill-name { flex: 1; color: #e4e4e7; }
.skill-cost { font-size: 10px; color: #71717a; background: #3f3f46; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>AI Town</h1>
<div class="status-bar">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">未连接</span>
</div>
</header>
<!-- 输入区域 -->
<section class="input-section">
<div class="input-row">
<input type="text" id="msgInput" placeholder="输入消息..." />
<button class="btn-primary" onclick="sendMessage()">发送</button>
</div>
<div class="quick-btns">
<button class="btn-quick" onclick="sendQuick('支持')">支持</button>
<button class="btn-quick" onclick="sendQuick('混乱')">混乱</button>
<button class="btn-quick" onclick="sendQuick('下雨')">下雨</button>
</div>
<div class="vote-section">
<span class="vote-label">投票:</span>
<button class="btn-vote btn-vote-opt" onclick="sendVote('optimists')">👍 乐观派</button>
<button class="btn-vote btn-vote-fear" onclick="sendVote('fearful')">👎 恐惧派</button>
<div class="votes-display">
<span class="vote-count opt" id="vote-opt">乐观: 0</span>
<span class="vote-count fear" id="vote-fear">恐惧: 0</span>
</div>
</div>
</section>
<!-- ① 能量条 -->
<div id="global-meter"><div id="meter-bar"></div></div>
<!-- ② 世界事件提示 -->
<div id="world-event"></div>
<!-- ② 阵营技能提示 -->
<div id="faction-event"></div>
<!-- ② 剧情事件提示 -->
<div id="story-event"></div>
<!-- ③ 持续影响 -->
<div id="world-effects"></div>
<!-- 主内容区 -->
<div class="main-grid">
<!-- 行动反馈提示 -->
<div id="action-feedback"></div>
<div>
<div class="card" style="margin-bottom: 16px;">
<div class="card-title">世界状态</div>
<div class="world-stats">
<div><div class="stat-value" id="tick">0</div><div class="stat-label">Tick</div></div>
<div><div class="stat-value" id="weather">-</div><div class="stat-label">天气</div></div>
<div><div class="stat-value" id="mood">0</div><div class="stat-label">情绪值</div></div>
</div>
<div class="factions-bar" id="factions-bar"></div>
<div class="factions-legend">
<span><span class="legend-dot" style="background:#22c55e"></span>乐观派</span>
<span><span class="legend-dot" style="background:#ef4444"></span>恐惧派</span>
</div>
<!-- 阵营能量条 -->
<div class="faction-power" id="faction-power"></div>
<!-- 剧情线 -->
<div class="story-arcs" id="story-arcs"></div>
<!-- 技能树 -->
<div class="skill-trees" id="skill-trees"></div>
</div>
<div class="card">
<div class="card-title">居民</div>
<div id="agents"></div>
</div>
</div>
<div class="card">
<div class="card-title">行动日志</div>
<div class="actions-list" id="actions"></div>
</div>
</div>
</div>
<script>
// WebSocket
let ws = null;
let reconnectDelay = 1000;
function connect() {
ws = new WebSocket('ws://localhost:3000');
ws.onopen = () => {
document.getElementById('statusDot').classList.add('connected');
document.getElementById('statusText').textContent = '已连接';
reconnectDelay = 1000;
};
ws.onclose = () => {
document.getElementById('statusDot').classList.remove('connected');
document.getElementById('statusText').textContent = '重连中...';
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
};
ws.onerror = () => ws.close();
ws.onmessage = (e) => updateUI(JSON.parse(e.data));
}
// 发送消息
function sendMessage() {
const input = document.getElementById('msgInput');
const text = input.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'comment', text, user: 'viewer' }));
input.value = '';
}
function sendQuick(text) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'comment', text, user: 'viewer' }));
}
// 发送投票
function sendVote(faction) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'vote', faction, text: '', user: 'viewer' }));
}
// 更新 UI
const weatherMap = { sunny: '☀️', rainy: '🌧️' };
const actionHistory = [];
// 显示行动反馈提示
function showActionFeedbacks(feedbacks) {
const container = document.getElementById('action-feedback');
feedbacks.forEach(fb => {
if (!fb.user) return;
const cls = fb.success ? 'success' : 'fail';
const icon = fb.success ? '✓' : '✗';
const toast = document.createElement('div');
toast.className = `feedback-toast ${cls}`;
toast.textContent = `${icon} ${fb.user}: ${fb.reason} (AP: ${fb.remaining_ap})`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
});
}
// 渲染技能列表
function renderSkills(skills, names) {
return Object.entries(skills).map(([id, s]) => {
const cls = s.unlocked ? 'unlocked' : 'locked';
const icon = s.unlocked ? '✅' : '🔒';
return `<div class="skill-item ${cls}">
<span class="skill-icon">${icon}</span>
<span class="skill-name">${names[id] || id}</span>
<span class="skill-cost">${s.cost}</span>
</div>`;
}).join('');
}
function updateUI(data) {
const { world_state, actions, global_event, triggered_faction_event, story_event, action_feedbacks } = data;
// 世界状态
document.getElementById('tick').textContent = world_state.tick;
document.getElementById('weather').textContent = weatherMap[world_state.weather] || world_state.weather;
document.getElementById('mood').textContent = world_state.town_mood;
// 行动反馈提示
if (action_feedbacks && action_feedbacks.length > 0) {
showActionFeedbacks(action_feedbacks);
}
// ① 能量条
if (world_state.global_meter) {
const meter = world_state.global_meter;
const percent = Math.min(100, meter.value / meter.threshold * 100);
document.getElementById('meter-bar').style.width = percent + '%';
}
// ① 投票统计
if (world_state.votes) {
document.getElementById('vote-opt').textContent = '乐观: ' + world_state.votes.optimists;
document.getElementById('vote-fear').textContent = '恐惧: ' + world_state.votes.fearful;
}
// ② 世界事件
const eventEl = document.getElementById('world-event');
if (global_event?.triggered && global_event.event) {
eventEl.textContent = '🌍 世界事件:' + global_event.event.description;
eventEl.style.display = 'block';
} else {
eventEl.style.display = 'none';
}
// ② 阵营技能提示
const factionEventEl = document.getElementById('faction-event');
if (triggered_faction_event?.type) {
const msg = triggered_faction_event.type === 'festival'
? '🎉 乐观派发动了节日庆典!'
: '😱 恐惧派引发了恐慌!';
factionEventEl.textContent = msg;
factionEventEl.style.display = 'block';
} else {
factionEventEl.style.display = 'none';
}
// ② 剧情事件提示
const storyEventEl = document.getElementById('story-event');
if (story_event?.triggered) {
storyEventEl.textContent = '📖 ' + story_event.event_name + '' + story_event.description;
storyEventEl.style.display = 'block';
} else {
storyEventEl.style.display = 'none';
}
// ③ 持续影响
const effectsEl = document.getElementById('world-effects');
if (world_state.world_effects?.length > 0) {
effectsEl.innerHTML = world_state.world_effects.map(e =>
`<span class="effect-item">${e.name}<span class="remaining">(${e.remaining_ticks})</span></span>`
).join('');
} else {
effectsEl.innerHTML = '';
}
// ④ 派系分布
if (world_state.factions) {
const f = world_state.factions;
const optimistCount = f.optimists.members.length;
const fearfulCount = f.fearful.members.length;
const total = optimistCount + fearfulCount || 1;
const bar = document.getElementById('factions-bar');
bar.innerHTML = '';
if (optimistCount > 0) bar.innerHTML += `<div class="faction-segment faction-optimists" style="width:${optimistCount/total*100}%">${optimistCount}</div>`;
if (fearfulCount > 0) bar.innerHTML += `<div class="faction-segment faction-fearful" style="width:${fearfulCount/total*100}%">${fearfulCount}</div>`;
// ⑤ 阵营能量条
const powerEl = document.getElementById('faction-power');
const optPct = Math.min(100, f.optimists.power / f.optimists.threshold * 100);
const fearPct = Math.min(100, f.fearful.power / f.fearful.threshold * 100);
powerEl.innerHTML = `
<div class="power-item">
<div class="power-label"><span>乐观派能量</span><span>${f.optimists.power}/${f.optimists.threshold}</span></div>
<div class="power-bar"><div class="power-fill optimist" style="width:${optPct}%"></div></div>
</div>
<div class="power-item">
<div class="power-label"><span>恐惧派能量</span><span>${f.fearful.power}/${f.fearful.threshold}</span></div>
<div class="power-bar"><div class="power-fill fearful" style="width:${fearPct}%"></div></div>
</div>`;
}
// ⑥ 剧情线进度
if (world_state.story_arcs) {
const arcsEl = document.getElementById('story-arcs');
const arcNames = { civil_unrest: '民众骚乱', golden_age: '黄金时代' };
arcsEl.innerHTML = Object.entries(world_state.story_arcs).map(([id, arc]) => {
const pct = Math.min(100, arc.progress / arc.threshold * 100);
return `<div class="arc-item">
<div class="arc-header">
<span class="arc-name">${arcNames[id] || id}</span>
<span class="arc-stage">阶段 ${arc.stage}</span>
</div>
<div class="arc-bar"><div class="arc-fill" style="width:${pct}%"></div></div>
</div>`;
}).join('');
}
// ⑦ 技能树
if (world_state.faction_skills) {
const skillsEl = document.getElementById('skill-trees');
const fs = world_state.faction_skills;
const skillNames = {
festival_boost: '节日增幅', unity: '团结一心',
panic_spread: '恐慌蔓延', control: '压制控制'
};
skillsEl.innerHTML = `
<div class="skill-trees-title">技能树</div>
<div class="skill-trees-grid">
<div class="skill-tree">
<div class="skill-tree-header opt">🌟 乐观派</div>
${renderSkills(fs.optimists.skills, skillNames)}
</div>
<div class="skill-tree">
<div class="skill-tree-header fear">💀 恐惧派</div>
${renderSkills(fs.fearful.skills, skillNames)}
</div>
</div>`;
}
// 角色
const agentsEl = document.getElementById('agents');
agentsEl.innerHTML = '';
for (const [id, agent] of Object.entries(world_state.agents)) {
const ap = agent.action_points || 0;
const maxAp = agent.max_action_points || 3;
const apDots = Array(maxAp).fill(0).map((_, i) =>
`<span class="ap-dot ${i < ap ? 'filled' : ''}"></span>`
).join('');
agentsEl.innerHTML += `
<div class="agent">
<div class="agent-header">
<span class="agent-name">${id}</span>
<span class="agent-emotion emotion-${agent.emotion}">${agent.emotion}</span>
</div>
<div class="agent-ap">
<span class="ap-label">AP:</span>
<div class="ap-dots">${apDots}</div>
<span class="ap-label">${ap}/${maxAp}</span>
</div>
<div class="agent-info">记忆: ${agent.memory.slice(-2).join(' | ') || '-'}</div>
</div>`;
}
// 行动日志(倒序:新的在上)
const now = new Date().toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).replace(/\//g, '-');
for (const a of actions) {
a.time = now;
actionHistory.unshift(a);
}
if (actionHistory.length > 50) actionHistory.length = 50;
document.getElementById('actions').innerHTML = actionHistory.map(a =>
`<div class="action-item"><span style="color:#71717a;font-size:11px;">${a.time}</span> <span class="action-agent">${a.agent_id}</span> <span class="action-say">「${a.say}」</span> <span class="action-do">→ ${a.do}</span></div>`
).join('');
}
// 回车发送
document.getElementById('msgInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendMessage();
});
// 启动
connect();
</script>
</body>
</html>