Compare commits
2 Commits
6c66764cce
...
3e89a17b69
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e89a17b69 | ||
|
|
a261eaa8ab |
176
README.md
Normal file
176
README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# The Island - 荒岛生存模拟游戏
|
||||
|
||||
一个实时多人互动的荒岛生存模拟游戏,玩家可以通过命令与 AI 角色互动,帮助他们在荒岛上生存。
|
||||
|
||||
## 项目架构
|
||||
|
||||
```
|
||||
the-island/
|
||||
├── backend/ # Python FastAPI 后端服务
|
||||
│ └── app/
|
||||
│ ├── main.py # 应用入口
|
||||
│ ├── server.py # WebSocket 服务器
|
||||
│ ├── engine.py # 游戏引擎核心逻辑
|
||||
│ ├── models.py # SQLAlchemy 数据模型
|
||||
│ ├── schemas.py # Pydantic 消息模式
|
||||
│ ├── llm.py # LLM 集成 (对话生成)
|
||||
│ └── database.py # 数据库配置
|
||||
├── frontend/ # Web 调试客户端
|
||||
│ ├── app.js # JavaScript 客户端
|
||||
│ └── debug_client.html # 调试页面
|
||||
├── unity-client/ # Unity 6 游戏客户端
|
||||
│ └── Assets/
|
||||
│ ├── Scripts/ # C# 游戏脚本
|
||||
│ ├── Fonts/ # 字体资源 (含中文支持)
|
||||
│ └── Editor/ # 编辑器工具
|
||||
└── island.db # SQLite 数据库
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 游戏系统
|
||||
- **生存机制**: 角色有 HP、能量、心情三大属性
|
||||
- **昼夜循环**: 黎明 → 白天 → 黄昏 → 夜晚
|
||||
- **天气系统**: 晴天、多云、雨天、暴风雨、炎热、雾天
|
||||
- **社交系统**: 角色间自主社交互动
|
||||
- **休闲模式**: 自动复活、降低难度
|
||||
|
||||
### 玩家命令
|
||||
| 命令 | 格式 | 金币消耗 | 效果 |
|
||||
|------|------|----------|------|
|
||||
| feed | `feed <角色名>` | 10g | +20 能量, +5 HP |
|
||||
| heal | `heal <角色名>` | 15g | +30 HP |
|
||||
| talk | `talk <角色名> [话题]` | 0g | 与角色对话 |
|
||||
| encourage | `encourage <角色名>` | 5g | +15 心情 |
|
||||
| revive | `revive <角色名>` | 10g | 复活死亡角色 |
|
||||
| check | `check` | 0g | 查看所有状态 |
|
||||
| reset | `reset` | 0g | 重置游戏 |
|
||||
|
||||
### AI 角色
|
||||
- **Jack** (勇敢) - 蓝色
|
||||
- **Luna** (狡猾) - 粉色
|
||||
- **Bob** (诚实) - 绿色
|
||||
|
||||
每个角色有独特性格,会根据性格做出不同反应和社交行为。
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端
|
||||
- **Python 3.11+**
|
||||
- **FastAPI** - 异步 Web 框架
|
||||
- **WebSocket** - 实时双向通信
|
||||
- **SQLAlchemy** - ORM 数据持久化
|
||||
- **SQLite** - 轻量级数据库
|
||||
- **Anthropic Claude** - LLM 对话生成
|
||||
|
||||
### Unity 客户端
|
||||
- **Unity 6 LTS** (6000.3.2f1)
|
||||
- **TextMeshPro** - 高质量文本渲染
|
||||
- **NativeWebSocket** - WebSocket 通信
|
||||
- **2.5D 风格** - 精灵 + Billboard UI
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启动后端服务
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 2. 启动 Unity 客户端
|
||||
|
||||
1. 使用 Unity 6 打开 `unity-client` 文件夹
|
||||
2. 打开 `Assets/Scenes/main.unity`
|
||||
3. 点击 Play 运行游戏
|
||||
|
||||
### 3. Web 调试客户端 (可选)
|
||||
|
||||
在浏览器打开 `frontend/debug_client.html`
|
||||
|
||||
## Unity 客户端结构
|
||||
|
||||
### 核心脚本
|
||||
| 脚本 | 功能 |
|
||||
|------|------|
|
||||
| `NetworkManager.cs` | WebSocket 连接管理、消息收发 |
|
||||
| `GameManager.cs` | 游戏状态管理、角色生成 |
|
||||
| `UIManager.cs` | 主 UI 界面 (顶部状态栏、底部命令输入) |
|
||||
| `EventLog.cs` | 事件日志面板 (显示游戏事件) |
|
||||
| `AgentVisual.cs` | 角色视觉组件 (精灵、血条、对话框) |
|
||||
| `EnvironmentManager.cs` | 环境场景 (沙滩、海洋、天空) |
|
||||
| `WeatherEffects.cs` | 天气粒子效果 (雨、雾、热浪) |
|
||||
|
||||
### 视觉特性
|
||||
- 程序化生成的 2.5D 角色精灵
|
||||
- Billboard UI (始终面向摄像机)
|
||||
- 动态天气粒子系统
|
||||
- 渐变天空盒 (随时间变化)
|
||||
- 海浪动画效果
|
||||
|
||||
## 中文字体支持
|
||||
|
||||
项目使用 **思源黑体 (Source Han Sans SC)** 支持中文显示。
|
||||
|
||||
### 手动配置步骤
|
||||
1. 选择 `Assets/Fonts/SourceHanSansSC-Regular.otf`
|
||||
2. 右键 → Create → TextMeshPro → Font Asset → SDF
|
||||
3. 打开 Edit → Project Settings → TextMesh Pro
|
||||
4. 在 Fallback Font Assets 中添加生成的字体资产
|
||||
|
||||
## 通信协议
|
||||
|
||||
### WebSocket 端点
|
||||
```
|
||||
ws://localhost:8000/ws/{username}
|
||||
```
|
||||
|
||||
### 事件类型
|
||||
```python
|
||||
# 核心事件
|
||||
TICK # 游戏心跳
|
||||
AGENTS_UPDATE # 角色状态更新
|
||||
AGENT_SPEAK # 角色发言
|
||||
AGENT_DIED # 角色死亡
|
||||
|
||||
# 时间系统
|
||||
PHASE_CHANGE # 时段变化 (黎明/白天/黄昏/夜晚)
|
||||
DAY_CHANGE # 新的一天
|
||||
WEATHER_CHANGE # 天气变化
|
||||
|
||||
# 玩家互动
|
||||
FEED # 喂食反馈
|
||||
HEAL # 治疗反馈
|
||||
TALK # 对话反馈
|
||||
ENCOURAGE # 鼓励反馈
|
||||
REVIVE # 复活反馈
|
||||
|
||||
# 社交系统
|
||||
SOCIAL_INTERACTION # 角色间社交
|
||||
AUTO_REVIVE # 自动复活 (休闲模式)
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
创建 `.env` 文件:
|
||||
```env
|
||||
ANTHROPIC_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
## 开发说明
|
||||
|
||||
### 添加新命令
|
||||
1. 在 `backend/app/schemas.py` 添加事件类型
|
||||
2. 在 `backend/app/engine.py` 添加命令处理逻辑
|
||||
3. 在 `unity-client/Assets/Scripts/Models.cs` 添加数据模型
|
||||
4. 在 `unity-client/Assets/Scripts/NetworkManager.cs` 添加事件处理
|
||||
|
||||
### 调试
|
||||
- Unity 控制台查看日志
|
||||
- Web 调试客户端查看原始消息
|
||||
- 后端日志查看服务器状态
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -65,3 +65,21 @@
|
||||
# Force mock mode (no API calls, uses predefined responses)
|
||||
# =============================================================================
|
||||
# LLM_MOCK_MODE=true
|
||||
|
||||
# =============================================================================
|
||||
# Twitch Configuration for Live Stream Integration (twitchio 2.x)
|
||||
# =============================================================================
|
||||
# Get your OAuth Token from: https://twitchtokengenerator.com/
|
||||
# 1. Select "Bot Chat Token"
|
||||
# 2. Authorize with your Twitch account
|
||||
# 3. Copy the Access Token (starts with oauth:)
|
||||
#
|
||||
# TWITCH_TOKEN=oauth:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# TWITCH_CHANNEL_NAME=your_channel_name
|
||||
# TWITCH_COMMAND_PREFIX=!
|
||||
|
||||
# =============================================================================
|
||||
# Game Configuration
|
||||
# =============================================================================
|
||||
# GAME_DIFFICULTY=casual
|
||||
# DEBUG=true
|
||||
|
||||
@@ -7,7 +7,9 @@ Configures the application, WebSocket routes, and lifecycle events.
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,6 +21,7 @@ from fastapi.responses import FileResponse
|
||||
from .server import ConnectionManager
|
||||
from .engine import GameEngine
|
||||
from .schemas import GameEvent, ClientMessage, EventType
|
||||
from .twitch_service import TwitchBot
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -39,12 +42,39 @@ FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Application lifespan manager.
|
||||
Starts the game engine on startup and stops it on shutdown.
|
||||
Starts the game engine and Twitch bot on startup, stops them on shutdown.
|
||||
"""
|
||||
logger.info("Starting application...")
|
||||
|
||||
# Start game engine
|
||||
await engine.start()
|
||||
|
||||
# Start Twitch bot if credentials are provided
|
||||
twitch_bot = None
|
||||
if os.getenv("TWITCH_TOKEN") and os.getenv("TWITCH_CHANNEL_NAME"):
|
||||
try:
|
||||
twitch_bot = TwitchBot(engine)
|
||||
# Start bot in background task
|
||||
asyncio.create_task(twitch_bot.start())
|
||||
logger.info("Twitch bot started in background")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start Twitch bot: {e}")
|
||||
else:
|
||||
logger.info("Twitch credentials not provided, skipping Twitch bot")
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down application...")
|
||||
|
||||
# Stop Twitch bot if it was started
|
||||
if twitch_bot:
|
||||
try:
|
||||
await twitch_bot.close()
|
||||
logger.info("Twitch bot stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping Twitch bot: {e}")
|
||||
|
||||
# Stop game engine
|
||||
await engine.stop()
|
||||
|
||||
|
||||
|
||||
112
backend/app/twitch_service.py
Normal file
112
backend/app/twitch_service.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Twitch service for connecting to Twitch chat and handling events.
|
||||
Integrates with the game engine to process chat commands and bits.
|
||||
|
||||
Compatible with twitchio 2.x (IRC-based)
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from twitchio.ext import commands
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .engine import GameEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TwitchBot(commands.Bot):
|
||||
"""
|
||||
Twitch bot that listens to chat messages and bits events.
|
||||
Forwards them to the game engine for processing.
|
||||
|
||||
Compatible with twitchio 2.x API (IRC-based).
|
||||
"""
|
||||
|
||||
def __init__(self, game_engine: "GameEngine"):
|
||||
# Initialize bot with environment variables
|
||||
self._token = os.getenv("TWITCH_TOKEN")
|
||||
self._channel = os.getenv("TWITCH_CHANNEL_NAME")
|
||||
prefix = os.getenv("TWITCH_COMMAND_PREFIX", "!")
|
||||
|
||||
if not self._token:
|
||||
raise ValueError("TWITCH_TOKEN environment variable is required")
|
||||
if not self._channel:
|
||||
raise ValueError("TWITCH_CHANNEL_NAME environment variable is required")
|
||||
|
||||
# Store game engine reference
|
||||
self._game_engine = game_engine
|
||||
|
||||
# Initialize the bot (twitchio 2.x API - IRC based)
|
||||
super().__init__(
|
||||
token=self._token,
|
||||
prefix=prefix,
|
||||
initial_channels=[self._channel]
|
||||
)
|
||||
|
||||
logger.info(f"TwitchBot initialized for channel: {self._channel}")
|
||||
|
||||
async def event_ready(self):
|
||||
"""Called when the bot successfully connects to Twitch."""
|
||||
logger.info(f"Twitch Bot logged in as: {self.nick}")
|
||||
logger.info(f"Connected to channels: {[c.name for c in self.connected_channels]}")
|
||||
|
||||
async def event_message(self, message):
|
||||
"""Called when a message is received in chat."""
|
||||
# Ignore messages from the bot itself
|
||||
if message.echo:
|
||||
return
|
||||
|
||||
# Handle commands first
|
||||
await self.handle_commands(message)
|
||||
|
||||
# Extract user and message content
|
||||
author = message.author
|
||||
if author is None:
|
||||
return
|
||||
|
||||
username = author.name
|
||||
content = message.content.strip()
|
||||
|
||||
# Log the message for debugging
|
||||
logger.info(f"Twitch chat [{username}]: {content}")
|
||||
|
||||
# Forward to game engine for command processing
|
||||
try:
|
||||
await self._game_engine.process_command(username, content)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing command: {e}")
|
||||
|
||||
# Check for bits in the message tags
|
||||
if hasattr(message, 'tags') and message.tags:
|
||||
bits = message.tags.get('bits')
|
||||
if bits:
|
||||
try:
|
||||
bits_amount = int(bits)
|
||||
logger.info(f"Received {bits_amount} bits from {username}")
|
||||
await self._game_engine.process_bits(username, bits_amount)
|
||||
|
||||
# Send special gift effect to Unity
|
||||
await self._game_engine._broadcast_event("gift_effect", {
|
||||
"type": "gift_effect",
|
||||
"user": username,
|
||||
"value": bits_amount,
|
||||
"message": f"{username} cheered {bits_amount} bits!"
|
||||
})
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"Error parsing bits amount: {e}")
|
||||
|
||||
async def event_command_error(self, context, error):
|
||||
"""Called when a command error occurs."""
|
||||
# Ignore command not found errors (most chat messages aren't commands)
|
||||
if isinstance(error, commands.CommandNotFound):
|
||||
return
|
||||
logger.error(f"Command error: {error}")
|
||||
|
||||
async def event_error(self, error: Exception, data: str = None):
|
||||
"""Called when an error occurs."""
|
||||
logger.error(f"Twitch bot error: {error}")
|
||||
if data:
|
||||
logger.debug(f"Error data: {data}")
|
||||
@@ -6,3 +6,4 @@ sqlalchemy>=2.0.0
|
||||
aiosqlite>=0.19.0
|
||||
litellm>=1.40.0
|
||||
python-dotenv>=1.0.0
|
||||
twitchio>=3.0.0
|
||||
|
||||
227
unity-client/Assets/Editor/ChineseFontSetup.cs
Normal file
227
unity-client/Assets/Editor/ChineseFontSetup.cs
Normal file
File diff suppressed because one or more lines are too long
2
unity-client/Assets/Editor/ChineseFontSetup.cs.meta
Normal file
2
unity-client/Assets/Editor/ChineseFontSetup.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 21528455f756640db912f845f44864a6
|
||||
8
unity-client/Assets/Fonts.meta
Normal file
8
unity-client/Assets/Fonts.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18b316c9d4e944c9ead9102780fda528
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
19872
unity-client/Assets/Fonts/SourceHanSansSC-Regular SDF.asset
Normal file
19872
unity-client/Assets/Fonts/SourceHanSansSC-Regular SDF.asset
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 05d7311017e2a4618aeb1598563375bc
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
unity-client/Assets/Fonts/SourceHanSansSC-Regular.otf
Normal file
BIN
unity-client/Assets/Fonts/SourceHanSansSC-Regular.otf
Normal file
Binary file not shown.
21
unity-client/Assets/Fonts/SourceHanSansSC-Regular.otf.meta
Normal file
21
unity-client/Assets/Fonts/SourceHanSansSC-Regular.otf.meta
Normal file
@@ -0,0 +1,21 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ce5da230aa9d2439c998f40a36e72588
|
||||
TrueTypeFontImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 4
|
||||
fontSize: 16
|
||||
forceTextureCase: -2
|
||||
characterSpacing: 0
|
||||
characterPadding: 1
|
||||
includeFontData: 1
|
||||
fontNames:
|
||||
- Source Han Sans SC
|
||||
fallbackFontReferences: []
|
||||
customCharacters:
|
||||
fontRenderingMode: 0
|
||||
ascentCalculationMode: 1
|
||||
useLegacyBoundsCalculation: 0
|
||||
shouldRoundAdvanceValue: 1
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
379
unity-client/Assets/Scripts/EventLog.cs
Normal file
379
unity-client/Assets/Scripts/EventLog.cs
Normal file
@@ -0,0 +1,379 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using TheIsland.Network;
|
||||
using TheIsland.Models;
|
||||
|
||||
namespace TheIsland.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 事件日志面板 - 显示游戏事件历史
|
||||
/// </summary>
|
||||
public class EventLog : MonoBehaviour
|
||||
{
|
||||
private static EventLog _instance;
|
||||
public static EventLog Instance => _instance;
|
||||
|
||||
// 自动创建 EventLog 实例
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
private static void AutoCreate()
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
var go = new GameObject("EventLog");
|
||||
go.AddComponent<EventLog>();
|
||||
DontDestroyOnLoad(go);
|
||||
Debug.Log("[EventLog] 自动创建实例");
|
||||
}
|
||||
}
|
||||
|
||||
// UI 组件
|
||||
private Canvas _canvas;
|
||||
private GameObject _panel;
|
||||
private ScrollRect _scrollRect;
|
||||
private RectTransform _content;
|
||||
private Button _toggleBtn;
|
||||
private TextMeshProUGUI _toggleText;
|
||||
private List<GameObject> _entries = new List<GameObject>();
|
||||
private bool _visible = true;
|
||||
private int _unread = 0;
|
||||
private bool _ready = false;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_instance != null && _instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
_instance = this;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
Debug.Log("[EventLog] Start - 开始初始化");
|
||||
StartCoroutine(InitCoroutine());
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator InitCoroutine()
|
||||
{
|
||||
// 等待一帧确保 Canvas 准备好
|
||||
yield return null;
|
||||
|
||||
_canvas = FindAnyObjectByType<Canvas>();
|
||||
if (_canvas == null)
|
||||
{
|
||||
Debug.LogError("[EventLog] 找不到 Canvas");
|
||||
yield break;
|
||||
}
|
||||
|
||||
Debug.Log($"[EventLog] 找到 Canvas: {_canvas.name}");
|
||||
BuildUI();
|
||||
|
||||
// 等待 NetworkManager
|
||||
int retries = 0;
|
||||
while (NetworkManager.Instance == null && retries < 20)
|
||||
{
|
||||
Debug.Log("[EventLog] 等待 NetworkManager...");
|
||||
yield return new WaitForSeconds(0.25f);
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
SubscribeEvents();
|
||||
_ready = true;
|
||||
AddLog("事件日志已就绪", Color.yellow);
|
||||
Debug.Log("[EventLog] 初始化完成");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("[EventLog] NetworkManager 超时");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
var n = NetworkManager.Instance;
|
||||
n.OnConnected -= OnConnected;
|
||||
n.OnAgentSpeak -= OnSpeak;
|
||||
n.OnSocialInteraction -= OnSocial;
|
||||
n.OnAgentDied -= OnDeath;
|
||||
n.OnFeed -= OnFeed;
|
||||
n.OnHeal -= OnHeal;
|
||||
n.OnEncourage -= OnEncourage;
|
||||
n.OnRevive -= OnRevive;
|
||||
n.OnTalk -= OnTalk;
|
||||
n.OnSystemMessage -= OnSystem;
|
||||
n.OnWeatherChange -= OnWeather;
|
||||
n.OnPhaseChange -= OnPhase;
|
||||
n.OnDayChange -= OnDay;
|
||||
}
|
||||
}
|
||||
|
||||
private void SubscribeEvents()
|
||||
{
|
||||
var n = NetworkManager.Instance;
|
||||
n.OnConnected += OnConnected;
|
||||
n.OnAgentSpeak += OnSpeak;
|
||||
n.OnSocialInteraction += OnSocial;
|
||||
n.OnAgentDied += OnDeath;
|
||||
n.OnFeed += OnFeed;
|
||||
n.OnHeal += OnHeal;
|
||||
n.OnEncourage += OnEncourage;
|
||||
n.OnRevive += OnRevive;
|
||||
n.OnTalk += OnTalk;
|
||||
n.OnSystemMessage += OnSystem;
|
||||
n.OnWeatherChange += OnWeather;
|
||||
n.OnPhaseChange += OnPhase;
|
||||
n.OnDayChange += OnDay;
|
||||
Debug.Log("[EventLog] 事件订阅完成");
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
private void OnConnected() => AddLog("已连接服务器", Color.green);
|
||||
private void OnSpeak(AgentSpeakData d) => AddLog($"{d.agent_name}: \"{d.text}\"", Color.white);
|
||||
private void OnSocial(SocialInteractionData d) => AddLog($"{d.initiator_name} -> {d.target_name}: {d.dialogue}", new Color(0.6f, 0.9f, 1f));
|
||||
private void OnDeath(AgentDiedData d) => AddLog($"{d.agent_name} 死亡!", Color.red);
|
||||
private void OnFeed(FeedEventData d) => AddLog($"{d.user} 喂食 {d.agent_name}", new Color(0.5f, 1f, 0.5f));
|
||||
private void OnHeal(HealEventData d) => AddLog($"{d.user} 治疗 {d.agent_name}", new Color(0.5f, 1f, 0.5f));
|
||||
private void OnEncourage(EncourageEventData d) => AddLog($"{d.user} 鼓励 {d.agent_name}", new Color(0.5f, 1f, 0.5f));
|
||||
private void OnRevive(ReviveEventData d) => AddLog($"{d.user} 复活 {d.agent_name}", Color.cyan);
|
||||
private void OnTalk(TalkEventData d) => AddLog($"{d.agent_name}: \"{d.response}\"", Color.white);
|
||||
private void OnSystem(SystemEventData d) => AddLog(d.message, Color.yellow);
|
||||
private void OnWeather(WeatherChangeData d) => AddLog($"天气: {d.new_weather}", new Color(0.7f, 0.85f, 1f));
|
||||
private void OnPhase(PhaseChangeData d) => AddLog($"时间: {d.new_phase}", new Color(1f, 0.9f, 0.7f));
|
||||
private void OnDay(DayChangeData d) => AddLog($"第 {d.day} 天!", new Color(1f, 0.9f, 0.7f));
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
// 切换按钮
|
||||
var toggleObj = new GameObject("LogToggle");
|
||||
toggleObj.transform.SetParent(_canvas.transform, false);
|
||||
var toggleRect = toggleObj.AddComponent<RectTransform>();
|
||||
toggleRect.anchorMin = new Vector2(0, 1);
|
||||
toggleRect.anchorMax = new Vector2(0, 1);
|
||||
toggleRect.pivot = new Vector2(0, 1);
|
||||
toggleRect.anchoredPosition = new Vector2(10, -75);
|
||||
toggleRect.sizeDelta = new Vector2(90, 24);
|
||||
|
||||
var toggleImg = toggleObj.AddComponent<Image>();
|
||||
toggleImg.color = new Color(0.2f, 0.35f, 0.5f, 0.9f);
|
||||
_toggleBtn = toggleObj.AddComponent<Button>();
|
||||
_toggleBtn.targetGraphic = toggleImg;
|
||||
_toggleBtn.onClick.AddListener(Toggle);
|
||||
|
||||
var toggleTextObj = new GameObject("Text");
|
||||
toggleTextObj.transform.SetParent(toggleObj.transform, false);
|
||||
_toggleText = toggleTextObj.AddComponent<TextMeshProUGUI>();
|
||||
_toggleText.text = "隐藏日志";
|
||||
_toggleText.fontSize = 12;
|
||||
_toggleText.color = Color.white;
|
||||
_toggleText.alignment = TextAlignmentOptions.Center;
|
||||
var ttRect = toggleTextObj.GetComponent<RectTransform>();
|
||||
ttRect.anchorMin = Vector2.zero;
|
||||
ttRect.anchorMax = Vector2.one;
|
||||
ttRect.sizeDelta = Vector2.zero;
|
||||
|
||||
// 主面板
|
||||
_panel = new GameObject("LogPanel");
|
||||
_panel.transform.SetParent(_canvas.transform, false);
|
||||
var panelRect = _panel.AddComponent<RectTransform>();
|
||||
panelRect.anchorMin = new Vector2(0, 0);
|
||||
panelRect.anchorMax = new Vector2(0, 1);
|
||||
panelRect.pivot = new Vector2(0, 0.5f);
|
||||
panelRect.offsetMin = new Vector2(10, 80);
|
||||
panelRect.offsetMax = new Vector2(360, -80);
|
||||
|
||||
var panelImg = _panel.AddComponent<Image>();
|
||||
panelImg.color = new Color(0.05f, 0.07f, 0.1f, 0.95f);
|
||||
|
||||
// 标题
|
||||
var header = new GameObject("Header");
|
||||
header.transform.SetParent(_panel.transform, false);
|
||||
var headerRect = header.AddComponent<RectTransform>();
|
||||
headerRect.anchorMin = new Vector2(0, 1);
|
||||
headerRect.anchorMax = new Vector2(1, 1);
|
||||
headerRect.pivot = new Vector2(0.5f, 1);
|
||||
headerRect.sizeDelta = new Vector2(0, 28);
|
||||
headerRect.anchoredPosition = Vector2.zero;
|
||||
|
||||
header.AddComponent<Image>().color = new Color(0.12f, 0.15f, 0.2f);
|
||||
|
||||
var titleObj = new GameObject("Title");
|
||||
titleObj.transform.SetParent(header.transform, false);
|
||||
var titleTmp = titleObj.AddComponent<TextMeshProUGUI>();
|
||||
titleTmp.text = "事件日志";
|
||||
titleTmp.fontSize = 14;
|
||||
titleTmp.fontStyle = FontStyles.Bold;
|
||||
titleTmp.color = Color.white;
|
||||
titleTmp.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
var titleRect = titleObj.GetComponent<RectTransform>();
|
||||
titleRect.anchorMin = Vector2.zero;
|
||||
titleRect.anchorMax = Vector2.one;
|
||||
titleRect.offsetMin = new Vector2(8, 0);
|
||||
titleRect.offsetMax = new Vector2(-50, 0);
|
||||
|
||||
// 清除按钮
|
||||
var clearObj = new GameObject("Clear");
|
||||
clearObj.transform.SetParent(header.transform, false);
|
||||
var clearRect = clearObj.AddComponent<RectTransform>();
|
||||
clearRect.anchorMin = new Vector2(1, 0.5f);
|
||||
clearRect.anchorMax = new Vector2(1, 0.5f);
|
||||
clearRect.pivot = new Vector2(1, 0.5f);
|
||||
clearRect.sizeDelta = new Vector2(45, 20);
|
||||
clearRect.anchoredPosition = new Vector2(-4, 0);
|
||||
|
||||
var clearImg = clearObj.AddComponent<Image>();
|
||||
clearImg.color = new Color(0.5f, 0.25f, 0.25f);
|
||||
var clearBtn = clearObj.AddComponent<Button>();
|
||||
clearBtn.targetGraphic = clearImg;
|
||||
clearBtn.onClick.AddListener(Clear);
|
||||
|
||||
var clearTextObj = new GameObject("Text");
|
||||
clearTextObj.transform.SetParent(clearObj.transform, false);
|
||||
var clearTmp = clearTextObj.AddComponent<TextMeshProUGUI>();
|
||||
clearTmp.text = "清除";
|
||||
clearTmp.fontSize = 11;
|
||||
clearTmp.color = Color.white;
|
||||
clearTmp.alignment = TextAlignmentOptions.Center;
|
||||
var ctRect = clearTextObj.GetComponent<RectTransform>();
|
||||
ctRect.anchorMin = Vector2.zero;
|
||||
ctRect.anchorMax = Vector2.one;
|
||||
ctRect.sizeDelta = Vector2.zero;
|
||||
|
||||
// 滚动区域
|
||||
var scrollObj = new GameObject("Scroll");
|
||||
scrollObj.transform.SetParent(_panel.transform, false);
|
||||
var scrollRect = scrollObj.AddComponent<RectTransform>();
|
||||
scrollRect.anchorMin = Vector2.zero;
|
||||
scrollRect.anchorMax = Vector2.one;
|
||||
scrollRect.offsetMin = new Vector2(4, 4);
|
||||
scrollRect.offsetMax = new Vector2(-4, -32);
|
||||
|
||||
scrollObj.AddComponent<Image>().color = new Color(0, 0, 0, 0.3f);
|
||||
scrollObj.AddComponent<Mask>().showMaskGraphic = true;
|
||||
|
||||
_scrollRect = scrollObj.AddComponent<ScrollRect>();
|
||||
_scrollRect.horizontal = false;
|
||||
_scrollRect.vertical = true;
|
||||
_scrollRect.movementType = ScrollRect.MovementType.Clamped;
|
||||
_scrollRect.scrollSensitivity = 25;
|
||||
_scrollRect.viewport = scrollRect;
|
||||
|
||||
// 内容容器
|
||||
var contentObj = new GameObject("Content");
|
||||
contentObj.transform.SetParent(scrollObj.transform, false);
|
||||
_content = contentObj.AddComponent<RectTransform>();
|
||||
_content.anchorMin = new Vector2(0, 1);
|
||||
_content.anchorMax = new Vector2(1, 1);
|
||||
_content.pivot = new Vector2(0.5f, 1);
|
||||
_content.anchoredPosition = Vector2.zero;
|
||||
_content.sizeDelta = new Vector2(0, 0);
|
||||
|
||||
var layout = contentObj.AddComponent<VerticalLayoutGroup>();
|
||||
layout.spacing = 2;
|
||||
layout.padding = new RectOffset(2, 2, 2, 2);
|
||||
layout.childControlWidth = true;
|
||||
layout.childControlHeight = true;
|
||||
layout.childForceExpandWidth = true;
|
||||
layout.childForceExpandHeight = false;
|
||||
|
||||
contentObj.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
_scrollRect.content = _content;
|
||||
|
||||
Debug.Log("[EventLog] UI 构建完成");
|
||||
}
|
||||
|
||||
public void AddLog(string msg, Color color)
|
||||
{
|
||||
if (_content == null) return;
|
||||
|
||||
var entry = new GameObject($"E{_entries.Count}");
|
||||
entry.transform.SetParent(_content, false);
|
||||
|
||||
entry.AddComponent<Image>().color = _entries.Count % 2 == 0
|
||||
? new Color(0.08f, 0.1f, 0.13f, 0.9f)
|
||||
: new Color(0.06f, 0.08f, 0.11f, 0.9f);
|
||||
|
||||
var le = entry.AddComponent<LayoutElement>();
|
||||
le.minHeight = 36;
|
||||
le.preferredHeight = 36;
|
||||
|
||||
// 颜色条
|
||||
var bar = new GameObject("Bar");
|
||||
bar.transform.SetParent(entry.transform, false);
|
||||
var barRect = bar.AddComponent<RectTransform>();
|
||||
barRect.anchorMin = new Vector2(0, 0);
|
||||
barRect.anchorMax = new Vector2(0, 1);
|
||||
barRect.pivot = new Vector2(0, 0.5f);
|
||||
barRect.sizeDelta = new Vector2(3, -4);
|
||||
barRect.anchoredPosition = new Vector2(1, 0);
|
||||
bar.AddComponent<Image>().color = color;
|
||||
|
||||
// 文本
|
||||
var textObj = new GameObject("Text");
|
||||
textObj.transform.SetParent(entry.transform, false);
|
||||
var tmp = textObj.AddComponent<TextMeshProUGUI>();
|
||||
string time = System.DateTime.Now.ToString("HH:mm:ss");
|
||||
tmp.text = $"<color=#666><size=10>{time}</size></color> {msg}";
|
||||
tmp.fontSize = 12;
|
||||
tmp.color = color;
|
||||
tmp.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
tmp.textWrappingMode = TextWrappingModes.Normal;
|
||||
tmp.overflowMode = TextOverflowModes.Ellipsis;
|
||||
tmp.richText = true;
|
||||
|
||||
var textRect = textObj.GetComponent<RectTransform>();
|
||||
textRect.anchorMin = Vector2.zero;
|
||||
textRect.anchorMax = Vector2.one;
|
||||
textRect.offsetMin = new Vector2(8, 2);
|
||||
textRect.offsetMax = new Vector2(-4, -2);
|
||||
|
||||
_entries.Add(entry);
|
||||
|
||||
while (_entries.Count > 100)
|
||||
{
|
||||
Destroy(_entries[0]);
|
||||
_entries.RemoveAt(0);
|
||||
}
|
||||
|
||||
if (!_visible)
|
||||
{
|
||||
_unread++;
|
||||
UpdateToggle();
|
||||
}
|
||||
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
|
||||
Canvas.ForceUpdateCanvases();
|
||||
_scrollRect.verticalNormalizedPosition = 0;
|
||||
}
|
||||
|
||||
private void Clear()
|
||||
{
|
||||
foreach (var e in _entries) Destroy(e);
|
||||
_entries.Clear();
|
||||
_unread = 0;
|
||||
UpdateToggle();
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
|
||||
AddLog("已清除", Color.yellow);
|
||||
}
|
||||
|
||||
private void Toggle()
|
||||
{
|
||||
_visible = !_visible;
|
||||
_panel.SetActive(_visible);
|
||||
if (_visible) _unread = 0;
|
||||
UpdateToggle();
|
||||
}
|
||||
|
||||
private void UpdateToggle()
|
||||
{
|
||||
if (_toggleText != null)
|
||||
_toggleText.text = _visible ? "隐藏日志" : (_unread > 0 ? $"日志({_unread})" : "显示日志");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/EventLog.cs.meta
Normal file
2
unity-client/Assets/Scripts/EventLog.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 29d056ffaee4442e2b947b862a50d329
|
||||
8
unity-client/Assets/Scripts/UI.meta
Normal file
8
unity-client/Assets/Scripts/UI.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a714afb8c6c4492e838e7fcc1e6de1b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -28,6 +28,7 @@ namespace TheIsland.UI
|
||||
private Button _resetButton;
|
||||
private GameObject _notificationPanel;
|
||||
private TextMeshProUGUI _notificationText;
|
||||
// EventLog 现在自己管理初始化
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
@@ -149,6 +150,7 @@ namespace TheIsland.UI
|
||||
CreateTopBar();
|
||||
CreateBottomBar();
|
||||
CreateNotificationPanel();
|
||||
// EventLog 自己管理初始化,不在这里创建
|
||||
}
|
||||
|
||||
private void CreateTopBar()
|
||||
|
||||
@@ -191,8 +191,10 @@ namespace TheIsland.Visual
|
||||
|
||||
var velocityOverLifetime = _fogSystem.velocityOverLifetime;
|
||||
velocityOverLifetime.enabled = true;
|
||||
velocityOverLifetime.x = new ParticleSystem.MinMaxCurve(-0.2f, 0.2f);
|
||||
velocityOverLifetime.y = new ParticleSystem.MinMaxCurve(0.05f, 0.1f);
|
||||
// 所有轴使用相同的曲线模式 (Constant)
|
||||
velocityOverLifetime.x = 0f;
|
||||
velocityOverLifetime.y = 0.08f;
|
||||
velocityOverLifetime.z = 0f;
|
||||
|
||||
var colorOverLifetime = _fogSystem.colorOverLifetime;
|
||||
colorOverLifetime.enabled = true;
|
||||
@@ -234,8 +236,10 @@ namespace TheIsland.Visual
|
||||
|
||||
var velocityOverLifetime = _heatSystem.velocityOverLifetime;
|
||||
velocityOverLifetime.enabled = true;
|
||||
// 所有轴使用相同的曲线模式 (Constant)
|
||||
velocityOverLifetime.x = 0f;
|
||||
velocityOverLifetime.y = 1f;
|
||||
velocityOverLifetime.x = new ParticleSystem.MinMaxCurve(-0.3f, 0.3f);
|
||||
velocityOverLifetime.z = 0f;
|
||||
|
||||
var colorOverLifetime = _heatSystem.colorOverLifetime;
|
||||
colorOverLifetime.enabled = true;
|
||||
@@ -277,7 +281,10 @@ namespace TheIsland.Visual
|
||||
|
||||
var velocityOverLifetime = _cloudSystem.velocityOverLifetime;
|
||||
velocityOverLifetime.enabled = true;
|
||||
// 所有轴使用相同的曲线模式 (Constant)
|
||||
velocityOverLifetime.x = 0.3f;
|
||||
velocityOverLifetime.y = 0f;
|
||||
velocityOverLifetime.z = 0f;
|
||||
|
||||
var renderer = cloudObj.GetComponent<ParticleSystemRenderer>();
|
||||
renderer.material = CreateCloudMaterial();
|
||||
|
||||
397
unity-client/Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF.asset
Executable file → Normal file
397
unity-client/Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF.asset
Executable file → Normal file
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@ MonoBehaviour:
|
||||
assetVersion: 2
|
||||
m_TextWrappingMode: 1
|
||||
m_enableKerning: 1
|
||||
m_ActiveFontFeatures: 00000000
|
||||
m_ActiveFontFeatures: 6e72656b
|
||||
m_enableExtraPadding: 0
|
||||
m_enableTintAllSprites: 0
|
||||
m_enableParseEscapeCharacters: 1
|
||||
@@ -36,17 +36,14 @@ MonoBehaviour:
|
||||
m_fallbackFontAssets: []
|
||||
m_matchMaterialPreset: 1
|
||||
m_HideSubTextObjects: 0
|
||||
m_defaultSpriteAsset: {fileID: 11400000, guid: c41005c129ba4d66911b75229fd70b45,
|
||||
type: 2}
|
||||
m_defaultSpriteAsset: {fileID: 11400000, guid: c41005c129ba4d66911b75229fd70b45, type: 2}
|
||||
m_defaultSpriteAssetPath: Sprite Assets/
|
||||
m_enableEmojiSupport: 1
|
||||
m_MissingCharacterSpriteUnicode: 0
|
||||
m_EmojiFallbackTextAssets: []
|
||||
m_defaultColorGradientPresetsPath: Color Gradient Presets/
|
||||
m_defaultStyleSheet: {fileID: 11400000, guid: f952c082cb03451daed3ee968ac6c63e,
|
||||
type: 2}
|
||||
m_defaultStyleSheet: {fileID: 11400000, guid: f952c082cb03451daed3ee968ac6c63e, type: 2}
|
||||
m_StyleSheetsResourcePath:
|
||||
m_leadingCharacters: {fileID: 4900000, guid: d82c1b31c7e74239bff1220585707d2b, type: 3}
|
||||
m_followingCharacters: {fileID: 4900000, guid: fade42e8bc714b018fac513c043d323b,
|
||||
type: 3}
|
||||
m_followingCharacters: {fileID: 4900000, guid: fade42e8bc714b018fac513c043d323b, type: 3}
|
||||
m_UseModernHangulLineBreakingRules: 0
|
||||
|
||||
@@ -165,7 +165,8 @@ PlayerSettings:
|
||||
androidSupportedAspectRatio: 1
|
||||
androidMaxAspectRatio: 2.4
|
||||
androidMinAspectRatio: 1
|
||||
applicationIdentifier: {}
|
||||
applicationIdentifier:
|
||||
Standalone: com.DefaultCompany.unity-client
|
||||
buildNumber:
|
||||
Standalone: 0
|
||||
VisionOS: 0
|
||||
|
||||
Reference in New Issue
Block a user