Compare commits

...

2 Commits

Author SHA1 Message Date
empty
3e89a17b69 fix: 修复 Twitch 集成 - 降级到 twitchio 2.x
- 从 twitchio 3.x 降级到 2.10.0 (IRC-based)
  - 3.x 使用 EventSub API 需要更复杂的配置
  - 2.x 使用 IRC 方式更简单可靠
- 简化 Twitch 配置,只需要 Token 和频道名
- 移除 client_id, client_secret, bot_id 要求
- 更新 .env.example 配置说明
2026-01-01 18:59:14 +08:00
empty
a261eaa8ab feat: add event log panel, Chinese font support, and documentation
- Add EventLog.cs: scrollable event log panel showing game events
  - Color-coded entries for different event types
  - Toggle visibility, clear button, timestamps
  - Auto-scroll to newest entries

- Add Chinese font support (Source Han Sans SC)
  - SourceHanSansSC-Regular.otf font file
  - ChineseFontSetup.cs editor tool for TMP font asset generation
  - Configure as fallback font for TextMeshPro

- Fix particle system velocity curve warnings in WeatherEffects.cs
  - Ensure all velocity axes use consistent curve mode

- Add comprehensive README.md with project documentation
  - Architecture overview, features, tech stack
  - Quick start guide, Unity client structure
  - Communication protocol, development notes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:46:56 +08:00
20 changed files with 21219 additions and 73 deletions

176
README.md Normal file
View 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

View File

@@ -65,3 +65,21 @@
# Force mock mode (no API calls, uses predefined responses) # Force mock mode (no API calls, uses predefined responses)
# ============================================================================= # =============================================================================
# LLM_MOCK_MODE=true # 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

View File

@@ -7,7 +7,9 @@ Configures the application, WebSocket routes, and lifecycle events.
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
import asyncio
import logging import logging
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -19,6 +21,7 @@ from fastapi.responses import FileResponse
from .server import ConnectionManager from .server import ConnectionManager
from .engine import GameEngine from .engine import GameEngine
from .schemas import GameEvent, ClientMessage, EventType from .schemas import GameEvent, ClientMessage, EventType
from .twitch_service import TwitchBot
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -39,12 +42,39 @@ FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
""" """
Application lifespan manager. 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...") logger.info("Starting application...")
# Start game engine
await engine.start() 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 yield
logger.info("Shutting down application...") 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() await engine.stop()

View 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}")

View File

@@ -6,3 +6,4 @@ sqlalchemy>=2.0.0
aiosqlite>=0.19.0 aiosqlite>=0.19.0
litellm>=1.40.0 litellm>=1.40.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
twitchio>=3.0.0

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 21528455f756640db912f845f44864a6

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 18b316c9d4e944c9ead9102780fda528
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 05d7311017e2a4618aeb1598563375bc
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View 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:

View 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})" : "显示日志");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 29d056ffaee4442e2b947b862a50d329

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3a714afb8c6c4492e838e7fcc1e6de1b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -28,6 +28,7 @@ namespace TheIsland.UI
private Button _resetButton; private Button _resetButton;
private GameObject _notificationPanel; private GameObject _notificationPanel;
private TextMeshProUGUI _notificationText; private TextMeshProUGUI _notificationText;
// EventLog 现在自己管理初始化
#endregion #endregion
#region State #region State
@@ -149,6 +150,7 @@ namespace TheIsland.UI
CreateTopBar(); CreateTopBar();
CreateBottomBar(); CreateBottomBar();
CreateNotificationPanel(); CreateNotificationPanel();
// EventLog 自己管理初始化,不在这里创建
} }
private void CreateTopBar() private void CreateTopBar()

View File

