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

View File

@@ -1,66 +1,81 @@
"""
Game entity models for The Island.
Defines Player and Boss data structures.
SQLAlchemy ORM models for The Island.
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
hp: int = Field(default=100, ge=0)
max_hp: int = Field(default=100, gt=0)
gold: int = Field(default=0, ge=0)
__tablename__ = "users"
def take_damage(self, amount: int) -> int:
"""Apply damage to player, returns actual damage dealt."""
actual = min(amount, self.hp)
self.hp -= actual
return actual
id = Column(Integer, primary_key=True, index=True)
username = Column(String(100), unique=True, index=True, nullable=False)
gold = Column(Integer, default=100) # Starting gold for new users
created_at = Column(DateTime, default=func.now())
def heal(self, amount: int) -> int:
"""Heal player, returns actual HP restored."""
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
def __repr__(self):
return f"<User {self.username} gold={self.gold}>"
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
hp: int = Field(ge=0)
max_hp: int = Field(gt=0)
__tablename__ = "agents"
def take_damage(self, amount: int) -> int:
"""Apply damage to boss, returns actual damage dealt."""
actual = min(amount, self.hp)
self.hp -= actual
return actual
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), unique=True, nullable=False)
personality = Column(String(50), nullable=False)
status = Column(String(20), default="Alive") # Alive, Exiled, Dead
hp = Column(Integer, default=100)
energy = Column(Integer, default=100)
inventory = Column(String(500), default="{}") # JSON string
def reset(self) -> None:
"""Reset boss to full HP."""
self.hp = self.max_hp
def __repr__(self):
return f"<Agent {self.name} ({self.personality}) HP={self.hp} Energy={self.energy} Status={self.status}>"
@property
def is_alive(self) -> bool:
"""Check if boss is alive."""
return self.hp > 0
def to_dict(self):
"""Convert to dictionary for JSON serialization."""
return {
"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:
"""Get HP as percentage."""
return (self.hp / self.max_hp) * 100 if self.max_hp > 0 else 0
class WorldState(Base):
"""
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):
"""Enumeration of all possible game event types."""
COMMENT = "comment"
AGENT_RESPONSE = "agent_response"
TICK = "tick"
SYSTEM = "system"
ERROR = "error"
# RPG-specific events
ATTACK = "attack"
HEAL = "heal"
STATUS = "status"
BOSS_UPDATE = "boss_update"
BOSS_DEFEATED = "boss_defeated"
PLAYER_UPDATE = "player_update"
# Island survival events
AGENTS_UPDATE = "agents_update" # All agents status broadcast
AGENT_DIED = "agent_died" # An agent has died
FEED = "feed" # User fed an agent
USER_UPDATE = "user_update" # User gold/status update
WORLD_UPDATE = "world_update" # World state update
CHECK = "check" # Status check response
class GameEvent(BaseModel):

View File

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

View File

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

View File

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