feat: pivot to island survival simulation with SQLite persistence

Phase 3 - "The Island" transformation:
- Add SQLAlchemy + SQLite for data persistence (database.py)
- Rewrite models.py with User, Agent, WorldState ORM models
- Refactor engine.py for survival mechanics (energy decay, starvation)
- Implement feed command (10 gold -> +20 energy)
- Auto-seed 3 NPCs on startup (Jack/Luna/Bob)
- Update frontend with agent card view and Chinese UI
- Remove old Boss/Player RPG mechanics
- Add .gitignore for database and cache files
- Fix SQLAlchemy session detachment issue

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2025-12-30 17:15:35 +08:00
parent 4c08b00832
commit cf1739b7f8
9 changed files with 676 additions and 613 deletions

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg
*.egg-info/
.eggs/
# Virtual environment
venv/
.venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
# OS
.DS_Store
Thumbs.db

View File

@@ -1,82 +0,0 @@
"""
Agent classes for processing game inputs and generating responses.
Provides base abstraction and rule-based implementation.
"""
from abc import ABC, abstractmethod
import logging
logger = logging.getLogger(__name__)
class BaseAgent(ABC):
"""
Abstract base class for all game agents.
Agents process input text and generate appropriate responses.
Subclasses must implement the process_input method.
"""
def __init__(self, name: str = "BaseAgent") -> None:
"""
Initialize the agent.
Args:
name: Display name for the agent
"""
self.name = name
@abstractmethod
def process_input(self, text: str) -> str:
"""
Process input text and return a response.
Args:
text: The input text to process
Returns:
The agent's response string
"""
pass
class RuleBasedAgent(BaseAgent):
"""
A simple rule-based agent that responds based on keyword matching.
This agent does not use LLMs - it returns static strings based on
detected keywords in the input text.
"""
def __init__(self, name: str = "RuleBot") -> None:
"""Initialize the rule-based agent."""
super().__init__(name)
self._rules: dict[str, str] = {
"attack": "Defending! Shield activated!",
"heal": "Healing spell cast! +50 HP",
"run": "Too slow! You can't escape!",
"help": "Allies are on the way!",
"fire": "Water shield deployed!",
"magic": "Counter-spell activated!",
}
self._default_response = "I heard you! Processing command..."
def process_input(self, text: str) -> str:
"""
Process input and return response based on keyword rules.
Args:
text: The input text to analyze
Returns:
Response string based on matched keywords
"""
text_lower = text.lower()
for keyword, response in self._rules.items():
if keyword in text_lower:
logger.info(f"Agent matched keyword: {keyword}")
return f"{self.name}: {response}"
logger.info("Agent using default response")
return f"{self.name}: {self._default_response}"

61
backend/app/database.py Normal file
View File