@@ -191,8 +191,10 @@ namespace TheIsland.Visual
var velocityOverLifetime = _fogSystem.velocityOverLifetime; var velocityOverLifetime = _fogSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true; velocityOverLifetime.enabled = true;
velocityOverLifetime.x = new ParticleSystem.MinMaxCurve(-0.2f, 0.2f); // 所有轴使用相同的曲线模式 (Constant)
velocityOverLifetime.y = new ParticleSystem.MinMaxCurve(0.05f, 0.1f); velocityOverLifetime.x = 0f;
velocityOverLifetime.y = 0.08f;
velocityOverLifetime.z = 0f;
var colorOverLifetime = _fogSystem.colorOverLifetime; var colorOverLifetime = _fogSystem.colorOverLifetime;
colorOverLifetime.enabled = true; colorOverLifetime.enabled = true;
@@ -234,8 +236,10 @@ namespace TheIsland.Visual
var velocityOverLifetime = _heatSystem.velocityOverLifetime; var velocityOverLifetime = _heatSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true; velocityOverLifetime.enabled = true;
// 所有轴使用相同的曲线模式 (Constant)
velocityOverLifetime.x = 0f;
velocityOverLifetime.y = 1f; velocityOverLifetime.y = 1f;
velocityOverLifetime.x = new ParticleSystem.MinMaxCurve(-0.3f, 0.3f); velocityOverLifetime.z = 0f;
var colorOverLifetime = _heatSystem.colorOverLifetime; var colorOverLifetime = _heatSystem.colorOverLifetime;
colorOverLifetime.enabled = true; colorOverLifetime.enabled = true;
@@ -277,7 +281,10 @@ namespace TheIsland.Visual
var velocityOverLifetime = _cloudSystem.velocityOverLifetime; var velocityOverLifetime = _cloudSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true; velocityOverLifetime.enabled = true;
// 所有轴使用相同的曲线模式 (Constant)
velocityOverLifetime.x = 0.3f; velocityOverLifetime.x = 0.3f;
velocityOverLifetime.y = 0f;
velocityOverLifetime.z = 0f;
var renderer = cloudObj.GetComponent<ParticleSystemRenderer>(); var renderer = cloudObj.GetComponent<ParticleSystemRenderer>();
renderer.material = CreateCloudMaterial(); renderer.material = CreateCloudMaterial();

File diff suppressed because one or more lines are too long

View File

@@ -15,7 +15,7 @@ MonoBehaviour:
assetVersion: 2 assetVersion: 2
m_TextWrappingMode: 1 m_TextWrappingMode: 1
m_enableKerning: 1 m_enableKerning: 1
m_ActiveFontFeatures: 00000000 m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0 m_enableExtraPadding: 0
m_enableTintAllSprites: 0 m_enableTintAllSprites: 0
m_enableParseEscapeCharacters: 1 m_enableParseEscapeCharacters: 1
@@ -36,17 +36,14 @@ MonoBehaviour:
m_fallbackFontAssets: [] m_fallbackFontAssets: []
m_matchMaterialPreset: 1 m_matchMaterialPreset: 1
m_HideSubTextObjects: 0 m_HideSubTextObjects: 0
m_defaultSpriteAsset: {fileID: 11400000, guid: c41005c129ba4d66911b75229fd70b45, m_defaultSpriteAsset: {fileID: 11400000, guid: c41005c129ba4d66911b75229fd70b45, type: 2}
type: 2}
m_defaultSpriteAssetPath: Sprite Assets/ m_defaultSpriteAssetPath: Sprite Assets/
m_enableEmojiSupport: 1 m_enableEmojiSupport: 1
m_MissingCharacterSpriteUnicode: 0 m_MissingCharacterSpriteUnicode: 0
m_EmojiFallbackTextAssets: [] m_EmojiFallbackTextAssets: []
m_defaultColorGradientPresetsPath: Color Gradient Presets/ m_defaultColorGradientPresetsPath: Color Gradient Presets/
m_defaultStyleSheet: {fileID: 11400000, guid: f952c082cb03451daed3ee968ac6c63e, m_defaultStyleSheet: {fileID: 11400000, guid: f952c082cb03451daed3ee968ac6c63e, type: 2}
type: 2}
m_StyleSheetsResourcePath: m_StyleSheetsResourcePath:
m_leadingCharacters: {fileID: 4900000, guid: d82c1b31c7e74239bff1220585707d2b, type: 3} m_leadingCharacters: {fileID: 4900000, guid: d82c1b31c7e74239bff1220585707d2b, type: 3}
m_followingCharacters: {fileID: 4900000, guid: fade42e8bc714b018fac513c043d323b, m_followingCharacters: {fileID: 4900000, guid: fade42e8bc714b018fac513c043d323b, type: 3}
type: 3}
m_UseModernHangulLineBreakingRules: 0 m_UseModernHangulLineBreakingRules: 0

View File

@@ -165,7 +165,8 @@ PlayerSettings:
androidSupportedAspectRatio: 1 androidSupportedAspectRatio: 1
androidMaxAspectRatio: 2.4 androidMaxAspectRatio: 2.4
androidMinAspectRatio: 1 androidMinAspectRatio: 1
applicationIdentifier: {} applicationIdentifier:
Standalone: com.DefaultCompany.unity-client
buildNumber: buildNumber:
Standalone: 0 Standalone: 0
VisionOS: 0 VisionOS: 0