@@ -0,0 +1,61 @@
"""
Database configuration for The Island.
SQLite + SQLAlchemy ORM setup.
"""
from pathlib import Path
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
# Database file path (in project root)
DB_PATH = Path(__file__).parent.parent.parent / "island.db"
DATABASE_URL = f"sqlite:///{DB_PATH}"
# Create engine with SQLite optimizations
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False}, # Required for SQLite with FastAPI
echo=False # Set True for SQL debugging
)
# Session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for ORM models
Base = declarative_base()
def init_db():
"""Initialize database tables."""
Base.metadata.create_all(bind=engine)
def get_db():
"""
Dependency for getting database session.
Use with FastAPI's Depends() or as context manager.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
@contextmanager
def get_db_session():
"""
Context manager for database session.
Usage: with get_db_session() as db: ...
"""
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()

View File

@@ -1,6 +1,6 @@
""" """
Core Game Engine - The "Heartbeat" of the game. Core Game Engine - The Island Survival Simulation.
Manages game state, processes commands, and coordinates broadcasts. Manages survival mechanics, agent states, and user interactions.
""" """
import asyncio import asyncio
@@ -10,35 +10,43 @@ import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .schemas import GameEvent, EventType from .schemas import GameEvent, EventType
from .models import Player, Boss from .database import init_db, get_db_session
from .models import User, Agent, WorldState
if TYPE_CHECKING: if TYPE_CHECKING:
from .server import ConnectionManager from .server import ConnectionManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Command patterns for string matching # Command patterns
ATTACK_PATTERN = re.compile(r"(attack|攻击|打|砍|杀)", re.IGNORECASE) FEED_PATTERN = re.compile(r"feed\s+(\w+)", re.IGNORECASE)
HEAL_PATTERN = re.compile(r"(heal|治疗|回血|加血|恢复)", re.IGNORECASE) CHECK_PATTERN = re.compile(r"(check|查询|状态)", re.IGNORECASE)
STATUS_PATTERN = re.compile(r"(status|查询|状态|信息)", re.IGNORECASE)
# Game constants # Game constants
ATTACK_DAMAGE = 10 TICK_INTERVAL = 5.0 # Seconds between ticks
ATTACK_GOLD_REWARD = 10 ENERGY_DECAY_PER_TICK = 2 # Energy lost per tick
HEAL_AMOUNT = 10 HP_DECAY_WHEN_STARVING = 5 # HP lost when energy is 0
BOSS_COUNTER_DAMAGE = 15 # Boss反击伤害 FEED_COST = 10 # Gold cost to feed an agent
FEED_ENERGY_RESTORE = 20 # Energy restored when fed
INITIAL_USER_GOLD = 100 # Starting gold for new users
# Initial NPC data
INITIAL_AGENTS = [
{"name": "Jack", "personality": "勇敢"},
{"name": "Luna", "personality": "狡猾"},
{"name": "Bob", "personality": "老实"},
]
class GameEngine: class GameEngine:
""" """
The core game engine that manages RPG state and game loop. The core game engine for island survival simulation.
Manages agents, users, and survival mechanics with database persistence.
Manages players, boss, and processes commands through string matching.
""" """
def __init__(self, connection_manager: "ConnectionManager") -> None: def __init__(self, connection_manager: "ConnectionManager") -> None:
""" """
Initialize the game engine with state storage. Initialize the game engine.
Args: Args:
connection_manager: The WebSocket connection manager for broadcasting connection_manager: The WebSocket connection manager for broadcasting
@@ -46,40 +54,50 @@ class GameEngine:
self._manager = connection_manager self._manager = connection_manager
self._running = False self._running = False
self._tick_count = 0 self._tick_count = 0
self._tick_interval = 2.0 # seconds self._tick_interval = TICK_INTERVAL
# Game state
self.players: dict[str, Player] = {}
self.boss = Boss(name="Dragon", hp=1000, max_hp=1000)
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
"""Check if the engine is currently running.""" """Check if the engine is currently running."""
return self._running return self._running
def _get_or_create_player(self, username: str) -> Player: def _seed_initial_data(self) -> None:
""" """Seed initial agents and world state if database is empty."""
Get existing player or create new one. with get_db_session() as db:
# Check if agents exist
agent_count = db.query(Agent).count()
if agent_count == 0:
logger.info("Seeding initial agents...")
for agent_data in INITIAL_AGENTS:
agent = Agent(
name=agent_data["name"],
personality=agent_data["personality"],
status="Alive",
hp=100,
energy=100
)
db.add(agent)
logger.info(f"Created {len(INITIAL_AGENTS)} initial agents")
Args: # Check if world state exists
username: The player's username world = db.query(WorldState).first()
if world is None:
logger.info("Seeding initial world state...")
world = WorldState(day_count=1, weather="Sunny", resource_level=100)
db.add(world)
Returns: def _get_or_create_user(self, db, username: str) -> User:
Player instance """Get existing user or create new one."""
""" user = db.query(User).filter(User.username == username).first()
if username not in self.players: if user is None:
self.players[username] = Player(name=username) user = User(username=username, gold=INITIAL_USER_GOLD)
logger.info(f"New player registered: {username}") db.add(user)
return self.players[username] db.flush() # Get the ID without committing
logger.info(f"New user registered: {username}")
return user
async def _broadcast_event(self, event_type: str, data: dict) -> None: async def _broadcast_event(self, event_type: str, data: dict) -> None:
""" """Create and broadcast a game event."""
Create and broadcast a game event.
Args:
event_type: Type of the event
data: Event payload data
"""
event = GameEvent( event = GameEvent(
event_type=event_type, event_type=event_type,
timestamp=time.time(), timestamp=time.time(),
@@ -87,216 +105,245 @@ class GameEngine:
) )
await self._manager.broadcast(event) await self._manager.broadcast(event)
async def _broadcast_boss_update(self) -> None: async def _broadcast_agents_status(self) -> None:
"""Broadcast current boss status to all clients.""" """Broadcast all agents' current status."""
with get_db_session() as db:
agents = db.query(Agent).all()
agents_data = [agent.to_dict() for agent in agents]
await self._broadcast_event( await self._broadcast_event(
EventType.BOSS_UPDATE, EventType.AGENTS_UPDATE,
{"agents": agents_data}
)
async def _process_survival_tick(self) -> None:
"""
Process survival mechanics for all alive agents.
- Decrease energy
- If energy <= 0, decrease HP
- If HP <= 0, mark as Dead
"""
deaths = []
with get_db_session() as db:
alive_agents = db.query(Agent).filter(Agent.status == "Alive").all()
for agent in alive_agents:
# Energy decay
agent.energy = max(0, agent.energy - ENERGY_DECAY_PER_TICK)
# Starvation damage
if agent.energy <= 0:
agent.hp = max(0, agent.hp - HP_DECAY_WHEN_STARVING)
# Check for death
if agent.hp <= 0:
agent.status = "Dead"
deaths.append({
"name": agent.name,
"personality": agent.personality
})
logger.info(f"Agent {agent.name} has died!")
# Broadcast death events
for death in deaths:
await self._broadcast_event(
EventType.AGENT_DIED,
{ {
"boss_name": self.boss.name, "agent_name": death["name"],
"boss_hp": self.boss.hp, "message": f"💀 {death['name']}{death['personality']})因饥饿而死亡..."
"boss_max_hp": self.boss.max_hp,
"boss_hp_percentage": self.boss.hp_percentage
} }
) )
async def _handle_attack(self, player: Player) -> None: async def _handle_feed(self, username: str, agent_name: str) -> None:
""" """
Handle attack command. Boss will counter-attack! Handle feed command.
Args: Args:
player: The attacking player username: The user feeding the agent
agent_name: Name of the agent to feed
""" """
if not self.boss.is_alive: # Variables to store data for broadcasting (outside session)
feed_result = None
with get_db_session() as db:
user = self._get_or_create_user(db, username)
# Find the agent
agent = db.query(Agent).filter(
Agent.name.ilike(agent_name)
).first()
if agent is None:
await self._broadcast_event( await self._broadcast_event(
EventType.SYSTEM, EventType.ERROR,
{"message": f"Boss is already defeated! Waiting for respawn..."} {"message": f"找不到名为 {agent_name} 的角色"}
) )
return return
# Player attacks boss if agent.status != "Alive":
damage = self.boss.take_damage(ATTACK_DAMAGE)
player.add_gold(ATTACK_GOLD_REWARD)
# Boss counter-attacks player
counter_damage = player.take_damage(BOSS_COUNTER_DAMAGE)
await self._broadcast_event( await self._broadcast_event(
EventType.ATTACK, EventType.ERROR,
{ {"message": f"{agent.name} 已经死亡,无法投喂"}
"user": player.name,
"damage": damage,
"counter_damage": counter_damage,
"gold_earned": ATTACK_GOLD_REWARD,
"boss_hp": self.boss.hp,
"boss_max_hp": self.boss.max_hp,
"player_hp": player.hp,
"player_max_hp": player.max_hp,
"player_gold": player.gold,
"message": f"⚔️ {player.name} attacked {self.boss.name}! "
f"Dealt {damage} dmg, received {counter_damage} counter-attack. "
f"HP: {player.hp}/{player.max_hp} | Boss: {self.boss.hp}/{self.boss.max_hp}"
}
) )
await self._broadcast_boss_update()
# Check if player died
if not player.is_alive:
await self._handle_player_death(player)
return return
# Check if boss defeated if user.gold < FEED_COST:
if not self.boss.is_alive:
await self._handle_boss_defeated()
async def _handle_player_death(self, player: Player) -> None:
"""Handle player death - respawn with full HP but lose half gold."""
lost_gold = player.gold // 2
player.gold -= lost_gold
player.hp = player.max_hp # Respawn with full HP
await self._broadcast_event( await self._broadcast_event(
EventType.SYSTEM, EventType.ERROR,
{ {
"user": player.name, "user": username,
"player_hp": player.hp, "message": f"金币不足!需要 {FEED_COST} 金币,当前只有 {user.gold} 金币"
"player_max_hp": player.max_hp, }
"player_gold": player.gold, )
"message": f"💀 {player.name} was slain by {self.boss.name}! " return
f"Lost {lost_gold} gold. Respawned with full HP."
# Perform feed
user.gold -= FEED_COST
old_energy = agent.energy
agent.energy = min(100, agent.energy + FEED_ENERGY_RESTORE)
actual_restore = agent.energy - old_energy
# Store data for broadcasting before session closes
feed_result = {
"agent_name": agent.name,
"actual_restore": actual_restore,
"agent_energy": agent.energy,
"user_gold": user.gold
}
# Broadcast outside of session
if feed_result:
await self._broadcast_event(
EventType.FEED,
{
"user": username,
"agent_name": feed_result["agent_name"],
"energy_restored": feed_result["actual_restore"],
"agent_energy": feed_result["agent_energy"],
"user_gold": feed_result["user_gold"],
"message": f"🍖 {username} 投喂了 {feed_result['agent_name']}"
f"恢复 {feed_result['actual_restore']} 点体力(当前: {feed_result['agent_energy']}/100"
} }
) )
async def _handle_boss_defeated(self) -> None:
"""Handle boss defeat and reset."""
await self._broadcast_event( await self._broadcast_event(
EventType.BOSS_DEFEATED, EventType.USER_UPDATE,
{ {
"boss_name": self.boss.name, "user": username,
"message": f"🎉 {self.boss.name} has been defeated! A new boss will spawn soon..." "gold": feed_result["user_gold"]
} }
) )
# Reset boss after short delay async def _handle_check(self, username: str) -> None:
await asyncio.sleep(3.0) """Handle check/status command."""
self.boss.reset() with get_db_session() as db:
await self._broadcast_event( user = self._get_or_create_user(db, username)
EventType.SYSTEM, agents = db.query(Agent).all()
{"message": f"⚔️ {self.boss.name} has respawned with full HP!"} world = db.query(WorldState).first()
)
await self._broadcast_boss_update()
async def _handle_heal(self, player: Player) -> None: # Extract data before session closes
""" user_data = {"username": user.username, "gold": user.gold}
Handle heal command. agents_data = [agent.to_dict() for agent in agents]
world_data = world.to_dict() if world else {}
Args: message = f"📊 {username} 的状态 - 金币: {user_data['gold']}"
player: The healing player
"""
healed = player.heal(HEAL_AMOUNT)
await self._broadcast_event( await self._broadcast_event(
EventType.HEAL, EventType.CHECK,
{ {
"user": player.name, "user": user_data,
"healed": healed, "agents": agents_data,
"player_hp": player.hp, "world": world_data,
"player_max_hp": player.max_hp, "message": message
"message": f"{player.name} healed themselves. "
f"Restored {healed} HP. HP: {player.hp}/{player.max_hp}"
}
)
async def _handle_status(self, player: Player) -> None:
"""
Handle status query command.
Args:
player: The querying player
"""
await self._broadcast_event(
EventType.STATUS,
{
"user": player.name,
"player_hp": player.hp,
"player_max_hp": player.max_hp,
"player_gold": player.gold,
"boss_name": self.boss.name,
"boss_hp": self.boss.hp,
"boss_max_hp": self.boss.max_hp,
"message": f"📊 {player.name}'s Status - HP: {player.hp}/{player.max_hp}, "
f"Gold: {player.gold} | Boss {self.boss.name}: {self.boss.hp}/{self.boss.max_hp}"
} }
) )
async def process_comment(self, user: str, message: str) -> None: async def process_comment(self, user: str, message: str) -> None:
""" """
Process a comment through string matching command system. Process a comment through command matching.
Args: Args:
user: Username of the commenter user: Username of the commenter
message: The comment text message: The comment text
""" """
# Get or create player # Broadcast the incoming comment
player = self._get_or_create_player(user)
# Broadcast the incoming comment first
await self._broadcast_event( await self._broadcast_event(
EventType.COMMENT, EventType.COMMENT,
{"user": user, "message": message} {"user": user, "message": message}
) )
# Process command through string matching # Match commands
if ATTACK_PATTERN.search(message): feed_match = FEED_PATTERN.search(message)
await self._handle_attack(player) if feed_match:
elif HEAL_PATTERN.search(message): agent_name = feed_match.group(1)
await self._handle_heal(player) await self._handle_feed(user, agent_name)
elif STATUS_PATTERN.search(message): return
await self._handle_status(player)
# If no command matched, treat as regular chat (no action needed) if CHECK_PATTERN.search(message):
await self._handle_check(user)
return
# No command matched - treat as regular chat
async def _game_loop(self) -> None: async def _game_loop(self) -> None:
""" """
The main game loop - runs continuously while engine is active. The main game loop - survival simulation.
Every tick broadcasts current game state. Every tick:
1. Process survival mechanics (energy/HP decay)
2. Broadcast agent states
""" """
logger.info("Game loop started") logger.info("Game loop started - Island survival simulation")
# Broadcast initial boss state # Initial broadcast
await self._broadcast_boss_update() await self._broadcast_agents_status()
while self._running: while self._running:
self._tick_count += 1 self._tick_count += 1
# Broadcast tick event with game state # Process survival mechanics
await self._process_survival_tick()
# Broadcast current state
await self._broadcast_agents_status()
# Broadcast tick event
with get_db_session() as db:
alive_count = db.query(Agent).filter(Agent.status == "Alive").count()
world = db.query(WorldState).first()
day = world.day_count if world else 1
await self._broadcast_event( await self._broadcast_event(
EventType.TICK, EventType.TICK,
{ {
"tick": self._tick_count, "tick": self._tick_count,
"boss_hp": self.boss.hp, "day": day,
"boss_max_hp": self.boss.max_hp, "alive_agents": alive_count
"player_count": len(self.players)
} }
) )
logger.debug(f"Tick {self._tick_count}: Boss HP {self.boss.hp}/{self.boss.max_hp}") logger.debug(f"Tick {self._tick_count}: {alive_count} agents alive")
await asyncio.sleep(self._tick_interval) await asyncio.sleep(self._tick_interval)
logger.info("Game loop stopped") logger.info("Game loop stopped")
async def start(self) -> None: async def start(self) -> None:
"""Start the game engine loop as a background task.""" """Start the game engine."""
if self._running: if self._running:
logger.warning("Engine already running") logger.warning("Engine already running")
return return
# Initialize database and seed data
logger.info("Initializing database...")
init_db()
self._seed_initial_data()
self._running = True self._running = True
asyncio.create_task(self._game_loop()) asyncio.create_task(self._game_loop())
logger.info("Game engine started") logger.info("Game engine started - The Island awaits...")
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the game engine loop.""" """Stop the game engine."""
self._running = False self._running = False
logger.info("Game engine stopping...") logger.info("Game engine stopping...")

View File

@@ -1,66 +1,81 @@
""" """
Game entity models for The Island. SQLAlchemy ORM models for The Island.
Defines Player and Boss data structures. Defines User (viewers), Agent (NPCs), and WorldState entities.
""" """
from pydantic import BaseModel, Field from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, func
from .database import Base
class Player(BaseModel): class User(Base):
""" """
Represents a player in the game world. Represents a viewer/donor from the live stream.
They can spend gold to influence the island.
""" """
name: str __tablename__ = "users"
hp: int = Field(default=100, ge=0)
max_hp: int = Field(default=100, gt=0)
gold: int = Field(default=0, ge=0)
def take_damage(self, amount: int) -> int: id = Column(Integer, primary_key=True, index=True)
"""Apply damage to player, returns actual damage dealt.""" username = Column(String(100), unique=True, index=True, nullable=False)
actual = min(amount, self.hp) gold = Column(Integer, default=100) # Starting gold for new users
self.hp -= actual created_at = Column(DateTime, default=func.now())
return actual
def heal(self, amount: int) -> int: def __repr__(self):
"""Heal player, returns actual HP restored.""" return f"<User {self.username} gold={self.gold}>"
actual = min(amount, self.max_hp - self.hp)
self.hp += actual
return actual
def add_gold(self, amount: int) -> None:
"""Add gold to player."""
self.gold += amount
@property
def is_alive(self) -> bool:
"""Check if player is alive."""
return self.hp > 0
class Boss(BaseModel): class Agent(Base):
""" """
Represents a boss enemy in the game. Represents an NPC survivor on the island.
Has personality, health, energy, and inventory.
""" """
name: str __tablename__ = "agents"
hp: int = Field(ge=0)
max_hp: int = Field(gt=0)
def take_damage(self, amount: int) -> int: id = Column(Integer, primary_key=True, index=True)
"""Apply damage to boss, returns actual damage dealt.""" name = Column(String(50), unique=True, nullable=False)
actual = min(amount, self.hp) personality = Column(String(50), nullable=False)
self.hp -= actual status = Column(String(20), default="Alive") # Alive, Exiled, Dead
return actual hp = Column(Integer, default=100)
energy = Column(Integer, default=100)
inventory = Column(String(500), default="{}") # JSON string
def reset(self) -> None: def __repr__(self):
"""Reset boss to full HP.""" return f"<Agent {self.name} ({self.personality}) HP={self.hp} Energy={self.energy} Status={self.status}>"
self.hp = self.max_hp
@property def to_dict(self):
def is_alive(self) -> bool: """Convert to dictionary for JSON serialization."""
"""Check if boss is alive.""" return {
return self.hp > 0 "id": self.id,
"name": self.name,
"personality": self.personality,
"status": self.status,
"hp": self.hp,
"energy": self.energy,
"inventory": self.inventory
}
@property
def hp_percentage(self) -> float: class WorldState(Base):
"""Get HP as percentage.""" """
return (self.hp / self.max_hp) * 100 if self.max_hp > 0 else 0 Global state of the island environment.
Tracks day count, weather, and shared resources.
"""
__tablename__ = "world_state"
id = Column(Integer, primary_key=True, index=True)
day_count = Column(Integer, default=1)
weather = Column(String(20), default="Sunny")
resource_level = Column(Integer, default=100)
def __repr__(self):
return f"<WorldState Day={self.day_count} Weather={self.weather} Resources={self.resource_level}>"
def to_dict(self):
"""Convert to dictionary for JSON serialization."""
return {
"day_count": self.day_count,
"weather": self.weather,
"resource_level": self.resource_level
}

View File

@@ -12,17 +12,16 @@ import time
class EventType(str, Enum): class EventType(str, Enum):
"""Enumeration of all possible game event types.""" """Enumeration of all possible game event types."""
COMMENT = "comment" COMMENT = "comment"
AGENT_RESPONSE = "agent_response"
TICK = "tick" TICK = "tick"
SYSTEM = "system" SYSTEM = "system"
ERROR = "error" ERROR = "error"
# RPG-specific events # Island survival events
ATTACK = "attack" AGENTS_UPDATE = "agents_update" # All agents status broadcast
HEAL = "heal" AGENT_DIED = "agent_died" # An agent has died
STATUS = "status" FEED = "feed" # User fed an agent
BOSS_UPDATE = "boss_update" USER_UPDATE = "user_update" # User gold/status update
BOSS_DEFEATED = "boss_defeated" WORLD_UPDATE = "world_update" # World state update
PLAYER_UPDATE = "player_update" CHECK = "check" # Status check response
class GameEvent(BaseModel): class GameEvent(BaseModel):

View File

@@ -2,3 +2,5 @@ fastapi>=0.109.0
uvicorn[standard]>=0.27.0 uvicorn[standard]>=0.27.0
websockets>=12.0 websockets>=12.0
pydantic>=2.5.0 pydantic>=2.5.0
sqlalchemy>=2.0.0
aiosqlite>=0.19.0

View File

@@ -1,17 +1,16 @@
/** /**
* The Island - Debug Client JavaScript * The Island - Survival Simulation Client
* Handles WebSocket connection, UI interactions, and game state display * Handles WebSocket connection, agent display, and user interactions
*/ */
let ws = null; let ws = null;
const WS_URL = 'ws://localhost:8080/ws'; const WS_URL = 'ws://localhost:8080/ws';
// Player state (tracked from server events) // User state
let playerState = { let userGold = 100;
hp: 100,
maxHp: 100, // Agents state
gold: 0 let agents = [];
};
// DOM Elements // DOM Elements
const statusDot = document.getElementById('statusDot'); const statusDot = document.getElementById('statusDot');
@@ -22,16 +21,8 @@ const usernameInput = document.getElementById('username');
const messageInput = document.getElementById('message'); const messageInput = document.getElementById('message');
const autoScrollCheckbox = document.getElementById('autoScroll'); const autoScrollCheckbox = document.getElementById('autoScroll');
const hideTicksCheckbox = document.getElementById('hideTicks'); const hideTicksCheckbox = document.getElementById('hideTicks');
const agentsGrid = document.getElementById('agentsGrid');
// Boss UI Elements const userGoldDisplay = document.getElementById('userGold');
const bossName = document.getElementById('bossName');
const bossHpText = document.getElementById('bossHpText');
const bossHealthBar = document.getElementById('bossHealthBar');
const bossHealthLabel = document.getElementById('bossHealthLabel');
// Player UI Elements
const playerHpDisplay = document.getElementById('playerHp');
const playerGoldDisplay = document.getElementById('playerGold');
/** /**
* Toggle WebSocket connection * Toggle WebSocket connection
@@ -48,30 +39,30 @@ function toggleConnection() {
* Establish WebSocket connection * Establish WebSocket connection
*/ */
function connect() { function connect() {
statusText.textContent = 'Connecting...'; statusText.textContent = '连接中...';
connectBtn.disabled = true; connectBtn.disabled = true;
ws = new WebSocket(WS_URL); ws = new WebSocket(WS_URL);
ws.onopen = () => { ws.onopen = () => {
statusDot.classList.add('connected'); statusDot.classList.add('connected');
statusText.textContent = 'Connected'; statusText.textContent = '已连接';
connectBtn.textContent = 'Disconnect'; connectBtn.textContent = '断开';
connectBtn.disabled = false; connectBtn.disabled = false;
logEvent({ event_type: 'system', data: { message: 'WebSocket connected' } }); logEvent({ event_type: 'system', data: { message: '已连接到荒岛服务器' } });
}; };
ws.onclose = () => { ws.onclose = () => {
statusDot.classList.remove('connected'); statusDot.classList.remove('connected');
statusText.textContent = 'Disconnected'; statusText.textContent = '未连接';
connectBtn.textContent = 'Connect'; connectBtn.textContent = '连接';
connectBtn.disabled = false; connectBtn.disabled = false;
logEvent({ event_type: 'system', data: { message: 'WebSocket disconnected' } }); logEvent({ event_type: 'system', data: { message: '与服务器断开连接' } });
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
logEvent({ event_type: 'error', data: { message: 'Connection error' } }); logEvent({ event_type: 'error', data: { message: '连接错误' } });
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -91,122 +82,123 @@ function handleGameEvent(event) {
const eventType = event.event_type; const eventType = event.event_type;
const data = event.data || {}; const data = event.data || {};
// Update UI based on event type
switch (eventType) { switch (eventType) {
case 'boss_update': case 'agents_update':
updateBossUI(data); updateAgentsUI(data.agents);
break; break;
case 'attack': case 'feed':
updateBossFromAttack(data); case 'user_update':
updatePlayerFromEvent(data); updateUserGold(data);
break; break;
case 'heal': case 'check':
updatePlayerFromEvent(data); if (data.user && data.user.username === getCurrentUser()) {
break; userGold = data.user.gold;
case 'status': userGoldDisplay.textContent = userGold;
updatePlayerFromEvent(data);
updateBossFromStatus(data);
break;
case 'system':
// Handle player death/respawn updates
if (data.user && data.player_hp !== undefined) {
updatePlayerFromEvent(data);
}
break;
case 'tick':
if (data.boss_hp !== undefined) {
updateBossUI({
boss_hp: data.boss_hp,
boss_max_hp: data.boss_max_hp
});
} }
break; break;
} }
// Log the event
logEvent(event); logEvent(event);
} }
/** /**
* Update Boss health bar UI * Get current username
*/ */
function updateBossUI(data) { function getCurrentUser() {
if (data.boss_name) { return usernameInput.value.trim() || '观众001';
bossName.textContent = data.boss_name; }
}
if (data.boss_hp !== undefined && data.boss_max_hp !== undefined) {
const hp = data.boss_hp;
const maxHp = data.boss_max_hp;
const percentage = maxHp > 0 ? (hp / maxHp) * 100 : 0;
bossHpText.textContent = `HP: ${hp} / ${maxHp}`; /**
bossHealthBar.style.width = `${percentage}%`; * Update user gold display
bossHealthLabel.textContent = `${Math.round(percentage)}%`; */
function updateUserGold(data) {
// Change color based on HP percentage if (data.user === getCurrentUser() && data.gold !== undefined) {
if (percentage <= 25) { userGold = data.gold;
bossHealthBar.style.background = 'linear-gradient(90deg, #ff2222 0%, #ff4444 100%)'; userGoldDisplay.textContent = userGold;
} else if (percentage <= 50) {
bossHealthBar.style.background = 'linear-gradient(90deg, #ff6600 0%, #ff8844 100%)';
} else {
bossHealthBar.style.background = 'linear-gradient(90deg, #ff4444 0%, #ff6666 100%)';
} }
if (data.user_gold !== undefined && data.user === getCurrentUser()) {
userGold = data.user_gold;
userGoldDisplay.textContent = userGold;
} }
} }
/** /**
* Update boss from attack event * Update agents UI with card view
*/ */
function updateBossFromAttack(data) { function updateAgentsUI(agentsData) {
if (data.boss_hp !== undefined && data.boss_max_hp !== undefined) { if (!agentsData || agentsData.length === 0) return;
updateBossUI({
boss_hp: data.boss_hp, agents = agentsData;
boss_max_hp: data.boss_max_hp agentsGrid.innerHTML = '';
agents.forEach(agent => {
const card = createAgentCard(agent);
agentsGrid.appendChild(card);
}); });
}
} }
/** /**
* Update boss from status event * Create an agent card element
*/ */
function updateBossFromStatus(data) { function createAgentCard(agent) {
if (data.boss_hp !== undefined && data.boss_max_hp !== undefined) { const isDead = agent.status !== 'Alive';
updateBossUI({ const card = document.createElement('div');
boss_name: data.boss_name, card.className = `agent-card ${isDead ? 'dead' : ''}`;
boss_hp: data.boss_hp, card.id = `agent-${agent.id}`;
boss_max_hp: data.boss_max_hp
}); const statusClass = isDead ? 'dead' : 'alive';
} const statusText = isDead ? '已死亡' : '存活';
card.innerHTML = `
<div class="agent-header">
<div>
<span class="agent-name">${agent.name}</span>
<span class="agent-personality">${agent.personality}</span>
</div>
<span class="agent-status ${statusClass}">${statusText}</span>
</div>
<div class="stat-bar-container">
<div class="stat-bar-label">
<span>❤️ 生命值</span>
<span>${agent.hp}/100</span>
</div>
<div class="stat-bar">
<div class="stat-bar-fill hp" style="width: ${agent.hp}%"></div>
</div>
</div>
<div class="stat-bar-container">
<div class="stat-bar-label">
<span>⚡ 体力</span>
<span>${agent.energy}/100</span>
</div>
<div class="stat-bar">
<div class="stat-bar-fill energy" style="width: ${agent.energy}%"></div>
</div>
</div>
<button class="feed-btn" onclick="feedAgent('${agent.name}')" ${isDead ? 'disabled' : ''}>
🍖 投喂 (10金币)
</button>
`;
return card;
} }
/** /**
* Update player stats from event data * Feed an agent
*/ */
function updatePlayerFromEvent(data) { function feedAgent(agentName) {
const currentUser = usernameInput.value.trim() || 'Anonymous'; if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('未连接到服务器');
// Only update if this event is for the current user return;
if (data.user !== currentUser) return;
if (data.player_hp !== undefined) {
playerState.hp = data.player_hp;
}
if (data.player_max_hp !== undefined) {
playerState.maxHp = data.player_max_hp;
}
if (data.player_gold !== undefined) {
playerState.gold = data.player_gold;
} }
updatePlayerUI(); const user = getCurrentUser();
} const payload = {
action: 'send_comment',
payload: { user, message: `feed ${agentName}` }
};
/** ws.send(JSON.stringify(payload));
* Update player stats UI
*/
function updatePlayerUI() {
playerHpDisplay.textContent = `${playerState.hp}/${playerState.maxHp}`;
playerGoldDisplay.textContent = playerState.gold;
} }
/** /**
@@ -214,15 +206,15 @@ function updatePlayerUI() {
*/ */
function sendComment() { function sendComment() {
if (!ws || ws.readyState !== WebSocket.OPEN) { if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('Not connected to server'); alert('未连接到服务器');
return; return;
} }
const user = usernameInput.value.trim() || 'Anonymous'; const user = getCurrentUser();
const message = messageInput.value.trim(); const message = messageInput.value.trim();
if (!message) { if (!message) {
alert('Please enter a message'); alert('请输入指令');
return; return;
} }
@@ -235,25 +227,6 @@ function sendComment() {
messageInput.value = ''; messageInput.value = '';
} }
/**
* Quick action buttons
*/
function quickAction(action) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('Not connected to server');
return;
}
const user = usernameInput.value.trim() || 'Anonymous';
const payload = {
action: 'send_comment',
payload: { user, message: action }
};
ws.send(JSON.stringify(payload));
}
/** /**
* Format timestamp for display * Format timestamp for display
*/ */
@@ -269,20 +242,18 @@ function formatEventData(eventType, data) {
switch (eventType) { switch (eventType) {
case 'comment': case 'comment':
return `${data.user}: ${data.message}`; return `${data.user}: ${data.message}`;
case 'agent_response':
return data.response;
case 'tick': case 'tick':
return `Tick #${data.tick} | Players: ${data.player_count || 0}`; return `Tick #${data.tick} | ${data.day}天 | 存活: ${data.alive_agents}`;
case 'system': case 'system':
case 'error': case 'error':
case 'feed':
case 'agent_died':
case 'check':
return data.message; return data.message;
case 'attack': case 'agents_update':
case 'heal': return `角色状态已更新`;
case 'status': case 'user_update':
case 'boss_defeated': return `${data.user} 金币: ${data.gold}`;
return data.message;
case 'boss_update':
return `Boss ${data.boss_name}: ${data.boss_hp}/${data.boss_max_hp} (${Math.round(data.boss_hp_percentage)}%)`;
default: default:
return JSON.stringify(data); return JSON.stringify(data);
} }
@@ -301,8 +272,8 @@ function logEvent(event) {
return; return;
} }
// Skip boss_update events to reduce log noise (they're reflected in the UI) // Skip agents_update to reduce noise
if (eventType === 'boss_update') { if (eventType === 'agents_update') {
return; return;
} }

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Island - Debug Client</title> <title>荒岛:人性的试炼</title>
<style> <style>
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -12,20 +12,26 @@
} }
body { body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); background: linear-gradient(135deg, #1a2a1a 0%, #0d1f0d 100%);
color: #eee; color: #eee;
min-height: 100vh; min-height: 100vh;
padding: 20px; padding: 20px;
} }
.container { .container {
max-width: 900px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
} }
h1 { h1 {
text-align: center; text-align: center;
margin-bottom: 10px;
color: #88cc88;
text-shadow: 0 0 10px rgba(136, 204, 136, 0.5);
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 20px; margin-bottom: 20px;
color: #00d4ff; font-size: 0.9rem;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
} }
.status-bar { .status-bar {
display: flex; display: flex;
@@ -52,87 +58,129 @@
box-shadow: 0 0 10px rgba(68, 255, 68, 0.5); box-shadow: 0 0 10px rgba(68, 255, 68, 0.5);
} }
/* Boss Health Bar */ /* User Stats */
.boss-panel { .user-panel {
background: rgba(255, 68, 68, 0.1); background: rgba(136, 204, 136, 0.1);
border: 1px solid rgba(255, 68, 68, 0.3); border: 1px solid rgba(136, 204, 136, 0.3);
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 15px 20px;
margin-bottom: 20px; margin-bottom: 20px;
}
.boss-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 10px;
} }
.boss-name { .user-info {
color: #ff6666;
font-size: 1.3rem;
font-weight: bold;
}
.boss-hp-text {
color: #ffaaaa;
font-size: 0.9rem;
}
.health-bar-container {
width: 100%;
height: 30px;
background: rgba(0, 0, 0, 0.4);
border-radius: 15px;
overflow: hidden;
position: relative;
}
.health-bar {
height: 100%;
background: linear-gradient(90deg, #ff4444 0%, #ff6666 100%);
border-radius: 15px;
transition: width 0.3s ease;
}
.health-bar-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
}
/* Player Stats Panel */
.player-panel {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.player-header {
color: #00d4ff;
font-size: 1.1rem;
margin-bottom: 15px;
}
.player-stats {
display: flex;
gap: 30px;
flex-wrap: wrap;
}
.stat-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 20px;
} }
.stat-icon { .gold-display {
font-size: 1.5rem; font-size: 1.3rem;
color: #ffd700;
} }
.stat-value {
/* Agent Cards */
.agents-section {
margin-bottom: 20px;
}
.section-title {
color: #88cc88;
margin-bottom: 15px;
font-size: 1.1rem;
}
.agents-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 15px;
}
.agent-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 20px;
transition: all 0.3s;
}
.agent-card:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
}
.agent-card.dead {
opacity: 0.5;
filter: grayscale(80%);
border-color: #444;
}
.agent-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.agent-name {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: bold; font-weight: bold;
} }
.stat-label { .agent-personality {
font-size: 0.85rem;
color: #aaa;
background: rgba(255,255,255,0.1);
padding: 3px 10px;
border-radius: 10px;
}
.agent-status {
font-size: 0.8rem; font-size: 0.8rem;
padding: 3px 8px;
border-radius: 8px;
}
.agent-status.alive { background: rgba(68, 255, 68, 0.2); color: #88ff88; }
.agent-status.dead { background: rgba(255, 68, 68, 0.2); color: #ff8888; }
/* Stat Bars */
.stat-bar-container {
margin-bottom: 10px;
}
.stat-bar-label {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
margin-bottom: 5px;
}
.stat-bar {
height: 12px;
background: rgba(0, 0, 0, 0.4);
border-radius: 6px;
overflow: hidden;
}
.stat-bar-fill {
height: 100%;
border-radius: 6px;
transition: width 0.3s ease;
}
.stat-bar-fill.hp { background: linear-gradient(90deg, #ff4444, #ff6666); }
.stat-bar-fill.energy { background: linear-gradient(90deg, #ffaa00, #ffcc44); }
/* Feed Button */
.feed-btn {
width: 100%;
margin-top: 10px;
padding: 8px;
background: #88cc88;
color: #1a2a1a;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
transition: all 0.2s;
}
.feed-btn:hover {
background: #66aa66;
}
.feed-btn:disabled {
background: #444;
color: #888; color: #888;
cursor: not-allowed;
} }
/* Panels */
.panels { .panels {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -145,7 +193,7 @@
padding: 20px; padding: 20px;
} }
.panel h2 { .panel h2 {
color: #00d4ff; color: #88cc88;
margin-bottom: 15px; margin-bottom: 15px;
font-size: 1.1rem; font-size: 1.1rem;
} }
@@ -166,39 +214,26 @@
} }
input[type="text"]:focus { input[type="text"]:focus {
outline: none; outline: none;
border-color: #00d4ff; border-color: #88cc88;
} }
button { button {
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
background: #00d4ff; background: #88cc88;
color: #1a1a2e; color: #1a2a1a;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
button:hover { button:hover {
background: #00b8e6; background: #66aa66;
transform: translateY(-1px); transform: translateY(-1px);
} }
button:disabled { button:disabled {
background: #666; background: #444;
cursor: not-allowed; cursor: not-allowed;
} }
.quick-actions {
display: flex;
gap: 10px;
margin-top: 10px;
flex-wrap: wrap;
}
.quick-btn {
padding: 8px 16px;
font-size: 13px;
}
.quick-btn.attack { background: #ff6666; }
.quick-btn.heal { background: #44ff88; }
.quick-btn.status { background: #ffaa44; }
.controls { .controls {
display: flex; display: flex;
gap: 15px; gap: 15px;
@@ -212,7 +247,7 @@
font-size: 14px; font-size: 14px;
} }
.event-log { .event-log {
height: 350px; height: 300px;
overflow-y: auto; overflow-y: auto;
background: rgba(0, 0, 0, 0.4); background: rgba(0, 0, 0, 0.4);
border-radius: 8px; border-radius: 8px;
@@ -226,17 +261,13 @@
border-radius: 6px; border-radius: 6px;
border-left: 3px solid; border-left: 3px solid;
} }
.event.comment { border-color: #ffaa00; background: rgba(255, 170, 0, 0.1); } .event.comment { border-color: #888; background: rgba(136, 136, 136, 0.1); }
.event.agent_response { border-color: #00ff88; background: rgba(0, 255, 136, 0.1); } .event.tick { border-color: #444; background: rgba(68, 68, 68, 0.05); opacity: 0.6; }
.event.tick { border-color: #888; background: rgba(136, 136, 136, 0.05); opacity: 0.6; } .event.system { border-color: #88cc88; background: rgba(136, 204, 136, 0.1); }
.event.system { border-color: #00d4ff; background: rgba(0, 212, 255, 0.1); }
.event.error { border-color: #ff4444; background: rgba(255, 68, 68, 0.1); } .event.error { border-color: #ff4444; background: rgba(255, 68, 68, 0.1); }
/* RPG Event Styles */ .event.feed { border-color: #ffaa00; background: rgba(255, 170, 0, 0.1); color: #ffcc66; }
.event.attack { border-color: #ff4444; background: rgba(255, 68, 68, 0.15); color: #ff8888; } .event.agent_died { border-color: #ff4444; background: rgba(255, 68, 68, 0.15); color: #ff8888; }
.event.heal { border-color: #44ff88; background: rgba(68, 255, 136, 0.15); color: #88ffaa; } .event.check { border-color: #88cc88; background: rgba(136, 204, 136, 0.1); }
.event.status { border-color: #ffaa44; background: rgba(255, 170, 68, 0.1); }
.event.boss_update { border-color: #ff6666; background: rgba(255, 102, 102, 0.1); }
.event.boss_defeated { border-color: #ffdd00; background: rgba(255, 221, 0, 0.2); color: #ffee88; font-weight: bold; }
.event-time { color: #888; font-size: 11px; } .event-time { color: #888; font-size: 11px; }
.event-type { font-weight: bold; text-transform: uppercase; font-size: 11px; } .event-type { font-weight: bold; text-transform: uppercase; font-size: 11px; }
.event-data { margin-top: 5px; } .event-data { margin-top: 5px; }
@@ -244,70 +275,57 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>The Island - Debug Client</h1> <h1>荒岛:人性的试炼</h1>
<p class="subtitle">The Island: Trial of Humanity</p>
<div class="status-bar"> <div class="status-bar">
<div class="status-indicator"> <div class="status-indicator">
<div class="status-dot" id="statusDot"></div> <div class="status-dot" id="statusDot"></div>
<span id="statusText">Disconnected</span> <span id="statusText">未连接</span>
</div> </div>
<button id="connectBtn" onclick="toggleConnection()">Connect</button> <button id="connectBtn" onclick="toggleConnection()">连接</button>
</div> </div>
<!-- Boss Health Panel --> <!-- User Panel -->
<div class="boss-panel"> <div class="user-panel">
<div class="boss-header"> <div class="user-info">
<span class="boss-name" id="bossName">Dragon</span> <span>玩家:</span>
<span class="boss-hp-text" id="bossHpText">HP: 1000 / 1000</span> <input type="text" id="username" value="观众001" style="width: 120px;">
</div> </div>
<div class="health-bar-container"> <div class="gold-display">
<div class="health-bar" id="bossHealthBar" style="width: 100%"></div> 💰 <span id="userGold">100</span> 金币
<span class="health-bar-label" id="bossHealthLabel">100%</span>
</div> </div>
</div> </div>
<!-- Player Stats Panel --> <!-- Agents Section -->
<div class="player-panel"> <div class="agents-section">
<div class="player-header">Your Stats</div> <h2 class="section-title">岛上幸存者</h2>
<div class="player-stats"> <div class="agents-grid" id="agentsGrid">
<div class="stat-item"> <!-- Agent cards will be dynamically generated -->
<span class="stat-icon">❤️</span> <div class="agent-card" id="agent-loading">
<div> <p style="text-align: center; color: #666;">等待连接...</p>
<div class="stat-value" id="playerHp">100/100</div>
<div class="stat-label">HP</div>
</div>
</div>
<div class="stat-item">
<span class="stat-icon">💰</span>
<div>
<div class="stat-value" id="playerGold">0</div>
<div class="stat-label">Gold</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="panels"> <div class="panels">
<div class="panel"> <div class="panel">
<h2>Send Command</h2> <h2>发送指令</h2>
<div class="input-group"> <div class="input-group">
<input type="text" id="username" placeholder="Username" value="TestUser"> <input type="text" id="message" placeholder="输入指令 (feed Jack, check, 查询...)">
<input type="text" id="message" placeholder="Message (attack, heal, status...)"> <button onclick="sendComment()">发送</button>
<button onclick="sendComment()">Send</button>
</div>
<div class="quick-actions">
<button class="quick-btn attack" onclick="quickAction('attack')">⚔️ Attack</button>
<button class="quick-btn heal" onclick="quickAction('heal')">💚 Heal</button>
<button class="quick-btn status" onclick="quickAction('status')">📊 Status</button>
</div> </div>
<p style="margin-top: 10px; font-size: 0.85rem; color: #888;">
指令: <code>feed [名字]</code> - 投喂角色 (消耗10金币) | <code>check</code> - 查询状态
</p>
</div> </div>
<div class="panel"> <div class="panel">
<h2>Event Log</h2> <h2>事件日志</h2>
<div class="controls"> <div class="controls">
<button onclick="clearLog()">Clear Log</button> <button onclick="clearLog()">清空</button>
<label><input type="checkbox" id="autoScroll" checked> Auto-scroll</label> <label><input type="checkbox" id="autoScroll" checked> 自动滚动</label>
<label><input type="checkbox" id="hideTicks"> Hide ticks</label> <label><input type="checkbox" id="hideTicks" checked> 隐藏 Tick</label>
</div> </div>
<div class="event-log" id="eventLog"></div> <div class="event-log" id="eventLog"></div>
</div> </div>